mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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 commitf4c58f2869. * 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 commit9619734307. * 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>
206 lines
13 KiB
TypeScript
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;
|
|
}
|