@workos/quickstudy 0.3.0 → 0.5.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.
@@ -32,6 +32,18 @@ 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 {
36
+ collectBands,
37
+ collectScorerHealth,
38
+ type EvalBand,
39
+ type ExperimentRole,
40
+ type ScorerHealth,
41
+ } from "./scorer-health.ts";
42
+ import { collectExperimentAggregate, compareLeaderboard, type ExperimentAggregate } from "./aggregate.ts";
43
+
44
+ export type { EvalBand, ExperimentRole, ScorerHealth, ScorerHealthSuspect } from "./scorer-health.ts";
45
+ export type { ExperimentAggregate } from "./aggregate.ts";
46
+ export { compareLeaderboard } from "./aggregate.ts";
35
47
 
36
48
  /** Loud, CLI-friendly failures (unknown run, missing report.json, ...). */
37
49
  export class ReportError extends Error {
@@ -41,7 +53,7 @@ export class ReportError extends Error {
41
53
  }
42
54
  }
43
55
 
44
- export const REPORT_SCHEMA_VERSION = 6;
56
+ export const REPORT_SCHEMA_VERSION = 10;
45
57
 
46
58
  /**
47
59
  * Substrings that must never appear in generated report prose: at n≤5 trials
@@ -68,7 +80,15 @@ export function methodologyNote(trials: number): string {
68
80
  `same scored counts: pass@k is the fraction of size-k subsets of that pair's scored trials containing at ` +
69
81
  `least one pass, and pass^k is (passed/scored)^k, the all-pass rate for k with-replacement draws from ` +
70
82
  `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.`
83
+ `usage) is summed only over attempts that reported it and shown as unavailable — never zero — when none did. ` +
84
+ `SDK recall is reported per experiment as recalled / attempts, where attempts are the scored attempts that ` +
85
+ `recorded at least one ungraded check and recalled are those whose ungraded checks all passed. Ungraded ` +
86
+ `checks are recorded and tallied but never enter pass/fail. Scorer health lists checks that failed in every ` +
87
+ `scored attempt of an eval under every arm while another check in the same attempts passed: a statement ` +
88
+ `about the instrument, not about any treatment. Control experiments — an oracle that applies each eval's ` +
89
+ `reference solution, a no-op that changes nothing — are reported alongside the agent experiments and ` +
90
+ `tagged as controls wherever they appear; bands and scorer health are decided over the agent experiments ` +
91
+ `only, and controls are ranked after every agent experiment.`
72
92
  );
73
93
  }
74
94
 
@@ -83,17 +103,53 @@ export interface ReportAgentIdentity {
83
103
  cli_version: string;
84
104
  }
85
105
 
106
+ /**
107
+ * SDK recall for one experiment, derived from `graded: false` checks. Only
108
+ * scored (completed) attempts count; graded timeouts are completed.
109
+ */
110
+ export interface ReportRecall {
111
+ /** Scored attempts of this experiment whose checks include at least one graded:false check. */
112
+ attempts: number;
113
+ /** Of those, attempts where every graded:false check passed. */
114
+ recalled: number;
115
+ /** recalled / attempts; null when attempts = 0. */
116
+ rate: number | null;
117
+ }
118
+
86
119
  /** One experiment's identity card: what exactly ran under this id. */
87
120
  export interface ReportExperiment {
88
121
  experiment_id: string;
89
122
  treatment: string | null;
90
123
  agent: ReportAgentIdentity;
124
+ /**
125
+ * "agent" for an arm; "positive-control" (the oracle) and "negative-control"
126
+ * (the echo no-op) never decide a band or a scorer-health suspect. Optional on
127
+ * the wire: absent in schema_version < 10 artifacts; readers treat absence as
128
+ * "agent".
129
+ */
130
+ role?: ExperimentRole;
131
+ /**
132
+ * Optional on the wire: absent in schema_version < 7 artifacts, which the
133
+ * export may still carry — readers must tolerate absence.
134
+ */
135
+ sdk_recall?: ReportRecall;
136
+ /**
137
+ * Leaderboard row over benchmark-suite attempts. Optional on the wire:
138
+ * absent in schema_version < 9 artifacts; readers must tolerate absence.
139
+ */
140
+ aggregate?: ExperimentAggregate;
91
141
  }
92
142
 
