Defense News Classifier
An LLM classifier for defense news, and the eval that graded every change.
Case study summary
- 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 declined three more complex approaches, and the simpler path stayed.
- Problem: Classify public defense news across category, operational domain, and region. A convincing demo is not proof of quality.
- Ownership: I built the classifier and the eval harness. I 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. Small apparent gains could therefore be noise.
- Actions: I replaced the synthetic eval with real text and I tested a classical baseline. I scaled a corroborating run to 300 examples. I measured retrieval, premium routing, and multi-agent review against held-out evidence.
The classifier labels public defense-news text on three axes. The axes are 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 applies all three labels. This page is the eval. It reports 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 from the DoD news wire and SEC filings. An Opus
judge cross-checked them. 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 that the remaining misses were
not scattered. They clustered on one boundary. The model read
technology as operations whenever a new system
was tested or demoed in an operational setting. It also over-assigned
land. Two targeted rubric clauses moved category from 90.7%
to 94.4% and domain from 90.7% to 92.6%. The 90.7%
start is the post-rubric eval snapshot the classifier recorded on
2026-07-17. It sits one row above the table’s 88.9% category
figure on the same 54 rows.
technology recall reached 1.000. Domain ended at 92.6%, one
row below the 94.4% column.
The lesson: the misses were a prompt problem. A stronger model did not move that boundary.
What the eval found
A doubled rule marks a decision that was reversed.
Reversed. The prompt that read better and performed worse
I sharpened the system prompt to separate procurement from
industry. procurement is a purchase from the
buyer's view, and industry is 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 move 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. I expected a clear win. The measured lift was +1.9% category accuracy, a net of one correct call in 54. On domain the lift was +0.0%: grounding fixed three calls and broke three. Lexical retrieval does not earn the cost of a move to embeddings here.
The comparison itself had a bug. I scored the new-prompt grounded arm against an old-prompt frozen baseline, and that inflated the lift. I rebuilt the baseline so both arms ran the same prompt, and the advantage disappeared. 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). It came out of the shipped path and out of the CI gate. The retrieval code, the corpus, and the measurement scripts stay in the repo, dormant and reproducible. The verdict is that 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. I scored it 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. The spend is therefore 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.
industry also 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 it picks 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.
The scaled eval
A couple of lucky rows can look like progress on n=54. So I scaled to 300 real snippets, and the Opus judge graded them. That roughly halves each interval: category to 93.3%, domain to 90.3%. Those corroborate the hand-graded numbers.
Plot 0295% 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, and the interval is what halves.
The caveat: the judge is the answer key here. This measures agreement with a model, and it inherits the judge's roughly 5–6% disagreement with humans. The source wire is also operations-heavy, which makes the category macro-F1 uninformative. The report states this.
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. I sent everything to the premium model, and it scored the same 94.4% category. So no router had any headroom.
Why it shipped as a no: the earlier prompt fix already took the ground routing was aimed at. One more finding stayed in the record: a new field in the schema flipped one benign snippet into a model refusal. The measurement apparatus changed the behavior it measured. The full verdict is in ADR-013. A separate project measured how reliable an LLM judge is, and it landed on the same verdict. Three independent measurements now state that 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, and it breaks the output contract on purpose.
Callers that expected two fields now get three. There are six labels, and
global is the single catch-all. It takes stories with no
geographic anchor, and stories that span several. A split of “no
geography” from “worldwide” would have put the fuzziest
boundary on the most common kind of story in the corpus. I wrote down the
label set, the boundary rules, and the scope limits 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. The model pulled each one to a specific region,
usually americas. The rule is to label the theater the
activity happens in, and only when the text names it. The model repeatedly
saw a US actor and inferred a US theater.
Region now reads 94.4%. The fix is one bullet of prompt. It tells the model that a named US command or contractor identifies the actor, not a place. The identical clause shipped in two rounds, and the second round used a ruler twice the size. The first experiment did not have the power to decide.
docs/specs/global-boundary-clause-rerun.md at v3.2.1
(263ae66). The axis carries the p-value only. It does not
show the effect size, the two axes the clause was not aimed at, or the
human-graded arm. americas rows to
global. The three come from
evals/gold_confusion_v3.md at v3.2.1 (263ae66). The plate shows the direction of a miss and not
the count. It does not show the rows the clause left correct. The over-calls are the collateral, and I priced them before the run. Six under-calls traded for two over-calls is the shape of the win.
Judge-versus-human agreement on region is the measured condition that gated the scaled eval. It fell from a perfect 100.0% to 96.3%. That is 2 disagreements in 54, against an eval-gate floor that budgets 3. Four 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. A reach for a tool does not move a level. A decision does.
- L1 a human runs it Shipped
- L2 + a tool Retired
- L3 it decides it’s done Shipped
- L4 agents coordinate Declined
I climbed L2 and then climbed back down. The retrieval grounding above is that rung, retired. L3’s optimisation loop improved against the split it could see, and it gave ground on held-out gold. The set it never saw vetoed it.
L4 fixed six of the seven rows it was aimed at, and I still declined it. Outside its target it broke more than it fixed. That was the repo’s first statistically significant harm. Two of the four levels shipped a “no,” and I wrote each one down before I built it. The full ladder, level by level → Replay the two autonomous runs →
Stack
The stack is Python and the Anthropic SDK, with Sonnet 5 for classification and Opus as the judge that grades. pandas builds the eval tables, FastAPI serves the HTTP endpoint, and rank-bm25 ran 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. pytest tests the code on mocked calls. The repo is public: github.com/sanlee-ys/defense-news-classifier.
The eval harness, the gold set, and the ADRs are checkable. That includes the experiments I 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.