GUESTBOOK.EXEcontact.exe
DISTILLATION

</distillation>

DISTILLATION

A 4B model I trained agrees with its 35B teacher on 85.4% of headlines

0.840

macro-F1 agreeing with the 35B teacher, on 500 headlines held out before a label existed

0.337

macro-F1 for the keyword classifier in production today, against the same teacher labels

2.4x

lower latency than the teacher, warm and sequential — 322 ms vs 782 ms p50

41.3x

cheaper per request — list-price arithmetic over measured token counts, never measured spend, and 8.3x if the 4.21B student is billed one parameter tier up

SPECIFICATIONS

ROLESOLO BUILD
YEAR2026
TYPERESEARCH
STATUSPUBLIC REPO · MIT
STACKpython · mlx · mlx-lm · lora · qwen3.5 · ollama · pytest · matplotlib +1 more
LINKS[github ↗]
AVAILABILITYsource on GitHub

Sentinel sorts every headline it ingests into one of eight topic classes, and the thing doing the sorting is a keyword regex whose if-chain returns on first match.

337 for the keyword regex doing that job in Sentinel's backend today.Three arms (the incumbent regex, the teacher, the student) are scored on the same 500 headlines, split before a single label was generated, by one harness.

README.TXT: DISTILLATION (13 KB)[full readme →]

== WHAT IS THIS ==

────────────────────────────────────────

A 4B open model, LoRA fine-tuned on a laptop, reproduces a 35B teacher's news-topic judgement on 85.4% of held-out headlines — 0.840 macro-F1, against 0.337 for the keyword regex doing that job in Sentinel's backend today. Three arms (the incumbent regex, the teacher, the student) are scored on the same 500 headlines, split before a single label was generated, by one harness. The gold labels are the teacher's own output, so the figure is agreement rather than correctness; the ceiling is a 50-example hand audit at 84%, measured and published before any student existed. Public MIT repo with every result committed as an artifact.

== </the problem> ==

────────────────────────────────────────

Sentinel sorts every headline it ingests into one of eight topic classes, and the thing doing the sorting is a keyword regex whose if-chain returns on first match. The order of the branches decides the answer, so "Amazon Prime Day" is tech, "SpaceX launch" is tech, and any headline containing `china` is geopolitics regardless of what it is about. Everything that matches no keyword falls through to `general` — 74.2% of the corpus. The obvious fix is to call a frontier API on every headline, which costs money forever, adds a network round trip to something that has to be fast, and breaks when somebody else's service does. This project measures the other option: train a small model to do the job.

role & context

Solo: the corpus harvester, the teacher labelling client, the scorer, the training config, the evaluation harness, the charts and the write-up. The incumbent it is measured against is my own production code, which is what made a faithful port — defects intact — the only honest baseline.

== </my approach> ==

────────────────────────────────────────

Rebuild the corpus from the same 63 public RSS feeds the product already reads — 3,706 headlines across 54 outlets, zero credentials, zero user data. Split off 500 held-out examples and freeze them before generating a single label. Label the rest with a 35B open-weight teacher running locally through Ollama, hand-audit 50 of its answers to establish the ceiling before training anything, then LoRA fine-tune a 4B open model on those labels on the same laptop. Score three arms — the incumbent regex ported with its defects intact, the teacher, and the student — through one harness on the identical held-out 500, and report quality, cost and latency for all three.

== </the story> ==

────────────────────────────────────────

Sentinel files every headline it ingests into one of eight topic classes, and it does that with a keyword regex — a first-match-wins if-chain in the production backend. It is broken in ways you can see just by reading it. `amazon` sits in the tech rule ahead of the consumer rule, so "Amazon Prime Day" is tech and the consumer rule's own `amazon prime` keyword can never be reached. `spacex` appears in both tech and science, and tech is checked first. Any headline containing `china` is geopolitics forever. Everything unmatched becomes `general`, which is most things: 74.2% of this corpus, and 375 of the 500 held-out headlines.

The obvious fix is to call a frontier model on every headline. That costs money on every request forever, puts a network round trip in front of something that has to be fast, and hands your uptime to somebody else. So the project measured the other option instead: have a big open model label the data, and train a small one to do the job by itself.

The corpus was rebuilt from the same 63 public RSS feeds the product already reads — 3,706 headlines across 54 outlets, no database access, no user data, no API keys. 500 were split off and frozen before a single label was generated. A 35B open-weight teacher (Apache-2.0, run locally through Ollama) labelled the rest at 0.00% unparseable output. Fifty of its answers were then graded by hand, before any training, and it agreed with a human 84% of the time — the ceiling on everything downstream, measured first rather than assumed.