93
143
  export interface ReportCheck {
94
144
  name: string;
95
145
  passed: number;
96
146
  total: number;
147
+ /**
148
+ * false when any attempt recorded this check with `graded: false` (a scorer
149
+ * that flips the flag mid-run marks the whole tally ungraded). Absent in
150
+ * schema_version < 7 artifacts; readers treat absence as graded.
151
+ */
152
+ graded: boolean;
97
153
  }
98
154
 
99
155
  /** One eval × experiment aggregation. Pairs with zero attempts are ABSENT, never 0%. */
@@ -120,6 +176,13 @@ export interface ReportPair {
120
176
  /** Completed trials disagreed on the outcome. */
121
177
  flaky: boolean;
122
178
  checks: ReportCheck[];
179
+ /**
180
+ * The eval's band, denormalised onto each of its pairs (control pairs too):
181
+ * decided over the eval's agent pairs with scored > 0; null when fewer than 2
182
+ * agent arms scored (before schema 10, controls counted as arms). Absent
183
+ * in schema_version < 8 artifacts; readers treat absence as unbanded.
184
+ */
185
+ band: EvalBand | null;
123
186
  }
124
187
 
125
188
  export interface ReportGroupArmCount {
@@ -229,6 +292,12 @@ export interface ReportJson {
229
292
  */
230
293
  experiments?: ReportExperiment[];
231
294
  pairs: ReportPair[];
295
+ /**
296
+ * Checks that failed under every arm while another graded check passed in
297
+ * the same attempts. Absent in schema_version < 8 artifacts; readers must
298
+ * tolerate absence.
299
+ */
300
+ scorer_health?: ScorerHealth;
232
301
  groups: ReportGroup[];
233
302
  usage: ReportExperimentUsage[];
234
303
  egress_denials: EgressDenialSummary[];
@@ -244,7 +313,7 @@ export interface ReportJson {
244
313
  const USAGE_VALUE_CAP = 20;
245
314
 
246
315
  export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
247
- const pairs = new Map<string, { pair: ReportPair; checks: Map<string, { passed: number; total: number }>; outcomes: Set<boolean> }>();
316
+ const pairs = new Map<string, { pair: ReportPair; checks: Map<string, { passed: number; total: number; graded: boolean }>; outcomes: Set<boolean> }>();
248
317
  for (const attempt of attempts) {
249
318
  const key = `${attempt.evalId}|${attempt.experimentId}`;
250
319
  let entry = pairs.get(key);
@@ -263,6 +332,7 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
263
332
  pass_rate: null,
264
333
  flaky: false,
265
334
  checks: [],
335
+ band: null,
266
336
  },
267
337
  checks: new Map(),
268
338
  outcomes: new Set(),
@@ -280,9 +350,10 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
280
350
  entry.outcomes.add(false);
281
351
  }
282
352
  for (const check of attempt.result?.checks ?? []) {
283
- const tally = entry.checks.get(check.name) ?? { passed: 0, total: 0 };
353
+ const tally = entry.checks.get(check.name) ?? { passed: 0, total: 0, graded: true };
284
354
  tally.total += 1;
285
355
  if (check.passed) tally.passed += 1;
356
+ if (check.graded === false) tally.graded = false;
286
357
  entry.checks.set(check.name, tally);
287
358
  }
288
359
  }
@@ -294,7 +365,7 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
294
365
  pair.pass_at_k = collectPassAtK(pair.passed, pair.scored);
295
366
  pair.flaky = outcomes.size > 1;
296
367
  pair.checks = [...checks.entries()]
297
- .map(([name, tally]) => ({ name, passed: tally.passed, total: tally.total }))
368
+ .map(([name, tally]) => ({ name, passed: tally.passed, total: tally.total, graded: tally.graded }))
298
369
  .sort((a, b) => a.name.localeCompare(b.name));
299
370
  result.push(pair);
300
371
  }
