@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.
@@ -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 = 6;
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
- function collectExperiments(run: RunRecord): ReportExperiment[] {
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
+ }
@@ -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
  }