A 4B open model was then LoRA fine-tuned on those labels, on the laptop, and all three arms were scored through one harness on the same held-out 500. The student reaches 0.840 macro-F1 and agrees with its teacher on 85.4% of headlines, at 2.4x lower latency and — on list prices applied to measured token counts, never measured spend — 41.3x lower cost. The regex it would replace scores 0.337 and cannot emit the `consumer` class even once in 500 headlines.

Getting there took two training runs. The first died at roughly iteration 1,170 of 1,200 in a macOS GPU-driver kernel panic that took its log with it; it was retrained from scratch rather than resumed, because resuming restarts the iteration counter and yields a curve that cannot be honestly plotted. The shipped weights are not the final ones either: iteration 800 beat iteration 1200 by 8.0 macro-F1 points, chosen on a validation split by a module that cannot read the test set. Both evaluations are committed.

== </architecture> ==

────────────────────────────────────────

One module per stage under src/, each runnable on its own, wired together by a single reproduce command that regenerates every result and chart the README cites — and deliberately does not train, because a 70-minute GPU job with its own failure modes should be launched on purpose rather than as a side effect of asking for a chart.

feeds.py holds 63 production feeds plus 83 same-outlet sections. harvest.py / rss.py / store.py fetch them asynchronously and dedup on normalised URL. split.py draws the held-out 500 once, freezes membership, and asserts disjointness rather than assuming it, so a harvester that keeps adding rows can never contaminate the evaluation set. teacher.py drives Qwen3.5-35B-A3B (Q4_K_M) through Ollama with schema-constrained decoding and parser guards that mark unparseable output rather than coercing it to the majority class. prepare_training.py owns student_messages(), the single shared prompt shape, so training and inference cannot drift apart. configs/lora.yaml pins the base model by revision SHA and feeds mlx_lm.lora at r=16, bf16, 16 of 32 layers, mask_prompt on — the adapter is 0.096% of the model's parameters. regex_baseline.py is the faithful port, compiled with re.ASCII to match JavaScript's word-boundary semantics. evaluate.py is one harness for all three arms, which is what makes the comparison a measurement rather than three separate stories.

Sequencing was dictated by 34 GB of free disk, not by RAM. The naive ordering needs about 37 GB — 19 GB of teacher, 8 GB of base model, 8 GB merged, plus the environment — so the plan deletes the teacher before the student's base model is pulled. That is why teacher latency had to be measured while its weights were still resident: sequentially, one request at a time, warm, over the same held-out 500, never inferred from batched labelling throughput.

== </key features> ==

────────────────────────────────────────

The incumbent is an arm, not a strawman

The production classifier is ported faithfully with all six of its defects preserved, each pinned by a passing test so a future fix fails loudly. Benchmarking only against the teacher would have been picking the easy opponent.

The teacher is never scored against itself

Gold is the teacher's output, so any teacher accuracy figure would be 100% by construction. That number appears nowhere in the repo. A 50-example hand audit — 84% strict, 93% excluding genuinely ambiguous cases — is published in its place, and was adjudicated before a student existed.

A frozen held-out set and a frozen scorer

The evaluation split is frozen on first draw and disjointness is asserted in code, so a harvester that keeps adding rows cannot leak into it. The label schema and the macro-F1 implementation were committed in the sprint before the first label was generated.

Checkpoint selection that provably cannot see the test set

The shipped adapter is iteration 800, chosen on a 160-example validation split by a module that never opens the held-out file, with the full ranking and the constraint recorded in selection.json. It is worth +8.0 macro-F1 points over mlx-lm's default final weights — and the worse evaluation is committed next to the better one.

Cost measured with the real tokeniser, and caveated in the artifact itself

Every held-out prompt is rendered through the exact builder each arm used and tokenised with the pinned tokeniser. The teacher needs ~303 input tokens of class definitions per call; the student needs ~36, because after training the task is in the weights rather than the prompt. The summary file carries the list-price disclaimer and a sensitivity table, so the pessimistic read travels with the headline.

Provenance on every result

summary.json records the base model, its pinned revision SHA, the adapter's SHA-256, hashes of the held-out inputs and labels, the git commit and a dirty flag, so no published number traces back to an unknown snapshot.

== </key decisions> ==

────────────────────────────────────────

DECISION 01

