Files
gstack/scripts/lib/measure-bar.ts
T
Garry Tanandcapy-ai[bot] db745675bd v1.91.33.0 feat(ship): measurement bar with explained-red and pooled decisions, weekly flaky-test sweep on main (#3059)
* refactor(eval-history): share pass-rate history loading as loadPassRateHistory()

eval:pass-rates' main block loaded local dirs or the weekly trial-outcomes
artifacts inline. Move that into an exported function returning records,
scope and fetch errors, so the off-ship sweep reads the same history the
same way. Behavior unchanged.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(ship-measure): the measurement bar and the weekly off-ship sweep CLI

Decide each diagnostic measurement with one bar (scripts/lib/measure-bar.ts):
MEETS at 9/10; MEETS-qualified at 8/10 when every red is a mechanical
provider error, judge noise or a cited model miss; EXTEND at 7/10 (or an
unqualified 8/10) for one more batch on identical inputs, decided once on
the pooled 20; BELOW otherwise and for any timeout, hang, regression or
fixable cause. Behavior uses 12 trials (11/10/9; 22/20 of 24); judge counts
median-panel outputs. A batch with more than 30% provider-evidence failures
is void and redispatched once. Below-strict reds nobody classified return
needs-classify.

ship-measure gains classify (append-only records; provider refused without
evidence), decide and extend (refuses a changed identity, names the field,
never a third batch), exit codes 5 and 6, and a pooling identity (frozen
tree, runner, CLI, model, CI image, Bun, policy and harness versions).

ship-measure sweep ranks main's gate cases by their cost to the all-green
probability from eval:pass-rates history, measures the top K with the same
runner and bar under a weekly cap shared with the last 7 days of sweep
reports (ship_measure_sweep_cases, ship_measure_sweep_budget_usd), and
writes a report with the predicted all-green probability before and after.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(ship): the measure loop decides by the bar's exit codes

Step 4 branches on MEETS/MEETS-qualified, needs-classify (with the
qualifying and fix-round classes), EXTEND once and BELOW; the rest of the
loop prose is tightened to offset most of the growth. ship's union size
cap moves from 1.495 to 1.4975 (measured 281,058 / 187,706 = 1.4973).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci: weekly eval-sweep workflow on main

Mondays 12:00 UTC and on dispatch (k, cap_usd, dry_run): runs
ship-measure sweep in the published CI image by digest with the census's
paid-eval setup, downloads the last 7 days of sweep reports first so the
weekly cap holds across runs, uploads the report and captures, and writes
the job summary. Read-only grants; it never pushes or opens a pull request.
One sweep at a time repository-wide, never cancelled mid-spend.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: the measurement bar table and the weekly sweep

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: v1.91.33.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(temp): run eval-sweep on push to this branch (K=2, $25) for end-to-end validation

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Revert "ci(temp): run eval-sweep on push to this branch (K=2, $25) for end-to-end validation"

This reverts commit f4c58f2869.

* ci: classify eval-sweep.yml, the sweep CLI and actionlint labels as free-only PR inputs

None is read by a PR-gate case, so a change to them selects no paid case
instead of restoring the full gate (test/free-fixtures.test.ts workflow
inventory).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-measure): the selection preflight accepts name-mode cases

A case registered beside others in its file lists its trial shard as
<file>#<id>~t1, not <file> (trial 1), so the preflight refused every
name-mode case (for example ship-exploratory-late-input) before any paid
trial. Accept both forms.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(temp): run eval-sweep on push to this branch (K=2, $25) for end-to-end validation

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Revert "ci(temp): run eval-sweep on push to this branch (K=2, $25) for end-to-end validation"

This reverts commit 9619734307.

* test: record the test-pr-profile.ts change as non-behavioral for HARNESS_VERSION

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): the name-mode preflight fix

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-measure): a partly funded sweep row shows the trials that ran

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(sweep): the fake single-case command is a Bun script, so it runs on Windows

The Windows free lane runs test/ship-measure-sweep.test.ts; CreateProcess
cannot exec a shebang shell stub, so every fake trial errored and the cap
stopped the sweep after one trial.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(plan-mode-no-op): the CEO sub-test reviews a fixed pasted plan

Without a plan, plan-ceo-review reviewed the checkout's branch diff, so its
pre-question work (audit and landscape searches) grew with whatever PR the
gate ran on. On this PR it timed out at 300s in 3 of 10 trials while still
researching the diff (ship-measure baseline 7/10). With a fixed small plan,
the same way the devex sub-test already runs, it measured 10/10 (CEO
sub-test 83-255s). No budget or threshold changed.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-measure): each diagnostic trial gets its own EVALS_RUN_ID

