pulpie-orange-small β Core ML
Core ML (.mlpackage) conversion of feyninc/pulpie-orange-small,
a 210M-parameter EuroBERT token classifier that labels HTML blocks as
main (content) or other (boilerplate).
All modelling credit belongs to Feyn Labs / feyninc. This repo contains only a format conversion β no retraining, no fine-tuning. The shipping artifact keeps the original float32 weights; a float16 variant was also produced and evaluated (see below) but is not recommended. Licensed Apache-2.0, same as the source model.
- Upstream model: https://huggingface.co/feyninc/pulpie-orange-small
- pulpie library: https://pypi.org/project/pulpie/
- Write-up: https://usefeyn.com/blog/pulpie-pareto-optimal-models-for-cleaning-the-web
What it does
pulpie simplifies a raw HTML page into a sequence of blocks, each tagged with
an _item_id, then packs those blocks into one sequence separated by a special
<|sep|> token:
[BOS] block_0 <|sep|> block_1 <|sep|> ... block_N <|sep|> [EOS]
A single encoder forward pass classifies every block at once. The prediction
for block i is read from the logits at the position of the <|sep|> token
that follows it β not from a pooled sequence output.
Which file to use
This bundle contains fp32 only β it is the shipping artifact.
| variant | size | block-label parity | Wikipedia page latency |
|---|---|---|---|
pulpie-orange-small-fp32.mlpackage |
~808 MB | 920/920 β 100% | 8.9 s |
fp16 was converted and evaluated but is not shipped: it loses exactness (912/920 β 99.13%) and runs ~3Γ slower (27.8 s vs 8.9 s), because the fp16 graph lands on an accelerated path that handles this model poorly (see Shape, below). Its only advantage is size, which is not worth an inexact and slower model. It is retained offline in the combined tarball (see Download) for reproducibility, and is not published here.
Download
One tarball is published here β the fp32-only shipping artifact:
https://huggingface.co/evan108108/pulpie-orange-small-coreml/resolve/main/pulpie-orange-small-coreml-fp32.tar.gz
| contains | fp32 .mlpackage + conversion_meta.json + tokenizer sidecars |
| bytes | 403,179,468 |
| sha256 | 116aeb6ca92b647c5b6536b2cd226c3d5961f82ec44bef6e6864627d93c14921 |
It unpacks to a single top-level pulpie-orange-small-coreml/ directory.
A combined fp32+fp16 tarball (732,209,538 bytes, sha256
2c15388f59fb9df764c2ab93b9a26eb4fa375e6ed7d78c299c377dcfb8ab29be) was also
produced during conversion and retained offline for reproducibility, but is
not published here β dropping fp16 cuts the first-run download by
329,030,070 bytes (44.93% smaller) and fp16 is not recommended for use. The
fp32 .mlpackage is byte-identical between the two (weight.bin sha256
995acb528764c21f6af2c3a7b2a7f7f4b8220ded2329f2a47e34313dcaa85576) β the
fp32-only tarball is a repackaging, not a re-conversion.
Do not replace this file in place. Consumers pin its sha256 and byte count,
and tar output is not reproducible across runs, so re-cutting an
"identical" tarball breaks every pinned client. Publish changes under a new
filename.
There is no config.json in either bundle. The fields a runtime needs
(sep_token_id, bos/eos/pad, sequence_length, label mapping) live in
conversion_meta.json.
Interface
| name | shape | dtype | notes | |
|---|---|---|---|---|
| in | input_ids |
[1, 8192] |
int32 | right-pad with 128001 |
| in | attention_mask |
[1, 8192] |
int32 | 1 real, 0 padding |
| out | logits |
[1, 8192, 2] |
float32 | index at `< |
argmax over the last axis: 1 = main, 0 = other.
Key token ids (also in conversion_meta.json):
| token | id |
|---|---|
| `< | sep |
| BOS `< | begin_of_text |
| EOS `< | end_of_text |
pad (config.pad_token_id) |
128001 |
<|sep|> is already in the tokenizer vocab (len(tokenizer) == vocab_size == 128257), so no embedding resize happens β do not add it as a new token.
Tokenizer sidecars are included: tokenizer.json, tokenizer_config.json,
special_tokens_map.json. Load them with swift-transformers or
tokenizers; the .mlpackage does no tokenization itself.
Deployment target: macOS 15 / iOS 18.
Parity with the Python reference
Verified against a live pulpie==0.0.2 service (torch 2.13.0, transformers
4.57.6) on a 4-page corpus of rendered HTML. The service's /classify
endpoint labels externally supplied simplified HTML, so both pipelines are
compared on byte-identical input with identical chunking (pulpie's own
pack_chunks builds the CoreML side).
| page | blocks | fp32 | fp16 |
|---|---|---|---|
| usaspending.gov β DOD FY2025 | 195 | 195/195 | 195/195 |
| data.cityofchicago.org β Building Permits | 193 | 193/193 | 192/193 |
| en.wikipedia.org β Retrieval-augmented generation | 483 | 483/483 | 477/483 |
| anthropic.com β Newsroom | 49 | 49/49 | 48/49 |
| total | 920 | 920/920 β 100% | 912/920 β 99.13% |
fp32 reproduces the reference exactly β every block label, and the emitted markdown byte-for-byte identical to the service's on all four pages.
For fp16, every one of the 8 disagreements is a near-tie: the reference's
own decision margin |logit_main β logit_other| at those blocks is 0.05β0.15,
against a median block margin of 2.7β7.2. Blocks the reference is confident
about agree universally.
Those 8 flips are attributable to the execution path, not to fp16 storage:
the same fp16 .mlpackage run under CPU_ONLY scores 49/49 on the Anthropic
page (vs 48/49 on ComputeUnit.ALL), with markdown matching the service. A
full CPU_ONLY sweep is not obtainable here β it segfaults partway through the
corpus at seq 8192 (see Limitations).
Run on Apple M1 Max, 64 GB, macOS 15.3, coremltools 9.0.
Conversion recipe
python3.12 -m venv .venv
.venv/bin/pip install coremltools==9.0 torch==2.13.0 transformers==4.57.6 \
huggingface_hub safetensors
Load exactly as pulpie.model_utils.load_model_and_tokenizer does β including
its fix_rotary_embeddings step, since EuroBERT registers inv_freq as a
non-persistent buffer that does not survive from_pretrained β then apply
three trace-time patches, torch.jit.trace, and convert.
Each patch below is asserted equivalent to the original in float32 before being applied; none of them changes the model's math.
1. rotate_half β bind the split point. It slices at x.shape[-1] // 2;
under trace that shape read becomes a tensor and emits
aten::floor_divide β aten::Int (48 of them), which coremltools cannot
constant-fold. The last dim is always head_dim=64, so the split is a
compile-time constant (32).
2. Attention mask β avoid .expand() and finfo.min. HF's
_expand_mask calls .expand() with traced shape scalars (more aten::Int),
and masks with finfo(float32).min β -3.4e38, which overflows to -inf in
fp16 β a fully-masked padding row then softmaxes to NaN, and because padded
positions remain keys/values for real queries, the NaN propagates into every
real token. Replaced with pure broadcasting and a mask value of -1e4: still
far below any real attention logit (masked weights underflow to exactly 0) but
finite in fp16.
3. RoPE β precompute cos/sin as a table. EuroBertRotaryEmbedding
computes inv_freq @ position_ids at runtime and forces float32 with
autocast(enabled=False), because at seq 8192 the angles reach ~8192 where
fp16's resolution is 4.0. compute_precision=FLOAT16 overrides that guard.
Building the table at conversion time moves the large-angle arithmetic offline;
the stored values are cos/sin outputs in [-1, 1], where fp16 is precise.
Precision: fp16 weights, fp32 RMSNorm reduction. A blanket
compute_precision=FLOAT16 produces garbage (measured logit correlation 0.07
vs the reference). EuroBertRMSNorm computes mean(xΒ²) over hidden_size=768
and the sum peaks near 84,000 in the deeper layers, past fp16's 65,504
ceiling β inf β rsqrt β 0, zeroing the normalized activations from layer 4
on. The reference avoids this by upcasting to fp32 inside RMSNorm, which
FLOAT16 overrides. Pinning just reduce_mean and rsqrt to fp32 fixes it at
no size cost β those ops carry no weights, while every weight-bearing op stays
fp16:
from coremltools.converters.mil.mil.passes.defs.quantization import FP16ComputePrecision
precision = FP16ComputePrecision(op_selector=lambda op: op.op_type not in {"reduce_mean", "rsqrt"})
Shape: fixed [1, 8192], deliberately not enumerated. EnumeratedShapes
buckets convert fine and are correct on CPU_ONLY, but on the accelerated path
(ComputeUnit.ALL, i.e. ANE/GPU) the fp16 model returns garbage β logits
uncorrelated with the reference, accompanied by mpsgraph bytecode errors, and
in some runs a hard EXC_BAD_ACCESS crash in
com.apple.coreml.MLE5ExecutionStream. Fixed shapes are correct on every
compute unit, so callers pad each chunk to 8192 and mask. (This is a runtime
backend bug on macOS 15.3 / coremltools 9.0, not a conversion error β the same
.mlpackage is exact under CPU_ONLY.)
mlmodel = ct.convert(
traced,
inputs=[ct.TensorType(name="input_ids", shape=ct.Shape((1, 8192)), dtype=np.int32),
ct.TensorType(name="attention_mask", shape=ct.Shape((1, 8192)), dtype=np.int32)],
outputs=[ct.TensorType(name="logits", dtype=np.float32)],
minimum_deployment_target=ct.target.macOS15, # <15 allows only ONE enumerated-shape input
compute_precision=precision,
convert_to="mlprogram",
)
Usage sketch
import numpy as np, coremltools as ct
from pulpie.chunker import extract_blocks, pack_chunks, tokenize_blocks
from pulpie.simplify import simplify
SEQ, SEP, PAD = 8192, 128256, 128001
ml = ct.models.MLModel("pulpie-orange-small-fp32.mlpackage")
simplified, map_html = simplify(raw_html)
blocks = extract_blocks(simplified)
preds = [0] * len(blocks)
for chunk_ids, block_indices in pack_chunks(
tokenize_blocks(blocks, tok), max_tokens=SEQ, sep_token_id=SEP,
bos_token_id=tok.bos_token_id, eos_token_id=tok.eos_token_id):
n = len(chunk_ids)
ids = np.full((1, SEQ), PAD, np.int32); ids[0, :n] = chunk_ids
am = np.zeros((1, SEQ), np.int32); am[0, :n] = 1
logits = np.asarray(ml.predict({"input_ids": ids, "attention_mask": am})["logits"])[0]
for i, bi in enumerate(block_indices): # SEP positions, in order
preds[bi] = int(logits[np.flatnonzero(ids[0, :n] == SEP)][i].argmax())
labels = {item_id: "main" if p == 1 else "other" for item_id, p in zip(item_ids, preds)}
Limitations
- Fixed 8192 sequence: short pages pay full-length attention. Chunks must be padded, not truncated.
- 8192 is also the chunking budget the reference uses; changing it changes chunk boundaries and therefore the labels a block sees context from.
- fp16 only: near-tie blocks (reference margin < ~0.2) may flip. Use the fp32 variant if you need exact agreement.
- Core ML runtime stability on this model is imperfect at seq 8192. Both
CPU_ONLYand enumerated-shape fp16 configurations have produced hardEXC_BAD_ACCESScrashes incom.apple.coreml.MLE5ExecutionStream.resetQueueon macOS 15.3. The shipped configuration (fixed shape,ComputeUnit.ALL) completed every run, but host apps should treat prediction as fallible and not assume a crash-free process. - Not verified on iOS or on Intel Macs.