ADR-044: Evaluation scoring visibility — golden resolution, judge-skip policy, replay rehydration¶
Status: Accepted Date: 2026-07-07 Related: issue #172, PR #173, ADR-0007 (server→scoring direction), ADR-014 (wire-format drift), ADR-032 (scoring extraction), ADR-042 (evalkit adoption)
Context¶
Issue #172: every successful run of a given shape produced an identical hybrid
evaluation score (83.97/B) regardless of generated content or duration. Three
compounding mechanisms — the LLM judge silently swallowed its own failure and
renormalized (removing the only content-quality layer), dataset
golden_output_path references were never resolved (so the overlap term
always scored against empty expected text), and the efficiency duration
penalty saturated at ~37s. A fourth was found during implementation: the
replay/compare re-evaluation endpoints scored with dataset_sample=None.
The common failure class is silent degradation: a scoring layer that cannot run must be visible in the output, not absorbed into a plausible-looking number.
Decision¶
-
Golden resolution lives in the server dataset loader, not the scorer.
agentic_v2/server/datasets.pyresolves a sample'sgolden_output_pathat load time (relative to the dataset file, confined to allowed dataset roots, size-capped, reduced to the golden'sfinal_outputsubtree, null-strip-serialized symmetrically with generated output) and inlines it asgolden_output_text. The scoring package stays I/O-free (ADR-0007 direction: server → scoring). The loader/scorer contract key is the shared constantscoring_criteria.GOLDEN_OUTPUT_TEXT_KEY. Resolution failures never fail the load — they are recorded asgolden_output_errorin dataset metadata and logged. -
A skipped judge is loud and typed. The evaluation payload carries
judge_skipped: bool,judge_skip_reason: str | None, and a machine-readablejudge_skip_code(not_configured|judge_error); the UI renders a warn pill + reason._apply_judge_scorescatches broadExceptiondeliberately — provider errors (e.g. executionkitRateLimitError/PermanentError) are plainExceptionsubclasses and an escape destroys the whole evaluation instead of recording a skip. Symmetrically,expected_text_present: boolrecords whether the overlap/similarity term engaged at all. -
evaluation.scoring.judge_requiredescalates, scoped to the evaluation. When true, an unavailable judge raises the typedJudgeRequiredError(aRuntimeErrorsubclass so it is distinguishable from the ordinary judge failures it escalates). The fresh-run path catches it, persists the run log withevaluation_error(a completed workflow must never vanish from run history because a post-hoc scoring policy failed), and the replay/compare endpoints map it to HTTP 422 with ajudge_required_unmetmarker. Default isfalseso the key-freeAGENTIC_NO_LLM=1baseline stays green. -
Replay re-evaluation rehydrates the dataset sample. Run logs persist dataset metadata only;
rehydrate_dataset_samplereloads the sample for local datasets (never a network fetch), refuses to guess on an unparseablesample_index, cross-checks the storedtask_idagainst the reloaded sample (a dataset that shrank or was reordered must not silently swap in another task's golden), and surfaces degradations asrehydration_errorin the payload's dataset block. It is re-exported through theserver/evaluationfaçade; routes must not importserver.datasetsdirectly. -
The efficiency SLO band is config with code-versioned defaults.
evaluation.scoring.efficiency_slo.{good_seconds,bad_seconds}(defaults 2s/60s) feeds the samelower_is_betternormalization as the advisory layer; invalid config falls back to the defaults. Changing the band re-baselines efficiency scores — historic and new runs are only comparable under the same band, which is why the defaults are also kept in code and the YAML carries a caution comment. Beyondbad_secondsthe duration term floors at 0 (per-workflow SLOs are deliberately deferred).
Implicit contracts inventoried for the ADR-042 migration¶
A future evalkit bridge (Slice C/D) must deliberately translate, not drop:
golden_output_textinline-key convention (evalkit'sEvalSamplehas typed expected-output fields — map, don't parallel).judge_skipped/judge_skip_reason/judge_skip_code/expected_text_presentpayload semantics (evalkit graders have their own missing-component semantics: abstention shrinks the averaging pool, whereas this scorer renormalizes explicitly and flags it).judge_requiredpolicy and itsJudgeRequiredErrorblast-radius contract.- The two
golden_output_pathinterpreters (scripts/eval_gate.pyvs the server loader — seedatasets/default/README.md) converge into one resolver when eval_gate is re-platformed.
Consequences¶
- Stored evaluations re-score lower and honestly once goldens/judge engage;
runs that predate the fields validate as
None(additive-only model fields, permissive payload validator, UI legacy fallback). RunEvaluationDetailremains a hand-mirrored shape (Pydantic ↔ hand-writtenui/src/api/types.ts) outside the ADR-014 drift gate; promoting it into the generated set (and extending theevaluation_completebroadcast whitelist) is tracked as deferred work.- The
AGENTIC_NO_LLM=1smoke path now emitsjudge_skipped: true+not_configuredon every evaluation — expected and asserted in tests.