Evidence-retaining cases (functional QA, docs faults) refuse to start
without EVALS_RUN_ID, which only CI sets, so measuring them locally ran
0/10 sessions. qa-functional-cli-report, red once in this PR's gate,
measured 10/10 once the trials started ($11.13).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-07 14:00:06 +00:00

206 lines
13 KiB
TypeScript

/**
* The ship-measure measurement bar (approved by Garry 2026-10-06): one pure
* decision over a case's diagnostic trials, shared by /ship's measure loop
* (scripts/ship-measure.ts) and the weekly off-ship sweep
* (scripts/ship-measure-sweep.ts). Nothing here runs a trial or reads a file.
*
* For N trials per batch (rule and judge N = 10; behavior N = 12, four panels
* of 3; a judge trial is one output scored by its median 3-sample panel), the
* counts round up from 9/10, 8/10 and 7/10 of N:
*
* MEETS passes >= 9/10 of N.
* MEETS-qualified passes >= 8/10 of N and every failed trial is in a
* qualifying class: provider (affirmative provider or
* transport evidence only), judge-noise, or model-miss
* citing its evidence (path:line).
* EXTEND passes >= 7/10 of N without meeting either bar, and no red
* has a known fixable cause: run N more trials on identical
* inputs, then decide once on the pooled 2N (>= 9/10 strict,
* >= 8/10 qualified). There is never a third batch.
* BELOW everything else, and any red in a fix-round class
* (timeout, hang, regression, fixable) or a contract
* violation: a fix round.
*
* Before the counts: a batch where more than 30% of trials fail with
* affirmative provider evidence is void (an outage, not a measurement) and may
* be redispatched once; both batches are reported. A batch below strict whose
* failed trials are not all classified returns needs-classify when the
* classification decides the outcome (passes at or above the 7/10 floor).
*/
export type BarKind = 'rule' | 'behavior' | 'judge';
export type TrialSet = 'initial' | 'redispatch' | 'extend' | 'extend-redispatch';
export const QUALIFYING_CLASSES = ['provider', 'judge-noise', 'model-miss'] as const;
export const FIX_ROUND_CLASSES = ['timeout', 'hang', 'regression', 'fixable'] as const;
export const FAILURE_CLASSES = [...QUALIFYING_CLASSES, ...FIX_ROUND_CLASSES, 'unclassified'] as const;
export type FailureClass = (typeof FAILURE_CLASSES)[number];
/** A batch is void when more than this share of its trials fail on affirmative provider evidence. */
export const VOID_SHARE = { num: 3, den: 10 } as const;
export interface BarTrial {
trial: number;
set: TrialSet;
passed: boolean;
contract?: boolean;
/** Judge outputs: each sample's pass; the output passes at 2 of 3. */
samples?: boolean[];
failureCause?: string;
failureDetail?: string;
failureEvidence?: string;
}
export interface Classification { trial: number; class: FailureClass; evidence: string; by: 'machine' | 'agent'; at: string }
export type BarDecision = 'MEETS' | 'MEETS-qualified' | 'EXTEND' | 'BELOW' | 'needs-classify' | 'void' | 'incomplete';
export type BarNext = 'none' | 'redispatch' | 'extend' | 'classify' | 'fix' | 'stop';
export interface BarThresholds { trials: number; strict: number; qualified: number; floor: number }
export interface SetSummary { set: TrialSet; trials: number; passes: number; providerFailures: number; void: boolean }
export interface BarOutcome {
decision: BarDecision;
next: BarNext;
passes: number;
thresholds: BarThresholds;
pooled: boolean;
sets: SetSummary[];
/** Failed counted trials that still need a class. */
needsClassify: number[];
reason: string;
}
const atLeast = (n: number, num: number) => Math.ceil((n * num) / 10);
/** Strict 9/10, qualified 8/10 and extend floor 7/10 of n, rounded up (10: 9/8/7; 12: 11/10/9; 20: 18/16/14; 24: 22/20/17). */
export function barThresholds(n: number): BarThresholds {
return { trials: n, strict: atLeast(n, 9), qualified: atLeast(n, 8), floor: atLeast(n, 7) };
}
/** A trial's pass under the bar: a judge output at 2 of its 3 samples; a contract violation never passes. */
export function trialPassed(kind: BarKind, t: BarTrial): boolean {
if (t.contract) return false;
if (kind === 'judge' && t.samples) return t.samples.length === 3 && t.samples.filter(Boolean).length >= 2;
return t.passed;
}
const PROVIDER_TEXT = /\b(?:429|5\d\d)\b|rate[ _-]?limit|overloaded|api_error|ECONNRESET|connection reset|socket hang up|ETIMEDOUT|EAI_AGAIN|service unavailable|bad gateway/i;
const TIMEOUT_CAUSES = new Set(['session_timeout', 'observer_timeout', 'provider_stall']);
/**
* The affirmative provider or transport evidence for a failed trial, or null.
* `api_error` is provider evidence by itself; `pre_turn_infra` only when its
* evidence or detail names a 429/5xx, rate limit, overload or connection reset
* (a crash or module-load failure is a harness defect, never an outage).
*/
export function providerEvidence(t: BarTrial): string | null {
if (t.passed && !t.contract) return null;
const text = [t.failureEvidence, t.failureDetail].filter(Boolean).join(' ');
if (t.failureCause === 'api_error') return text || 'failure_cause api_error';
if (t.failureCause === 'pre_turn_infra' && PROVIDER_TEXT.test(text)) return text;
return null;
}
/** The class a failed trial gets without review: provider on affirmative evidence; timeout or hang from its cause. */
export function machineClass(t: BarTrial): FailureClass | null {
if (providerEvidence(t)) return 'provider';
if (t.failureCause === 'provider_stall') return 'hang';
if (t.failureCause && TIMEOUT_CAUSES.has(t.failureCause)) return 'timeout';
return null;
}
const CITATION = /[\w./-]+:\d+/;
/** Why an agent classification is refused, or null when it is accepted. */
export function classificationProblem(t: BarTrial | undefined, kind: BarKind, cls: string, evidence: string): string | null {
if (!t) return 'no such trial in this measurement';
if (trialPassed(kind, t)) return `trial ${t.trial} passed; only failed trials are classified`;
if (!(FAILURE_CLASSES as readonly string[]).includes(cls)) return `class must be one of ${FAILURE_CLASSES.join(', ')}. Received: ${cls}`;
if (!evidence.trim()) return 'every classification needs --evidence (what you read: a capture path:line, a failure line, a transcript line)';
if (cls === 'provider' && !providerEvidence(t)) {
return `provider needs affirmative provider or transport evidence (failure_cause api_error, or pre_turn_infra naming a 429/5xx, rate limit or connection reset); trial ${t.trial}'s failure_cause is ${t.failureCause ?? 'unrecorded'}`;
}
if ((QUALIFYING_CLASSES as readonly string[]).includes(cls) && t.failureCause && TIMEOUT_CAUSES.has(t.failureCause)) {
return `trial ${t.trial} failed on ${t.failureCause}: a timeout or hang never qualifies; it goes to a fix round`;
}
return null;
}
/** Each failed trial's effective class: its latest agent record, else its machine class. */
export function effectiveClasses(trials: readonly BarTrial[], records: readonly Classification[]): Map<number, Classification> {
const out = new Map<number, Classification>();
for (const t of trials) {
const cls = machineClass(t);
if (cls) out.set(t.trial, { trial: t.trial, class: cls, evidence: providerEvidence(t) ?? t.failureCause ?? '', by: 'machine', at: '' });
}
for (const r of records) if (r.by === 'agent') out.set(r.trial, r);
return out;
}
const qualifies = (c: Classification) => (QUALIFYING_CLASSES as readonly string[]).includes(c.class) && (c.class !== 'model-miss' || CITATION.test(c.evidence));
const fixRound = (c: Classification) => (FIX_ROUND_CLASSES as readonly string[]).includes(c.class);
function summarize(kind: BarKind, set: TrialSet, trials: BarTrial[]): SetSummary {
const providerFailures = trials.filter(t => !trialPassed(kind, t) && providerEvidence(t)).length;
return { set, trials: trials.length, passes: trials.filter(t => trialPassed(kind, t)).length, providerFailures,
void: trials.length > 0 && providerFailures * VOID_SHARE.den > trials.length * VOID_SHARE.num };
}
/** The counted batch of a pair (first try, its one redispatch), or a void/incomplete stop. */
function countedBatch(n: number, first: SetSummary, retry: SetSummary, firstTrials: BarTrial[], retryTrials: BarTrial[], label: string):
{ trials: BarTrial[] } | { stop: Pick<BarOutcome, 'decision' | 'next' | 'reason'> } {
const use = first.void ? retry : first;
if (use.trials === 0) return { stop: { decision: 'void', next: 'redispatch', reason: `${label} batch is void: ${first.providerFailures} of ${first.trials} trials failed on provider evidence (> 30%); redispatch it once` } };
if (use.void) return { stop: { decision: 'void', next: 'stop', reason: `${label} batch and its one redispatch are both void (provider evidence in ${first.providerFailures}/${first.trials} and ${retry.providerFailures}/${retry.trials}); stop with a named infrastructure red` } };
if (use.trials < n) return { stop: { decision: 'incomplete', next: 'stop', reason: `${label} batch ran ${use.trials} of ${n} trials` } };
return { trials: first.void ? retryTrials : firstTrials };
}
/**
* The bar's decision over a measurement's trials and its classification
* records (append-only; the latest agent record for a trial wins).
*/
export function decide(kind: BarKind, n: number, trials: readonly BarTrial[], records: readonly Classification[] = []): BarOutcome {
const of = (set: TrialSet) => trials.filter(t => t.set === set);
const sets = (['initial', 'redispatch', 'extend', 'extend-redispatch'] as const).map(set => summarize(kind, set, of(set)));
const [initial, redispatch, extend, extendRedispatch] = sets as [SetSummary, SetSummary, SetSummary, SetSummary];
const reported = sets.filter(s => s.trials > 0);
const base = countedBatch(n, initial, redispatch, of('initial'), of('redispatch'), 'the first');
const empty = { passes: 0, pooled: false, sets: reported, needsClassify: [] as number[] };
if ('stop' in base) return { ...empty, thresholds: barThresholds(n), ...base.stop };
const pooled = extend.trials > 0;
const ext = pooled ? countedBatch(n, extend, extendRedispatch, of('extend'), of('extend-redispatch'), 'the extension') : { trials: [] };
if ('stop' in ext) return { ...empty, pooled, thresholds: barThresholds(2 * n), ...ext.stop };
const counted = [...base.trials, ...ext.trials];
const thresholds = barThresholds(counted.length);
const passes = counted.filter(t => trialPassed(kind, t)).length;
const failed = counted.filter(t => !trialPassed(kind, t));
const classes = effectiveClasses(failed, records);
const result = (decision: BarDecision, next: BarNext, reason: string, needsClassify: number[] = []): BarOutcome =>
({ decision, next, passes, thresholds, pooled, sets: reported, needsClassify, reason });
const count = `${passes}/${counted.length}${pooled ? ' pooled' : ''}`;
const contract = failed.find(t => t.contract);
if (contract) return result('BELOW', 'fix', `${count}; trial ${contract.trial} violated a contract: a fix round, whatever the count`);
if (passes >= thresholds.strict) return result('MEETS', 'none', `${count} meets the strict bar (${thresholds.strict}/${counted.length})`);
if (passes < (pooled ? thresholds.qualified : thresholds.floor)) {
return result('BELOW', 'fix', `${count} is under the ${pooled ? `qualified pooled bar (${thresholds.qualified}/${counted.length})` : `extend floor (${thresholds.floor}/${counted.length})`}: a fix round`);
}
const unclassified = failed.filter(t => !classes.has(t.trial)).map(t => t.trial);
if (unclassified.length) return result('needs-classify', 'classify', `${count} is below strict; classify trial(s) ${unclassified.map(t => `t${t}`).join(', ')}, then decide`, unclassified);
const fix = failed.find(t => fixRound(classes.get(t.trial)!));
if (fix) return result('BELOW', 'fix', `${count}; trial ${fix.trial} is ${classes.get(fix.trial)!.class}, which never qualifies: a fix round`);
if (passes >= thresholds.qualified && failed.every(t => qualifies(classes.get(t.trial)!))) {
return result('MEETS-qualified', 'none', `${count} meets the qualified bar (${thresholds.qualified}/${counted.length}); every red qualifies (${failed.map(t => `t${t.trial} ${classes.get(t.trial)!.class}`).join(', ')})`);
}
if (pooled) return result('BELOW', 'fix', `${count} does not meet the pooled bar (${thresholds.strict} strict, or ${thresholds.qualified} with every red qualifying); there is no third batch: a fix round`);
return result('EXTEND', 'extend', `${count} is at or above the extend floor (${thresholds.floor}/${counted.length}) without meeting a bar: run ${n} more trials on identical inputs, then decide once on the pooled ${2 * n}`);
}
/** The first identity field that differs between two batches, or null when they may pool. */
export function identityMismatch(a: Record<string, string>, b: Record<string, string>): string | null {
for (const key of [...new Set([...Object.keys(a), ...Object.keys(b)])].sort()) {
if (a[key] !== b[key]) return `${key} changed (${a[key] ?? 'absent'} -> ${b[key] ?? 'absent'})`;
}
return null;
}