ADR-017: Classical ML baseline bake-off — the LLM spend is justified by measurement¶
Status: Accepted Date: 2026-07-25 Deciders: San Lee
Related: ADR-004 (amended scope: sklearn may model, it may not measure) · ADR-012 / ADR-013 (the prior measure-before-escalate verdicts) · spec · autonomy-ladder (this harness is rung 2's substrate)
Context¶
Every prior measurement in this repo priced a spend layered on top of an LLM — grounding (retired, ADR-012) and routing (declined, ADR-013). The LLM itself had never been baselined: nobody had asked what TF-IDF + logistic regression scores on this task for zero dollars. The spec called the question publishable in either direction; this ADR records which direction it fell.
Setup (full detail in the spec and src/baseline_ml.py): train on the 300 judge-graded
DVIDS snippets (judge_* labels only — training on pred_* would distill the workhorse),
test once on the 54-row human gold set, deliberately standard configuration (word 1–2 grams,
English stop words, min_df=2, one LogisticRegression(class_weight="balanced") per axis).
Metrics come from the same hand-rolled eval.py functions that grade the LLM.
Decision¶
The bake-off ran; the LLM wins decisively on both axes, and the result is recorded as the first measured justification of the foundational LLM spend. The baseline is not swapped in anywhere; the shipped classifier is unchanged.
The verdict (evals/baseline_eval.txt, n=54, human answer key, same stored v3 LLM
predictions on both sides of the pairing):
| Axis | Baseline | LLM (v3 run) | McNemar (exact, paired) |
|---|---|---|---|
| category | 72.2% [59.1, 82.4] (39/54) | 92.6% [82.5, 97.1] (50/54) | 14 vs 3 discordant, p=0.013 |
| operational_domain | 66.7% [53.4, 77.8] (36/54) | 92.6% [82.5, 97.1] (50/54) | 15 vs 1 discordant, p=0.0005 |
Unlike the grounding and routing deltas, these clear significance even at n=54. The gap is not noise; it is the finding.
Where the baseline fails is as informative as that it fails:
policycollapses (recall 1/6) — short snippets about legislation share surface vocabulary with operations coverage; lexical features can't separate intent from topic.industryis structurally unlearnable here: the DVIDS-skewed train set contains one industry row. The 5-class baseline is really a 4-class baseline; disclosed, not hidden.landrecall 3/11 on domain — the axis the LLM also finds hardest, but the baseline falls to lexical traps (Army units doing air/multi things) the LLM reads through.
The cost/latency half of the comparison went the expected way: $0.00 and ~0.1 ms/article locally vs one workhorse API call per article. If ~70% accuracy were acceptable, the baseline would win on economics — for this project's bar it is not, and now that trade is a number instead of an assumption.
Secondary numbers, clearly separated because they measure agreement with the judge, not
with humans: 5-fold CV inside the 300 train rows scores 83.7% (category) / 73.3% (domain),
and class_weight="balanced" beat unweighted on both axes (83.7 vs 75.7, 73.3 vs 68.3),
settling the spec's §9 question in favor of the shipped default.
Consequences¶
- The portfolio's conspicuous hole is closed: "why an LLM at all?" now has a measured answer — +20 points on category, +26 on domain, p<0.02 on both.
- Known handicaps, stated rather than hidden: train labels are judge-generated (the
baseline inherits the judge's ~5–6% human-disagreement ceiling — the direction that
handicaps the baseline, not flatters it); region is uncovered until v3.1.0 supplies
labels; the single-row
industryclass caps what any train-side fix can do. - scikit-learn enters the dev/eval dependency group only. Runtime deps are unchanged; nothing in the shipped path imports it. ADR-004's amendment already scoped its ban to metric computation, and the metrics here remain hand-rolled.
- The harness (
src/baseline_ml.py) is autonomy-ladder rung 2's substrate: the agent-driven ML loop will wrap exactly this train/predict/score cycle with error-driven feature engineering. Building the manual version first was the spec's point. - The spec said "next free ADR is 016"; 016 was taken by the PR-review lane in the interim, so the verdict lands here as 017.
Downstream surfaces¶
Touched by this change (all updated in the same PR):
src/baseline_ml.py,tests/test_baseline_ml.py— the harness and its guards (new).evals/baseline_eval.txt,evals/baseline_predictions.csv— the record (new);evals/baseline_model.joblibis gitignored (binary, reproducible offline).pyproject.toml+uv.lock— sklearn/joblib in the dev group only.README.md— "Why an LLM at all" section; LLM figures carry SYS-019 metric markers, baseline figures are a frozen experiment record like routing's.CHANGELOG.md[Unreleased],decisions/README.mdindex row.docs/specs/ml-baseline-bakeoff.md— status flips to Executed; its "next ADR is 016" note is superseded by this ADR's existence.
To sweep later, deliberately NOT in this PR:
docs/specs/autonomy-ladder.md— rung 2's substrate now exists; the rung-2 build session updates its status line.- Portfolio project page / architecture narrative — the bake-off verdict is new portfolio material; quoting it there follows SYS-019 (add keys or cite the frozen report), handled when that content is written.