@workos/quickstudy 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/adapters/oracle.ts +67 -0
- package/src/adapters/types.ts +13 -0
- package/src/build-info.generated.ts +1 -1
- package/src/cli.ts +26 -3
- package/src/diagnose/excerpt.ts +19 -8
- package/src/diagnose/select.ts +1 -1
- package/src/diagnose/types.ts +5 -2
- package/src/evals/discovery.ts +32 -2
- package/src/evals/result.ts +1 -1
- package/src/evals/types.ts +12 -0
- package/src/export-types.ts +7 -0
- package/src/export.ts +15 -4
- package/src/manifest.ts +6 -0
- package/src/report/aggregate.ts +133 -0
- package/src/report/report.ts +137 -7
- package/src/report/scorer-health.ts +104 -0
- package/src/runner/context.ts +19 -2
- package/src/runner/execute.ts +148 -61
- package/src/runner/oracle-validate.ts +109 -0
- package/src/runner/workspace-source.ts +34 -0
- package/src/store/db.ts +58 -2
- package/src/store/schema.ts +7 -1
- package/ui/dist/index.html +11 -11
package/src/report/report.ts
CHANGED
|
@@ -32,6 +32,12 @@ import { activeAttempts, type AttemptRecord, type ResultsStore, type RunRecord }
|
|
|
32
32
|
import type { SurfaceUsage } from "../surface-usage.ts";
|
|
33
33
|
import { collectPassAtK, displayPassAtK, type PassAtKEntry } from "./pass-at-k.ts";
|
|
34
34
|
import { estimateRunCosts, type Pricing, type RunCostEstimate } from "./costs.ts";
|
|
35
|
+
import { collectBands, collectScorerHealth, type EvalBand, type ScorerHealth } from "./scorer-health.ts";
|
|
36
|
+
import { collectExperimentAggregate, compareLeaderboard, type ExperimentAggregate } from "./aggregate.ts";
|
|
37
|
+
|
|
38
|
+
export type { EvalBand, ScorerHealth, ScorerHealthSuspect } from "./scorer-health.ts";
|
|
39
|
+
export type { ExperimentAggregate } from "./aggregate.ts";
|
|
40
|
+
export { compareLeaderboard } from "./aggregate.ts";
|
|
35
41
|
|
|
36
42
|
/** Loud, CLI-friendly failures (unknown run, missing report.json, ...). */
|
|
37
43
|
export class ReportError extends Error {
|
|
@@ -41,7 +47,7 @@ export class ReportError extends Error {
|
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
export const REPORT_SCHEMA_VERSION =
|
|
50
|
+
export const REPORT_SCHEMA_VERSION = 9;
|
|
45
51
|
|
|
46
52
|
/**
|
|
47
53
|
* Substrings that must never appear in generated report prose: at n≤5 trials
|
|
@@ -68,7 +74,12 @@ export function methodologyNote(trials: number): string {
|
|
|
68
74
|
`same scored counts: pass@k is the fraction of size-k subsets of that pair's scored trials containing at ` +
|
|
69
75
|
`least one pass, and pass^k is (passed/scored)^k, the all-pass rate for k with-replacement draws from ` +
|
|
70
76
|
`those trials. Both describe this run's trials only — no wider claim. Telemetry (cost, tokens, surface ` +
|
|
71
|
-
`usage) is summed only over attempts that reported it and shown as unavailable — never zero — when none did
|
|
77
|
+
`usage) is summed only over attempts that reported it and shown as unavailable — never zero — when none did. ` +
|
|
78
|
+
`SDK recall is reported per experiment as recalled / attempts, where attempts are the scored attempts that ` +
|
|
79
|
+
`recorded at least one ungraded check and recalled are those whose ungraded checks all passed. Ungraded ` +
|
|
80
|
+
`checks are recorded and tallied but never enter pass/fail. Scorer health lists checks that failed in every ` +
|
|
81
|
+
`scored attempt of an eval under every arm while another check in the same attempts passed: a statement ` +
|
|
82
|
+
`about the instrument, not about any treatment.`
|
|
72
83
|
);
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -83,17 +94,46 @@ export interface ReportAgentIdentity {
|
|
|
83
94
|
cli_version: string;
|
|
84
95
|
}
|
|
85
96
|
|
|
97
|
+
/**
|
|
98
|
+
* SDK recall for one experiment, derived from `graded: false` checks. Only
|
|
99
|
+
* scored (completed) attempts count; graded timeouts are completed.
|
|
100
|
+
*/
|
|
101
|
+
export interface ReportRecall {
|
|
102
|
+
/** Scored attempts of this experiment whose checks include at least one graded:false check. */
|
|
103
|
+
attempts: number;
|
|
104
|
+
/** Of those, attempts where every graded:false check passed. */
|
|
105
|
+
recalled: number;
|
|
106
|
+
/** recalled / attempts; null when attempts = 0. */
|
|
107
|
+
rate: number | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
86
110
|
/** One experiment's identity card: what exactly ran under this id. */
|
|
87
111
|
export interface ReportExperiment {
|
|
88
112
|
experiment_id: string;
|
|
89
113
|
treatment: string | null;
|
|
90
114
|
agent: ReportAgentIdentity;
|
|
115
|
+
/**
|
|
116
|
+
* Optional on the wire: absent in schema_version < 7 artifacts, which the
|
|
117
|
+
* export may still carry — readers must tolerate absence.
|
|
118
|
+
*/
|
|
119
|
+
sdk_recall?: ReportRecall;
|
|
120
|
+
/**
|
|
121
|
+
* Leaderboard row over benchmark-suite attempts. Optional on the wire:
|
|
122
|
+
* absent in schema_version < 9 artifacts; readers must tolerate absence.
|
|
123
|
+
*/
|
|
124
|
+
aggregate?: ExperimentAggregate;
|
|
91
125
|
}
|
|
92
126
|
|
|
93
127
|
export interface ReportCheck {
|
|
94
128
|
name: string;
|
|
95
129
|
passed: number;
|
|
96
130
|
total: number;
|
|
131
|
+
/**
|
|
132
|
+
* false when any attempt recorded this check with `graded: false` (a scorer
|
|
133
|
+
* that flips the flag mid-run marks the whole tally ungraded). Absent in
|
|
134
|
+
* schema_version < 7 artifacts; readers treat absence as graded.
|
|
135
|
+
*/
|
|
136
|
+
graded: boolean;
|
|
97
137
|
}
|
|
98
138
|
|
|
99
139
|
/** One eval × experiment aggregation. Pairs with zero attempts are ABSENT, never 0%. */
|
|
@@ -120,6 +160,12 @@ export interface ReportPair {
|
|
|
120
160
|
/** Completed trials disagreed on the outcome. */
|
|
121
161
|
flaky: boolean;
|
|
122
162
|
checks: ReportCheck[];
|
|
163
|
+
/**
|
|
164
|
+
* The eval's band, denormalised onto each of its pairs: decided over the
|
|
165
|
+
* eval's pairs with scored > 0; null when fewer than 2 arms scored. Absent
|
|
166
|
+
* in schema_version < 8 artifacts; readers treat absence as unbanded.
|
|
167
|
+
*/
|
|
168
|
+
band: EvalBand | null;
|
|
123
169
|
}
|
|
124
170
|
|
|
125
171
|
export interface ReportGroupArmCount {
|
|
@@ -229,6 +275,12 @@ export interface ReportJson {
|
|
|
229
275
|
*/
|
|
230
276
|
experiments?: ReportExperiment[];
|
|
231
277
|
pairs: ReportPair[];
|
|
278
|
+
/**
|
|
279
|
+
* Checks that failed under every arm while another graded check passed in
|
|
280
|
+
* the same attempts. Absent in schema_version < 8 artifacts; readers must
|
|
281
|
+
* tolerate absence.
|
|
282
|
+
*/
|
|
283
|
+
scorer_health?: ScorerHealth;
|
|
232
284
|
groups: ReportGroup[];
|
|
233
285
|
usage: ReportExperimentUsage[];
|
|
234
286
|
egress_denials: EgressDenialSummary[];
|
|
@@ -244,7 +296,7 @@ export interface ReportJson {
|
|
|
244
296
|
const USAGE_VALUE_CAP = 20;
|
|
245
297
|
|
|
246
298
|
export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
|
|
247
|
-
const pairs = new Map<string, { pair: ReportPair; checks: Map<string, { passed: number; total: number }>; outcomes: Set<boolean> }>();
|
|
299
|
+
const pairs = new Map<string, { pair: ReportPair; checks: Map<string, { passed: number; total: number; graded: boolean }>; outcomes: Set<boolean> }>();
|
|
248
300
|
for (const attempt of attempts) {
|
|
249
301
|
const key = `${attempt.evalId}|${attempt.experimentId}`;
|
|
250
302
|
let entry = pairs.get(key);
|
|
@@ -263,6 +315,7 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
|
|
|
263
315
|
pass_rate: null,
|
|
264
316
|
flaky: false,
|
|
265
317
|
checks: [],
|
|
318
|
+
band: null,
|
|
266
319
|
},
|
|
267
320
|
checks: new Map(),
|
|
268
321
|
outcomes: new Set(),
|
|
@@ -280,9 +333,10 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
|
|
|
280
333
|
entry.outcomes.add(false);
|
|
281
334
|
}
|
|
282
335
|
for (const check of attempt.result?.checks ?? []) {
|
|
283
|
-
const tally = entry.checks.get(check.name) ?? { passed: 0, total: 0 };
|
|
336
|
+
const tally = entry.checks.get(check.name) ?? { passed: 0, total: 0, graded: true };
|
|
284
337
|
tally.total += 1;
|
|
285
338
|
if (check.passed) tally.passed += 1;
|
|
339
|
+
if (check.graded === false) tally.graded = false;
|
|
286
340
|
entry.checks.set(check.name, tally);
|
|
287
341
|
}
|
|
288
342
|
}
|
|
@@ -294,7 +348,7 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
|
|
|
294
348
|
pair.pass_at_k = collectPassAtK(pair.passed, pair.scored);
|
|
295
349
|
pair.flaky = outcomes.size > 1;
|
|
296
350
|
pair.checks = [...checks.entries()]
|
|
297
|
-
.map(([name, tally]) => ({ name, passed: tally.passed, total: tally.total }))
|
|
351
|
+
.map(([name, tally]) => ({ name, passed: tally.passed, total: tally.total, graded: tally.graded }))
|
|
298
352
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
299
353
|
result.push(pair);
|
|
300
354
|
}
|
|
@@ -340,7 +394,30 @@ function treatmentOf(run: RunRecord, experimentId: string): string | null {
|
|
|
340
394
|
* experiment; "unspecified" is its own explicit missing-value sentinel, so
|
|
341
395
|
* this never invents a value the run did not pin.
|
|
342
396
|
*/
|
|
343
|
-
|
|
397
|
+
/**
|
|
398
|
+
* SDK recall over one experiment's scored attempts: the denominator is every
|
|
399
|
+
* completed attempt that recorded at least one `graded: false` check; an
|
|
400
|
+
* attempt is recalled when all of those checks passed. Null rate at zero.
|
|
401
|
+
*/
|
|
402
|
+
export function collectRecall(attempts: AttemptRecord[], experimentId: string): ReportRecall {
|
|
403
|
+
let total = 0;
|
|
404
|
+
let recalled = 0;
|
|
405
|
+
for (const attempt of attempts) {
|
|
406
|
+
if (attempt.experimentId !== experimentId || attempt.status !== "completed") continue;
|
|
407
|
+
const ungraded = (attempt.result?.checks ?? []).filter((check) => check.graded === false);
|
|
408
|
+
if (ungraded.length === 0) continue;
|
|
409
|
+
total += 1;
|
|
410
|
+
if (ungraded.every((check) => check.passed)) recalled += 1;
|
|
411
|
+
}
|
|
412
|
+
return { attempts: total, recalled, rate: total > 0 ? recalled / total : null };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function collectExperiments(
|
|
416
|
+
run: RunRecord,
|
|
417
|
+
attempts: AttemptRecord[],
|
|
418
|
+
pairs: ReportPair[],
|
|
419
|
+
costEstimate: RunCostEstimate | undefined,
|
|
420
|
+
): ReportExperiment[] {
|
|
344
421
|
return [...run.config.experimentIds]
|
|
345
422
|
.sort((a, b) => a.localeCompare(b))
|
|
346
423
|
.map((experimentId) => {
|
|
@@ -355,6 +432,8 @@ function collectExperiments(run: RunRecord): ReportExperiment[] {
|
|
|
355
432
|
reasoning: agent?.reasoning ?? "unspecified",
|
|
356
433
|
cli_version: agent?.cliVersion ?? "unspecified",
|
|
357
434
|
},
|
|
435
|
+
sdk_recall: collectRecall(attempts, experimentId),
|
|
436
|
+
aggregate: collectExperimentAggregate(attempts, pairs, experimentId, costEstimate),
|
|
358
437
|
};
|
|
359
438
|
});
|
|
360
439
|
}
|
|
@@ -520,6 +599,9 @@ export function buildReport(options: BuildReportOptions): ReportJson {
|
|
|
520
599
|
if (publication && publication.eligible.length === 0) throw new ReportError(`no eligible complete samples for run ${runId}: ${publication.omitted.map((p) => `${p.eval_id} × ${p.experiment_id}: ${p.reasons.join("; ")}`).join(" | ")}`);
|
|
521
600
|
const attempts = publication ? current.filter((a) => publication.eligible.some((p) => p.eval_id === a.evalId && p.experiment_id === a.experimentId)) : current;
|
|
522
601
|
const pairs = collectPairs(attempts);
|
|
602
|
+
const bands = collectBands(pairs);
|
|
603
|
+
for (const pair of pairs) pair.band = bands.get(pair.eval_id) ?? null;
|
|
604
|
+
const scorerHealth = collectScorerHealth(attempts, pairs);
|
|
523
605
|
const costEstimate = options.pricing === undefined ? undefined : estimateRunCosts(run, attempts, options.resultsDir, options.pricing);
|
|
524
606
|
|
|
525
607
|
const methodology = methodologyNote(run.config.trials) + (costEstimate === undefined ? "" :
|
|
@@ -528,6 +610,10 @@ export function buildReport(options: BuildReportOptions): ReportJson {
|
|
|
528
610
|
if (banned.length > 0) {
|
|
529
611
|
throw new ReportError(`report methodology violates the language guard: ${banned.join(", ")}`);
|
|
530
612
|
}
|
|
613
|
+
const bannedSummary = findBannedLanguage(scorerHealth.summary);
|
|
614
|
+
if (bannedSummary.length > 0) {
|
|
615
|
+
throw new ReportError(`report scorer-health summary violates the language guard: ${bannedSummary.join(", ")}`);
|
|
616
|
+
}
|
|
531
617
|
|
|
532
618
|
return {
|
|
533
619
|
schema_version: REPORT_SCHEMA_VERSION,
|
|
@@ -550,8 +636,9 @@ export function buildReport(options: BuildReportOptions): ReportJson {
|
|
|
550
636
|
telemetry: collectTelemetry(attempts),
|
|
551
637
|
...(costEstimate === undefined ? {} : { cost_estimate: costEstimate }),
|
|
552
638
|
judge_telemetry: collectJudgeTelemetry(attempts),
|
|
553
|
-
experiments: collectExperiments(run),
|
|
639
|
+
experiments: collectExperiments(run, attempts, pairs, costEstimate),
|
|
554
640
|
pairs,
|
|
641
|
+
scorer_health: scorerHealth,
|
|
555
642
|
groups: collectGroups(run, pairs),
|
|
556
643
|
usage: collectUsage(run, attempts),
|
|
557
644
|
egress_denials: collectEgressDenials(options.resultsDir, runId, attempts),
|
|
@@ -571,6 +658,29 @@ function pct(rate: number | null): string {
|
|
|
571
658
|
return rate === null ? "n/a" : `${Math.round(rate * 100)}%`;
|
|
572
659
|
}
|
|
573
660
|
|
|
661
|
+
function renderLeaderboard(experiments: ReportExperiment[]): string[] {
|
|
662
|
+
const header = ["experiment", "model", "pass (all/disc)", "agent median", "tokens in", "cost", "recall"];
|
|
663
|
+
const rows = [...experiments].sort(compareLeaderboard).map((experiment) => {
|
|
664
|
+
const agg = experiment.aggregate;
|
|
665
|
+
const model = experiment.agent.model === "unspecified" ? "unpinned" : experiment.agent.model;
|
|
666
|
+
const reasoning = experiment.agent.reasoning === "unspecified" ? "" : ` · ${experiment.agent.reasoning}`;
|
|
667
|
+
if (!agg) return [experiment.experiment_id, `${model}${reasoning}`, "n/a", "n/a", "n/a", "n/a", "n/a"];
|
|
668
|
+
const all = agg.pass_rate_all === null ? "n/a" : `${agg.passed}/${agg.passed + agg.failed}`;
|
|
669
|
+
const disc = agg.pass_rate_discriminating === null ? "n/a" : `${agg.discriminating_passed}/${agg.discriminating_scored}`;
|
|
670
|
+
const time = agg.median_agent_ms === null ? "n/a" : `${(agg.median_agent_ms / 1000).toFixed(1)}s (${agg.agent_time_attempts})`;
|
|
671
|
+
const tokens = agg.mean_tokens_in === null ? "n/a" : `${Math.round(agg.mean_tokens_in)}`;
|
|
672
|
+
const cost =
|
|
673
|
+
(agg.cost_usd === null ? `not reported (0/${agg.attempts})` : `$${agg.cost_usd.toFixed(4)} ${agg.cost_attempts}/${agg.attempts} reported`) +
|
|
674
|
+
(typeof agg.estimated_cost_usd === "number" ? ` +$${agg.estimated_cost_usd.toFixed(4)} est.` : "");
|
|
675
|
+
const recall = experiment.sdk_recall;
|
|
676
|
+
const recallText = recall && recall.rate !== null ? `${recall.recalled}/${recall.attempts}` : "n/a";
|
|
677
|
+
return [experiment.experiment_id, `${model}${reasoning}`, `${all} · ${disc}`, time, tokens, cost, recallText];
|
|
678
|
+
});
|
|
679
|
+
const widths = header.map((title, i) => Math.max(title.length, ...rows.map((row) => row[i]!.length)));
|
|
680
|
+
const format = (cells: string[]): string => ` ${cells.map((cell, i) => cell.padEnd(widths[i]!)).join(" ").trimEnd()}`;
|
|
681
|
+
return ["leaderboard (benchmark suite, sorted by discriminating pass rate):", format(header), ...rows.map(format)];
|
|
682
|
+
}
|
|
683
|
+
|
|
574
684
|
/** Render the report as plain text. Renders FROM the JSON, never the store. */
|
|
575
685
|
export function renderReportText(report: ReportJson): string {
|
|
576
686
|
const lines: string[] = [];
|
|
@@ -598,8 +708,13 @@ export function renderReportText(report: ReportJson): string {
|
|
|
598
708
|
` ${experiment.experiment_id}${experiment.treatment !== null ? ` [${experiment.treatment}]` : ""}: ` +
|
|
599
709
|
`${model}${reasoning} (adapter ${agent.adapter}, provider ${agent.provider}, cli ${agent.cli_version})`,
|
|
600
710
|
);
|
|
711
|
+
const recall = experiment.sdk_recall;
|
|
712
|
+
lines.push(` sdk recall: ${recall && recall.rate !== null ? `${recall.recalled}/${recall.attempts} (${pct(recall.rate)})` : "n/a"}`);
|
|
601
713
|
}
|
|
602
714
|
lines.push("");
|
|
715
|
+
if (experiments.some((experiment) => experiment.aggregate !== undefined)) {
|
|
716
|
+
lines.push(...renderLeaderboard(experiments), "");
|
|
717
|
+
}
|
|
603
718
|
}
|
|
604
719
|
|
|
605
720
|
if (report.retry_history?.length) lines.push(`prior retry history: ${report.retry_history.length} unsuccessful executions retained in the source artifacts`);
|
|
@@ -652,6 +767,21 @@ export function renderReportText(report: ReportJson): string {
|
|
|
652
767
|
}
|
|
653
768
|
}
|
|
654
769
|
|
|
770
|
+
// Bands and suspects are absent on schema_version < 8 reports — skip them there.
|
|
771
|
+
if (report.pairs.some((pair) => pair.band !== undefined)) {
|
|
772
|
+
const bands = new Map(report.pairs.map((pair) => [pair.eval_id, pair.band]));
|
|
773
|
+
const count = (band: EvalBand): number => [...bands.values()].filter((value) => value === band).length;
|
|
774
|
+
lines.push(
|
|
775
|
+
`bands: discriminating ${count("discriminating")} · solved by all ${count("solved-by-all")} · failed by all ${count("failed-by-all")}`,
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
for (const suspect of report.scorer_health?.suspects ?? []) {
|
|
779
|
+
lines.push(
|
|
780
|
+
`suspect: ${suspect.eval_id} · ${suspect.check} failed in ${suspect.attempts} attempts under ` +
|
|
781
|
+
`${suspect.experiments.length} arms; passing: ${suspect.passing_checks.join(", ")}`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
655
785
|
if (report.groups.length > 0) {
|
|
656
786
|
lines.push("");
|
|
657
787
|
lines.push("comparison groups:");
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eval bands and scorer health — deterministic, offline statements about the
|
|
3
|
+
* instrument, derived from the same pairs and attempts as the report.
|
|
4
|
+
*
|
|
5
|
+
* A band says whether an eval separates the arms at all; scorer health lists
|
|
6
|
+
* checks that failed in every scored attempt of an eval under every arm while
|
|
7
|
+
* another graded check in those same attempts passed. Names never enter the
|
|
8
|
+
* generated summary sentence (the language guard cannot vet data the harness
|
|
9
|
+
* does not control); they travel in the structured `suspects` array.
|
|
10
|
+
*/
|
|
11
|
+
import type { AttemptRecord } from "../store/db.ts";
|
|
12
|
+
import type { ReportPair } from "./report.ts";
|
|
13
|
+
|
|
14
|
+
export type EvalBand = "discriminating" | "solved-by-all" | "failed-by-all";
|
|
15
|
+
|
|
16
|
+
export interface ScorerHealthSuspect {
|
|
17
|
+
eval_id: string;
|
|
18
|
+
check: string;
|
|
19
|
+
/** Experiments whose scored attempts all failed this check. */
|
|
20
|
+
experiments: string[];
|
|
21
|
+
/** Scored attempts of the eval (across those experiments) — every one failed this check. */
|
|
22
|
+
attempts: number;
|
|
23
|
+
/** Graded checks that passed in every one of those attempts (the evidence the work was done). */
|
|
24
|
+
passing_checks: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ScorerHealth {
|
|
28
|
+
suspects: ScorerHealthSuspect[];
|
|
29
|
+
/** Fixed template, names never interpolated — must pass findBannedLanguage. */
|
|
30
|
+
summary: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Per eval: over pairs with scored > 0. null when fewer than 2 arms scored. */
|
|
34
|
+
export function collectBands(pairs: ReportPair[]): Map<string, EvalBand | null> {
|
|
35
|
+
const byEval = new Map<string, ReportPair[]>();
|
|
36
|
+
for (const pair of pairs) {
|
|
37
|
+
const list = byEval.get(pair.eval_id) ?? [];
|
|
38
|
+
list.push(pair);
|
|
39
|
+
byEval.set(pair.eval_id, list);
|
|
40
|
+
}
|
|
41
|
+
const bands = new Map<string, EvalBand | null>();
|
|
42
|
+
for (const [evalId, evalPairs] of byEval) {
|
|
43
|
+
const scored = evalPairs.filter((pair) => pair.scored > 0);
|
|
44
|
+
if (scored.length < 2) bands.set(evalId, null);
|
|
45
|
+
else if (scored.every((pair) => pair.pass_rate === 1)) bands.set(evalId, "solved-by-all");
|
|
46
|
+
else if (scored.every((pair) => pair.pass_rate === 0)) bands.set(evalId, "failed-by-all");
|
|
47
|
+
else bands.set(evalId, "discriminating");
|
|
48
|
+
}
|
|
49
|
+
return bands;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const plural = (count: number): string => (count === 1 ? "" : "s");
|
|
53
|
+
|
|
54
|
+
export function scorerHealthSummary(suspects: ScorerHealthSuspect[]): string {
|
|
55
|
+
if (suspects.length === 0) return "No check failed under every arm while another check passed.";
|
|
56
|
+
const evals = new Set(suspects.map((suspect) => suspect.eval_id)).size;
|
|
57
|
+
return (
|
|
58
|
+
`${suspects.length} check${plural(suspects.length)} failed in every scored attempt of ${evals} eval${plural(evals)} ` +
|
|
59
|
+
`under every arm while another check passed in those attempts. Treat those evals' pass rates as unverified ` +
|
|
60
|
+
`until the scorer is reviewed.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Suspects come only from `failed-by-all` evals (≥2 scored arms). A check is a
|
|
66
|
+
* suspect when it is present and failing in every scored (completed) attempt
|
|
67
|
+
* of those arms, and at least one other graded check is present and passing
|
|
68
|
+
* in every one of them. A name flagged `graded: false` in any attempt (the
|
|
69
|
+
* pair tally's rule) is excluded from both sides.
|
|
70
|
+
*/
|
|
71
|
+
export function collectScorerHealth(attempts: AttemptRecord[], pairs: ReportPair[]): ScorerHealth {
|
|
72
|
+
const bands = collectBands(pairs);
|
|
73
|
+
const suspects: ScorerHealthSuspect[] = [];
|
|
74
|
+
for (const [evalId, band] of bands) {
|
|
75
|
+
if (band !== "failed-by-all") continue;
|
|
76
|
+
const arms = pairs.filter((pair) => pair.eval_id === evalId && pair.scored > 0);
|
|
77
|
+
const experiments = arms.map((pair) => pair.experiment_id).sort((a, b) => a.localeCompare(b));
|
|
78
|
+
const ungraded = new Set(arms.flatMap((pair) => pair.checks.filter((check) => check.graded === false).map((check) => check.name)));
|
|
79
|
+
const scored = attempts.filter(
|
|
80
|
+
(attempt) => attempt.evalId === evalId && attempt.status === "completed" && experiments.includes(attempt.experimentId),
|
|
81
|
+
);
|
|
82
|
+
if (scored.length === 0) continue;
|
|
83
|
+
const names = new Set<string>();
|
|
84
|
+
for (const attempt of scored) {
|
|
85
|
+
for (const check of attempt.result?.checks ?? []) {
|
|
86
|
+
if (check.graded === false) ungraded.add(check.name);
|
|
87
|
+
names.add(check.name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const outcomeEverywhere = (name: string, passed: boolean): boolean =>
|
|
91
|
+
scored.every((attempt) => (attempt.result?.checks ?? []).some((check) => check.name === name && check.passed === passed));
|
|
92
|
+
const graded = [...names].filter((name) => !ungraded.has(name)).sort((a, b) => a.localeCompare(b));
|
|
93
|
+
const failing = graded.filter((name) => outcomeEverywhere(name, false));
|
|
94
|
+
const passing = graded.filter((name) => outcomeEverywhere(name, true));
|
|
95
|
+
if (passing.length === 0) continue;
|
|
96
|
+
for (const check of failing) {
|
|
97
|
+
suspects.push({ eval_id: evalId, check, experiments: [...experiments], attempts: scored.length, passing_checks: [...passing] });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
suspects.sort(
|
|
101
|
+
(a, b) => b.attempts - a.attempts || a.eval_id.localeCompare(b.eval_id) || a.check.localeCompare(b.check),
|
|
102
|
+
);
|
|
103
|
+
return { suspects, summary: scorerHealthSummary(suspects) };
|
|
104
|
+
}
|
package/src/runner/context.ts
CHANGED
|
@@ -68,6 +68,23 @@ async function hostExec(cwd: string, cmd: string[], opts?: { timeoutMs?: number
|
|
|
68
68
|
return { exitCode, stdout, stderr, timedOut };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* A scorer called a capability the current context does not provide — a
|
|
73
|
+
* runtime without `query()`/`getClient()`, or anything runtime-only under
|
|
74
|
+
* `validate --oracle` (including `exec`). The class does not survive the
|
|
75
|
+
* scorer worker boundary (only the message does), so callers that need to
|
|
76
|
+
* know a capability was requested track invocations themselves.
|
|
77
|
+
*/
|
|
78
|
+
export class RuntimeCapabilityError extends Error {
|
|
79
|
+
constructor(
|
|
80
|
+
readonly capability: "exec" | "query" | "getClient",
|
|
81
|
+
detail: string,
|
|
82
|
+
) {
|
|
83
|
+
super(detail);
|
|
84
|
+
this.name = "RuntimeCapabilityError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
71
88
|
export function buildEvalContext(options: BuildEvalContextOptions): EvalContext {
|
|
72
89
|
const { workspaceDir } = options;
|
|
73
90
|
return {
|
|
@@ -85,12 +102,12 @@ export function buildEvalContext(options: BuildEvalContextOptions): EvalContext
|
|
|
85
102
|
query:
|
|
86
103
|
options.query ??
|
|
87
104
|
(() => {
|
|
88
|
-
throw new
|
|
105
|
+
throw new RuntimeCapabilityError("query", `this experiment runtime does not provide query() (experiment "${options.experimentId}")`);
|
|
89
106
|
}),
|
|
90
107
|
getClient:
|
|
91
108
|
options.getClient ??
|
|
92
109
|
(() => {
|
|
93
|
-
throw new
|
|
110
|
+
throw new RuntimeCapabilityError("getClient", `this experiment runtime does not provide getClient() (experiment "${options.experimentId}")`);
|
|
94
111
|
}),
|
|
95
112
|
};
|
|
96
113
|
}
|