@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.
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/adapters/echo.ts +2 -0
- package/src/adapters/oracle.ts +69 -0
- package/src/adapters/types.ts +27 -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 +136 -0
- package/src/report/report.ts +175 -8
- package/src/report/scorer-health.ts +129 -0
- package/src/runner/context.ts +19 -2
- package/src/runner/execute.ts +160 -61
- package/src/runner/oracle-validate.ts +109 -0
- package/src/runner/workspace-source.ts +34 -0
- package/src/store/db.ts +65 -2
- package/src/store/schema.ts +7 -1
- package/ui/dist/index.html +11 -11
package/src/report/report.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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
|
}
|