ADR-005: Optimize the classifier prompt with an autonomous, error-driven agent, guarded by a 3-way split¶
Status: Accepted
Date: 2026-06-27
Deciders: San Lee
Context¶
The classifier prompt is tuned by hand: a human reads the misclassifications, edits the prompt, re-runs the eval, repeats. That manual loop has a clear feedback signal (the eval) and a clear goal (higher macro-F1), which makes it a candidate to automate.
Two motivations:
- Demonstrate "loop engineering" — design an environment where an agent iterates to a goal autonomously, with a real feedback signal and an explicit stop condition. This is a portfolio aim in its own right (see docs/specs/prompt-optimization-loop.md).
- Possibly improve the prompt — a secondary, reported result, not a goal we can guarantee.
The central risk is Goodhart's law: any optimizer pointed at a metric will learn to game it. An agent that edits the prompt to fix the exact examples it is scored on will overfit, and a naive setup would hide that by reporting only the score it optimized. The decision has to make overfitting measurable, not invisible.
Decision¶
Build an autonomous, error-driven agent loop that reads eval failures, revises the classifier prompt, re-scores, and stops on an explicit signal. Two design choices define it:
1. Error-driven, not mechanical. Each iteration the agent reads raw misclassified examples plus confusion-matrix stats and edits the prompt to address why it failed. The qualitative judgment is the point; a blind parameter sweep is not loop engineering and a prompt has no hyperparameters to sweep.
2. A 3-way split as the overfitting guard.
| Set | Source | Role | Agent sees it? |
|---|---|---|---|
| A optimize | data/synthetic_articles.csv (~210) |
agent reads these failures | yes |
| B validation | data/synthetic_articles.csv (~90) |
drives the done-signal | no |
| C test | data/gold/gold.csv (real, n≈54) |
honest generalization number, reported only | no |
The done-signal is threshold OR plateau on B (N=3) OR token/iteration budget. Optimizing and stopping on the same set would bias plateau detection, so B is held back from the agent specifically to keep the stop honest. C is never used for any decision; the A-vs-C gap is reported as the overfitting measurement.
Primary metric: category macro-F1 (macro_average from src/eval.py). Optimizer model: claude-sonnet-4-6 (repo default). Improvement is reported, never a release gate.
Consequences¶
- Enables the loop-engineering demo and reuses the existing eval (
compute_metrics,macro_average,confusion_matrix) as the feedback signal — no new metric code. - Overfitting becomes a measured, reported quantity rather than a hidden failure. The honest story is the deliverable.
- Requires parameterizing
classify()to accept a custom prompt without changing its public default or the pinned wire contract. This is the build linchpin. - Shrinks the optimization set. Holding back B leaves ~210 of 300 synthetic rows to optimize on. Accepted: honest stop-detection is worth more than 90 extra rows.
- C is noisy at n≈54. The done-signal rides B for that reason; scaling C with the validated judge (v2.1.0) would later strengthen the held-out number.
- Establishes the run-log schema and replay pattern that the future agent-driven ML loop (rung 2) reuses.
Alternatives Considered¶
| Option | Reason Not Chosen |
|---|---|
| Keep hand-tuning the prompt (status quo) | Not autonomous; demonstrates no loop-engineering skill and does not scale |
Mechanical sweep / GridSearchCV-style search |
A prompt has no hyperparameter grid; no qualitative judgment, so it is not loop engineering — it would be a worse version of a sweep |
| Single 2-way train/test split | Optimizing and detecting plateau on the same set biases stop-detection; the A-vs-B memorization signal disappears |
| Optimize directly on the real gold set (C) | Overfits n≈54 and destroys the one honest held-out number we have |
| A prompt-optimization framework (DSPy, etc.) | A hand-built loop is more legible as the artifact — the loop is the deliverable, and a framework hides the mechanism this ADR exists to demonstrate. (Re-grounded 2026-07-19: this entry also cited ADR-004's minimal-deps rule, which ADR-004's amendment scoped to metric computation. The legibility argument was always the stronger half and now carries the rejection alone.) |
| Gate "done" on measurable improvement | Couples release to an outcome we cannot control (label-ambiguity ceiling) and pressures gaming the eval — the exact failure this design exists to expose |
Resolved during implementation¶
The spec's open questions (section 13) are resolved here, conservatively, rather than by editing the spec itself — the spec stays the historical contract; this ADR is where implementation-time decisions get recorded.
- Run-log persistence (§13 "confirm location + naming"): one append-only JSONL file per run
at
evals/optimize/run_<UTC-timestamp>.jsonl— a leadingrun_metadataline, one line per iteration, a trailingrun_summaryline. Append-only (not one rewritten JSON object) is what gives N4 its crash-safety: a crash costs at most the one in-progress iteration's record, never the whole log. Schema documented inevals/optimize/README.md. A single pretty-printed JSON object would read nicer but loses that property — rejected for the same reason a single 2-way split was rejected above: the safety property matters more than the convenience for an unattended, real-money loop. To be precise about what N4 does not buy here: the data survives, but the process does not resume — unlikeeval.py's per-row checkpoint, re-running after a crash starts a fresh run from iteration 0 and (live) re-spends the tokens already paid. Continuing a partial run from its log (--resume <run-log>) is deliberate future work, not silently claimed. - Winner selection (not explicitly asked in §13, but consequential and spec-silent): the
reported "best" prompt is
argmaxof B macro-F1 across all iterations (recorded asbest_iterationin the run log's trailer), never C (would consume the held-out set for a decision) and never "whichever iteration ran last" (a plateau-stop's last few iterations are, by definition, not the best — that is why the loop stopped). Ties keep the earliest iteration. classify()prompt-parameterization (§13 F1 "confirm the cleanest refactor"): already shipped ahead of this build —classify()gained an optionalsystem_promptparameter in #43 (feat: let classify() accept a custom system prompt), with the public default and the pinned wire contract (tests/test_contract.py) both untouched. No further change needed here.- A/B split ratio and seeding (§13 "confirm ratio, confirm seeded"): 0.7 (~210/90, matching
the spec's proposed split), seeded and recorded —
--seeddefaults to a fixed value for reproducibility, and every run'ssplit_hashes(a stable hash of each set's sorted id list) are logged so a reader can confirm two runs used the same split, or that A/B/C were disjoint. - Primary metric (§13 "confirm category macro-F1 vs. a combined score"): confirmed as spec'd
—
categorymacro-F1 alone drives the done-signal;operational_domainis tracked and reported (both split'sscoresblock carriesdomain_macro_f1/domain_accuracy) but never gates anything, since the loop's proposer prompt only asks it to fix category-level confusion. - Confusion-signal computation (an implementation-level deviation from N1's letter, not its
spirit):
build_feedback's confusion stats are computed with a direct groupby over mismatched rows rather thansrc/eval.py'sconfusion_matrix(). That function reindexes both axes to the true-label set, so a predicted label that never occurs as a ground-truth label in a given split — a real risk once a small split's errors concentrate onto one systematically overpredicted label, not just a theoretical one — would be silently dropped from the feedback the agent sees.compute_metrics/macro_average(the actual scoring math N1 is protecting) are still reused verbatim everywhere else; only this one aggregation, which was never metric math to begin with, is hand-rolled. - Frontend replay (§13 "confirm step-through-on-click for v1"): deferred out of this repo entirely, per the spec's own cascade (§12) and the autonomy-ladder's one-concern-per-session rule — this build ships the backend loop, the run-log schema, a committed dry-run sample log, and this ADR; the recorded-replay player on the portfolio project page is a downstream, portfolio-repo session once this lands.
Open for San, not resolved here: the default --target-f1 (0.90, a placeholder — see
src/optimize.py's DEFAULT_TARGET_F1 comment) is not derived from any measurement and should
be revisited once a live baseline B-score is in hand; and whether this ADR flipping to Accepted
pre-merge (rather than on merge to main) matches San's usual convention is itself a small
process question worth a one-line confirmation.