@workos/quickstudy 0.0.1

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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +270 -0
  3. package/examples/harbor-notes/README.md +40 -0
  4. package/examples/harbor-notes/evals/create-note/EVAL.ts +14 -0
  5. package/examples/harbor-notes/evals/create-note/PROMPT.md +9 -0
  6. package/examples/harbor-notes/evals/create-note/local/README.txt +1 -0
  7. package/examples/harbor-notes/experiments/scripted.ts +6 -0
  8. package/examples/harbor-notes/package.json +6 -0
  9. package/examples/harbor-notes/quickstudy.identity.json +1 -0
  10. package/examples/harbor-notes/runtime.ts +48 -0
  11. package/examples/harbor-notes/semantic-example.ts +21 -0
  12. package/images/agent-runtime/Dockerfile +58 -0
  13. package/images/egress-proxy/Dockerfile +28 -0
  14. package/images/mcp-proxy/Dockerfile +30 -0
  15. package/package.json +53 -0
  16. package/src/adapters/claude.ts +107 -0
  17. package/src/adapters/codex.ts +107 -0
  18. package/src/adapters/echo.ts +57 -0
  19. package/src/adapters/parse.ts +117 -0
  20. package/src/adapters/types.ts +152 -0
  21. package/src/build-info.generated.ts +12 -0
  22. package/src/cli.ts +787 -0
  23. package/src/completeness.ts +104 -0
  24. package/src/diagnose/excerpt.ts +106 -0
  25. package/src/diagnose/prompt.ts +175 -0
  26. package/src/diagnose/render.ts +55 -0
  27. package/src/diagnose/run.ts +290 -0
  28. package/src/diagnose/select.ts +110 -0
  29. package/src/diagnose/types.ts +88 -0
  30. package/src/evals/discovery.ts +173 -0
  31. package/src/evals/prompt.ts +190 -0
  32. package/src/evals/result.ts +10 -0
  33. package/src/evals/types.ts +115 -0
  34. package/src/execution-policy.ts +71 -0
  35. package/src/experiments/discovery.ts +76 -0
  36. package/src/experiments/groups.ts +119 -0
  37. package/src/experiments/types.ts +116 -0
  38. package/src/export-types.ts +127 -0
  39. package/src/export.ts +381 -0
  40. package/src/hash.ts +74 -0
  41. package/src/identity-diff.ts +30 -0
  42. package/src/ids.ts +30 -0
  43. package/src/index.ts +58 -0
  44. package/src/isolation/docker.ts +639 -0
  45. package/src/isolation/image-contexts.generated.ts +927 -0
  46. package/src/isolation/images.ts +138 -0
  47. package/src/isolation/mcp-proxy/server.ts +260 -0
  48. package/src/isolation/mcp.ts +144 -0
  49. package/src/isolation/proxy/allowlist.ts +148 -0
  50. package/src/isolation/proxy/server.ts +382 -0
  51. package/src/llm.ts +132 -0
  52. package/src/manifest.ts +228 -0
  53. package/src/model-identity.ts +12 -0
  54. package/src/plan.ts +55 -0
  55. package/src/probe.ts +426 -0
  56. package/src/report/pass-at-k.ts +76 -0
  57. package/src/report/report.ts +731 -0
  58. package/src/runner/context.ts +96 -0
  59. package/src/runner/deadline.ts +37 -0
  60. package/src/runner/execute.ts +992 -0
  61. package/src/runner/run-lock.ts +32 -0
  62. package/src/runner/scheduler.ts +62 -0
  63. package/src/runner/score-worker.ts +107 -0
  64. package/src/runner/scorer-worker.ts +61 -0
  65. package/src/runtime/types.ts +89 -0
  66. package/src/secrets.ts +151 -0
  67. package/src/semantic.ts +185 -0
  68. package/src/serve.ts +52 -0
  69. package/src/source-identity.ts +76 -0
  70. package/src/store/artifacts.ts +146 -0
  71. package/src/store/db.ts +318 -0
  72. package/src/store/schema.ts +39 -0
  73. package/src/surface-usage.ts +297 -0
  74. package/src/ui-bundle.generated.ts +12 -0
  75. package/ui/dist/index.html +32 -0