@@ -340,7 +411,45 @@ function treatmentOf(run: RunRecord, experimentId: string): string | null {
340
411
  * experiment; "unspecified" is its own explicit missing-value sentinel, so
341
412
  * this never invents a value the run did not pin.
342
413
  */
343
- function collectExperiments(run: RunRecord): ReportExperiment[] {
414
+ /**
415
+ * SDK recall over one experiment's scored attempts: the denominator is every
416
+ * completed attempt that recorded at least one `graded: false` check; an
417
+ * attempt is recalled when all of those checks passed. Null rate at zero.
418
+ */
419
+ export function collectRecall(attempts: AttemptRecord[], experimentId: string): ReportRecall {
420
+ let total = 0;
421
+ let recalled = 0;
422
+ for (const attempt of attempts) {
423
+ if (attempt.experimentId !== experimentId || attempt.status !== "completed") continue;
424
+ const ungraded = (attempt.result?.checks ?? []).filter((check) => check.graded === false);
425
+ if (ungraded.length === 0) continue;
426
+ total += 1;
427
+ if (ungraded.every((check) => check.passed)) recalled += 1;
428
+ }
429
+ return { attempts: total, recalled, rate: total > 0 ? recalled / total : null };
430
+ }
431
+
432
+ /**
433
+ * An experiment's role as the run recorded it (RunConfig.roles, from the
434
+ * adapter that actually ran). A run from before roles were recorded keeps no
435
+ * record of which adapter ran, so every experiment in it reads as an agent —
436
+ * exactly as before roles existed; nothing is guessed from a name or identity.
437
+ */
438
+ function roleOf(run: RunRecord, experimentId: string): ExperimentRole {
439
+ return run.config.roles?.[experimentId] ?? "agent";
440
+ }
441
+
442
+ /** The run's control experiments, from the roles it recorded. */
443
+ function controlExperiments(run: RunRecord): Set<string> {
444
+ return new Set(run.config.experimentIds.filter((id) => roleOf(run, id) !== "agent"));
445
+ }
446
+
447
+ function collectExperiments(
448
+ run: RunRecord,
449
+ attempts: AttemptRecord[],
450
+ pairs: ReportPair[],
451
+ costEstimate: RunCostEstimate | undefined,
452
+ ): ReportExperiment[] {
344
453
  return [...run.config.experimentIds]
345
454
  .sort((a, b) => a.localeCompare(b))
346
455
  .map((experimentId) => {
@@ -348,6 +457,7 @@ function collectExperiments(run: RunRecord): ReportExperiment[] {
348
457
  return {
349
458
  experiment_id: experimentId,
350
459
  treatment: treatmentOf(run, experimentId),
460
+ role: roleOf(run, experimentId),
351
461
  agent: {
352
462
  adapter: agent?.adapter ?? "unspecified",
353
463
  provider: agent?.provider ?? "unspecified",
@@ -355,6 +465,8 @@ function collectExperiments(run: RunRecord): ReportExperiment[] {
355
465
  reasoning: agent?.reasoning ?? "unspecified",
356
466
  cli_version: agent?.cliVersion ?? "unspecified",
357
467
  },
468
+ sdk_recall: collectRecall(attempts, experimentId),
469
+ aggregate: collectExperimentAggregate(attempts, pairs, experimentId, costEstimate),
358
470
  };
359
471
  });
360
472
  }
@@ -520,6 +632,10 @@ export function buildReport(options: BuildReportOptions): ReportJson {
520
632
  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
633
  const attempts = publication ? current.filter((a) => publication.eligible.some((p) => p.eval_id === a.evalId && p.experiment_id === a.experimentId)) : current;
522
634
  const pairs = collectPairs(attempts);
635
+ const controls = controlExperiments(run);
636
+ const bands = collectBands(pairs, controls);
637
+ for (const pair of pairs) pair.band = bands.get(pair.eval_id) ?? null;
638
+ const scorerHealth = collectScorerHealth(attempts, pairs, controls);
523
639
  const costEstimate = options.pricing === undefined ? undefined : estimateRunCosts(run, attempts, options.resultsDir, options.pricing);
524
640
 
525
641
  const methodology = methodologyNote(run.config.trials) + (costEstimate === undefined ? "" :
@@ -528,6 +644,10 @@ export function buildReport(options: BuildReportOptions): ReportJson {
528
644
  if (banned.length > 0) {
529
645
  throw new ReportError(`report methodology violates the language guard: ${banned.join(", ")}`);
530
646
  }
647
+ const bannedSummary = findBannedLanguage(scorerHealth.summary);
648
+ if (bannedSummary.length > 0) {
649
+ throw new ReportError(`report scorer-health summary violates the language guard: ${bannedSummary.join(", ")}`);
650
+ }
531
651
 
532
652
  return {
533
653
  schema_version: REPORT_SCHEMA_VERSION,
@@ -550,8 +670,9 @@ export function buildReport(options: BuildReportOptions): ReportJson {
550
670
  telemetry: collectTelemetry(attempts),
551
671
  ...(costEstimate === undefined ? {} : { cost_estimate: costEstimate }),
552
672
  judge_telemetry: collectJudgeTelemetry(attempts),
553
- experiments: collectExperiments(run),
673
+ experiments: collectExperiments(run, attempts, pairs, costEstimate),
554
674
  pairs,
675
+ scorer_health: scorerHealth,
555
676
  groups: collectGroups(run, pairs),
556
677
  usage: collectUsage(run, attempts),
557
678
  egress_denials: collectEgressDenials(options.resultsDir, runId, attempts),
@@ -571,6 +692,30 @@ function pct(rate: number | null): string {
571
692
  return rate === null ? "n/a" : `${Math.round(rate * 100)}%`;
572
693
  }
573
694
 
695
+ function renderLeaderboard(experiments: ReportExperiment[]): string[] {
696
+ const header = ["experiment", "model", "pass (all/disc)", "agent median", "tokens in", "cost", "recall"];
697
+ const rows = [...experiments].sort(compareLeaderboard).map((experiment) => {
698
+ const agg = experiment.aggregate;
699
+ const model = experiment.agent.model === "unspecified" ? "unpinned" : experiment.agent.model;
700
+ const reasoning = experiment.agent.reasoning === "unspecified" ? "" : ` · ${experiment.agent.reasoning}`;
701
+ if (!agg) return [experiment.experiment_id, `${model}${reasoning}`, "n/a", "n/a", "n/a", "n/a", "n/a"];
702
+ const all = agg.pass_rate_all === null ? "n/a" : `${agg.passed}/${agg.passed + agg.failed}`;
703
+ const disc = agg.pass_rate_discriminating === null ? "n/a" : `${agg.discriminating_passed}/${agg.discriminating_scored}`;
704
+ const time = agg.median_agent_ms === null ? "n/a" : `${(agg.median_agent_ms / 1000).toFixed(1)}s (${agg.agent_time_attempts})`;
705
+ const tokens = agg.mean_tokens_in === null ? "n/a" : `${Math.round(agg.mean_tokens_in)}`;
706
+ const cost =
707
+ (agg.cost_usd === null ? `not reported (0/${agg.attempts})` : `$${agg.cost_usd.toFixed(4)} ${agg.cost_attempts}/${agg.attempts} reported`) +
708
+ (typeof agg.estimated_cost_usd === "number" ? ` +$${agg.estimated_cost_usd.toFixed(4)} est.` : "");
709
+ const recall = experiment.sdk_recall;
710
+ const recallText = recall && recall.rate !== null ? `${recall.recalled}/${recall.attempts}` : "n/a";
711
+ const id = (experiment.role ?? "agent") === "agent" ? experiment.experiment_id : `${experiment.experiment_id} (control)`;
712
+ return [id, `${model}${reasoning}`, `${all} · ${disc}`, time, tokens, cost, recallText];
713
+ });
714
+ const widths = header.map((title, i) => Math.max(title.length, ...rows.map((row) => row[i]!.length)));
715
+ const format = (cells: string[]): string => ` ${cells.map((cell, i) => cell.padEnd(widths[i]!)).join(" ").trimEnd()}`;
716
+ return ["leaderboard (benchmark suite, sorted by discriminating pass rate; controls last):", format(header), ...rows.map(format)];
717
+ }
718
+
574
719
  /** Render the report as plain text. Renders FROM the JSON, never the store. */
575
720
  export function renderReportText(report: ReportJson): string {
576
721
  const lines: string[] = [];
@@ -598,8 +743,13 @@ export function renderReportText(report: ReportJson): string {
598
743
  ` ${experiment.experiment_id}${experiment.treatment !== null ? ` [${experiment.treatment}]` : ""}: ` +
599
744
  `${model}${reasoning} (adapter ${agent.adapter}, provider ${agent.provider}, cli ${agent.cli_version})`,
600
745
  );
746
+ const recall = experiment.sdk_recall;
747
+ lines.push(` sdk recall: ${recall && recall.rate !== null ? `${recall.recalled}/${recall.attempts} (${pct(recall.rate)})` : "n/a"}`);
601
748
  }
602
749
  lines.push("");
750
+ if (experiments.some((experiment) => experiment.aggregate !== undefined)) {
751
+ lines.push(...renderLeaderboard(experiments), "");
752
+ }
603
753
  }
604
754
 
605
755
  if (report.retry_history?.length) lines.push(`prior retry history: ${report.retry_history.length} unsuccessful executions retained in the source artifacts`);
@@ -652,6 +802,21 @@ export function renderReportText(report: ReportJson): string {
652
802
  }
653
803
  }
654
804
 
805
+ // Bands and suspects are absent on schema_version < 8 reports — skip them there.
806
+ if (report.pairs.some((pair) => pair.band !== undefined)) {
807
+ const bands = new Map(report.pairs.map((pair) => [pair.eval_id, pair.band]));
808
+ const count = (band: EvalBand): number => [...bands.values()].filter((value) => value === band).length;
809
+ lines.push(
810
+ `bands: discriminating ${count("discriminating")} · solved by all ${count("solved-by-all")} · failed by all ${count("failed-by-all")}`,
811
+ );
812
+ }
813
+ for (const suspect of report.scorer_health?.suspects ?? []) {
814
+ lines.push(
815
+ `suspect: ${suspect.eval_id} · ${suspect.check} failed in ${suspect.attempts} attempts under ` +
816
+ `${suspect.experiments.length} arms; passing: ${suspect.passing_checks.join(", ")}`,
817
+ );
818
+ }
819
+
655
820
  if (report.groups.length > 0) {
656
821
  lines.push("");
657
822
  lines.push("comparison groups:");
@@ -664,7 +829,9 @@ export function renderReportText(report: ReportJson): string {
664
829
  lines.push(` ${group.group}: compared (raw counts per arm)`);
665
830
  for (const arm of group.arms) {
666
831
  const cells = arm.evals.map((entry) => `${entry.eval_id} ${entry.display}`).join(", ");
667
- lines.push(` ${arm.experiment_id}${arm.treatment !== null ? ` [${arm.treatment}]` : ""}: ${cells || "no scored attempts"}`);
832
+ const role = experiments.find((experiment) => experiment.experiment_id === arm.experiment_id)?.role ?? "agent";
833
+ const control = role === "agent" ? "" : " (control)";
834
+ lines.push(` ${arm.experiment_id}${control}${arm.treatment !== null ? ` [${arm.treatment}]` : ""}: ${cells || "no scored attempts"}`);
668
835
  }
669
836
  }
670
837
  }
@@ -0,0 +1,129 @@
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
+ /**
17
+ * What an experiment is for (the run's recorded RunConfig.roles). A control
18
+ * says whether the instrument works — the oracle must pass, the echo no-op must
19
+ * fail — so bands and scorer health are decided over agent experiments only:
20
+ * with an oracle and a no-op in the run, no eval could otherwise be solved or
21
+ * failed by all.
22
+ */
23
+ export type ExperimentRole = import("../adapters/types.ts").AdapterRole;
24
+
25
+ export interface ScorerHealthSuspect {
26
+ eval_id: string;
27
+ check: string;
28
+ /** Experiments whose scored attempts all failed this check. */
29
+ experiments: string[];
30
+ /** Scored attempts of the eval (across those experiments) — every one failed this check. */
31
+ attempts: number;
32
+ /** Graded checks that passed in every one of those attempts (the evidence the work was done). */
33
+ passing_checks: string[];
34
+ }
35
+
36
+ export interface ScorerHealth {
37
+ suspects: ScorerHealthSuspect[];
38
+ /** Fixed template, names never interpolated — must pass findBannedLanguage. */
39
+ summary: string;
40
+ }
41
+
42
+ /**
43
+ * Per eval: over agent pairs with scored > 0 (`controls` names the control
44
+ * experiments, which never decide a band). null when fewer than 2 agent arms
45
+ * scored. Every eval of the run gets an entry, control-only ones as null.
46
+ */
47
+ export function collectBands(
48
+ pairs: ReportPair[],
49
+ controls: ReadonlySet<string> = new Set(),
50
+ ): Map<string, EvalBand | null> {
51
+ const byEval = new Map<string, ReportPair[]>();
52
+ for (const pair of pairs) {
53
+ if (!byEval.has(pair.eval_id)) byEval.set(pair.eval_id, []);
54
+ if (controls.has(pair.experiment_id)) continue;
55
+ const list = byEval.get(pair.eval_id) ?? [];
56
+ list.push(pair);
57
+ byEval.set(pair.eval_id, list);
58
+ }
59
+ const bands = new Map<string, EvalBand | null>();
60
+ for (const [evalId, evalPairs] of byEval) {
61
+ const scored = evalPairs.filter((pair) => pair.scored > 0);
62
+ if (scored.length < 2) bands.set(evalId, null);
63
+ else if (scored.every((pair) => pair.pass_rate === 1)) bands.set(evalId, "solved-by-all");
64
+ else if (scored.every((pair) => pair.pass_rate === 0)) bands.set(evalId, "failed-by-all");
65
+ else bands.set(evalId, "discriminating");
66
+ }
67
+ return bands;
68
+ }
69
+
70
+ const plural = (count: number): string => (count === 1 ? "" : "s");
71
+
72
+ export function scorerHealthSummary(suspects: ScorerHealthSuspect[]): string {
73
+ if (suspects.length === 0) return "No check failed under every arm while another check passed.";
74
+ const evals = new Set(suspects.map((suspect) => suspect.eval_id)).size;
75
+ return (
76
+ `${suspects.length} check${plural(suspects.length)} failed in every scored attempt of ${evals} eval${plural(evals)} ` +
77
+ `under every arm while another check passed in those attempts. Treat those evals' pass rates as unverified ` +
78
+ `until the scorer is reviewed.`
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Suspects come only from `failed-by-all` evals (≥2 scored agent arms). A
84
+ * check is a suspect when it is present and failing in every scored
85
+ * (completed) attempt of those arms, and at least one other graded check is
86
+ * present and passing in every one of them. A name flagged `graded: false` in
87
+ * any attempt (the pair tally's rule) is excluded from both sides. Control
88
+ * experiments (`controls`) are never arms.
89
+ */
90
+ export function collectScorerHealth(
91
+ attempts: AttemptRecord[],
92
+ pairs: ReportPair[],
93
+ controls: ReadonlySet<string> = new Set(),
94
+ ): ScorerHealth {
95
+ const bands = collectBands(pairs, controls);
96
+ const suspects: ScorerHealthSuspect[] = [];
97
+ for (const [evalId, band] of bands) {
98
+ if (band !== "failed-by-all") continue;
99
+ const arms = pairs.filter(
100
+ (pair) => pair.eval_id === evalId && pair.scored > 0 && !controls.has(pair.experiment_id),
101
+ );
102
+ const experiments = arms.map((pair) => pair.experiment_id).sort((a, b) => a.localeCompare(b));
103
+ const ungraded = new Set(arms.flatMap((pair) => pair.checks.filter((check) => check.graded === false).map((check) => check.name)));
104
+ const scored = attempts.filter(
105
+ (attempt) => attempt.evalId === evalId && attempt.status === "completed" && experiments.includes(attempt.experimentId),
106
+ );
107
+ if (scored.length === 0) continue;
108
+ const names = new Set<string>();
109
+ for (const attempt of scored) {
110
+ for (const check of attempt.result?.checks ?? []) {
111
+ if (check.graded === false) ungraded.add(check.name);
112
+ names.add(check.name);
113
+ }
114
+ }
115
+ const outcomeEverywhere = (name: string, passed: boolean): boolean =>
116
+ scored.every((attempt) => (attempt.result?.checks ?? []).some((check) => check.name === name && check.passed === passed));
117
+ const graded = [...names].filter((name) => !ungraded.has(name)).sort((a, b) => a.localeCompare(b));
118
+ const failing = graded.filter((name) => outcomeEverywhere(name, false));
119
+ const passing = graded.filter((name) => outcomeEverywhere(name, true));
120
+ if (passing.length === 0) continue;
121
+ for (const check of failing) {
122
+ suspects.push({ eval_id: evalId, check, experiments: [...experiments], attempts: scored.length, passing_checks: [...passing] });
123
+ }
124
+ }
125
+ suspects.sort(
126
+ (a, b) => b.attempts - a.attempts || a.eval_id.localeCompare(b.eval_id) || a.check.localeCompare(b.check),
127
+ );
128
+ return { suspects, summary: scorerHealthSummary(suspects) };
129
+ }
@@ -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 Error(`this experiment runtime does not provide query() (experiment "${options.experimentId}")`);
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 Error(`this experiment runtime does not provide getClient() (experiment "${options.experimentId}")`);
110
+ throw new RuntimeCapabilityError("getClient", `this experiment runtime does not provide getClient() (experiment "${options.experimentId}")`);
94
111
  }),
95
112
  };
96
113
  }