Trial-based verdict — pass@k, flakiness & statistical regression
Status: M1 in progress. Slice 1 (contracts + pure aggregation math) landed; slices 2–5 follow. SSOT for how Everdict turns N repeated trials of a case into a defensible verdict instead of a single noisy pass/fail.
Why
A single run of an agent case is a coin flip: the same harness on the same case can pass once and fail the next time (non-determinism in the model, the environment, timeouts, tool flakiness). A scorecard built from one run per case therefore reports noise as signal — and a version diff off two single runs flags "regressions" that are just variance.
The eval literature's answer is to run each case N times and report:
- pass@k — the unbiased probability that a size-
ksample of the N trials contains ≥1 pass (Chen et al., 2021, Evaluating LLMs Trained on Code).pass@1= the mean per-case pass rate;pass@k(k = trials) = "did the agent solve it at least once in k attempts". - flakiness — a case that both passes and fails across its trials (
0 < passes < trials). - statistical regression gate — a case counts as regressed only when the drop in pass rate is beyond sampling noise (a two-proportion test), not on a single pass→fail flip.
This is the differentiator the market thesis calls out: a verdict, not another scoring dashboard. Everdict = eval + verdict; trials are what make the verdict defensible.
Data model — trials are just repeated CaseResults
A trial is one execution of a case. We do not introduce a new aggregate wire type; instead a
CaseResult carries an optional trial index (packages/contracts/src/execution/eval-case.ts):
CaseResult { caseId, harness, trial?, trace, snapshot, scores, failure?, … }
trialabsent (or0) = a single-run case — fully backward compatible. Every existing scorecard, ingest path, and child run keeps working unchanged (each case has exactly one result).trial: 0..N-1= the i-th repetition. AScorecard.resultsarray may hold N entries with the samecaseId, distinguished bytrial. The child-run model already fans a case into an addressableRunRecord; trials extend that to N children per case (slice 2).
Aggregation groups results by caseId; the per-trial verdict reuses the existing authority-ranked
caseVerdict (ground-truth > objective > judge). Nothing about how a single trial is judged changes.
Pure math (packages/domain/src/scorecard/trials.ts) — slice 1
All pure, dependency-free, no I/O — same discipline as scorecard.ts/leaderboard.ts.
passAtK(n, c, k)— unbiased estimator1 - C(n-c, k)/C(n, k), computed in the numerically stable product form from the paper's reference code.kis clamped ton(pass@k with k>n is undefined → treated as pass@n).pass@1 = c/n. ThrowsBadRequestErroronn<=0,c∉[0,n],k<=0.groupTrials(sc)—Map<caseId, CaseResult[]>, insertion-ordered.caseTrialStats(caseId, results)—{ trials, passes, passRate, flaky }, counting only trials whosecaseVerdictis defined (a case with no pass-deciding grader is excluded, same asscorecardPassRate).summarizeTrials(sc, k?)— scorecard roll-up{ cases, minTrials, maxTrials, passAt1, k, passAtK, flakyCases, flakeRate }.passAt1/passAtKare means over cases (each case weighted once, regardless of trial count).kdefaults tomaxTrials; per case it is clamped to that case's trials.diffTrials(baseline, candidate, opts?)— the statistical regression gate. Per shared case it runs a two-proportion z-test on(passes/trials)baseline vs candidate:p̂=(c_b+c_c)/(n_b+n_c),se=√(p̂(1-p̂)(1/n_b+1/n_c)),z=(p_c-p_b)/se. A case is a regression only whenz ≤ -zThreshold(default1.96, i.e. 95%) and the rate dropped; an improvement whenz ≥ +zThresholdand it rose. Cases with zero scored trials on either side are skipped (can't compare).
diffTrials is the trial-aware sibling of diffScorecards; the single-run diffScorecards
(pass-transition based) is left untouched for trials=1 batches.
Slices
- Contracts + pure math (this doc +
trials.ts+ tests) —trialonCaseResult; pass@k / flakiness / statistical diff. ✅ Green incore+suite, no wiring yet. runSuiteN-trial fan-out — atrialsknob expands each case into N jobs (trialstamped);ScorecardServicethreadstrialsfromRunScorecardInput, one child run per trial.- Persist + read —
ScorecardRecordgains a lightweighttrialSummary(likesummary/models);finalizeBatch/trackcompute it;GET /scorecards/:idreturns it. - API/MCP parity —
trialsparam onPOST /scorecards+run_scorecard;GET /scorecards/diffusesdiffTrialswhen either side has trials; a?statisticalopt-in on the gate. - Web — pass@k / flake-rate surfaced on the scorecard detail + diff, through the shared score atoms.
Non-goals (for now)
- Adaptive/early-stopping trial counts (run more trials only for flaky cases). Fixed N first.
- Bayesian / bootstrap intervals. The normal-approx two-proportion test is the defensible first cut;
swap the estimator behind
diffTrialslater without changing callers. - pass^k (all-k-pass) and other estimators — add as pure fns beside
passAtKwhen a benchmark needs them.