@@ -0,0 +1,731 @@
1
+ /**
2
+ * The v3 report: honest aggregation over the eval × experiment store, and
3
+ * nothing else. JSON is the source shape (written as `report.json` into the
4
+ * run's results dir); the text renderer renders FROM that JSON; the UI reads
5
+ * the same JSON — one aggregation codepath for all three consumers.
6
+ *
7
+ * What the report states, by design:
8
+ * - pass rates per eval × experiment pair (scored = passed + failed;
9
+ * incomplete and errored attempts are counted separately and NEVER enter
10
+ * the denominator);
11
+ * - per-pair pass@k / pass^k, restatements of the same scored counts
12
+ * (see pass-at-k.ts) — within-run only, no wider claim;
13
+ * - named check outcomes with per-check tallies;
14
+ * - cost/token telemetry summed only over attempts that reported it
15
+ * ("unavailable", never 0, when none did);
16
+ * - raw per-arm counts ("3/3" vs "1/3") for declared comparison groups,
17
+ * with withheld groups rendering their component diff instead;
18
+ * - offered-versus-used surface telemetry per experiment;
19
+ * - egress denials, when the proxied run recorded any.
20
+ *
21
+ * There are deliberately no intervals, no estimates, no analyst layers, and
22
+ * no language stronger than the counts themselves (see BANNED_LANGUAGE).
23
+ */
24
+
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { hashString } from "../hash.ts";
28
+ import { publicationCoverage, trialCoverage, type PairCoverage, type PublicationCoverage } from "../completeness.ts";
29
+ import type { ComparisonGroup } from "../experiments/groups.ts";
30
+ import { activeAttempts, type AttemptRecord, type ResultsStore, type RunRecord } from "../store/db.ts";
31
+ import type { SurfaceUsage } from "../surface-usage.ts";
32
+ import { collectPassAtK, displayPassAtK, type PassAtKEntry } from "./pass-at-k.ts";
33
+
34
+ /** Loud, CLI-friendly failures (unknown run, missing report.json, ...). */
35
+ export class ReportError extends Error {
36
+ constructor(message: string) {
37
+ super(message);
38
+ this.name = "ReportError";
39
+ }
40
+ }
41
+
42
+ export const REPORT_SCHEMA_VERSION = 6;
43
+
44
+ /**
45
+ * Substrings that must never appear in generated report prose: at n≤5 trials
46
+ * the honest statement is the raw counts, so the report never claims more.
47
+ * (Note "caus" also catches "because" — write around it.)
48
+ */
49
+ export const BANNED_LANGUAGE = ["caus", "effect of", "significant"] as const;
50
+
51
+ /** Return every banned-language fragment present in `text` (case-insensitive). */
52
+ export function findBannedLanguage(text: string): string[] {
53
+ const lower = text.toLowerCase();
54
+ return BANNED_LANGUAGE.filter((banned) => lower.includes(banned));
55
+ }
56
+
57
+ /** Methodology is generated prose — it must itself pass the language guard. */
58
+ export function methodologyNote(trials: number): string {
59
+ return (
60
+ `Pass rates are per eval × experiment pair: passed / (passed + failed). Incomplete and errored ` +
61
+ `attempts are counted separately and never enter that denominator. Each pair has ${trials} planned ` +
62
+ `trial${trials === 1 ? "" : "s"} — at these counts the raw counts are the honest statement, so comparison ` +
63
+ `groups render per-arm counts only: no intervals, no estimates, no attribution. A group whose members ` +
64
+ `differ beyond their declared treatment has its comparison withheld and the differing components are ` +
65
+ `listed instead; its raw per-experiment results remain visible. Per-pair pass@k and pass^k restate the ` +
66
+ `same scored counts: pass@k is the fraction of size-k subsets of that pair's scored trials containing at ` +
67
+ `least one pass, and pass^k is (passed/scored)^k, the all-pass rate for k with-replacement draws from ` +
68
+ `those trials. Both describe this run's trials only — no wider claim. Telemetry (cost, tokens, surface ` +
69
+ `usage) is summed only over attempts that reported it and shown as unavailable — never zero — when none did.`
70
+ );
71
+ }
72
+
73
+ /** Agent identity recorded verbatim in the manifest — never inferred. */
74
+ export interface ReportAgentIdentity {
75
+ adapter: string;
76
+ provider: string;
77
+ /** The pinned model id; "unspecified" when the experiment pinned none. */
78
+ model: string;
79
+ /** The pinned reasoning effort; "unspecified" when the experiment pinned none. */
80
+ reasoning: string;
81
+ cli_version: string;
82
+ }
83
+
84
+ /** One experiment's identity card: what exactly ran under this id. */
85
+ export interface ReportExperiment {
86
+ experiment_id: string;
87
+ treatment: string | null;
88
+ agent: ReportAgentIdentity;
89
+ }
90
+
91
+ export interface ReportCheck {
92
+ name: string;
93
+ passed: number;
94
+ total: number;
95
+ }
96
+
97
+ /** One eval × experiment aggregation. Pairs with zero attempts are ABSENT, never 0%. */
98
+ export interface ReportPair {
99
+ eval_id: string;
100
+ experiment_id: string;
101
+ suite: string;
102
+ total: number;
103
+ passed: number;
104
+ failed: number;
105
+ incomplete: number;
106
+ errors: number;
107
+ /** passed + failed — the pass-rate denominator. */
108
+ scored: number;
109
+ /** passed / scored; null when nothing was scored (never 0-by-default). */
110
+ pass_rate: number | null;
111
+ /**
112
+ * pass@k / pass^k for every k from 1 to scored — per-k restatements of the
113
+ * same counts (see pass-at-k.ts), empty when nothing was scored. Optional
114
+ * on the wire: absent in schema_version < 5 artifacts, which the export
115
+ * deliberately copies verbatim — readers must tolerate absence.
116
+ */
117
+ pass_at_k?: PassAtKEntry[];
118
+ /** Completed trials disagreed on the outcome. */
119
+ flaky: boolean;
120
+ checks: ReportCheck[];
121
+ }
122
+
123
+ export interface ReportGroupArmCount {
124
+ eval_id: string;
125
+ passed: number;
126
+ scored: number;
127
+ /** The honest raw statement, e.g. "3/3". */
128
+ display: string;
129
+ }
130
+
131
+ export interface ReportGroupArm {
132
+ experiment_id: string;
133
+ treatment: string | null;
134
+ evals: ReportGroupArmCount[];
135
+ }
136
+
137
+ /**
138
+ * One declared comparison group. `compared` renders raw per-arm counts;
139
+ * `withheld` renders the component diff and NO counts-as-comparison.
140
+ */
141
+ export interface ReportGroup {
142
+ group: string;
143
+ experiment_ids: string[];
144
+ comparison: "compared" | "withheld";
145
+ /** Component-diff lines naming members and values (withheld groups only). */
146
+ details: string[];
147
+ /** Raw per-arm counts (compared groups only). */
148
+ arms: ReportGroupArm[];
149
+ }
150
+
151
+ export interface ReportUsageChannel {
152
+ /** Attempts with at least one observed event on this channel. */
153
+ attempts: number;
154
+ /** Total observed events across those attempts. */
155
+ events: number;
156
+ /** Redacted identifiers (deduplicated, capped). */
157
+ values: string[];
158
+ }
159
+
160
+ /** Offered-versus-used surface telemetry for one experiment. */
161
+ export interface ReportExperimentUsage {
162
+ experiment_id: string;
163
+ treatment: string | null;
164
+ /** Attempts whose raw stream supported extraction. */
165
+ telemetry_available: number;
166
+ /** Attempts stored with NULL usage (no supported stream) — never counted as zero use. */
167
+ telemetry_unavailable: number;
168
+ channels: { docs: ReportUsageChannel; mcp: ReportUsageChannel; cli: ReportUsageChannel };
169
+ }
170
+
171
+ export interface EgressDenialSummary {
172
+ attempt_id: string;
173
+ eval_id: string;
174
+ experiment_id: string;
175
+ count: number;
176
+ top_hosts: Array<{ host: string; count: number }>;
177
+ }
178
+
179
+ export interface ReportRunInfo {
180
+ id: string;
181
+ /** ISO timestamps on the wire (the store keeps epoch milliseconds). */
182
+ started_at: string;
183
+ finished_at: string | null;
184
+ trials: number;
185
+ eval_ids: string[];
186
+ experiment_ids: string[];
187
+ manifest_hash: string;
188
+ }
189
+
190
+ export interface ReportTotals {
191
+ attempts: number;
192
+ passed: number;
193
+ failed: number;
194
+ incomplete: number;
195
+ errors: number;
196
+ }
197
+
198
+ export interface ReportTelemetry {
199
+ /** null = no attempt reported cost (unavailable, never 0). */
200
+ cost_usd: number | null;
201
+ cost_attempts: number;
202
+ tokens_in: number | null;
203
+ tokens_out: number | null;
204
+ token_attempts: number;
205
+ }
206
+
207
+ export interface ReportJson {
208
+ schema_version: number;
209
+ generated_at: string;
210
+ run: ReportRunInfo;
211
+ /** Unfinished run or missing/invalid trial coverage. */
212
+ incomplete: boolean;
213
+ /** Added in schema 6; historical reports retain their recorded semantics. */
214
+ coverage?: PairCoverage[];
215
+ source_fingerprint?: string;
216
+ publication?: PublicationCoverage & { source_totals: ReportTotals; source_incomplete: boolean };
217
+ retry_history?: Array<{ attempt_id: string; status: string; error: string | null }>;
218
+ totals: ReportTotals;
219
+ telemetry: ReportTelemetry;
220
+ judge_telemetry?: { provenance: "semantic-judge"; calls: number; tokens_in: number | null; tokens_out: number | null; cost_usd: number | null };
221
+ /**
222
+ * Per-experiment identity cards (model, reasoning, adapter, ...). Optional
223
+ * on the wire: absent in schema_version < 4 artifacts, which the export
224
+ * deliberately copies verbatim — readers must tolerate absence.
225
+ */
226
+ experiments?: ReportExperiment[];
227
+ pairs: ReportPair[];
228
+ groups: ReportGroup[];
229
+ usage: ReportExperimentUsage[];
230
+ egress_denials: EgressDenialSummary[];
231
+ /** v6: empty denial logs are observed zero; absent logs are unavailable. */
232
+ egress_observation?: { available: string[]; unavailable: string[] };
233
+ methodology: string;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Aggregation
238
+ // ---------------------------------------------------------------------------
239
+
240
+ const USAGE_VALUE_CAP = 20;
241
+
242
+ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
243
+ const pairs = new Map<string, { pair: ReportPair; checks: Map<string, { passed: number; total: number }>; outcomes: Set<boolean> }>();
244
+ for (const attempt of attempts) {
245
+ const key = `${attempt.evalId}|${attempt.experimentId}`;
246
+ let entry = pairs.get(key);
247
+ if (!entry) {
248
+ entry = {
249
+ pair: {
250
+ eval_id: attempt.evalId,
251
+ experiment_id: attempt.experimentId,
252
+ suite: attempt.suite,
253
+ total: 0,
254
+ passed: 0,
255
+ failed: 0,
256
+ incomplete: 0,
257
+ errors: 0,
258
+ scored: 0,
259
+ pass_rate: null,
260
+ flaky: false,
261
+ checks: [],
262
+ },
263
+ checks: new Map(),
264
+ outcomes: new Set(),
265
+ };
266
+ pairs.set(key, entry);
267
+ }
268
+ entry.pair.total += 1;
269
+ if (attempt.status === "incomplete") entry.pair.incomplete += 1;
270
+ else if (attempt.status === "error") entry.pair.errors += 1;
271
+ else if (attempt.result?.passed) {
272
+ entry.pair.passed += 1;
273
+ entry.outcomes.add(true);
274
+ } else {
275
+ entry.pair.failed += 1;
276
+ entry.outcomes.add(false);
277
+ }
278
+ for (const check of attempt.result?.checks ?? []) {
279
+ const tally = entry.checks.get(check.name) ?? { passed: 0, total: 0 };
280
+ tally.total += 1;
281
+ if (check.passed) tally.passed += 1;
282
+ entry.checks.set(check.name, tally);
283
+ }
284
+ }
285
+
286
+ const result: ReportPair[] = [];
287
+ for (const { pair, checks, outcomes } of pairs.values()) {
288
+ pair.scored = pair.passed + pair.failed;
289
+ pair.pass_rate = pair.scored > 0 ? pair.passed / pair.scored : null;
290
+ pair.pass_at_k = collectPassAtK(pair.passed, pair.scored);
291
+ pair.flaky = outcomes.size > 1;
292
+ pair.checks = [...checks.entries()]
293
+ .map(([name, tally]) => ({ name, passed: tally.passed, total: tally.total }))
294
+ .sort((a, b) => a.name.localeCompare(b.name));
295
+ result.push(pair);
296
+ }
297
+ return result.sort(
298
+ (a, b) => a.eval_id.localeCompare(b.eval_id) || a.experiment_id.localeCompare(b.experiment_id),
299
+ );
300
+ }
301
+
302
+ function collectTotals(attempts: AttemptRecord[]): ReportTotals {
303
+ const completed = attempts.filter((attempt) => attempt.status === "completed");
304
+ const passed = completed.filter((attempt) => attempt.result?.passed).length;
305
+ return {
306
+ attempts: attempts.length,
307
+ passed,
308
+ failed: completed.length - passed,
309
+ incomplete: attempts.filter((attempt) => attempt.status === "incomplete").length,
310
+ errors: attempts.filter((attempt) => attempt.status === "error").length,
311
+ };
312
+ }
313
+
314
+ function collectTelemetry(attempts: AttemptRecord[]): ReportTelemetry {
315
+ // Telemetry is NULL when unavailable — sums only cover attempts that
316
+ // reported it, and a run with none reports null rather than 0.
317
+ const costs = attempts.map((a) => a.costUsd).filter((v): v is number => v !== null);
318
+ const tokensIn = attempts.map((a) => a.tokensIn).filter((v): v is number => v !== null);
319
+ const tokensOut = attempts.map((a) => a.tokensOut).filter((v): v is number => v !== null);
320
+ const sum = (values: number[]): number => values.reduce((total, v) => total + v, 0);
321
+ return {
322
+ cost_usd: costs.length > 0 ? sum(costs) : null,
323
+ cost_attempts: costs.length,
324
+ tokens_in: tokensIn.length > 0 ? sum(tokensIn) : null,
325
+ tokens_out: tokensOut.length > 0 ? sum(tokensOut) : null,
326
+ token_attempts: Math.max(tokensIn.length, tokensOut.length),
327
+ };
328
+ }
329
+
330
+ function treatmentOf(run: RunRecord, experimentId: string): string | null {
331
+ return run.manifest.experiments[experimentId]?.components.treatment ?? null;
332
+ }
333
+
334
+ /**
335
+ * Identity cards from the manifest, verbatim. The manifest records every
336
+ * experiment; "unspecified" is its own explicit missing-value sentinel, so
337
+ * this never invents a value the run did not pin.
338
+ */
339
+ function collectExperiments(run: RunRecord): ReportExperiment[] {
340
+ return [...run.config.experimentIds]
341
+ .sort((a, b) => a.localeCompare(b))
342
+ .map((experimentId) => {
343
+ const agent = run.manifest.experiments[experimentId]?.components.agent;
344
+ return {
345
+ experiment_id: experimentId,
346
+ treatment: treatmentOf(run, experimentId),
347
+ agent: {
348
+ adapter: agent?.adapter ?? "unspecified",
349
+ provider: agent?.provider ?? "unspecified",
350
+ model: agent?.model ?? "unspecified",
351
+ reasoning: agent?.reasoning ?? "unspecified",
352
+ cli_version: agent?.cliVersion ?? "unspecified",
353
+ },
354
+ };
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Comparison groups come from the run's recorded config (computed at run
360
+ * time by `compareGroups`). Singleton groups have nothing to compare and are
361
+ * omitted — their raw results already render as pairs. A withheld group
362
+ * renders its diff details and never counts-as-comparison.
363
+ */
364
+ function collectGroups(run: RunRecord, pairs: ReportPair[]): ReportGroup[] {
365
+ const declared: ComparisonGroup[] = run.config.groups ?? [];
366
+ const groups: ReportGroup[] = [];
367
+ for (const group of declared) {
368
+ if (group.experimentIds.length < 2) continue;
369
+ if (group.comparison === "withheld") {
370
+ groups.push({
371
+ group: group.group,
372
+ experiment_ids: [...group.experimentIds],
373
+ comparison: "withheld",
374
+ details: [...group.details],
375
+ arms: [],
376
+ });
377
+ continue;
378
+ }
379
+ const arms: ReportGroupArm[] = group.experimentIds.map((experimentId) => ({
380
+ experiment_id: experimentId,
381
+ treatment: treatmentOf(run, experimentId),
382
+ evals: pairs
383
+ .filter((pair) => pair.experiment_id === experimentId)
384
+ .map((pair) => ({
385
+ eval_id: pair.eval_id,
386
+ passed: pair.passed,
387
+ scored: pair.scored,
388
+ display: `${pair.passed}/${pair.scored}`,
389
+ })),
390
+ }));
391
+ groups.push({
392
+ group: group.group,
393
+ experiment_ids: [...group.experimentIds],
394
+ comparison: "compared",
395
+ details: [],
396
+ arms,
397
+ });
398
+ }
399
+ return groups.sort((a, b) => a.group.localeCompare(b.group));
400
+ }
401
+
402
+ function emptyChannel(): ReportUsageChannel {
403
+ return { attempts: 0, events: 0, values: [] };
404
+ }
405
+
406
+ function addChannel(channel: ReportUsageChannel, count: number, values: string[]): void {
407
+ if (count === 0) return;
408
+ channel.attempts += 1;
409
+ channel.events += count;
410
+ for (const value of values) {
411
+ if (channel.values.length >= USAGE_VALUE_CAP) break;
412
+ if (!channel.values.includes(value)) channel.values.push(value);
413
+ }
414
+ }
415
+
416
+ function collectUsage(run: RunRecord, attempts: AttemptRecord[]): ReportExperimentUsage[] {
417
+ const byExperiment = new Map<string, ReportExperimentUsage>();
418
+ const experimentIds = [...new Set(attempts.map((attempt) => attempt.experimentId))].sort((a, b) => a.localeCompare(b));
419
+ for (const experimentId of experimentIds) {
420
+ byExperiment.set(experimentId, {
421
+ experiment_id: experimentId,
422
+ treatment: treatmentOf(run, experimentId),
423
+ telemetry_available: 0,
424
+ telemetry_unavailable: 0,
425
+ channels: { docs: emptyChannel(), mcp: emptyChannel(), cli: emptyChannel() },
426
+ });
427
+ }
428
+ for (const attempt of attempts) {
429
+ const usage = byExperiment.get(attempt.experimentId) as ReportExperimentUsage;
430
+ const observed: SurfaceUsage | null = attempt.surfaceUsage;
431
+ if (observed === null) {
432
+ usage.telemetry_unavailable += 1;
433
+ continue;
434
+ }
435
+ usage.telemetry_available += 1;
436
+ addChannel(usage.channels.docs, observed.docs.count, observed.docs.urls);
437
+ addChannel(usage.channels.mcp, observed.mcp.count, observed.mcp.tools);
438
+ addChannel(usage.channels.cli, observed.cli.count, observed.cli.commands);
439
+ }
440
+ return [...byExperiment.values()];
441
+ }
442
+
443
+ /** Denied-host counts, descending — the drill-down summary shape. */
444
+ export function summarizeDenialHosts(
445
+ denials: readonly { host: string }[],
446
+ limit: number,
447
+ ): Array<{ host: string; count: number }> {
448
+ const counts = new Map<string, number>();
449
+ for (const { host } of denials) counts.set(host, (counts.get(host) ?? 0) + 1);
450
+ return [...counts.entries()]
451
+ .map(([host, count]) => ({ host, count }))
452
+ .sort((a, b) => b.count - a.count || a.host.localeCompare(b.host))
453
+ .slice(0, limit);
454
+ }
455
+
456
+ /**
457
+ * Read each attempt's egress-denials.log (written only when the proxied
458
+ * attempt was denied at least once) into the per-attempt rollup. Unreadable
459
+ * or malformed logs are skipped — the rollup is a summary, never a gate.
460
+ */
461
+ function collectEgressDenials(resultsDir: string, runId: string, attempts: AttemptRecord[]): EgressDenialSummary[] {
462
+ const summaries: EgressDenialSummary[] = [];
463
+ for (const attempt of attempts) {
464
+ const path = join(resultsDir, runId, attempt.id, "egress-denials.log");
465
+ if (!existsSync(path)) continue;
466
+ let hosts: Array<{ host: string }>;
467
+ try {
468
+ hosts = readFileSync(path, "utf8")
469
+ .split("\n")
470
+ .filter((line) => line !== "")
471
+ .map((line) => JSON.parse(line) as { host: string });
472
+ } catch {
473
+ continue;
474
+ }
475
+ if (hosts.length === 0) continue;
476
+ summaries.push({
477
+ attempt_id: attempt.id,
478
+ eval_id: attempt.evalId,
479
+ experiment_id: attempt.experimentId,
480
+ count: hosts.length,
481
+ top_hosts: summarizeDenialHosts(hosts, 3),
482
+ });
483
+ }
484
+ return summaries.sort((a, b) => b.count - a.count || a.attempt_id.localeCompare(b.attempt_id));
485
+ }
486
+
487
+ export interface BuildReportOptions {
488
+ expectedTrials?: number;
489
+ store: ResultsStore;
490
+ runId: string;
491
+ /** Artifacts root (the harness `--results` dir) — egress denial logs live here. */
492
+ resultsDir: string;
493
+ /** Overrides the `generated_at` stamp — fixture seeding only. */
494
+ generatedAt?: string;
495
+ }
496
+
497
+ /** Build the report from the store + manifest. Deterministic, offline, synchronous. */
498
+ export function buildReport(options: BuildReportOptions): ReportJson {
499
+ const { store, runId } = options;
500
+ const run = store.getRun(runId);
501
+ if (!run) {
502
+ const known = store
503
+ .listRuns()
504
+ .slice(0, 5)
505
+ .map((candidate) => ` ${candidate.id} (started ${new Date(candidate.startedAt).toISOString()})`);
506
+ throw new ReportError(
507
+ `no run "${runId}" in this results database${known.length > 0 ? `; most recent runs:\n${known.join("\n")}` : ""}`,
508
+ );
509
+ }
510
+ const history = store.listAttempts(runId);
511
+ const current = activeAttempts(history);
512
+ const coverage = trialCoverage(run, current);
513
+ const publication = options.expectedTrials === undefined ? undefined : publicationCoverage(run, current, options.expectedTrials);
514
+ 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(" | ")}`);
515
+ const attempts = publication ? current.filter((a) => publication.eligible.some((p) => p.eval_id === a.evalId && p.experiment_id === a.experimentId)) : current;
516
+ const pairs = collectPairs(attempts);
517
+
518
+ const methodology = methodologyNote(run.config.trials);
519
+ const banned = findBannedLanguage(methodology);
520
+ if (banned.length > 0) {
521
+ throw new ReportError(`report methodology violates the language guard: ${banned.join(", ")}`);
522
+ }
523
+
524
+ return {
525
+ schema_version: REPORT_SCHEMA_VERSION,
526
+ generated_at: options.generatedAt ?? new Date().toISOString(),
527
+ run: {
528
+ id: run.id,
529
+ started_at: new Date(run.startedAt).toISOString(),
530
+ finished_at: run.finishedAt === null ? null : new Date(run.finishedAt).toISOString(),
531
+ trials: run.config.trials,
532
+ eval_ids: [...run.config.evalIds],
533
+ experiment_ids: [...run.config.experimentIds],
534
+ manifest_hash: run.manifestHash,
535
+ },
536
+ incomplete: publication ? false : run.finishedAt === null || coverage.length === 0 || coverage.some((pair) => !pair.complete),
537
+ source_fingerprint: hashString(JSON.stringify({ manifest: run.manifestHash, finishedAt: run.finishedAt, trials: run.config.trials, evals: run.config.evalIds, experiments: run.config.experimentIds, history: history.map(({ transcriptRef, diffRef, ...row }) => ({ ...row, transcript: Boolean(transcriptRef), diff: Boolean(diffRef) })) })),
538
+ ...(publication ? { publication: { ...publication, source_totals: collectTotals(current), source_incomplete: run.finishedAt === null || coverage.some((p) => !p.complete) } } : {}),
539
+ coverage,
540
+ retry_history: history.filter((a) => !current.includes(a)).map((a) => ({ attempt_id: a.id, status: a.status, error: a.error })),
541
+ totals: collectTotals(attempts),
542
+ telemetry: collectTelemetry(attempts),
543
+ judge_telemetry: collectJudgeTelemetry(attempts),
544
+ experiments: collectExperiments(run),
545
+ pairs,
546
+ groups: collectGroups(run, pairs),
547
+ usage: collectUsage(run, attempts),
548
+ egress_denials: collectEgressDenials(options.resultsDir, runId, attempts),
549
+ egress_observation: {
550
+ available: attempts.filter((a) => existsSync(join(options.resultsDir, runId, a.id, "egress-denials.log"))).map((a) => a.id),
551
+ unavailable: attempts.filter((a) => !existsSync(join(options.resultsDir, runId, a.id, "egress-denials.log"))).map((a) => a.id),
552
+ },
553
+ methodology,
554
+ };
555
+ }
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // Text renderer (consumes ONLY ReportJson)
559
+ // ---------------------------------------------------------------------------
560
+
561
+ function pct(rate: number | null): string {
562
+ return rate === null ? "n/a" : `${Math.round(rate * 100)}%`;
563
+ }
564
+
565
+ /** Render the report as plain text. Renders FROM the JSON, never the store. */
566
+ export function renderReportText(report: ReportJson): string {
567
+ const lines: string[] = [];
568
+ lines.push(`run ${report.run.id}`);
569
+ lines.push(` started: ${report.run.started_at}`);
570
+ if (report.incomplete) {
571
+ lines.push(` finished: INCOMPLETE — unfinished run or invalid trial coverage; treat every number below as partial`);
572
+ for (const pair of report.coverage ?? []) {
573
+ if (!pair.complete) lines.push(` ${pair.eval_id} × ${pair.experiment_id}: ${pair.reasons.join("; ")}`);
574
+ }
575
+ } else {
576
+ lines.push(` finished: ${report.run.finished_at}`);
577
+ }
578
+ lines.push(` manifest: ${report.run.manifest_hash}`);
579
+ lines.push("");
580
+
581
+ const experiments = report.experiments ?? [];
582
+ if (experiments.length > 0) {
583
+ lines.push("experiments:");
584
+ for (const experiment of experiments) {
585
+ const agent = experiment.agent;
586
+ const model = agent.model === "unspecified" ? "model unpinned" : `model ${agent.model}`;
587
+ const reasoning = agent.reasoning === "unspecified" ? "" : `, reasoning ${agent.reasoning}`;
588
+ lines.push(
589
+ ` ${experiment.experiment_id}${experiment.treatment !== null ? ` [${experiment.treatment}]` : ""}: ` +
590
+ `${model}${reasoning} (adapter ${agent.adapter}, provider ${agent.provider}, cli ${agent.cli_version})`,
591
+ );
592
+ }
593
+ lines.push("");
594
+ }
595
+
596
+ if (report.retry_history?.length) lines.push(`prior retry history: ${report.retry_history.length} unsuccessful executions retained in the source artifacts`);
597
+ if (report.publication) {
598
+ lines.push(`complete-sample publication: ${report.publication.eligible.length} eligible pair(s), ${report.publication.omitted.length} omitted; expected ${report.publication.expected_trials} trials`);
599
+ lines.push(`source attempts: ${report.publication.source_totals.attempts}, including ${report.publication.source_totals.incomplete} incomplete and ${report.publication.source_totals.errors} errors`);
600
+ for (const pair of report.publication.omitted) lines.push(` omitted ${pair.eval_id} × ${pair.experiment_id}: ${pair.reasons.join("; ")}`);
601
+ }
602
+ const t = report.totals;
603
+ lines.push(`attempts: ${t.attempts} — ${t.passed} passed, ${t.failed} failed, ${t.incomplete} incomplete, ${t.errors} errored`);
604
+ const tel = report.telemetry;
605
+ lines.push(
606
+ ` cost: ${tel.cost_usd === null ? "unavailable" : `$${tel.cost_usd.toFixed(4)} across ${tel.cost_attempts} attempt(s)`}`,
607
+ );
608
+ lines.push(
609
+ ` tokens: ${
610
+ tel.tokens_in === null && tel.tokens_out === null
611
+ ? "unavailable"
612
+ : `${tel.tokens_in ?? 0} in / ${tel.tokens_out ?? 0} out (from ${tel.token_attempts} attempt(s))`
613
+ }`,
614
+ );
615
+ lines.push("");
616
+
617
+ for (const pair of report.pairs) {
618
+ lines.push(
619
+ `${pair.eval_id} × ${pair.experiment_id}${pair.suite !== "benchmark" ? ` [${pair.suite}]` : ""}: ` +
620
+ `${pct(pair.pass_rate)} pass rate (${pair.passed}/${pair.scored} scored` +
621
+ `${pair.incomplete > 0 ? `, ${pair.incomplete} incomplete` : ""}` +
622
+ `${pair.errors > 0 ? `, ${pair.errors} errored` : ""}` +
623
+ `${pair.flaky ? ", trials disagreed" : ""})`,
624
+ );
625
+ const kEntries = displayPassAtK(pair.pass_at_k);
626
+ if (kEntries.length > 0) {
627
+ lines.push(
628
+ ` pass@k / pass^k — ${kEntries.map((entry) => `k=${entry.k}: ${pct(entry.pass_at_k)} / ${pct(entry.pass_to_the_k)}`).join(", ")}`,
629
+ );
630
+ }
631
+ for (const check of pair.checks) {
632
+ lines.push(` ${check.passed === check.total ? "PASS" : "FAIL"} ${check.name} (${check.passed}/${check.total})`);
633
+ }
634
+ }
635
+
636
+ if (report.groups.length > 0) {
637
+ lines.push("");
638
+ lines.push("comparison groups:");
639
+ for (const group of report.groups) {
640
+ if (group.comparison === "withheld") {
641
+ lines.push(` ${group.group}: WITHHELD — members differ beyond their declared treatment; raw results above still stand`);
642
+ for (const detail of group.details) lines.push(` ${detail}`);
643
+ continue;
644
+ }
645
+ lines.push(` ${group.group}: compared (raw counts per arm)`);
646
+ for (const arm of group.arms) {
647
+ const cells = arm.evals.map((entry) => `${entry.eval_id} ${entry.display}`).join(", ");
648
+ lines.push(` ${arm.experiment_id}${arm.treatment !== null ? ` [${arm.treatment}]` : ""}: ${cells || "no scored attempts"}`);
649
+ }
650
+ }
651
+ }
652
+
653
+ if (report.usage.length > 0) {
654
+ lines.push("");
655
+ lines.push("surface usage (offered vs observed):");
656
+ for (const usage of report.usage) {
657
+ const channels = (["docs", "mcp", "cli"] as const)
658
+ .map((channel) => `${channel} ${usage.channels[channel].events} event(s) in ${usage.channels[channel].attempts} attempt(s)`)
659
+ .join(", ");
660
+ lines.push(
661
+ ` ${usage.experiment_id}${usage.treatment !== null ? ` [offered: ${usage.treatment}]` : ""}: ` +
662
+ `${usage.telemetry_available} attempt(s) with telemetry` +
663
+ `${usage.telemetry_unavailable > 0 ? `, ${usage.telemetry_unavailable} unavailable` : ""} — ${channels}`,
664
+ );
665
+ }
666
+ }
667
+
668
+ if (report.egress_denials.length > 0) {
669
+ lines.push("");
670
+ lines.push("egress denials:");
671
+ for (const denial of report.egress_denials) {
672
+ lines.push(
673
+ ` ${denial.attempt_id} (${denial.eval_id} × ${denial.experiment_id}): ${denial.count} denial(s) — ` +
674
+ denial.top_hosts.map(({ host, count }) => `${host} (${count})`).join(", "),
675
+ );
676
+ }
677
+ }
678
+
679
+ lines.push("");
680
+ lines.push(`methodology: ${report.methodology}`);
681
+ return lines.join("\n");
682
+ }
683
+
684
+ // ---------------------------------------------------------------------------
685
+ // report.json plumbing
686
+ // ---------------------------------------------------------------------------
687
+
688
+ /** Canonical location of a run's report.json. */
689
+ export function reportJsonPath(resultsDir: string, runId: string): string {
690
+ return join(resultsDir, runId, "report.json");
691
+ }
692
+
693
+ /** Write report.json into the run's results dir. Returns the path. */
694
+ export function writeReportJson(report: ReportJson, resultsDir: string): string {
695
+ const dir = join(resultsDir, report.run.id);
696
+ mkdirSync(dir, { recursive: true });
697
+ const path = reportJsonPath(resultsDir, report.run.id);
698
+ writeFileSync(path, `${JSON.stringify(report, null, 2)}\n`, "utf8");
699
+ return path;
700
+ }
701
+
702
+ /** Load a previously written report.json, refusing other schema versions. */
703
+ export function readReportJson(resultsDir: string, runId: string): ReportJson {
704
+ const path = reportJsonPath(resultsDir, runId);
705
+ if (!existsSync(path)) {
706
+ throw new ReportError(`no report.json for run "${runId}" (expected ${path}) — run \`quickstudy report ${runId}\` first`);
707
+ }
708
+ try {
709
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<ReportJson>;
710
+ if (parsed.schema_version !== REPORT_SCHEMA_VERSION) {
711
+ throw new Error(
712
+ `unsupported report schema ${String(parsed.schema_version ?? "legacy")} (expected ${REPORT_SCHEMA_VERSION})`,
713
+ );
714
+ }
715
+ return parsed as ReportJson;
716
+ } catch (err) {
717
+ throw new ReportError(
718
+ `report.json for run "${runId}" is not readable (${err instanceof Error ? err.message : String(err)}) — ` +
719
+ `re-generate it with \`quickstudy report ${runId}\``,
720
+ );
721
+ }
722
+ }
723
+
724
+ function collectJudgeTelemetry(attempts: AttemptRecord[]): NonNullable<ReportJson["judge_telemetry"]> {
725
+ const records = attempts.flatMap((a) => a.result?.judgments ?? a.judgeRecords ?? []);
726
+ const sum = (key: "tokensIn" | "tokensOut" | "costUsd") => {
727
+ const available = records.map((r) => r[key]).filter((v): v is number => v !== null);
728
+ return available.length ? available.reduce((a, b) => a + b, 0) : null;
729
+ };
730
+ return { provenance: "semantic-judge", calls: records.length, tokens_in: sum("tokensIn"), tokens_out: sum("tokensOut"), cost_usd: sum("costUsd") };
731
+ }