@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
package/src/probe.ts ADDED
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Reusable probe scaffolding for checks that boot the attempt's app and
3
+ * inspect it from the outside — over plain HTTP or through a Playwright
4
+ * spec run against the live sandbox.
5
+ *
6
+ * Originally shared with the legacy `app-http-probe` built-in family and
7
+ * v2 sidecar graders (both deleted with the v2 authoring stack); the only
8
+ * remaining consumers are consumer scorer libraries, which call it directly
9
+ * with an eval context's `exec`.
10
+ *
11
+ * The shell shape generalizes the probes consumers hand-rolled before this
12
+ * module existed: `set -e`, background the start command with its output in
13
+ * {@link PROBE_LOG_PATH} (inspectable via `--debug-keep`), poll readiness on
14
+ * a bounded budget, re-assert readiness, then fetch the path under grading.
15
+ * The fetch deliberately does NOT use `curl -f`: a probe may expect a
16
+ * non-2xx status (`expectStatus: 404`), so the script captures the status
17
+ * code separately from the body instead of letting curl abort on it.
18
+ */
19
+
20
+ /** Output of a command run through a probe's exec facility. */
21
+ export interface ProbeExecResult {
22
+ exitCode: number;
23
+ stdout: string;
24
+ stderr: string;
25
+ }
26
+
27
+ /**
28
+ * The exec facility a probe drives: a container grader's `ctx.exec` or a v3
29
+ * eval context's `exec` (both return supersets of {@link ProbeExecResult}).
30
+ */
31
+ export type ProbeExecFn = (cmd: string[], opts?: { timeoutMs?: number }) => Promise<ProbeExecResult>;
32
+
33
+ /**
34
+ * In-container log file the backgrounded start command writes to. Diagnostic
35
+ * detail lives here and only here (probes return plain booleans); inspect it
36
+ * with `--debug-keep`.
37
+ */
38
+ export const PROBE_LOG_PATH = "/tmp/quickstudy-probe.log";
39
+
40
+ /** Budget for one whole probe script run (start + poll + fetch). */
41
+ export const DEFAULT_PROBE_TIMEOUT_MS = 60_000;
42
+
43
+ /** Default readiness poll: 40 attempts × 250ms ≈ 10s of app boot budget. */
44
+ export const DEFAULT_POLL_ATTEMPTS = 40;
45
+ export const DEFAULT_POLL_INTERVAL_MS = 250;
46
+
47
+ /** What {@link buildStartScript} needs to compose the probe shell script. */
48
+ export interface ProbeScriptOptions {
49
+ /** Shell command that starts the app; backgrounded, output to {@link PROBE_LOG_PATH}. */
50
+ start: string;
51
+ /** TCP port the app listens on (composed into 127.0.0.1 URLs). */
52
+ port: number;
53
+ /** Path polled until it answers 2xx before the request fires. */
54
+ readinessPath: string;
55
+ /** Path fetched once ready; its status and body become the script's stdout. */
56
+ requestPath: string;
57
+ /**
58
+ * HTTP method for the request fetch. Omitted = curl's default (GET),
59
+ * keeping the composed script byte-identical for existing consumers.
60
+ */
61
+ requestMethod?: string;
62
+ /** Readiness poll budget; defaults {@link DEFAULT_POLL_ATTEMPTS} × {@link DEFAULT_POLL_INTERVAL_MS}. */
63
+ pollAttempts?: number;
64
+ pollIntervalMs?: number;
65
+ }
66
+
67
+ /**
68
+ * Build the `sh -c` probe script: start the app in the background, poll the
69
+ * readiness path on a bounded budget, re-assert readiness (so a flapping app
70
+ * fails loudly), then fetch the request path. stdout is the response body
71
+ * followed by a newline and the three-digit HTTP status — parse it with
72
+ * {@link fetchViaExec}. Any failure before the fetch (app never came up,
73
+ * readiness lost) exits nonzero via `set -e`.
74
+ */
75
+ export function buildStartScript(opts: ProbeScriptOptions): string {
76
+ const attempts = opts.pollAttempts ?? DEFAULT_POLL_ATTEMPTS;
77
+ const intervalSeconds = (opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS) / 1000;
78
+ const base = `http://127.0.0.1:${opts.port}`;
79
+ const readinessUrl = `${base}${opts.readinessPath}`;
80
+ const method = opts.requestMethod === undefined ? "" : `-X ${opts.requestMethod} `;
81
+ return `
82
+ set -e
83
+ cd /workspace
84
+ (${opts.start} >${PROBE_LOG_PATH} 2>&1 &)
85
+ i=0
86
+ while [ $i -lt ${attempts} ]; do
87
+ if curl -sf -o /dev/null "${readinessUrl}"; then break; fi
88
+ i=$((i + 1))
89
+ sleep ${intervalSeconds}
90
+ done
91
+ curl -sf -o /dev/null "${readinessUrl}"
92
+ curl -s ${method}-w '\\n%{http_code}' "${base}${opts.requestPath}"
93
+ `;
94
+ }
95
+
96
+ /** A captured HTTP response: the status code and the raw body text. */
97
+ export interface ProbeResponse {
98
+ status: number;
99
+ body: string;
100
+ }
101
+
102
+ /**
103
+ * Run a probe script (see {@link buildStartScript}) in the attempt container
104
+ * and split its stdout into status + body. Returns `undefined` when the
105
+ * script exited nonzero or its stdout carries no status line — the app never
106
+ * came up, readiness failed, or curl could not connect: a failed check, not
107
+ * a crash. Deliberately no try/catch around `exec`: a rejecting exec is the
108
+ * facility itself failing, which must reach the runner's errors map instead
109
+ * of masquerading as a failed check.
110
+ */
111
+ export async function fetchViaExec(
112
+ exec: ProbeExecFn,
113
+ script: string,
114
+ opts?: { timeoutMs?: number },
115
+ ): Promise<ProbeResponse | undefined> {
116
+ const result = await exec(["sh", "-c", script], { timeoutMs: opts?.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS });
117
+ if (result.exitCode !== 0) return undefined;
118
+ // The script's last output is `\n` + the three-digit status; everything
119
+ // before that separator is the body, byte-exact (its own trailing newline,
120
+ // if any, sits before the separator and survives).
121
+ const trimmed = result.stdout.trimEnd();
122
+ const separator = trimmed.lastIndexOf("\n");
123
+ const statusText = (separator === -1 ? trimmed : trimmed.slice(separator + 1)).trim();
124
+ if (!/^\d{3}$/.test(statusText)) return undefined;
125
+ return {
126
+ status: Number.parseInt(statusText, 10),
127
+ body: separator === -1 ? "" : trimmed.slice(0, separator),
128
+ };
129
+ }
130
+
131
+ // ── Playwright-spec probes ───────────────────────────────────────────────────
132
+ //
133
+ // The second probe shape: boot the workspace's dev server in the background,
134
+ // wait for it to answer, deliver a Playwright spec into the sandbox's
135
+ // preinstalled probe project, and run it (optionally `--grep`-scoped so one
136
+ // spec file can serve several independently-scored checks). Everything runs
137
+ // through the probe's exec facility, so the same code drives a live attempt
138
+ // container or a scripted fake in tests.
139
+
140
+ /** Where sandbox images preinstall the Playwright probe project. */
141
+ export const DEFAULT_PROBE_PROJECT_DIR = "/opt/quickstudy/probe";
142
+
143
+ /** Shell expression resolving the probe project dir inside the sandbox. */
144
+ const PROBE_DIR_EXPR = `\${QUICKSTUDY_PROBE_DIR:-${DEFAULT_PROBE_PROJECT_DIR}}`;
145
+
146
+ /** Budget for one Playwright spec run (browser flows are slow). */
147
+ export const PLAYWRIGHT_RUN_TIMEOUT_MS = 3 * 60 * 1000;
148
+
149
+ /** Dev-server readiness: generous, first boots compile. */
150
+ export const SERVER_READY_TIMEOUT_MS = 90 * 1000;
151
+ export const SERVER_POLL_INTERVAL_MS = 1500;
152
+
153
+ /** In-sandbox log/pid files for the backgrounded dev server. */
154
+ export const APP_LOG_PATH = "/tmp/quickstudy-dev.log";
155
+ export const APP_PID_PATH = "/tmp/quickstudy-dev.pid";
156
+
157
+ /**
158
+ * Start a dev server in the background inside the sandbox. The command runs
159
+ * in its own process group (`setsid`) so {@link stopBackgroundApp} can kill
160
+ * the whole tree, with output captured in {@link APP_LOG_PATH}.
161
+ */
162
+ export async function startAppInBackground(exec: ProbeExecFn, start: string): Promise<void> {
163
+ await exec(
164
+ ["sh", "-c", `nohup setsid sh -c 'exec ${start}' > ${APP_LOG_PATH} 2>&1 & echo $! > ${APP_PID_PATH}`],
165
+ { timeoutMs: 15_000 },
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Poll the app until any HTTP answer arrives (readiness means "listening",
171
+ * not "healthy" — probes assert health themselves). `baseUrlExpr` is a shell
172
+ * expression evaluated inside the sandbox, so credentials and per-attempt
173
+ * URLs never appear in the composed command line.
174
+ */
175
+ export async function waitForAppReady(
176
+ exec: ProbeExecFn,
177
+ baseUrlExpr: string,
178
+ opts?: { readyTimeoutMs?: number; pollIntervalMs?: number; sleep?: (ms: number) => Promise<void> },
179
+ ): Promise<boolean> {
180
+ const sleep = opts?.sleep ?? ((ms: number) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)));
181
+ const deadline = Date.now() + (opts?.readyTimeoutMs ?? SERVER_READY_TIMEOUT_MS);
182
+ while (Date.now() < deadline) {
183
+ const poll = await exec(["sh", "-c", `curl -s -o /dev/null "${baseUrlExpr}"`], { timeoutMs: 10_000 });
184
+ if (poll.exitCode === 0) return true;
185
+ await sleep(opts?.pollIntervalMs ?? SERVER_POLL_INTERVAL_MS);
186
+ }
187
+ return false;
188
+ }
189
+
190
+ /** The last lines of the backgrounded dev server's log, for failure notes. */
191
+ export async function readAppLogTail(exec: ProbeExecFn, lines = 40): Promise<string> {
192
+ const tail = await exec(["sh", "-c", `tail -n ${lines} ${APP_LOG_PATH} 2>/dev/null || true`], {
193
+ timeoutMs: 10_000,
194
+ });
195
+ return tail.stdout;
196
+ }
197
+
198
+ /** Kill the backgrounded dev server (process group first, pid as fallback). */
199
+ export async function stopBackgroundApp(exec: ProbeExecFn): Promise<void> {
200
+ await exec(
201
+ [
202
+ "sh",
203
+ "-c",
204
+ `pid="$(cat ${APP_PID_PATH} 2>/dev/null)"; [ -z "$pid" ] || kill -TERM -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true`,
205
+ ],
206
+ { timeoutMs: 10_000 },
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Deliver a spec's source into the sandbox's probe project. Images
212
+ * preinstall the Playwright toolchain at QUICKSTUDY_PROBE_DIR but never bake
213
+ * spec files, so specs travel at probe time — as an exec argument, never
214
+ * interpolated into the script itself.
215
+ */
216
+ export async function deliverSpecSource(exec: ProbeExecFn, specFile: string, specSource: string): Promise<void> {
217
+ await exec(
218
+ ["sh", "-c", `dir="${PROBE_DIR_EXPR}" && mkdir -p "$dir" && printf "%s\\n" "$1" > "$dir/${specFile}"`, "sh", specSource],
219
+ { timeoutMs: 10_000 },
220
+ );
221
+ }
222
+
223
+ /** One Playwright spec run inside the sandbox's probe project. */
224
+ export interface PlaywrightRunOptions {
225
+ /** Spec filename inside the probe project (delivered beforehand). */
226
+ specFile: string;
227
+ /**
228
+ * Environment handed to the run as `NAME="<expr>"` pairs. Values are shell
229
+ * expressions expanded inside the sandbox (`$PROVISIONED_VAR`), so secrets
230
+ * stay out of the composed command line.
231
+ */
232
+ env: Record<string, string>;
233
+ /** `--grep` scope, so one spec file can serve several independent checks. */
234
+ grep?: string;
235
+ timeoutMs?: number;
236
+ }
237
+
238
+ /**
239
+ * Run a delivered Playwright spec. The exit code is the verdict (0 = the
240
+ * probed flow held); stdout/stderr carry the line-reporter output for
241
+ * failure notes. No try/catch: a rejecting exec is an infrastructure fault.
242
+ */
243
+ export async function runPlaywrightSpec(exec: ProbeExecFn, opts: PlaywrightRunOptions): Promise<ProbeExecResult> {
244
+ const envPairs = Object.entries(opts.env)
245
+ .map(([key, expr]) => `${key}="${expr}"`)
246
+ .join(" ");
247
+ const grep = opts.grep === undefined ? "" : `--grep ${opts.grep} `;
248
+ return exec(
249
+ ["sh", "-c", `cd "${PROBE_DIR_EXPR}" && ${envPairs} npx playwright test ${opts.specFile} ${grep}--reporter=line`],
250
+ { timeoutMs: opts.timeoutMs ?? PLAYWRIGHT_RUN_TIMEOUT_MS },
251
+ );
252
+ }
253
+
254
+ /** Bound failure-note output so notes stay readable in reports. */
255
+ export function truncateOutput(text: string): string {
256
+ const trimmed = text.trim();
257
+ return trimmed.length > 2000 ? `${trimmed.slice(0, 2000)}…` : trimmed;
258
+ }
259
+
260
+ // ── declarative JSON assertions ──────────────────────────────────────────────
261
+
262
+ /**
263
+ * A declarative check over a JSON response body. `path` is a deliberate
264
+ * subset of jsonPath — `$`, dot keys, `[<index>]`, and at most one `[*]`
265
+ * projection (e.g. `$.widgets[*].sku`) — not a jsonpath engine; anything
266
+ * richer is a sidecar case.
267
+ */
268
+ export type JsonAssertion =
269
+ | { path: string; equals: unknown }
270
+ | { path: string; ascending: true }
271
+ | { path: string; every: { equals: unknown } };
272
+
273
+ /** A parsed probe path: segments before and (optionally) after one `[*]`. */
274
+ interface ParsedProbePath {
275
+ prefix: Array<string | number>;
276
+ /** Present only when the path contains a `[*]` projection. */
277
+ projected?: Array<string | number>;
278
+ }
279
+
280
+ /**
281
+ * Parse the jsonPath subset. Throws a plain Error on malformed paths — a
282
+ * syntactically bad path is a programming error (the built-in family rejects
283
+ * it at load time; sidecars surface it as a grader error), while a
284
+ * well-formed path that doesn't match the body is a failed CHECK.
285
+ */
286
+ export function parseProbePath(path: string): ParsedProbePath {
287
+ if (!path.startsWith("$")) {
288
+ throw new Error(`assertion path must start with "$" (got "${path}")`);
289
+ }
290
+ const prefix: Array<string | number> = [];
291
+ let projected: Array<string | number> | undefined;
292
+ let current = prefix;
293
+ let i = 1;
294
+ while (i < path.length) {
295
+ if (path[i] === ".") {
296
+ const match = /^[^.[\]]+/.exec(path.slice(i + 1));
297
+ if (!match) throw new Error(`empty key segment at position ${i} in "${path}"`);
298
+ current.push(match[0]);
299
+ i += 1 + match[0].length;
300
+ } else if (path.startsWith("[*]", i)) {
301
+ if (projected !== undefined) throw new Error(`at most one "[*]" projection is supported (got "${path}")`);
302
+ projected = [];
303
+ current = projected;
304
+ i += 3;
305
+ } else if (path[i] === "[") {
306
+ const close = path.indexOf("]", i);
307
+ if (close === -1) throw new Error(`unclosed "[" at position ${i} in "${path}"`);
308
+ const inner = path.slice(i + 1, close);
309
+ if (!/^\d+$/.test(inner)) {
310
+ throw new Error(`bracket segments must be "*" or a non-negative integer index (got "[${inner}]" in "${path}")`);
311
+ }
312
+ current.push(Number(inner));
313
+ i = close + 1;
314
+ } else {
315
+ throw new Error(`unexpected character "${path[i]}" at position ${i} in "${path}"`);
316
+ }
317
+ }
318
+ return projected === undefined ? { prefix } : { prefix, projected };
319
+ }
320
+
321
+ /** Walk plain segments into a value; `found: false` when any hop is missing. */
322
+ function resolveSegments(value: unknown, segments: ReadonlyArray<string | number>): { found: boolean; value: unknown } {
323
+ let current = value;
324
+ for (const segment of segments) {
325
+ if (typeof segment === "number") {
326
+ if (!Array.isArray(current) || segment >= current.length) return { found: false, value: undefined };
327
+ current = current[segment];
328
+ } else {
329
+ if (current === null || typeof current !== "object" || Array.isArray(current) || !Object.hasOwn(current, segment)) {
330
+ return { found: false, value: undefined };
331
+ }
332
+ current = (current as Record<string, unknown>)[segment];
333
+ }
334
+ }
335
+ return { found: true, value: current };
336
+ }
337
+
338
+ type ProbePathResolution = { kind: "missing" } | { kind: "one"; value: unknown } | { kind: "many"; values: unknown[] };
339
+
340
+ /**
341
+ * Resolve a parsed path against a body. A missing key, an out-of-range
342
+ * index, a `[*]` over a non-array, or a projected segment absent from any
343
+ * element all resolve to "missing" — which fails the assertion (missing is a
344
+ * wrong answer, never an error).
345
+ */
346
+ function resolveProbePath(body: unknown, parsed: ParsedProbePath): ProbePathResolution {
347
+ const head = resolveSegments(body, parsed.prefix);
348
+ if (!head.found) return { kind: "missing" };
349
+ if (parsed.projected === undefined) return { kind: "one", value: head.value };
350
+ if (!Array.isArray(head.value)) return { kind: "missing" };
351
+ const values: unknown[] = [];
352
+ for (const element of head.value) {
353
+ const resolved = resolveSegments(element, parsed.projected);
354
+ if (!resolved.found) return { kind: "missing" };
355
+ values.push(resolved.value);
356
+ }
357
+ return { kind: "many", values };
358
+ }
359
+
360
+ /**
361
+ * Semantic (key-order-independent) deep equality over JSON data. Arrays are
362
+ * order-sensitive; objects compare by key set + values; primitives by `===`.
363
+ */
364
+ export function deepJsonEqual(a: unknown, b: unknown): boolean {
365
+ if (a === b) return true;
366
+ if (Array.isArray(a) && Array.isArray(b)) {
367
+ return a.length === b.length && a.every((item, index) => deepJsonEqual(item, b[index]));
368
+ }
369
+ if (a !== null && b !== null && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
370
+ const aKeys = Object.keys(a);
371
+ const bRecord = b as Record<string, unknown>;
372
+ return (
373
+ aKeys.length === Object.keys(bRecord).length &&
374
+ aKeys.every((key) => Object.hasOwn(bRecord, key) && deepJsonEqual((a as Record<string, unknown>)[key], bRecord[key]))
375
+ );
376
+ }
377
+ return false;
378
+ }
379
+
380
+ /**
381
+ * Ordering check for `ascending: true`. All values must be strings (compared
382
+ * by code unit, so "W-102" < "W-103") or all numbers (compared numerically);
383
+ * a mixed or non-orderable list is false. Adjacent equals are allowed
384
+ * (non-strict order), and zero or one value is vacuously true — including a
385
+ * `[*]` projection over an empty array.
386
+ */
387
+ function isAscending(values: readonly unknown[]): boolean {
388
+ if (values.length <= 1) return true;
389
+ const allStrings = values.every((value) => typeof value === "string");
390
+ const allNumbers = values.every((value) => typeof value === "number");
391
+ if (!allStrings && !allNumbers) return false;
392
+ for (let i = 1; i < values.length; i++) {
393
+ if ((values[i] as string | number) < (values[i - 1] as string | number)) return false;
394
+ }
395
+ return true;
396
+ }
397
+
398
+ /** The list a list-shaped operator (`ascending`, `every`) works over, or undefined. */
399
+ function asList(resolution: ProbePathResolution): unknown[] | undefined {
400
+ if (resolution.kind === "many") return resolution.values;
401
+ if (resolution.kind === "one" && Array.isArray(resolution.value)) return resolution.value;
402
+ return undefined;
403
+ }
404
+
405
+ function evaluateAssertion(body: unknown, assertion: JsonAssertion): boolean {
406
+ const resolution = resolveProbePath(body, parseProbePath(assertion.path));
407
+ if (resolution.kind === "missing") return false;
408
+ if ("equals" in assertion) {
409
+ return deepJsonEqual(resolution.kind === "many" ? resolution.values : resolution.value, assertion.equals);
410
+ }
411
+ const list = asList(resolution);
412
+ if (list === undefined) return false;
413
+ if ("ascending" in assertion) return isAscending(list);
414
+ // `every` over an empty list is vacuously true, matching `[*]` semantics.
415
+ return list.every((value) => deepJsonEqual(value, assertion.every.equals));
416
+ }
417
+
418
+ /**
419
+ * Evaluate a list of declarative assertions against a parsed JSON body; they
420
+ * AND together, and an empty list is vacuously true. Pure — safe to call
421
+ * from sidecars against any decoded response. Malformed assertion paths
422
+ * throw (see {@link parseProbePath}); everything else is a boolean verdict.
423
+ */
424
+ export function evaluateAssertions(body: unknown, assertions: readonly JsonAssertion[]): boolean {
425
+ return assertions.every((assertion) => evaluateAssertion(body, assertion));
426
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * pass@k / pass^k: per-k restatements of a pair's scored counts.
3
+ *
4
+ * Both are computed from this run's trials only and stay within the report's
5
+ * no-attribution stance: they describe the observed outcomes, not a wider
6
+ * population.
7
+ *
8
+ * - pass@k — the fraction of size-k subsets of the scored trials that
9
+ * contain at least one pass ("given k of these attempts, how often was
10
+ * one good"). Exact, not sampled: 1 - C(scored-passed, k) / C(scored, k).
11
+ * - pass^k — (passed/scored)^k, the all-pass rate for k with-replacement
12
+ * draws from the scored trials ("how often do k attempts ALL pass").
13
+ *
14
+ * References: Chen et al., "Evaluating Large Language Models Trained on
15
+ * Code" (the unbiased pass@k estimator).
16
+ */
17
+
18
+ export interface PassAtKEntry {
19
+ k: number;
20
+ /** Fraction of size-k subsets of the scored trials containing ≥1 pass. */
21
+ pass_at_k: number;
22
+ /** (passed/scored)^k — the all-k-pass rate. */
23
+ pass_to_the_k: number;
24
+ }
25
+
26
+ function assertCounts(passed: number, scored: number, k: number): void {
27
+ if (!Number.isInteger(passed) || !Number.isInteger(scored) || !Number.isInteger(k)) {
28
+ throw new RangeError(`pass@k needs integer counts (passed=${passed}, scored=${scored}, k=${k})`);
29
+ }
30
+ if (passed < 0 || scored < 1 || passed > scored || k < 1 || k > scored) {
31
+ throw new RangeError(`pass@k needs 0 ≤ passed ≤ scored and 1 ≤ k ≤ scored (passed=${passed}, scored=${scored}, k=${k})`);
32
+ }
33
+ }
34
+
35
+ /** Exact pass@k over the scored trials: 1 - C(scored-passed, k) / C(scored, k). */
36
+ export function passAtK(passed: number, scored: number, k: number): number {
37
+ assertCounts(passed, scored, k);
38
+ const failed = scored - passed;
39
+ if (failed < k) return 1;
40
+ let allFail = 1;
41
+ for (let i = 0; i < k; i++) {
42
+ allFail *= (failed - i) / (scored - i);
43
+ }
44
+ return 1 - allFail;
45
+ }
46
+
47
+ /** pass^k over the scored trials: (passed/scored)^k. */
48
+ export function passToTheK(passed: number, scored: number, k: number): number {
49
+ assertCounts(passed, scored, k);
50
+ return (passed / scored) ** k;
51
+ }
52
+
53
+ /**
54
+ * The full per-k series for one pair, k = 1..scored. Empty when nothing was
55
+ * scored — a pair with no scored trials gets no values, never zeros.
56
+ */
57
+ export function collectPassAtK(passed: number, scored: number): PassAtKEntry[] {
58
+ if (scored === 0) return [];
59
+ const entries: PassAtKEntry[] = [];
60
+ for (let k = 1; k <= scored; k++) {
61
+ entries.push({ k, pass_at_k: passAtK(passed, scored, k), pass_to_the_k: passToTheK(passed, scored, k) });
62
+ }
63
+ return entries;
64
+ }
65
+
66
+ /**
67
+ * The ks worth rendering: k=1 duplicates the pass rate and k beyond the
68
+ * scored count does not exist, so displays anchor on a few in-between sizes.
69
+ * The JSON keeps the full series; this only shapes text/UI rendering.
70
+ */
71
+ export const PASS_AT_K_DISPLAY_KS = [2, 3, 5, 10] as const;
72
+
73
+ /** Filter a pair's series down to the display anchors. */
74
+ export function displayPassAtK(entries: PassAtKEntry[] | undefined): PassAtKEntry[] {
75
+ return (entries ?? []).filter((entry) => (PASS_AT_K_DISPLAY_KS as readonly number[]).includes(entry.k));
76
+ }