← Back

Defense News Classifier

An LLM classifier for defense news, and the eval that graded every change.

Case study summary

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.

Plate 01 The eval rebuild: v1 self-graded 97.3 percent domain; v2 hand-labeled 88.9 percent. The drop is the trustworthy reading. V1 SYNTHETIC 97.3% domain, self-graded V2 REAL 88.9% domain, 54 gold READING FLATTERING TRUSTWORTHY

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.

Metricv1 syntheticv2 real (Sonnet 4.6)v2 + Sonnet 5v2 + prompt fixv3 three-axis (current, v3.2.1)
Category accuracy79.0%88.9%88.9%94.4%94.4%
Category macro-F10.7650.9060.8880.9500.930
Domain accuracy97.3%88.9%94.4%92.6%98.1%
Domain macro-F10.9730.8940.9470.9320.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.

Contents

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.

Contents

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 →

Contents

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 02 80 85 90 95 100 Category accuracy n=54 · 94.4% n=300 · 93.3% Domain accuracy n=54 · 92.6% n=300 · 90.3%

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, 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.

Contents

The third axis

Plate 03 US actor inferred as americas. Gold was global. Actor is not a place. ACTOR US command named in text INFERRED americas no place stated GOLD: global actor is not a place

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.

Plot 04 One pre-registered bar. The first run missed it and reverted. The re-run cleared it and shipped. PRE-REGISTERED BAR FIRST RUN REVERTED RE-RUN SHIPPED less evidence more evidence
The same clause, measured twice against one pre-registered bar at p<0.05. The bar never moved. Round one reverted at p=0.0522. Round two landed at p=0.0002 and shipped. Geometry from 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.
Plate 05 The miss direction inverted. One old-cluster miss is left, and two run the other way. FIRST PASS gold global → a region one direction NOW gold global → americas the old cluster gold americas → global the converse
Seven region misses became three, and the mode inverted. One is still the old cluster. Two are the converse, where the clause over-called 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.

Contents

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.

Contents

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.

Strip 06
  1. L1 a human runs it Shipped
  2. L2 + a tool Retired
  3. L3 it decides it’s done Shipped
  4. L4 agents coordinate Declined
Four rungs, and two of them shipped a no. L2 retired the retrieval grounding. L4 was declined: outside its target it broke more than it fixed, at p=0.016 and four times the cost. The strip carries the verdicts only. Each rung’s reasoning and its caveats sit in the prose below, and on the ladder page.

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 →

Contents

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.

Contents

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.