Three arms, not two. The regex is the actual incumbent. Benchmarking only against the teacher would have been choosing the flattering opponent, and the rule was set in advance: if the student loses to a keyword regex anywhere, that leads the write-up. It loses one — `general` recall — and that sentence is in the README.

DECISION 02

An open-weight teacher, run locally. Training on a closed frontier model's output and then publishing the resulting weights would breach its terms, making the only real deliverable unpublishable. Apache-2.0 weights on the same laptop cost $0 and keep the result path clean.

DECISION 03

Macro-F1 over all eight classes, not over the classes present in the data. The library default silently forgives a class the model never learned — 0.7222 against this implementation's 0.2708 on the same fixture. A distilled model dropping a tail class entirely is exactly the failure this project exists to surface, so it must count as a zero. Written from first principles in ~60 lines so the choice is visible in the repo rather than inherited.

DECISION 04

The schema and the scorer were frozen before the first label existed. Choosing a metric after seeing its result is the easiest way to manufacture a favourable one. Both were written, tested and committed a sprint early.

DECISION 05

The teacher is never scored against gold. Gold is the teacher, so that figure would be 100% by construction; it appears nowhere in the repo. A 50-example hand audit — 84% strict, 93% excluding ambiguous cases — is reported instead, and it was adjudicated before any student existed so it could not be tuned.

DECISION 06

Unparseable outputs are marked and reported, never coerced. Coercing them to `general` would bias the teacher toward the majority class and corrupt the ceiling everything else is measured against. The scorer raises if it ever sees one, so the rule is enforced by code.

DECISION 07

The student gets a lean prompt, reversing an earlier decision to hold the prompt constant. The original reasoning was backwards: a distilled student is meant to stop needing the instructions, and making it re-read 262 tokens of class definitions would understate the win in the exact place the project measures it. Both arms still see identical information and the identical 500. The sensitivity table publishes what the old decision would have cost — 5.1x instead of 41.3x.

DECISION 08

Ship the best-validation checkpoint. Iteration 800 beat the final iteration by 8.0 macro-F1 points. Selecting on the held-out set would leak it, so the selection module never opens that file and records the ranking and the constraint in selection.json — the guard is that it cannot read the test set, not that someone remembered the rule. The final-checkpoint evaluation is committed alongside, so nobody has to wonder what the unselected weights looked like.

DECISION 09

MLX, LoRA r=16, bf16, no QLoRA. Unsloth and bitsandbytes are CUDA-only and this is an M4 Pro; TRL/peft on MPS is slow and flaky. At 4B in bf16 the memory is there on 48 GB unified, so QLoRA buys nothing and is advised against for this model family because of quantization artifacts.

== </what i learned> ==

────────────────────────────────────────
>

Checkpoint choice was worth more than most hyperparameters. The default final weights were 8.0 macro-F1 points worse than iteration 800, and only the validation curve caught it — the last 200 iterations raised training and validation loss together, which is an optimisation excursion rather than overfitting.

>

Auditing the thing you intend to beat is where the findings are. Porting the regex surfaced a defect nobody had listed: bare-noun keywords plus a word boundary mean `russia` never matches "Russian", and headlines use the adjectival form constantly.

>

Publish the ceiling before you publish the result. Hand-auditing 50 teacher labels first meant the 84% could not be tuned to flatter the student, and it predicted the student's worst confusion pair before a student existed — the taxonomy has no `politics` class, which is an irreducible noise floor for every arm.

>

Check your own favourable numbers hardest. "45.4x cheaper" survived until the token constants were re-measured against the real prompts; two of the four were wrong in the direction that flattered the result. The correction is in the git history.

>

Small models fail silently. An architecture probe run before training caught the chat template opening a thinking block at inference that the training data closed — the adapter answered "Thinking Process:" on every test case, and it would have looked like a training failure rather than a template mismatch.

next:

Two things would close the gaps a reviewer can legitimately attack, and both are named in the repo's own limitations. A second training seed costs nothing but local GPU time and would put a variance estimate on 0.840, which one run cannot. And the hand audit found the eight-class taxonomy has no home for domestic politics, which is an irreducible noise floor for every arm — a ninth `politics` class is a real, actionable finding for the product, and would likely improve the incumbent regex too. Publishing the adapter and the labelled dataset is deferred, not declined.

python · mlx · mlx-lm · lora · qwen3.5 · ollama · pytest · matplotlib · uv

BUILD.TXT: DISTILLATION (8 KB)[the build record →]

== </what the audit found> ==

────────────────────────────────────────

