Defense News Classifier
An LLM classifier for defense news, and the eval that graded every change.
Case study summary
- Problem: Classify public defense news across category, operational domain, and region without treating a convincing demo as proof of quality.
- Ownership: I built the classifier and evaluation harness, labeled the gold set, defined the merge gate, and made the ship-or-cut decisions.
- Constraints: The initial 300-row synthetic set graded model-written examples; the replacement human-labeled set had only 54 rows, so small apparent gains could be noise.
- Actions: I replaced synthetic evaluation with real text, tested a classical baseline, scaled a corroborating run to 300 examples, and measured retrieval, premium routing, and multi-agent review against held-out evidence.
- Result and evidence: The shipped single-call classifier scores 94.4% category, 98.1% domain, and 94.4% region accuracy on the current gold. The gate rejected three more complex approaches, preserving the simpler path.
This labels public defense-news text on three axes: category (procurement, operations, policy, technology, industry), operational domain (air, land, sea, cyber, space, multi), and region (indo-pacific, europe, middle-east, africa, americas, global). One model call does the labeling. This page is the eval: what the gold set did to the numbers, and which upgrades failed it.
The number fell when the gold got real. Same model, harder test.
Gold and versions
v1 graded the model on 300 snippets it generated itself.
industry recall was 0.217. v2 replaced that set with 54
hand-labeled real snippets (DoD news wire plus SEC filings), cross-checked
by an Opus judge. On that gold, industry F1 went to 1.000.
| Metric | v1 synthetic | v2 real (Sonnet 4.6) | v2 + Sonnet 5 | v2 + prompt fix | v3 three-axis (current, v3.2.1) |
|---|---|---|---|---|---|
| Category accuracy | 79.0% | 88.9% | 88.9% | 94.4% | 94.4% |
| Category macro-F1 | 0.765 | 0.906 | 0.888 | 0.950 | 0.930 |
| Domain accuracy | 97.3% | 88.9% | 94.4% | 92.6% | 98.1% |
| Domain macro-F1 | 0.973 | 0.894 | 0.947 | 0.932 | 0.982 |
| Region accuracy | — | — | — | — | 94.4% |
| Region macro-F1 | — | — | — | — | 0.975 |
Every column after the first is the same 54 hand-labeled snippets. In July 2026 the workhorse moved from Sonnet 4.6 to Sonnet 5 on that set: domain rose to 94.4%; category did not move.
The ceiling that wasn’t
A per-cell confusion breakdown showed the remaining misses were not
scattered. They clustered on one boundary: technology read
as operations whenever a new system was tested or demoed in
an operational setting, plus a habit of over-assigning land.
Two targeted rubric clauses moved category from 90.7% to
94.4% and domain from 90.7% to 92.6%, with
technology recall reaching 1.000 and nothing regressing.
The lesson: the misses were a prompt problem. A stronger model did not move that boundary.
What the eval caught
Reversed. The prompt that read better and performed worse
I sharpened the system prompt to draw a cleaner line between
procurement (a purchase, the buyer's view) and
industry (a company's own business news). Category accuracy
fell from 79.0% to 76.7%, and industry recall dropped from
0.217 to 0.100. The sharper rule gave the model a cleaner excuse to dump
borderline company stories into procurement. I reverted it.
The lesson: the prompt read cleaner. The numbers did not. Reverted.
Reversed. The grounding that did not pay
I added BM25 lexical retrieval to ground each classification in similar documents, expecting a clear win. Measured lift: +1.9% category accuracy (a net of one correct call in 54) and +0.0% on domain, where grounding fixed three calls and broke three. Lexical retrieval does not earn the cost of moving to embeddings here.
The comparison itself had a bug: I was scoring the new-prompt grounded arm against an old-prompt frozen baseline, which inflated the lift. Rebuilding the baseline so both arms ran the same prompt killed the advantage. A three-pass confirm settled it: across 162 grounded classifications, grounding fixed a domain call zero times and broke four.
So I retired it (ADR-012): out of the shipped path, out of the CI gate. The retrieval code, the corpus, and the measurement scripts stay in the repo, dormant and reproducible. The verdict: BM25 does not justify itself.
The lesson: the first measurement said skip embeddings. A fair baseline said cut BM25. The comparison only holds if exactly one thing changes.
Why an LLM at all
Does this task need an LLM? I built the boring baseline: TF-IDF into logistic regression, standard and untuned, scored against the same 54 hand-labeled rows as everything else on this page.
The LLM won by 20 points on category and 26 on domain
(paired exact McNemar, p=0.013 and p=0.0005). The baseline collapses toward
whichever class is lexically loud, so the spend is for the boundaries that
need reading comprehension. Two handicaps cut against the baseline: its
training labels came from a judge that disagrees with humans 5–6% of
the time, and industry had exactly one training row. A margin
this size survives them.
The full bake-off, handicaps included →
The baseline runs with no key and no server. It is vendored into this site and classifies live in your tab. Pick a row it gets wrong and watch which class lights up instead. Run the losing baseline →
Scale and routing
Fifty-four hand-labeled snippets is a small set. The 95% interval on that gold is about 13 points wide. Two follow-ups sit on that.
Scaling the eval
A couple of lucky rows can look like progress on n=54. So I scaled to 300 real snippets graded by the Opus judge, which roughly halves each interval — category to 93.3%, domain to 90.3%. Those corroborate the hand-graded numbers.
95% Wilson intervals on an 80–100% axis. Wide bar: the n=54 human-graded gold the scaled run was checked against — category [84.9, 98.1], domain [82.5, 97.1]. Narrow bar: the n=300 judge-graded run — category [89.9, 95.6], domain [86.5, 93.2]. The point estimates barely move; the interval is what halves.
The caveat: the judge is the answer key here, so this measures agreement with a model and inherits its roughly 5–6% disagreement with humans. The source wire is also operations-heavy, which makes the category macro-F1 uninformative. The report says so.
Reversed. The upgrade that bought nothing
The roadmap called for tiered routing: escalate the uncertain cases to the more expensive model. I built the trigger, measured it, and shipped the negative result. Routing moved +0 rows on both axes at about 1.97× the cost per article. Of nine escalations on the gold set it fixed zero and broke one, and even sending everything to the premium model scored the same 94.4% category, so there was no headroom for any router to capture.
Why it shipped as a no: the earlier prompt fix had already taken the ground routing was aimed at. One more finding stayed in the record: adding a field to the schema flipped one benign snippet into a model refusal, so the measurement apparatus itself changed the behavior being measured. Full verdict in ADR-013. A separate project, measuring how reliable an LLM judge is, later landed on the same verdict. Three independent measurements now say escalation barely pays.
The third axis
All seven first-pass misses were this shape. Naming a US command identifies the actor, not a theater.
v3 added region, which breaks the output contract on purpose:
callers that expected two fields now get three. Six labels, with
global as the single catch-all for stories with no
geographic anchor and stories spanning several. Splitting “no
geography” from “worldwide” would have put the fuzziest
boundary on the most common kind of story in the corpus. The label set,
the boundary rules, and the scope limits were written down before the
build in
ADR-014.
Region landed at 87.0% against fresh hand labels when the axis shipped.
All seven first-pass misses were stories whose true label
is global that the model pulled to a specific region, usually
americas. The rule says label the theater the activity
happens in, and only when the text says so. The model kept seeing a US
actor and inferring a US theater.
Region now reads 94.4%. The fix is one bullet of prompt telling the model that naming a US command or contractor identifies the actor, not a place. It shipped in two rounds: measured once and reverted at p=0.0522 against a pre-registered p<0.05, then re-run against a ruler twice the size, where the identical clause landed at p=0.0002 and shipped. The bar never moved. The first experiment did not have the power to decide.
Three region misses remain instead of seven, and the error mode inverted:
only one is still the old cluster, and the other two are the converse
— americas rows the clause over-called to
global. That collateral was priced before the run. Five
under-calls traded for two over-calls is the shape of the win.
Judge-versus-human agreement on region, the measured condition that gated the scaled eval, fell from a perfect 100.0% to 96.3%. That is 2 disagreements in 54 against a CI floor budgeting 3. Three disagreements would have stopped the release.
The decisions
Decision · ADR-002
Force structured output with tool-use, not prompt parsing
The model is called with a forced tool schema (all three fields required,
strict enums), so an invalid label is rejected at the API layer before it
reaches application code. When v3 added region, it was one
more required strict enum. The schema is where a new axis goes.
Why: the schema becomes the contract, and the app code stays free of defensive parsing. Tradeoff: tool-use costs a few extra tokens and a more verbose call signature than plain chat. Out-of-enum responses were rare (once in 300) and a single re-sample handled them.
Decision
One model call, not a multi-step pipeline
Classification is a single call, not a generate-reasoning-then-classify chain.
Why: a single call is a cleaner baseline for the eval. Tradeoff: gives up any lift a reasoning step might add, which the eval can revisit later.
Decision · ADR-004
Implement the metrics by hand
Precision, recall, F1, and confusion matrices are computed directly with pandas and arithmetic, no ML framework.
Why: hold off on ML tooling until it
earns its place. It keeps the dependency surface to
anthropic plus pandas.
Tradeoff: about 40 lines that
sklearn.metrics would replace, accepted for clarity.
Decision · SYS-005 (system-level)
In-process, idempotent writeback to the notes API
As a component of the larger system, the classifier is invoked by a FastAPI BackgroundTask in notes-api and writes labels back as namespaced tags with replace semantics, so reprocessing the same note converges instead of piling up duplicate tags.
Why: replace semantics make reprocessing safe by design, so idempotency is a requirement. Tradeoff: the background task carries real retry and error-handling logic. See The System for the full loop.
Autonomy ladder
This classifier is also the portfolio’s autonomy ladder. Each level is the same system handed more self-direction. The axis is who drives. Reaching for a tool does not move a level; deciding does.
L1 a human runs it → L2 + a tool → L3 it decides it’s done → L4 agents coordinate
L2 was climbed and then climbed back down: the retrieval grounding above is that rung, retired. L3’s optimisation loop improved against the split it could see while giving ground on held-out gold, so it was vetoed by the set it never saw. L4 fixed six of the seven rows it was aimed at and was still declined: outside its target it broke more than it fixed, the repo’s first statistically significant harm (p=0.016) at four times the cost. Two of the four levels shipped a “no,” and each was written down before it was built. The full ladder, level by level → Replay the two autonomous runs →
Stack
Python, the Anthropic SDK (Sonnet 5 for classification, Opus as the grading judge), pandas for the eval tables, FastAPI for the HTTP endpoint, and rank-bm25 for the retired grounding experiment. scikit-learn appears in the eval dependencies only, for the classical baseline the model was measured against; nothing in the shipped path uses it. Tested with pytest on mocked calls. The repo is public: github.com/sanlee-ys/defense-news-classifier. The eval harness, gold set, and ADRs are checkable, including the experiments that were measured and cut.
What it demonstrates
The gold set is 54 rows. An eval gates the merge. That method declined retrieval three ways, premium-model routing, and a multi-agent review pipeline. The production system stayed a single well-prompted call, and each “no” is a checkable artifact. The floor got the same treatment: the classical baseline lost by 20 points.