feat(llm_judge): multi-sample verdicts with median aggregation#53
feat(llm_judge): multi-sample verdicts with median aggregation#53IgnazioDS wants to merge 1 commit into
Conversation
Add `samples` (1-9, default 1 = unchanged single-call behavior) to llm_judge. With N > 1 the judge grades the same rendered prompt N times; the criterion scores the median verdict, the sample closest to the median (earliest on ties) supplies rationale/findings/transcript, per-sample scores render in details, and token usage sums across samples. A sample that produces no verdict degrades to the median of the valid samples with a degraded note; when every sample fails, single-sample failure semantics apply unchanged (JudgeInfrastructureError still escalates, parse failures still score 0.0). agent_judge stays single-sample.
uipreliga
left a comment
There was a problem hiding this comment.
Review: coder_eval — pr:53 (4 files) axis:1,2,3,4,5,6,7,8
Scope: pr:53 (4 files) axis:1,2,3,4,5,6,7,8 · branch feat/llm-judge-multi-sample (PR #53 head; local review branch ci/publish-github-releases) · 2de482f · 2026-07-26T04:54Z · workflow variant
Change class: complex — introduces a new multi-sample aggregation code path in a scoring-relevant criterion (median selection, per-sample degradation, and error-escalation semantics), so correctness requires reasoning about how a task's score is derived
The judge-sampling feature lands on a healthy codebase — clean security, type safety, and test posture (9.6/10 overall, zero critical/high-severity defects outside one observability gap) — but the real risks are concentrated in criteria/llm_judge.py: N samples run serially inside the shared task_timeout watchdog (so a large samples value can flip an otherwise-passing row to FinalStatus.TIMEOUT for identical agent output, and multiplies judge spend that max_usd cannot see), and the sample loop's bare except Exception silently swallows first-party bugs into a still-green median; fix those two plus the duplicated single-sample/multi-sample construction tails and this is ready to ship.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 8.9 / 10 | 0 | 0 | 2 | 1 | _grade_with_sampling duplicates _check_impl's result-construction tail instead of treating N=1 as the general case (two parallel judge paths, CC 15) |
| 2. Type Safety | 9.8 / 10 | 0 | 0 | 0 | 2 | _build_transcript takes 4 positional params, two of them adjacent same-typed str — a swap is invisible to pyright and breaks the module's keyword-only convention |
| 3. Test Health | 9.9 / 10 | 0 | 0 | 0 | 1 | samples>1 tested only on the Direct/Anthropic arm with default toggles |
| 4. Security | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 5. Architecture & Design | 9.9 / 10 | 0 | 0 | 0 | 1 | Judge sampling is asymmetric: samples exists only on LLMJudgeCriterion, with no recorded rationale and no doc/error pointer for agent_judge |
| 6. Error Handling & Resilience | 9 / 10 | 0 | 1 | 0 | 0 | Sample-loop exception handling discards diagnostics: blanket except Exception + swallowed partial infra errors are never logged and never surfaced in error= (only prose in details) |
| 7. API Surface & Maintainability | 9.8 / 10 | 0 | 0 | 0 | 2 | Field name samples collides with the existing dataset-row "sample" vocabulary in the same YAML/CLI surface |
| 8. Evaluation Harness Quality | 9.5 / 10 | 0 | 0 | 1 | 0 | samples runs N judge calls serially inside the task_timeout watchdog — up to 9x judge wall-clock (and 9x judge spend max_usd cannot see), with the guide documenting neither |
Overall Score: 9.6 / 10 · Weakest Axis: Code Quality & Style at 8.9 / 10
Totals: 🔴 0 · 🟠 1 · 🟡 3 · 🔵 7 across 8 axes.
Blockers
-
[Axis 6] Sample-loop exception handling discards diagnostics: blanket
except Exception+ swallowed partial infra errors are never logged and never surfaced inerror=(only prose indetails) (src/coder_eval/criteria/llm_judge.py:271) — Lines 271-272 are an unqualified catch-all with no logging and no comment:except Exception as exc:
unexpected_errors.append(exc)
At samples: 1 an unexpected exception from our own code (e.g. a TypeError/AttributeError in extract_verdict_from_anthropic_response, a pydantic ValidationError building JudgeVerdict, or a bug in token_usage_from_anthropic_dict) is loud: handle_criterion_errors (criteria/base.py:79-96) does logger.error(..., exc_info=True) and returns error=exc_info. At samples: 3 with one such failure and two good samples, the exception is appended to a list that is then read only at lines 281-282 — never logged, never in error (stays None), and its type/message appears nowhere in details; the only artifact is the generic count note at line 304. tests/test_llm_judge_criterion.py::test_judge_multi_sample_transport_exception_degrades locks that in (RuntimeError("gateway down") → result.error is None). So a systematic first-party regression that trips on, say, 1-in-3 responses ships as a green criterion with a slightly different median. Two secondary losses on the same handler: (a) precedence at lines 279-282 raises infra_errors[0] before unexpected_errors[0], so a genuine code bug is masked whenever any sample also hit a transient infra failure; (b) only element [0] is re-raised, so the remaining N−1 exception messages are discarded. Fix: log each swallowed exception at WARNING with exc_info=True, record type(exc).__name__: exc in the degraded note (or in a non-fatal error string) so the report names the failure, and narrow the catch to the exception classes the transports can actually surface rather than bare Exception. Candidate lint rule: forbid except Exception bodies in src/coder_eval/ that neither log nor re-raise (would also cover future accumulate-and-continue loops).
Non-blocking, but please consider before merge
- [Axis 1]
_grade_with_samplingduplicates_check_impl's result-construction tail instead of treating N=1 as the general case (two parallel judge paths, CC 15) (src/coder_eval/criteria/llm_judge.py:229) — BothJudgeCriterionResult(...)construction sites are near-verbatim copies of the single-sample path. Parse-failure: lines 145-154 (score=0.0, details=scrubbed[:500], error=scrub_reference(parse_error, scrub_key), transcript=_build_transcript(criterion, raw_verdict_text, user_msg, scrub_key), token_usage=judge_usage) vs lines 286-294 (identical modulo thefirst.prefix andtoken_usage=token_usage). Success: lines 158-166 vs lines 309-317 — identical exceptscore=verdict.scorevsscore=median_scoreand the rep-verdict source. Theassert ... # parser contract: verdict is set iff parse_error is Nonecomment is copied verbatim too (line 155 → line 284). Prefer collapsing to ONE path: run the looprange(criterion.samples)unconditionally (N=1 is a one-element list whose median is its own score, and_sum_usageof one usage is that usage), and gate only the two N>1-specific renderings — thesample_scores:line at 307-308 and the degraded note at 303-304 — oncriterion.samples > 1. That deletes theif criterion.samples > 1:dispatch at 122-129, removes both duplicated construction blocks, and drops_grade_with_samplingwell below its current radon C(15). While there,infra_errors: list[JudgeInfrastructureError] = []/unexpected_errors: list[Exception] = [](lines 255-256) accumulate every sample's exception but are only ever read at index 0 (raise infra_errors[0]line 280,raise unexpected_errors[0]line 282) — two accumulator lists for a value that is 'the first error seen', which is machinery the goal does not need and part of what pushes the branch count to 15. n/a - [Axis 1]
_SampleOutcomeduplicates_invoke_tool_channel's 4-tuple return shape and is populated by an order-dependent positional splat (src/coder_eval/criteria/llm_judge.py:220) — The 4-field shape is now declared twice: as a bare tuple annotation at line 175 (-> tuple[JudgeVerdict | None, str | None, str, TokenUsage | None]) and again as the NamedTuple at lines 220-226, whose own docstring admits the coupling —"""One judge invocation's outcome, in ``_invoke_tool_channel`` return order.""". It is then filled by splat at the one call site (lines 259-268:_SampleOutcome(*_invoke_tool_channel(...))), so field identity is carried purely by position. Fix in two lines: annotate_invoke_tool_channel(...) -> _SampleOutcome(move the class above it) and have its threereturnstatements (lines 213, 216, 217) construct_SampleOutcome(...)by keyword. A NamedTuple unpacks positionally, so the existing single-sample destructuring at line 133 (verdict, parse_error, raw_verdict_text, response_usage = _invoke_tool_channel(...)) keeps working unchanged. That leaves one declaration of the shape, deletes the splat and the 'return order' comment, and makes the abstraction load-bearing instead of a second copy of a type that already existed. n/a - [Axis 8]
samplesruns N judge calls serially inside thetask_timeoutwatchdog — up to 9x judge wall-clock (and 9x judge spendmax_usdcannot see), with the guide documenting neither (src/coder_eval/criteria/llm_judge.py:257) — The samples run strictly serially —for _ in range(criterion.samples):(line 257) with a blocking call per iteration — and each call carries a 120 s transport timeout (timeout_seconds: float = 120.0at src/coder_eval/evaluation/judge_anthropic.py:29 and src/coder_eval/evaluation/judge_bedrock.py:51, the latter plus its retry loop).check_allruns inside the task-timeout watchdog (src/coder_eval/orchestrator.py:468-475 wrapsself._evaluation_loop(), which callscheck_allat orchestrator.py:1408-1414), and the shipped default istask_timeout: 600(experiments/default.yaml:29). Sosamples: 9admits up to ~18 minutes of judge time against a 600 s task budget shared with the agent — enough to flip a row toFinalStatus.TIMEOUTfor identical agent output, which is a status change from a knob the guide presents purely as a scoring-quality dial.
Cost is multiplied with no gate at all: judge usage is deliberately excluded from the budget aggregate — src/coder_eval/orchestrator.py:848-852 states judge usage "is intentionally NOT included in this aggregate — it represents the main agent's bill" — so run_limits.max_usd cannot see it, and samples: 9 on a dataset-fanned suite silently multiplies ungated judge spend up to 9× per row.
Fix: state the trade in docs/TASK_DEFINITION_GUIDE.md:840 (which currently sells samples: 3 with no mention of latency, task_timeout, or cost) — concretely, that N samples multiply judge wall-clock inside task_timeout and multiply judge spend, which max_usd does not gate. Cheapest real mitigation: run the samples concurrently (they are independent and check_all is already off the event loop via asyncio.to_thread), which removes the latency multiplier entirely and leaves only the cost multiplier to document. n/a
Nits
- [Axis 1] 🔵 Median-aggregation semantics restated in four places, against the codebase's 'field descriptions defined once in the Pydantic model' rule (
src/coder_eval/models/criteria.py:661) — The same behavior is written out four times: the class-docstring paragraph at criteria.py:661-663 ('Setsamples> 1 to invoke the judge several times ... (see the field description for the aggregation semantics)' — which explicitly defers to the SSOT it is duplicating), the 12-line field description at criteria.py:771-782, the 17-line_grade_with_samplingdocstring at llm_judge.py:237-253, and the guide (docs/TASK_DEFINITION_GUIDE.md table row + the 'Sampling the judge' paragraph). CLAUDE.md's Design Principles state 'Field descriptions, validation rules, and documentation defined once in Pydantic models'; a later change (e.g. median → trimmed mean, or a tie-break tweak) now needs four synchronized edits and will silently drift. Drop the class-docstring paragraph (it adds nothing over the field description it points at) and trim the function docstring to the parts that are implementation-only (the exception-triage order), leaving user-facing semantics to the field description and the guide. n/a - [Axis 2]
_build_transcripttakes 4 positional params, two of them adjacent same-typedstr— a swap is invisible to pyright and breaks the module's keyword-only convention (src/coder_eval/criteria/llm_judge.py:331) — The refactor turned the zero-arg closuredef _maybe_transcript() -> JudgeTranscript | None(origin/main llm_judge.py:130, which capturedraw_verdict_text/user_msg/scrub_keyso mis-ordering was impossible) into a module-level positional helper:def _build_transcript(criterion, raw_verdict_text, user_msg, scrub_key)(line 331), now called from 4 sites —_build_transcript(criterion, raw_verdict_text, user_msg, scrub_key)(lines 152 and 164),_build_transcript(criterion, first.raw_verdict_text, user_msg, scrub_key)(line 292),_build_transcript(criterion, rep_raw, user_msg, scrub_key)(line 315). Params 2 and 3 are both plainstr, so swapping them type-checks cleanly (pyright reports 0 diagnostics on this file) and would silently persist the prompt asJudgeTranscript.raw_verdictand the verdict asjudge_prompt. Both other helpers in this module are keyword-only —def _invoke_tool_channel(*, criterion, route, system_msg, user_msg)(line 169) anddef _grade_with_sampling(*, criterion, route, user_msg, judge_ctx, scrub_key)(line 229). Add*,to_build_transcriptand pass by keyword at all 4 sites. - [Axis 2] Redundant
float()cast aroundmedian(scores)—statistics.medianoverlist[float]is alreadyfloat(src/coder_eval/criteria/llm_judge.py:297) — Line 297 ismedian_score = float(median(scores)), wherescores = [verdict.score for verdict, _ in valid](line 296) andJudgeVerdict.scoreis declaredscore: float(src/coder_eval/models/judge.py:32). I verified with pyrightreveal_typeon an isolated repro of this exact pattern:Type of "scores" is "list[float]"andType of "median(scores)" is "float", so the cast is a no-op at both the type and runtime level (the even-N branch returns(a + b) / 2, also afloat). Drop the wrapper (median_score = median(scores)) so the line doesn't imply the return type is uncertain — or keep it with a one-line comment if it is deliberately guarding a future non-floatnumeric element. - [Axis 3] samples>1 tested only on the Direct/Anthropic arm with default toggles (
tests/test_llm_judge_criterion.py:1131) — Every test in the new# --- multi-sample judging (samples > 1) ---block (line 1084 onward) patchescoder_eval.criteria.llm_judge.invoke_anthropic_judgewithroute=DirectRoute(), e.g. line 1135:with patch("coder_eval.criteria.llm_judge.invoke_anthropic_judge", side_effect=responses) as m_anthropic:. Three combinations therefore go unexercised at samples>1: (a) the Bedrock arm of_invoke_tool_channel(src/coder_eval/criteria/llm_judge.py:190) — the route the nightly uses via--backend bedrock, and the one whose invoker raisesJudgeInfrastructureErrorfor every failure mode (src/coder_eval/evaluation/judge_bedrock.py:104/110/112/114), which is the escalate-vs-degrade branch in_grade_with_sampling; (b)capture_transcript=Falsewith samples>1 — thenot criterion.capture_transcriptearly return in the newly extracted_build_transcript(llm_judge.py:331) is only reached from the single-sample test at line 716; (c) usage-summing fidelity —test_judge_multi_sample_token_usage_summed(line 1288) usesusage = {"input_tokens": 100, "output_tokens": 10}, omittingcache_creation_input_tokens/cache_read_input_tokens, which are exactly the buckets N repeated identical prompts would populate (calls 2..N read the cache) and which drive cost viaTokenUsage.__add__; and no multi-sample test assertsresult.token_usage is Nonewhen no sample reports usage (theif not present: return Nonebranch of_sum_usage), the None-vs-zero distinction the single-sample test at line 1024 protects. I confirmed the Bedrock arm is functionally fine at samples>1 with a probe test (BEDROCK CALLS 3 SCORE 0.6 ERR None), so this is coverage debt rather than a latent bug — add one Bedrock multi-sample test (patchinginvoke_bedrock_judgewithBedrockRoute(bearer_token="t", region="us-east-1"), mirroring line 927), and extend the usage test with cache buckets plus atoken_usage is Nonecase. - [Axis 5] Judge sampling is asymmetric:
samplesexists only onLLMJudgeCriterion, with no recorded rationale and no doc/error pointer foragent_judge(src/coder_eval/models/criteria.py:767) —samples: int = Field(default=1, ge=1, le=9, ...)is added at models/criteria.py:767 toLLMJudgeCriteriononly.AgentJudgeCriterion(models/criteria.py:816) is the other judge criterion, deliberately mirrors llm_judge's authoring surface (its own comment at :851 reads# Prompt & context — mirrors LLMJudgeCriterion for author consistency), shares the sameJudgeVerdict/JudgeCriterionResult/format_detailsmachinery, and has the same judge-variance problem — yetagent_judge: {samples: 3}now hard-fails onmodel_config = ConfigDict(extra="forbid")with a bare unknown-key error. Per-criterion placement is the right call (sampling is meaningless for deterministic file checks, soBaseSuccessCriterionwould be wrong, and N× agent-judge runs are genuinely expensive), so the gap here is only that the asymmetry is unrecorded: add one line to theAgentJudgeCriteriondocstring and/or the docs/TASK_DEFINITION_GUIDE.mdagent_judgesection saying sampling is llm_judge-only and why (cost), so the next author does not read it as an oversight — or hoistsamplesinto a small shared judge mixin if agent_judge sampling is intended later. - [Axis 7] Field name
samplescollides with the existing dataset-row "sample" vocabulary in the same YAML/CLI surface (src/coder_eval/models/criteria.py:767) — The new knob issamples: int = Field(default=1, ge=1, le=9, ...)(line 767), but "sample" is already taken in this config surface for something unrelated — dataset row selection:Dataset.sample_per_stratum(src/coder_eval/models/tasks.py:219),Dataset.stratify_field(:230),Dataset.sample_seed(:234), and the CLI flags--sample/--sample-per-stratum(src/coder_eval/cli/run_command.py:239,248). A single task YAML can legitimately carrydataset: {sample_per_stratum: 5}alongsidesuccess_criteria: [{type: llm_judge, samples: 3}], where the two "sample" keys mean rows and judge invocations respectively. The docs paragraph disambiguates againstreplicates(docs/TASK_DEFINITION_GUIDE.md:840) but not againstsample_*. Since the project is greenfield ("No worries about backward compatibility" per CLAUDE.md), rename tojudge_samples(orvote_samples) so the key is self-disambiguating at the point of use, or at minimum extend the field description to contrast it withdataset.sample_per_stratumthe way the guide already contrasts it withreplicates. n/a - [Axis 7]
le=9upper bound onsampleshas no stated rationale in the field description or the guide (src/coder_eval/models/criteria.py:770) — Line 770 is a barele=9,and neither the 12-line field description (lines 771-782) nor the guide row (docs/TASK_DEFINITION_GUIDE.md:837, which only restates the range as "(1–9)") says why 9. The error itself is fine — verified against the PR worktree:LLMJudgeCriterion(description='x', prompt='p', samples=10)raises1 validation error for LLMJudgeCriterion / samples / Input should be less than or equal to 9 [type=less_than_equal, input_value=10], which names the field and the bound. The gap is only the missing "why": add one clause to the description (e.g. "capped at 9 — the marginal variance reduction past ~5 samples does not justify the linear API cost, and judge spend is outsiderun_limitsbudget caps"), so a future reader knows whether the cap is a cost guard that can be raised or a semantic limit. n/a
What's Missing
Downstream consumers:
- 🟡 Downstream consumers 🟠 — The per-sample scores land only in the free-text
detailsstring (llm_judge.py:307-308,sample_scores: [0.800, 0.600]);JudgeCriterionResult(models/results.py:167) gained no typed field (e.g.sample_scores: list[float]), so no consumer can read judge spread — notreports_html._render_judge_card, notreports_stats, notreports_junit, not the cross-repo evalboard, and not the criterion's ownaggregate(). The variance number the feature exists to measure is unreadable by anything except a human eyeballing a prose blob, while every other structured judge signal on that model (findings,token_usage,transcript) is a typed field. (trigger: src/coder_eval/criteria/llm_judge.py) - 🔵 Downstream consumers 🟡 — Suite-aggregate semantics changed meaning with no doc or guard:
BaseCriterion.aggregate()still emits count/mean/median/std/min/max over per-row scores, but those scores are now medians-of-N, so asuite_thresholds: {std: …}gate and the cross-variant tables inreports_experiment.pymeasure a different quantity in asamples: 3run than in asamples: 1run — the two are no longer comparable, and nothing records that. No intra-row sample-spread metric was added to the aggregate either. (trigger: src/coder_eval/criteria/llm_judge.py)
Daily/nightly:
- 🟡 Daily/nightly pipeline impact 🟠 — The PR never states the nightly blast radius.
samplesdefaults to 1 so the nightly is byte-for-behavior unchanged today, but the nightly is exactly the--backend bedrockjudge route that is untested at samples>1 and whose invoker wraps its own retry loop inside every sample (so the serial latency multiplier is worst on the production route), and notasks/*.yamladopts the field (3 task files mentionllm_judge, none setsamples:), so the feature ships unexercised in production. The missing artifact is a one-line "nightly unchanged until a suite opts in; opting in multiplies judge wall-clock insidetask_timeoutand multiplies judge spend" statement. (trigger: src/coder_eval/models/criteria.py) (restates: Axis 8:samplesruns N judge calls serially inside thetask_timeoutwatchdog) - 🟡 Daily/nightly pipeline impact 🟠 — Cross-repo contract not updated:
docs/REPORT_SCHEMA.md:120-123documents the judge result variant as addingfindings,token_usage("kept distinct from the agent total") andtranscript_path, buttoken_usagenow aggregates N calls anddetailsgrows an extrasample_scores:line intask.json. The externalcoder-eval-uipath/ eval-runner + evalboard consume that record shape, so a field's meaning changed under them with no schema-doc edit — REPORT_SCHEMA.md is not in the diff at all. (trigger: src/coder_eval/criteria/llm_judge.py) - 🔵 Daily/nightly pipeline impact 🟡 — Request-rate amplification on the shared Bedrock judge account is unstated:
run_batchalready runs rows in parallel, sosamples: Nmultiplies concurrent judge calls N-fold, and a throttled sample is swallowed intoinfra_errors/unexpected_errorswith nothing logged — the nightly would quietly start scoring medians over fewer samples, with only a prose count note indetailsto show it. (trigger: src/coder_eval/criteria/llm_judge.py) (restates: Axis 6: Sample-loop exception handling discards diagnostics)
Parallel paths:
- 🟡 Parallel paths 🟠 — The sibling model doc is now false and only 1 of N verdicts survives:
models/results.py:131-133still asserts "llm_judgeis one-shot so its transcript only carriesraw_verdict", yet_build_transcript(criterion, rep_raw, …)(llm_judge.py:315) persists only the representative sample's raw verdict tojudge-<idx>.yaml— the N−1 other verdicts, including the outlier readings the median exists to damp, are discarded. Nothing onJudgeTranscriptrecords that it is one draw of N, so post-hoc "why did the judge disagree with itself?" auditing (the transcript's stated purpose) is impossible for exactly the runs that need it. (trigger: src/coder_eval/criteria/llm_judge.py) - 🔵 Parallel paths 🟡 — Ownership of the judge
detailsformat split in two:evaluation/judge_context.py::format_detailsis the shared single formatter for both judges (and the shapereports_html._extract_rationaleparses by line prefix), but the newsample_scores:line is appended outside it atllm_judge.py:307-308. The shared formatter gained nosample_scoresparameter, so any future sampler (agent_judge) will re-invent the line ad hoc and consumers parsingdetailsline-by-line now have an undeclared line to handle. (trigger: src/coder_eval/criteria/llm_judge.py) - 🔵 Parallel paths 🟡 —
samplesis unreachable from every override layer:-D/--setaccepts only theagent/run_limits/sandboxroots (config_merge.ALLOWED_OVERRIDE_ROOTS) andExperimentVariant(models/experiment.py:25-66) has nosuccess_criteriaoverride, so the calibration A/B this field exists for — the same suite atsamples: 1vssamples: 3— cannot be expressed, and adoption means hand-editing every task YAML. A pre-existing platform limit, but it collides head-on with the field's stated purpose and the PR doesn't acknowledge it. (trigger: src/coder_eval/models/criteria.py) - 🔵 Parallel paths 🟡 —
AgentJudgeCriterion(models/criteria.py:816), which deliberately mirrors llm_judge's authoring surface and shares the sameJudgeVerdict/JudgeCriterionResult/format_detailsmachinery, did not getsamplesand carries no note explaining why, soagent_judge: {samples: 3}fails with a bareextra_forbiddenerror. (trigger: src/coder_eval/models/criteria.py) (restates: Axis 5: Judge sampling is asymmetric —samplesexists only onLLMJudgeCriterion)
Tests:
- 🟡 Tests 🟠 — All 16 new multi-sample tests patch
invoke_anthropic_judgeon aDirectRoutewith default toggles, leaving unexercised at samples>1: the Bedrock arm (llm_judge.py:187-198— the nightly's route, and the only invoker that raisesJudgeInfrastructureErrorfor every failure mode, i.e. the escalate-vs-degrade branch),capture_transcript=False, the cache-token buckets that N repeated identical prompts actually populate, and the_sum_usage→token_usage is Nonebranch. (trigger: tests/test_llm_judge_criterion.py) (restates: Axis 3: samples>1 tested only on the Direct/Anthropic arm with default toggles) - 🔵 Tests 🟡 — The all-samples-parse-fail branch (
llm_judge.py:286-294) is asserted only onscore/error(test_judge_multi_sample_all_no_verdict_keeps_failure_path,…_all_fail_reports_first_sample_diagnostic); its two other outputs — the summedtoken_usageand thetranscriptbuilt from the first sample — have no assertion, so a regression that drops judge cost or the audit transcript on the total-failure path lands green. (trigger: tests/test_llm_judge_criterion.py) - 🔵 Tests 🟡 — No test carries a multi-sample result through a report renderer: nothing pins that the appended
sample_scores:line leavesreports_html._extract_rationale(which matches on therationale:line prefix) and thereports_junitfailure-body truncation intact, even though this PR is the first change to alter the judgedetailsline set since those parsers were written. (trigger: tests/test_llm_judge_criterion.py) - 🔵 Tests 🔵 — Every new test runs at the default
temperature: 0.0while mocking divergent per-sample scores — a combination the real transport is unlikely to produce. Nothing (validator, warning, field-description clause, or doc line) records thatsamples > 1buys real variance reduction mainly when the judge is sampled at a non-zero temperature, so an author can pay up to 9x judge cost for nine near-identical draws with no signal from the harness. (trigger: tests/test_llm_judge_criterion.py)
Display & mapping dicts:
- 🔵 Display / mapping 🟡 — No new enum /
Literal/ status value was added, so thereports*.pyicon and label dicts need no new entries. What is missing is any rendering that says "this score is a median over N samples":reports_html._render_judge_cardshows one score pill plus the representative sample's rationale, so an HTML reader cannot distinguish a single-call verdict from a 9-sample median, and the markdown failure-reason line (reports.py:823) shows the median with no sample context — the only trace anywhere is rawdetailstext inside the criteria-table disclosure. (trigger: src/coder_eval/criteria/llm_judge.py)
Harness & Lint Improvements
Static checks (lint / type):
- [ce-lint] CE026 — broad
exceptmust not store the exception object as its only handling. New rule/Users/religa/src/coder_eval/tests/lint/rules/ce026_no_exception_swallowed_into_collection.py(BaseRule,visit_ExceptHandler), wired intoALL_RULESin/Users/religa/src/coder_eval/tests/lint/runner.py(CE026 is the only free number below CE027; also add"CE026"to the[tool.ruff.lint] externallist in pyproject.toml, which currently stops at CE018, so a# noqa: CE026isn't reported as unused). Pattern forbidden: a broad handler (except Exception as e/ bare / tuple containingException) in which every use of the bound name is an argument to a collection store (.append/.add/.extend/.setdefault/.update) and the body contains noraise, nologger.*call, and no interpolation of the name into an f-string /str()/repr(). This is exactly the gap that let the defect through the existing CE005: CE005 treats any reference to the bound name as "handled", soexcept Exception as exc: unexpected_errors.append(exc)passes — I confirmedcheck_file()returns[]on the PR file. I prototyped the refined predicate oversrc/andtests/: exactly one violation repo-wide,src/coder_eval/criteria/llm_judge.py:271. Deliberately narrow: the four superficially similar sites stay clean without noqa —orchestration/batch.py:157(passesexcto_create_error_task_result, not a collection store),cli/report_command.py:119andcriteria/json_check.py:177(interpolate{e}into user-visible text),orchestration/experiment.py:706(narrow exception tuple). Prevents: A6-high (grouped A7-medium / A8-high):except Exception as exc: unexpected_errors.append(exc)at llm_judge.py:271 swallows a first-partyTypeError/ValidationErrorwith no log record and no exception identity anywhere on the result. Also covers future accumulate-and-continue loops, which is the shape the finding itself asked for. - [ce-lint] CE032 — no
*-splat construction of a NamedTuple / dataclass / model from a call. ForbidFoo(*bar())whereFoois a Name whose identifier (leading underscores stripped) is CapWords, i.e. a type constructor. Field identity then travels by position between two independently declared signatures, which pyright cannot guard once two fields share a type — precisely the_SampleOutcome(*_invoke_tool_channel(...))case where fields 2 and 3 arestr | None/str. Fix direction the rule pushes authors toward: annotate the producer-> _SampleOutcomeandreturn _SampleOutcome(...)by keyword (a NamedTuple still unpacks positionally, so the existing destructuring at llm_judge.py:133 keeps working). Measured over all ofsrc/coder_eval: exactly one hit,src/coder_eval/criteria/llm_judge.py:260— zero pre-existing violations to baseline. Same file/runner wiring as CE026. Prevents: A1/A2-medium:_SampleOutcome(llm_judge.py:220) redeclares_invoke_tool_channel's 4-field return shape (declared a third time in its docstring at :178) and is populated by an order-dependent positional splat. - [ce-lint] CE033 — do not re-raise only the first of an accumulated exception list. Forbid
raise <name>[0]where<name>is a local list the enclosing function appends exception objects to; requireraise ExceptionGroup(...)or logging the remainder before re-raising the representative. Measured oversrc/: exactly two hits, both introduced by this PR —src/coder_eval/criteria/llm_judge.py:280(raise infra_errors[0]) and:282(raise unexpected_errors[0]) — so no baseline. Detectability is high (ast.Raisewhoseexcis aSubscriptwithConstant(0)), and the accumulator-provenance check keeps it from firing on a legitimateraise errors[0]over a single-element container. Prevents: The two secondary losses inside A6-high: infra errors are raised in precedence overunexpected_errors, so a genuine code bug is masked whenever any sample also hit a transient transport failure, and the remaining N−1 exception messages are discarded outright. - [ce-lint] CE035 — adjacent same-annotated parameters must be keyword-only (scoped to the criterion/judge surface). For functions in
src/coder_eval/criteria/**andsrc/coder_eval/evaluation/**(skip dunders) with ≥3 positional parameters where two adjacent ones carry identical annotations, require*,._build_transcript(criterion, raw_verdict_text, user_msg, scrub_key)(llm_judge.py:331) has two adjacent plain-strparams, is called from 4 sites, and a swap type-checks cleanly (pyright reports 0 diagnostics on the file) while silently persisting the prompt asJudgeTranscript.raw_verdict— pyright cannot reach this, only an arity/keyword rule can. Scope matters: repo-wide the pattern has 35 pre-existing sites (unviable as a hard gate), but inside these two packages it is 7 —criteria/run_command.py:41,74,108,203,criteria/llm_judge.py:331,criteria/command_executed.py:70,evaluation/judge_context.py:363— all module-private or single-consumer helpers, so the one-time*,conversion is mechanical, and the convention already holds for_invoke_tool_channeland_grade_with_samplingin the same module. Prevents: A2-low:_build_transcriptbroke the module's keyword-only convention with two adjacentstrparams whose swap pyright cannot detect. - [ce-lint] CE034 — model docstrings must not defer to a field description. A doc-surface check (wire as a
@pytest.mark.lintclass next to/Users/religa/src/coder_eval/tests/lint/doc_schema_parity.py, following the CE027–CE031 whole-tree pattern rather thanBaseRule): fail when a class docstring undersrc/coder_eval/models/containssee the field description/see the field docs/see the field's description. A docstring that defers to aField(description=...)is by construction restating semantics that already have an SSOT — the exact violation of CLAUDE.md's "field descriptions defined once in Pydantic models". Verified oversrc/anddocs/: exactly one hit,src/coder_eval/models/criteria.py:662, so zero baseline. Prevents: A1-low: median-aggregation semantics restated in four places (class docstring criteria.py:661-663, field description :771-782,_grade_with_samplingdocstring, TASK_DEFINITION_GUIDE) — the self-deferring class-docstring copy is the one a static check can kill. - [ce-lint] CE036 (optional, low priority) — a numeric upper bound must be justified in its field description. For the CE030-tracked user-authored config models, require that
Field(..., le=N)/lt=Nrestate the bound value indescription, so "why this number" is authored where the number lives. Cost is measured and small but non-zero: 10 fields acrossmodels/criteria.py+models/tasks.pycarry anle/ltbound and 9 do not mention it (criteria.py:112, 677, 758, 767, 860;tasks.py:84, 112, 267, 282), so this needs a one-time pass over 9 short descriptions before the gate can go green. Worth adopting only if bound rationale should be a standing invariant; otherwise this finding stays a review catch. Prevents: A7-low: barele=9,at models/criteria.py:770 with no rationale in either the 12-line field description or the guide row, leaving the next reader unable to tell a cost guard from a semantic limit. - [ce-lint] CE037 (optional, low priority) — judge-criterion field-parity allowlist.
LLMJudgeCriterionandAgentJudgeCriteriondeliberately mirror each other's authoring surface (the comment at criteria.py:851 says so). Measured today: 13 shared fields, 4 llm-only (model,temperature,max_tokens,samples), 3 agent-only (agent,max_turns,turn_timeout). A check requiring every asymmetric field to appear in an explicit_JUDGE_FIELD_ASYMMETRY = {field: reason}map turns "oversight or decision?" into a compile-time answer with a 7-entry baseline. Because both models areextra="forbid", an unrecorded asymmetry surfaces to users only as a bareextra_forbiddenerror onagent_judge: {samples: 3}. Prevents: A5-low + grouped A7-low:samplesadded to only one of the two sibling judge criteria, with no recorded rationale and no doc/error pointer foragent_judge.
Harness improvements (not statically reachable):
- Cyclomatic-complexity ratchet in
make lint, radon-based with a frozen baseline. Add a whole-tree lint test (CE027–CE031 style) that runsradon.complexity.cc_visitoversrc/coder_evaland fails when a function is newly above CC 10 or exceeds its recorded baseline value, with the baseline checked in as a JSON/TOML map. radon is already a runtime dependency and already wrapped bysrc/coder_eval/scoring/complexity.py, so this adds no tooling. Why not static: Ruff's mccabe implementation scores_grade_with_samplingat 8, not 15 (verified: silent at--select C901 --config lint.mccabe.max-complexity=10; it only trips at 7), so no non-disruptiveC901threshold catches this function — radon's counting does (CC 15, rank C). But a flat radon threshold is not adoptable either: 129 functions insrc/already exceed CC 10 (98 C / 23 D / 6 E / 2 F), topping out atorchestration/experiment.py:789 aggregate_resultsat 48. Hence a baseline ratchet rather than a gate. I also tested and reject the more direct check — flagging duplicate result-construction shapes within a criterion module — as unviable noise: 9 of 14 criterion modules already construct the same*CriterionResultkeyword shape more than once (reference_comparison.py6x,run_command.py4x,uipath_eval.py4x). Prevents: A1-medium:_grade_with_sampling(llm_judge.py:229, CC 15) duplicates_check_impl's result-construction tail into a second judge path that will drift, plus the two error-accumulator lists only ever read at index 0. - Route-matrix fixture for the judge test suite. Add a shared parametrized
judge_routefixture to/Users/religa/src/coder_eval/tests/test_llm_judge_criterion.py(orconftest.py) yielding(route, patch_target)forDirectRoute()->coder_eval.criteria.llm_judge.invoke_anthropic_judgeandBedrockRoute(bearer_token=..., region=...)->invoke_bedrock_judge, then move the behavioral judge tests onto it so every present and future judge test runs on both arms by construction. Once the fixture exists a small lint test can sustain it: any test patchinginvoke_*_judgedirectly must consumejudge_routeor carry an explicit route-specific marker. While there, extendtest_judge_multi_sample_token_usage_summedwithcache_creation_input_tokens/cache_read_input_tokens(the buckets N identical prompts actually populate) and add atoken_usage is Nonecase for_sum_usage's no-usage branch. Why not static: This is a combination gap, not a branch gap: both arms of thematch route:dispatch and thecapture_transcript=Falseearly return are already executed by single-sample tests, so branch coverage (already on —branch = truein[tool.coverage.run]) and any per-file or diff-coverage floor report them as covered. Which feature x route pairs get exercised is a property of test parametrization at runtime; no AST or coverage check can see thatsamples>1was never run on the Bedrock arm. Prevents: A3-low:samples>1exercised only on the Direct/Anthropic arm with default toggles, leaving untested the Bedrock route (the one the nightly uses via--backend bedrock, and the one whose invoker raisesJudgeInfrastructureErrorfor every failure mode — i.e. the escalate-vs-degrade branch),capture_transcript=False, cache-bucket summing, and thetoken_usage is Nonecase. - Close the judge cost/latency blind spot around
samples. Three pieces: (1) fan the N samples out concurrently instead of the serialfor _ in range(criterion.samples):at llm_judge.py:257 — they are independent andcheck_allalready runs off the event loop viaasyncio.to_thread— removing the wall-clock multiplier entirely; (2) add a regression test with a fake judge that sleeps, assertingsamples=Nwall-clock stays ~ one sample (the assertion that keeps it concurrent); (3) give judge spend a gate it currently lacks — arun_limits.max_judge_usdcap or a separate reported judge-budget line — sinceorchestrator.py:848-852documents that judge usage is intentionally excluded from themax_usdaggregate. Also document the trade atdocs/TASK_DEFINITION_GUIDE.md:840: N samples multiply judge spend,max_usddoes not see it, and in simulation mode withcheck_criteria: every_turnthe multiplier compounds to samples x turns against a singletask_timeout. Why not static: The failure is an interaction between accumulated wall-clock and a watchdog (ThreadedWatchdog(timeout_seconds=task_timeout)at orchestrator.py:468 wrapping thecheck_allat :1409, defaulttask_timeout: 600in experiments/default.yaml:29) and between per-call USD and a cap living in a different aggregate — both runtime state no AST can evaluate. I also tested the one plausibly static form — flagging a known network invoker called inside a loop incriteria/— and it detects nothing here: the loop body calls the local_invoke_tool_channel, so the invoker is one level of indirection away and detection would need a call graph. Prevents: A8-medium:samplesruns up to 9 serial judge calls (each with a 120 s transport ceiling, more on Bedrock's retry loop) inside thetask_timeoutwatchdog, able to flip a row toFinalStatus.TIMEOUTfor identical agent output, while multiplying judge spend thatrun_limits.max_usdcannot gate. - Recorded boundary — two findings and one check shape static analysis cannot reach; leave to review, add no rule. (a) The redundant
float(median(scores))at llm_judge.py:297 is redundant only becausescoresislist[float];statistics.medianreturns the element type, sofloat(median(xs))overlist[int]is a real conversion. An AST-only CE rule would therefore be wrong, and there is no type-aware setting to flip — verified ruff 0.15.x has noFURB123(--select FURB123errors out),RUF046is int-only, and pyright'sreportUnnecessaryCastcovers onlytyping.cast. (b) Thesamplesvsdataset.sample_per_stratumvocabulary collision is not mechanically separable from legitimate reuse: a stem-collision check across the YAML-authored config models finds 17 colliding stems today (max_*alone has 11 members,expected_*6), so the rule would be pure noise. Both stay in the reviewer's judgment column, and this bullet is the deliberate record of that. Why not static: (a) needs inferred element types at the call site — pyright's domain, with no corresponding diagnostic available; (b) needs semantic judgment about whether two names in the same YAML surface denote the same concept, with a measured noise floor of 17 pre-existing collisions proving a mechanical version unusable. Prevents: A2-low (redundantfloat()cast) and A7-low (samplescollides with the dataset-rowsample_*vocabulary) — recorded as review-only rather than silently omitted from the lint proposals.
Top 5 Priority Actions
- Run the judge samples concurrently instead of serially at src/coder_eval/criteria/llm_judge.py:257 (they are independent and
check_allis already off the event loop viaasyncio.to_thread), which removes the up-to-9x wall-clock multiplier that today can consume the sharedtask_timeout: 600budget (experiments/default.yaml:29) and flip a row toFinalStatus.TIMEOUTfor identical agent output — worse in simulationcheck_criteria: every_turnmode where it becomes samples x turns. - Stop discarding diagnostics in the sample loop at src/coder_eval/criteria/llm_judge.py:271-272: log each swallowed exception at WARNING with
exc_info=Trueand puttype(exc).__name__into the degraded note, so a systematic first-party regression (e.g. aTypeError/ValidationErrorin verdict parsing that trips 1-in-3 responses) can no longer ship as a green criterion with a quietly shifted median the waycriteria/base.py:79-96would have caught atsamples: 1. - Collapse the two parallel judge paths into one by deleting the
if criterion.samples > 1:dispatch at src/coder_eval/criteria/llm_judge.py:122-129 and always runningrange(criterion.samples)(N=1 is a one-element median and_sum_usageof one usage is that usage), gating only thesample_scoresand degraded-note renderings onsamples > 1— this removes two near-verbatimJudgeCriterionResultconstruction blocks (145-166 vs 286-317) that will otherwise drift apart and drops_grade_with_samplingbelow its current radon C(15). - Add the missing samples>1 coverage in tests/test_llm_judge_criterion.py (the new block at :1084-1131 is Direct/Anthropic-only): one Bedrock multi-sample test — the nightly's
--backend bedrockroute, and the arm whose invoker raisesJudgeInfrastructureErrorfor every failure mode, i.e. the escalate-vs-degrade branch — plus cache-bucket fields and atoken_usage is Nonecase intest_judge_multi_sample_token_usage_summed(:1288), since repeated identical prompts are exactly what populates the cache-read buckets that drive cost. - Close the authoring-surface gaps around the new knob: document the latency/
task_timeoutand ungated-judge-spend trade at docs/TASK_DEFINITION_GUIDE.md:840, record whysamplesisllm_judge-only (soagent_judge: {samples: 3}stops failing with a bareextra_forbidden, src/coder_eval/models/criteria.py:816) and why the bound isle=9(:770), disambiguate the name against the existing datasetsample_*/--samplevocabulary, and while in the file make_build_transcript(llm_judge.py:331) keyword-only and have_invoke_tool_channelreturn_SampleOutcomeby keyword instead of the positional*-splat at :259-268.
Stats: 0 🔴 · 1 🟠 · 3 🟡 · 7 🔵 across 8 axes reviewed.
Why
Judge verdicts are a per-criterion noise source that today can't be isolated or damped:
temperaturealready defaults to0.0, but temperature-0 sampling doesn't guarantee repeatability — the same artifacts can draw a strict or a lenient reading of the rubric on different calls. The test suite says as much: the comment attests/test_agent_judge_criterion.py:1023notes a trailing"\n"made small models "flip between a strict and lenient reading of 'contains', flaking the score", and the live integration test assertsscore >= 0.7rather than a point value for exactly this reason.replicatesre-run the whole agent, so they can't tell judge variance from agent variance — the two mix into the same spread. Per-verdict sampling grades the SAME artifacts N times, so the spread across samples is judge variance by construction, at the cost of a few extra judge calls instead of full agent re-runs.What
New
samplesfield onllm_judge(1–9, default 1):samples: 1never enters the aggregation code. No existing test was modified; the pre-existingtest_llm_judge_criterion.pysuite passes unchanged.rationale,findings, and the persisted transcript, so the audit trail is always a real verdict, never a synthetic blend.details(sample_scores: [0.800, 0.600]) so a reviewer can see the spread that produced the median.submit_verdictcall, or a transport failure) degrades to the median of the valid samples, with a note indetails(1/3 judge samples produced no verdict; median over 2 valid samples). This kills the "one flaky verdict zeroes the criterion" failure mode without touching single-sample semantics.JudgeInfrastructureErrorstill escalates toFinalStatus.ERROR(judge infra failure is not an agent failure — same principlechecker.pystates), unexpected exceptions still reach@handle_criterion_errors, and all-parse-failures still score 0.0 with the diagnostic.Scope is deliberately
llm_judge-only:agent_judgespawns a full SDK sub-agent per verdict, so multi-sampling it has a very different cost profile — better considered separately if wanted.Tests
18 new mocked tests (no live calls) pin the aggregation semantics: default single-invocation with no aggregation artifacts, field bounds, odd/even median, deterministic first-index tie-break, identical prompt across samples, partial-failure degradation (no-verdict / transport / infra, including the minimal N=2 case), all-fail behavior per failure class (infra escalation,
@handle_criterion_errors, first-sample parse diagnostic), usage summing, representative findings + transcript, and reference scrubbing across every multi-sample surface.ruff format --check,ruff check,pyright(0 errors), the CE lint suite, and the full test suite pass locally.Docs
TASK_DEFINITION_GUIDE.mdgains asamplesrow in thellm_judgefield table and a short "Sampling the judge" paragraph covering when to reach for it and how it differs fromreplicates.