@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,190 @@
1
+ /**
2
+ * PROMPT.md parser: YAML frontmatter (the eval's metadata) followed by the
3
+ * task body, which is passed through verbatim. The frontmatter schema is
4
+ * deliberately tiny and closed — unknown keys are rejected with a pointer at
5
+ * the offending line, because new metadata fields require a proven
6
+ * filtering/reporting use case before they exist. Two keys are control flow
7
+ * for discovery rather than metadata: `frameworks` (expand the directory
8
+ * into one eval per framework) and `local` (a shared per-framework
9
+ * starting-state root for variant evals). Both are consumed by discovery
10
+ * and never appear on the expanded metadata.
11
+ */
12
+
13
+ import { isAbsolute } from "node:path";
14
+ import YAML from "yaml";
15
+ import { EVAL_SUITES, type EvalMetadata, type EvalSuite } from "./types.ts";
16
+
17
+ /** A PROMPT.md failed to parse or validate. Carries file + 1-based line. */
18
+ export class EvalLoadError extends Error {
19
+ readonly file: string;
20
+ readonly line: number;
21
+
22
+ constructor(file: string, line: number, detail: string) {
23
+ super(`${file}:${line}: ${detail}`);
24
+ this.name = "EvalLoadError";
25
+ this.file = file;
26
+ this.line = line;
27
+ }
28
+ }
29
+
30
+ export interface ParsedPrompt {
31
+ metadata: EvalMetadata;
32
+ /** Everything after the closing frontmatter fence, verbatim. */
33
+ promptBody: string;
34
+ /** `frameworks:` variant list — discovery expands one eval per entry. */
35
+ frameworks?: string[];
36
+ /** `local:` shared starting-state root, relative to the eval directory. */
37
+ local?: string;
38
+ }
39
+
40
+ const KNOWN_KEYS = ["id", "suite", "product", "framework", "frameworks", "local", "topics"] as const;
41
+
42
+ /** Framework tokens become id suffixes (`<dirname>-<fw>`), so they must be kebab-case. */
43
+ const FRAMEWORK_TOKEN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
44
+
45
+ /** Parse a PROMPT.md source string. `file` is used for error messages only. */
46
+ export function parsePrompt(source: string, file: string): ParsedPrompt {
47
+ const lines = source.split(/\r?\n/);
48
+ if (lines[0]?.trim() !== "---") {
49
+ throw new EvalLoadError(
50
+ file,
51
+ 1,
52
+ `expected YAML frontmatter opening "---", found ${JSON.stringify(lines[0] ?? "<empty file>")}`,
53
+ );
54
+ }
55
+ const fmClose = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
56
+ if (fmClose === -1) {
57
+ throw new EvalLoadError(file, lines.length, `frontmatter never closed: expected a "---" line`);
58
+ }
59
+
60
+ let raw: unknown;
61
+ try {
62
+ raw = YAML.parse(lines.slice(1, fmClose).join("\n"));
63
+ } catch (err) {
64
+ const yamlLine = err instanceof YAML.YAMLParseError ? (err.linePos?.[0]?.line ?? 1) : 1;
65
+ throw new EvalLoadError(file, 1 + yamlLine, `invalid YAML in frontmatter: ${(err as Error).message.split("\n")[0]}`);
66
+ }
67
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
68
+ throw new EvalLoadError(file, 2, `frontmatter must be a YAML mapping (id, suite, ...)`);
69
+ }
70
+ const fm = raw as Record<string, unknown>;
71
+
72
+ // 1-based line of the `key:` line, for precise errors.
73
+ const keyLine = (key: string): number => {
74
+ const idx = lines.findIndex((l, index) => index > 0 && index < fmClose && l.startsWith(`${key}:`));
75
+ return idx === -1 ? 2 : idx + 1;
76
+ };
77
+
78
+ for (const key of Object.keys(fm)) {
79
+ if (!(KNOWN_KEYS as readonly string[]).includes(key)) {
80
+ throw new EvalLoadError(
81
+ file,
82
+ keyLine(key),
83
+ `unknown frontmatter key "${key}" (allowed: ${KNOWN_KEYS.join(", ")}); ` +
84
+ `new metadata fields need a proven filtering or reporting use case first`,
85
+ );
86
+ }
87
+ }
88
+
89
+ const id = fm["id"];
90
+ if (typeof id !== "string" || id.trim() === "") {
91
+ throw new EvalLoadError(file, keyLine("id"), `"id" must be a non-empty string`);
92
+ }
93
+ const suite = fm["suite"];
94
+ if (typeof suite !== "string" || !(EVAL_SUITES as readonly string[]).includes(suite)) {
95
+ throw new EvalLoadError(
96
+ file,
97
+ keyLine("suite"),
98
+ `"suite" must be one of: ${EVAL_SUITES.join(", ")}${suite === undefined ? " (missing)" : ` (got ${JSON.stringify(suite)})`}`,
99
+ );
100
+ }
101
+ const optionalString = (key: "product" | "framework"): string | undefined => {
102
+ const value = fm[key];
103
+ if (value === undefined) return undefined;
104
+ if (typeof value !== "string" || value.trim() === "") {
105
+ throw new EvalLoadError(file, keyLine(key), `"${key}" must be a non-empty string`);
106
+ }
107
+ return value;
108
+ };
109
+ const product = optionalString("product");
110
+ const framework = optionalString("framework");
111
+ let frameworks: string[] | undefined;
112
+ if (fm["frameworks"] !== undefined) {
113
+ const value = fm["frameworks"];
114
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string")) {
115
+ throw new EvalLoadError(file, keyLine("frameworks"), `"frameworks" must be a non-empty list of framework tokens`);
116
+ }
117
+ const tokens = value as string[];
118
+ for (const token of tokens) {
119
+ if (!FRAMEWORK_TOKEN.test(token)) {
120
+ throw new EvalLoadError(
121
+ file,
122
+ keyLine("frameworks"),
123
+ `"frameworks" entry ${JSON.stringify(token)} must be a kebab-case token ` +
124
+ `(lowercase letters, digits, hyphens — it becomes the "<dirname>-<framework>" id suffix)`,
125
+ );
126
+ }
127
+ }
128
+ if (new Set(tokens).size !== tokens.length) {
129
+ throw new EvalLoadError(file, keyLine("frameworks"), `"frameworks" entries must be unique`);
130
+ }
131
+ if (framework !== undefined) {
132
+ throw new EvalLoadError(
133
+ file,
134
+ keyLine("framework"),
135
+ `"framework" cannot be set alongside "frameworks" — expansion sets framework per variant`,
136
+ );
137
+ }
138
+ frameworks = tokens;
139
+ }
140
+ let local: string | undefined;
141
+ if (fm["local"] !== undefined) {
142
+ const value = fm["local"];
143
+ if (typeof value !== "string" || value.trim() === "") {
144
+ throw new EvalLoadError(file, keyLine("local"), `"local" must be a non-empty relative path`);
145
+ }
146
+ if (isAbsolute(value)) {
147
+ throw new EvalLoadError(
148
+ file,
149
+ keyLine("local"),
150
+ `"local" must be a relative path (resolved from the eval directory), got an absolute path`,
151
+ );
152
+ }
153
+ if (frameworks === undefined) {
154
+ throw new EvalLoadError(
155
+ file,
156
+ keyLine("local"),
157
+ `"local" requires "frameworks" — it points at per-framework starting states ` +
158
+ `(<local>/<framework>); a single-directory eval uses its local/ directory`,
159
+ );
160
+ }
161
+ local = value;
162
+ }
163
+ let topics: string[] | undefined;
164
+ if (fm["topics"] !== undefined) {
165
+ const value = fm["topics"];
166
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) {
167
+ throw new EvalLoadError(file, keyLine("topics"), `"topics" must be a list of non-empty strings`);
168
+ }
169
+ topics = value as string[];
170
+ }
171
+
172
+ const promptBody = lines.slice(fmClose + 1).join("\n");
173
+ if (promptBody.trim() === "") {
174
+ throw new EvalLoadError(file, fmClose + 1, `PROMPT.md has no task body after the frontmatter`);
175
+ }
176
+
177
+ const metadata: EvalMetadata = {
178
+ id,
179
+ suite: suite as EvalSuite,
180
+ ...(product !== undefined ? { product } : {}),
181
+ ...(framework !== undefined ? { framework } : {}),
182
+ ...(topics !== undefined ? { topics } : {}),
183
+ };
184
+ return {
185
+ metadata,
186
+ promptBody,
187
+ ...(frameworks !== undefined ? { frameworks } : {}),
188
+ ...(local !== undefined ? { local } : {}),
189
+ };
190
+ }
@@ -0,0 +1,10 @@
1
+ import type { EvalResult } from "./types.ts";
2
+ export function validEvalResult(value: unknown): value is EvalResult {
3
+ if (!value || typeof value !== "object") return false;
4
+ const result = value as EvalResult;
5
+ if (typeof result.passed !== "boolean" || !Array.isArray(result.checks)) return false;
6
+ if (result.checks.some((c) => !c || typeof c.name !== "string" || c.name === "" || typeof c.passed !== "boolean")) return false;
7
+ if (new Set(result.checks.map((c) => c.name)).size !== result.checks.length) return false;
8
+ return result.judgments === undefined || (Array.isArray(result.judgments) && result.judgments.every((j) => j && typeof j === "object" && j.provenance === "semantic-judge" &&
9
+ typeof j.provider === "string" && typeof j.model === "string" && [j.tokensIn, j.tokensOut, j.costUsd].every((v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0))));
10
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The v3 eval vocabulary: one directory = one eval, described by a PROMPT.md
3
+ * (frontmatter metadata + task body) and scored by an EVAL.ts whose default
4
+ * export is an {@link EvalScorer}. No dialect, no registry — the scorer is
5
+ * plain TypeScript handed an {@link EvalContext} over the finished workspace.
6
+ */
7
+
8
+ /** Closed set of eval suites. `regression` never moves headline scores. */
9
+ export const EVAL_SUITES = ["benchmark", "regression", "other"] as const;
10
+ export type EvalSuite = (typeof EVAL_SUITES)[number];
11
+
12
+ /** PROMPT.md frontmatter. New fields require a proven filtering/reporting use case. */
13
+ export interface EvalMetadata {
14
+ /** Must equal the eval's directory name (prevents silent aliasing). */
15
+ id: string;
16
+ suite: EvalSuite;
17
+ product?: string;
18
+ framework?: string;
19
+ topics?: string[];
20
+ }
21
+
22
+ /** One discovered eval directory, validated and ready to plan. */
23
+ export interface LoadedEval {
24
+ metadata: EvalMetadata;
25
+ /** Absolute path of the eval directory. */
26
+ dir: string;
27
+ /** Absolute path of PROMPT.md. */
28
+ promptPath: string;
29
+ /** The task body after the frontmatter, passed through verbatim. */
30
+ promptBody: string;
31
+ /** Absolute path of EVAL.ts (imported lazily at score time). */
32
+ scorerPath: string;
33
+ /** Absolute path of the optional `local/` starting-state directory, or null. */
34
+ localDir: string | null;
35
+ }
36
+
37
+ /** A pointer at evidence backing a check verdict (workspace-relative path). */
38
+ export interface EvidenceReference {
39
+ path: string;
40
+ note?: string;
41
+ }
42
+
43
+ /** One named check's verdict inside an eval result. */
44
+ export interface CheckResult {
45
+ name: string;
46
+ passed: boolean;
47
+ notes?: string;
48
+ evidence?: EvidenceReference[];
49
+ }
50
+
51
+ /** What a scorer returns for one attempt. */
52
+ export interface JudgeRecord {
53
+ provenance: "semantic-judge";
54
+ provider: string;
55
+ model: string;
56
+ tokensIn: number | null;
57
+ tokensOut: number | null;
58
+ costUsd: number | null;
59
+ }
60
+ export interface AgentOutput {
61
+ finalReport: string | null;
62
+ transcript: string | null;
63
+ truncated: boolean;
64
+ }
65
+ export interface EvalResult {
66
+ judgments?: JudgeRecord[];
67
+ passed: boolean;
68
+ checks: CheckResult[];
69
+ }
70
+
71
+ /** Output of a command run through {@link EvalContext.exec}. */
72
+ export interface EvalExecResult {
73
+ exitCode: number;
74
+ stdout: string;
75
+ stderr: string;
76
+ /** True when the command was killed by its wall-clock timeout. */
77
+ timedOut: boolean;
78
+ }
79
+
80
+ /**
81
+ * What a scorer gets. File helpers read the EXPORTED workspace (stable on
82
+ * disk); `exec` runs in the live sandbox when the experiment ran in a
83
+ * container — the runner keeps it alive until the scorer resolves — or in
84
+ * the exported workspace directory for host experiments. Capability helpers
85
+ * (`query`, `getClient`) are wired by experiment runtimes in a later phase;
86
+ * a runtime that does not provide one makes the helper throw with
87
+ * "this experiment runtime does not provide X".
88
+ */
89
+ export interface EvalContext {
90
+ agentOutput?: AgentOutput;
91
+ /** Redact known runtime/provider secrets before sending evidence to a judge. */
92
+ redact?(text: string): Promise<string>;
93
+ /** Aborted when the current stage expires. Implementations must stop work. */
94
+ signal?: AbortSignal;
95
+ evalId: string;
96
+ experimentId: string;
97
+ trialIndex: number;
98
+ /** The eval's framework (`metadata.framework`); absent for framework-neutral evals. */
99
+ framework?: string;
100
+ /** Absolute path of the exported workspace being scored. */
101
+ workspaceDir: string;
102
+ /** Does this workspace-relative path exist? */
103
+ fileExists(path: string): Promise<boolean>;
104
+ /** Read a workspace-relative file as UTF-8. Throws when missing. */
105
+ readFile(path: string): Promise<string>;
106
+ /** Run a command in the sandbox (container) or workspace dir (host). */
107
+ exec(cmd: string[], opts?: { timeoutMs?: number }): Promise<EvalExecResult>;
108
+ /** Runtime-provided data query capability (wired by runtimes later). */
109
+ query(request: unknown): Promise<unknown>;
110
+ /** Runtime-provided client capability (wired by runtimes later). */
111
+ getClient(name: string): unknown;
112
+ }
113
+
114
+ /** EVAL.ts's default export. */
115
+ export type EvalScorer = ((ctx: EvalContext) => Promise<EvalResult>) & { identity?: Record<string, unknown> };
@@ -0,0 +1,71 @@
1
+ import type { LoadedEval } from "./evals/types.ts";
2
+ import type { LoadedExperiment } from "./experiments/types.ts";
3
+ import type { RuntimeImageIdentity } from "./manifest.ts";
4
+ import { composeAllowlistFromHosts } from "./isolation/proxy/allowlist.ts";
5
+ import type { WebPolicy } from "./runtime/types.ts";
6
+
7
+ export interface LifecycleBudgets {
8
+ provisionMs: number;
9
+ agentMs: number;
10
+ scoreMs: number;
11
+ exportMs: number;
12
+ cleanupMs: number;
13
+ }
14
+ export const DEFAULT_BUDGETS: LifecycleBudgets = {
15
+ provisionMs: 120_000,
16
+ agentMs: 600_000,
17
+ scoreMs: 180_000,
18
+ exportMs: 60_000,
19
+ cleanupMs: 30_000,
20
+ };
21
+ export interface PairEnvironment {
22
+ image: RuntimeImageIdentity | null;
23
+ egress: { proxy: boolean; allowlist: string[] };
24
+ webPolicy: WebPolicy;
25
+ pathTreatment: string | null;
26
+ mcp: string | null;
27
+ budgets: LifecycleBudgets;
28
+ }
29
+ export function pairKey(evalId: string, experimentId: string): string {
30
+ return JSON.stringify([evalId, experimentId]);
31
+ }
32
+ export function resolvePairEnvironments(options: {
33
+ evals: LoadedEval[];
34
+ experiments: LoadedExperiment[];
35
+ images?: Record<string, RuntimeImageIdentity>;
36
+ egressProxy?: boolean;
37
+ adapterHosts?: Record<string, readonly string[]>;
38
+ budgets?: Partial<LifecycleBudgets>;
39
+ mcpHashes?: Record<string, string>;
40
+ imageTags?: Record<string, string>;
41
+ }): Record<string, PairEnvironment> {
42
+ const budgets = { ...DEFAULT_BUDGETS, ...options.budgets };
43
+ for (const [name, value] of Object.entries(budgets))
44
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`invalid lifecycle budget ${name}`);
45
+ const pairs: Record<string, PairEnvironment> = {};
46
+ for (const loaded of options.evals)
47
+ for (const { experiment } of options.experiments) {
48
+ const runtime = experiment.runtime;
49
+ const tag =
50
+ options.imageTags?.[pairKey(loaded.metadata.id, experiment.id)] ??
51
+ runtime.image ??
52
+ runtime.containerImage?.(loaded.metadata);
53
+ pairs[pairKey(loaded.metadata.id, experiment.id)] = {
54
+ image: tag ? (options.images?.[tag] ?? { tag, id: "unresolved", repoDigests: [] }) : null,
55
+ // With the flag, every container is proxied. No runtime hosts means
56
+ // only the adapter's provider endpoints are permitted.
57
+ egress: {
58
+ proxy: runtime.kind === "container" && (options.egressProxy ?? false),
59
+ allowlist: composeAllowlistFromHosts([
60
+ ...(runtime.egressHosts?.(loaded.metadata) ?? []),
61
+ ...(options.adapterHosts?.[experiment.id] ?? []),
62
+ ]),
63
+ },
64
+ webPolicy: runtime.webPolicy?.() ?? "native-web-blocked",
65
+ pathTreatment: runtime.pathTreatment?.() ?? null,
66
+ mcp: options.mcpHashes?.[experiment.id] ?? null,
67
+ budgets: { ...budgets },
68
+ };
69
+ }
70
+ return pairs;
71
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Experiment discovery: import every `<root>/*.ts` module and collect its
3
+ * default-exported experiment. Loud on the first problem — a module without
4
+ * a valid default export, or two modules claiming one id, aborts the scan.
5
+ */
6
+
7
+ import { existsSync, readdirSync, statSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+ import type { Experiment, LoadedExperiment } from "./types.ts";
11
+
12
+ /** An experiment module failed to import or its exported shape is invalid. */
13
+ export class ExperimentLoadError extends Error {
14
+ readonly file: string;
15
+
16
+ constructor(file: string, detail: string) {
17
+ super(`${file}: ${detail}`);
18
+ this.name = "ExperimentLoadError";
19
+ this.file = file;
20
+ }
21
+ }
22
+
23
+ function isExperimentShaped(value: unknown): value is Experiment {
24
+ if (value === null || typeof value !== "object") return false;
25
+ const candidate = value as Partial<Experiment>;
26
+ return (
27
+ typeof candidate.id === "string" &&
28
+ candidate.id !== "" &&
29
+ typeof candidate.agent?.adapter === "string" &&
30
+ (candidate.runtime?.kind === "host" || candidate.runtime?.kind === "container")
31
+ );
32
+ }
33
+
34
+ /** Discover every experiment under `root`, sorted by id. */
35
+ export async function discoverExperiments(root: string): Promise<LoadedExperiment[]> {
36
+ const rootDir = resolve(root);
37
+ if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
38
+ throw new Error(`experiments root "${root}" is not a directory`);
39
+ }
40
+
41
+ const loaded: LoadedExperiment[] = [];
42
+ const files = readdirSync(rootDir)
43
+ .filter((name) => name.endsWith(".ts"))
44
+ .sort((a, b) => a.localeCompare(b));
45
+ for (const name of files) {
46
+ const path = join(rootDir, name);
47
+ let module: Record<string, unknown>;
48
+ try {
49
+ module = (await import(pathToFileURL(path).href)) as Record<string, unknown>;
50
+ } catch (err) {
51
+ throw new ExperimentLoadError(path, `failed to import: ${err instanceof Error ? err.message : String(err)}`);
52
+ }
53
+ const experiment = module["default"];
54
+ if (!isExperimentShaped(experiment)) {
55
+ throw new ExperimentLoadError(
56
+ path,
57
+ `must default-export defineExperiment({ id, agent, runtime }); got ${experiment === undefined ? "no default export" : "an invalid shape"}`,
58
+ );
59
+ }
60
+ loaded.push({ experiment, path });
61
+ }
62
+
63
+ const byId = new Map<string, LoadedExperiment>();
64
+ for (const entry of loaded) {
65
+ const existing = byId.get(entry.experiment.id);
66
+ if (existing) {
67
+ throw new ExperimentLoadError(
68
+ entry.path,
69
+ `duplicate experiment id "${entry.experiment.id}" — also declared by ${existing.path}`,
70
+ );
71
+ }
72
+ byId.set(entry.experiment.id, entry);
73
+ }
74
+
75
+ return loaded.sort((a, b) => a.experiment.id.localeCompare(b.experiment.id));
76
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Comparison groups with withhold-and-diff semantics.
3
+ *
4
+ * Experiments sharing a `comparisonGroup` claim to be treatments of ONE
5
+ * configuration — same agent, same model, same runtime — varying only the
6
+ * declared `treatment`. `compareGroups` checks that claim: members must
7
+ * agree on every identity component EXCEPT the treatment itself and the
8
+ * treatment's own runtime config subtree (by convention
9
+ * `runtime.config[treatment]`).
10
+ *
11
+ * On mismatch the group is marked `comparison: "withheld"` with a
12
+ * component-level diff — the run is NEVER blocked and raw per-experiment
13
+ * results are unaffected. (src/compare.ts is the diff-rendering precedent;
14
+ * its throw-unless---force blocking is deliberately NOT copied here.)
15
+ *
16
+ * Excluded from the comparison by construction:
17
+ * - `source` — two experiment files always hash differently;
18
+ * - `treatment` — the declared variation;
19
+ * - `runtime.config[treatment]` — the treatment's resulting configuration;
20
+ * - `harness` — equal within one manifest by construction.
21
+ * The manifest's opaque `runtime` hash is replaced by the raw runtime data
22
+ * (kind/image/command/config) so the diff can name the differing key.
23
+ */
24
+
25
+ import { pairKey } from "../execution-policy.ts";
26
+ import { diffIdentity, type IdentityMismatch } from "../identity-diff.ts";
27
+ import type { RunManifest } from "../manifest.ts";
28
+ import type { Experiment } from "./types.ts";
29
+
30
+ export interface ComparisonGroup {
31
+ group: string;
32
+ /** Member experiment ids, sorted. */
33
+ experimentIds: string[];
34
+ /** "allowed" = automatic treatment comparison may proceed. */
35
+ comparison: "allowed" | "withheld";
36
+ /** Component-level mismatches (empty when allowed). */
37
+ mismatches: IdentityMismatch[];
38
+ /** Rendered diff lines naming the members and component (empty when allowed). */
39
+ details: string[];
40
+ }
41
+
42
+ /**
43
+ * The comparable view of one experiment: manifest identity components where
44
+ * they are precise (agent fields, resolved image), raw runtime data where
45
+ * the manifest is opaque (a single hash), minus every excluded component.
46
+ */
47
+ function comparableView(experiment: Experiment, manifest: RunManifest): Record<string, unknown> {
48
+ const components = manifest.experiments[experiment.id]?.components;
49
+ const config: Record<string, unknown> = { ...experiment.runtime.config };
50
+ if (experiment.treatment !== undefined) delete config[experiment.treatment];
51
+ const environments = Object.fromEntries(Object.keys(manifest.evals).sort().map((evalId) => {
52
+ const value = { ...manifest.pairs?.[pairKey(evalId, experiment.id)] };
53
+ for (const field of experiment.runtime.treatmentPolicyFields ?? []) delete value[field];
54
+ return [evalId, value];
55
+ }));
56
+ return {
57
+ environments,
58
+ treatmentPolicyFields: [...(experiment.runtime.treatmentPolicyFields ?? [])].sort(),
59
+ agent: components?.agent ?? {
60
+ adapter: experiment.agent.adapter,
61
+ provider: experiment.agent.provider ?? "unspecified",
62
+ model: experiment.agent.model ?? "unspecified",
63
+ reasoning: experiment.agent.reasoning ?? "unspecified",
64
+ cliVersion: experiment.agent.cliVersion ?? "unspecified",
65
+ },
66
+ image: components?.image ?? null,
67
+ runtime: {
68
+ kind: experiment.runtime.kind,
69
+ observationTargets: experiment.runtime.observationTargets ?? null,
70
+ image: experiment.runtime.image ?? null,
71
+ command: experiment.runtime.command ?? null,
72
+ config,
73
+ },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Group experiments by `comparisonGroup` and check treatment comparability.
79
+ * Pure — no store access, never throws, never blocks a run; the report layer
80
+ * consumes its output. Experiments without a group are not compared at all;
81
+ * a singleton group is trivially allowed.
82
+ */
83
+ export function compareGroups(experiments: readonly Experiment[], manifest: RunManifest): ComparisonGroup[] {
84
+ const byGroup = new Map<string, Experiment[]>();
85
+ for (const experiment of experiments) {
86
+ if (experiment.comparisonGroup === undefined) continue;
87
+ const members = byGroup.get(experiment.comparisonGroup) ?? [];
88
+ members.push(experiment);
89
+ byGroup.set(experiment.comparisonGroup, members);
90
+ }
91
+
92
+ const groups: ComparisonGroup[] = [];
93
+ for (const [group, members] of [...byGroup.entries()].sort(([a], [b]) => a.localeCompare(b))) {
94
+ const sorted = [...members].sort((a, b) => a.id.localeCompare(b.id));
95
+ const baseline = sorted[0] as Experiment;
96
+ const baselineView = comparableView(baseline, manifest);
97
+
98
+ const mismatches: IdentityMismatch[] = [];
99
+ const details: string[] = [];
100
+ for (const member of sorted.slice(1)) {
101
+ for (const mismatch of diffIdentity(baselineView, comparableView(member, manifest), "identity")) {
102
+ mismatches.push(mismatch);
103
+ details.push(
104
+ `${group}: ${baseline.id} vs ${member.id}: ${mismatch.path} changed ` +
105
+ `(${JSON.stringify(mismatch.a)} -> ${JSON.stringify(mismatch.b)})`,
106
+ );
107
+ }
108
+ }
109
+
110
+ groups.push({
111
+ group,
112
+ experimentIds: sorted.map((member) => member.id),
113
+ comparison: mismatches.length === 0 ? "allowed" : "withheld",
114
+ mismatches,
115
+ details,
116
+ });
117
+ }
118
+ return groups;
119
+ }