The thing being replaced was audited before anything was trained. Sentinel's production topic classifier is a first-match-wins keyword if-chain, and it is wrong in ways you can read rather than measure: `amazon` sits in the tech rule ahead of the consumer rule, so "Amazon Prime Day" files as tech and the consumer rule's own `amazon prime` keyword is unreachable dead code; `spacex` appears in both the tech and science rules and only tech can ever win; any headline containing `china` is geopolitics forever; and anything matching no keyword falls through to `general`, which on this corpus is 74.2% of it. The port in src/regex_baseline.py keeps every defect intact and tests/test_regex_baseline.py pins each one with a passing test, so a later "fix" fails loudly instead of quietly turning the baseline into a different program. On the held-out 500 the incumbent sends 375 headlines to `general`; the teacher moves 310 of those somewhere else.

== </what was repaired> ==

────────────────────────────────────────

The audit turned up a sixth defect nobody had listed. Every keyword in the chain is a bare noun and the word boundary demands a non-word character after it, so `russia` never matches "Russian", `china` never matches "Chinese", `israel` never matches "Israeli" — and headlines use the adjectival form constantly, so every one of them lands in `general`. Porting it faithfully also needed re.ASCII: JavaScript's word boundary is ASCII-based and Python's is Unicode-aware, so without the flag the incumbent arm would have been quietly weaker on accented text than the code actually running in production, on a corpus that includes DW, France24, SCMP and Haaretz. Three more failures were silent rather than loud. An architecture probe run before training caught Qwen3.5-4B opening a thinking block at inference while the training data carried a closed one — the adapter answered "Thinking Process:" for all five test cases, and the same adapter scored 5/5 once enable_thinking=False reproduced the training prefix byte for byte. Ollama defaulted the 35B teacher to a 32,768-token context for ~250-token prompts and drove the machine into 7.7 GB of swap, taking free disk from 12 GB to 4 GB mid-run; pinning num_ctx to 2048 stopped it, and the 60 examples labelled before the change were re-labelled to prove it moved no label (60/60 identical). And the first training run died at roughly iteration 1,170 of 1,200 in a macOS GPU-driver kernel panic that took its stdout log with it — rather than resume from a checkpoint, which restarts the iteration counter and produces a curve that cannot be honestly plotted, it was retrained from scratch with the log written inside the repo. One design choice removed a whole class of this: held-out membership is frozen on the first split rather than re-drawn, so a background harvester that keeps adding rows cannot leak into the evaluation set — impossible by construction rather than by remembering the rule.

== </what was measured> ==

────────────────────────────────────────

On the same 500 held-out headlines, the student reaches macro-F1 0.8400 / accuracy 0.8540 against the incumbent's 0.3372 / 0.3420, with zero unparseable outputs from either. It wins all eight classes on F1 and loses exactly one on recall: `general`, where the regex scores 0.985 only because it answers `general` three times in four, at 0.173 precision. Head to head, the student is right where the regex is wrong 285 times; the regex is right where the student is wrong 29 times. The regex never emits `consumer` once in 500 headlines — precision, recall and F1 all exactly 0.0. Two measurements changed what shipped. Selecting the best-validation checkpoint (iteration 800) over mlx-lm's default final weights (iteration 1200) is worth +8.0 macro-F1 points, 0.8400 against 0.7599, and both evaluations are committed rather than only the flattering one. And the last 200 iterations were an optimisation excursion rather than overfitting: validation loss went 0.075 to 0.280 while mean training loss rose with it, 0.071 to 0.256 — 1,200 iterations was simply too many. The cost model was also wrong before it was right: all four token constants were wrong when re-tokenised over the real 500 held-out prompts, moving the published headline from 45.4x to 41.3x cheaper. Teacher reproducibility is evidenced three ways — 100/100 unanimous at temperature 0, 60/60 across a mid-run config change, and 500/500 on an independent re-prediction of the whole held-out set.

== </decisions> ==

────────────────────────────────────────

DECISION 01

Three arms — the incumbent regex, the teacher, the student — not two

because The regex is what actually runs in production. Benchmarking only against the teacher would have been choosing the flattering opponent, and the acceptance rule was written up front: if the student loses to a keyword regex anywhere, that is the finding and it leads the write-up. It does lose one, on `general` recall, and that sentence is in the README.

DECISION 02

Open-weight teacher only — Qwen3.5-35B-A3B, Apache-2.0, run locally

because Training on a closed frontier model's output and then publishing the resulting weights would breach its terms, which would make the one deliverable that matters unpublishable. An open teacher costs $0, runs on the same laptop, and keeps the whole result path clean.

DECISION 03

Macro-F1 over all eight classes, not over the classes present in the data

because The library default silently forgives a class the model never learned — 0.7222 against this implementation's 0.2708 on the same fixture. A distilled small model dropping a tail class entirely is precisely the failure this project exists to surface, so it has to count as a zero. Written from first principles in ~60 lines so the choice is visible rather than inherited from a default.

DECISION 04

The label schema and the scorer were frozen and committed before a single label existed

because Choosing a metric after seeing what it says about you is the easiest way to manufacture a favourable one. Both were written, tested and committed in the sprint before the teacher was pulled.

DECISION 05

The teacher is never scored against gold

because Gold IS the teacher's output, so "teacher accuracy" would read 100% by construction. Printing a constructed 100% next to a real student number would be the single most misleading thing this project could publish; a 50-example hand audit at 84% strict agreement is reported instead — and it was adjudicated before any student existed, so it could not be tuned to flatter one.

DECISION 06

The student gets a lean 32-token prompt, reversing an earlier decision to hold the prompt constant across arms

because The original reasoning was backwards. A distilled student is supposed to stop needing the instructions; making it re-read 262 tokens of class definitions would have understated the distillation win in the exact place the project measures it. Both arms still see identical information and the identical held-out 500, so quality is unaffected — and the sensitivity table publishes what the old decision would have cost: 5.1x instead of 41.3x.

DECISION 07

Unparseable teacher outputs are marked and reported, never coerced to `general`

because Silently coercing them would bias the teacher toward the majority class and corrupt the ceiling everything downstream is measured against. The scorer raises if it ever sees one, so the rule is enforced by code rather than documented in a comment.

DECISION 08

Ship the best-validation checkpoint, chosen on the validation split alone

because The final iteration was 8.0 macro-F1 points worse, so checkpoint choice was worth more than most of the hyperparameters. Choosing it on the held-out set would leak that set, so src/select_checkpoint.py never opens the file and records the ranking, the chosen iteration and that constraint in selection.json. The guard is that the module cannot read the test set, not that somebody remembered not to.

DECISION 09

MLX and LoRA r=16 in bf16, no QLoRA

because Unsloth and bitsandbytes are CUDA-only and this is an M4 Pro; TRL/peft on MPS is slow and flaky. At 4B in bf16 the memory is available on 48 GB unified, so QLoRA buys nothing here and is advised against for this model family because of quantization artifacts.

DECISION 10

Publish the final-checkpoint evaluation next to the shipped one

because Reporting only the checkpoint that was selected for being best invites the reader to wonder what the others looked like. results/summary_final_checkpoint.json answers it without them having to ask.

DECISION 11

Rebuild the corpus from public RSS with zero credentials and zero user data

because It reproduces the production distribution without touching the product's database or any user-generated content, so there is no privacy disclosure attached to the repo and nothing was blocked waiting on a key.

LIMITS.TXT: DISTILLATION (2 KB)[where it doesn't work →]

== </where it doesn’t work> ==

────────────────────────────────────────

The gold labels are the teacher's own output, so 85.4% measures agreement with the teacher, not correctness — the hand audit puts the teacher's own agreement with a human at 84%, which is a ceiling and not a rival figure, and the two must never be read as the student beating its teacher. The student's real weakness is `consumer` recall at 0.529 — the smallest class at ~190 training examples, exactly the tail-class cost that was written down in advance when the corpus target was lowered from 5,500 to 3,500. It also loses to the incumbent regex on `general` recall, 0.682 against 0.985, though the regex earns that by answering `general` for three quarters of everything at 0.173 precision. There is one training run, one seed, one machine and one 500-example held-out draw, so there is no variance estimate at all: treat the third decimal place as noise, and do not read any of this as a robustness or generality result. It is headline-only and English-only despite a corpus that includes DW, France24, NHK and SCMP. The cost figures are list-price arithmetic over measured token counts and not measured spend — everything ran locally at $0 — and 41.3x is the optimistic read against 8.3x if the 4.21B student is billed one parameter tier up. Nothing here is deployed: the trained adapter is gitignored and undistributed, the labelled dataset is local, the incumbent's original TypeScript lives in a private backend so only the port and its tests are inspectable, and while one command regenerates every result, a stranger cloning the repo would first have to re-harvest the feeds and retrain, because data/ and models/ are deliberately not in git.

PUBLIC REPO · MIT