@davesheffer/hunch 1.6.0 → 1.7.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 (65) hide show
  1. package/README.md +220 -0
  2. package/bench/constitution-exp03-v1.json +70 -0
  3. package/dist/cli/index.js +1278 -44
  4. package/dist/constitution/adapters.js +487 -0
  5. package/dist/constitution/behaviorAttestationBinding.js +17 -0
  6. package/dist/constitution/behaviorEvaluator.js +220 -0
  7. package/dist/constitution/behaviorProof.js +205 -0
  8. package/dist/constitution/behaviorWorkspace.js +124 -0
  9. package/dist/constitution/bootstrap.js +133 -0
  10. package/dist/constitution/canonical.js +51 -0
  11. package/dist/constitution/card.js +133 -0
  12. package/dist/constitution/compiler.js +176 -0
  13. package/dist/constitution/composition.js +101 -0
  14. package/dist/constitution/corpus.js +58 -0
  15. package/dist/constitution/delta.js +154 -0
  16. package/dist/constitution/disposition.js +141 -0
  17. package/dist/constitution/evaluator.js +435 -0
  18. package/dist/constitution/experiment.js +948 -0
  19. package/dist/constitution/experimentRunner.js +344 -0
  20. package/dist/constitution/g2.js +291 -0
  21. package/dist/constitution/g2BehaviorAttestation.js +209 -0
  22. package/dist/constitution/g2BehaviorCandidates.js +703 -0
  23. package/dist/constitution/g2BehaviorDependencies.js +379 -0
  24. package/dist/constitution/g2BehaviorMaterialization.js +171 -0
  25. package/dist/constitution/g2BehaviorPolicyMaterializer.js +241 -0
  26. package/dist/constitution/g2CandidateAttestation.js +179 -0
  27. package/dist/constitution/g2Candidates.js +195 -0
  28. package/dist/constitution/g2Drills.js +122 -0
  29. package/dist/constitution/g3.js +511 -0
  30. package/dist/constitution/g3Conformance.js +115 -0
  31. package/dist/constitution/lifecycle.js +189 -0
  32. package/dist/constitution/mutation.js +262 -0
  33. package/dist/constitution/nodeTestEvidence.js +47 -0
  34. package/dist/constitution/plan.js +172 -0
  35. package/dist/constitution/policyRuntime.js +8 -0
  36. package/dist/constitution/proof.js +166 -0
  37. package/dist/constitution/replay.js +361 -0
  38. package/dist/constitution/replayCache.js +89 -0
  39. package/dist/constitution/replayWorker.js +34 -0
  40. package/dist/constitution/repository.js +533 -0
  41. package/dist/constitution/schema.js +545 -0
  42. package/dist/constitution/scorecard.js +106 -0
  43. package/dist/constitution/service.js +1149 -0
  44. package/dist/constitution/shadow.js +235 -0
  45. package/dist/constitution/sourceMutation.js +316 -0
  46. package/dist/constitution/structural.js +601 -0
  47. package/dist/core/autoreview.js +27 -3
  48. package/dist/core/dupdetect.js +10 -3
  49. package/dist/core/events.js +61 -0
  50. package/dist/core/externalImports.js +24 -0
  51. package/dist/core/hookpolicy.js +3 -0
  52. package/dist/core/relativeImports.js +33 -0
  53. package/dist/core/stats.js +115 -0
  54. package/dist/extractors/git.js +81 -0
  55. package/dist/extractors/indexer.js +39 -38
  56. package/dist/extractors/nativeTreeSitter.js +108 -0
  57. package/dist/extractors/parse.js +5 -15
  58. package/dist/integrations/claudemd.js +8 -1
  59. package/dist/integrations/gitignore.js +8 -0
  60. package/dist/integrations/providers.js +32 -10
  61. package/dist/integrations/sync.js +16 -1
  62. package/dist/mcp/server.js +284 -0
  63. package/dist/synthesis/provider.js +145 -37
  64. package/dist/synthesis/synthesize.js +4 -4
  65. package/package.json +5 -1
@@ -0,0 +1,948 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { writeFileAtomic } from "../core/io.js";
5
+ import { shortHash } from "../core/ids.js";
6
+ import { canonicalHash } from "./canonical.js";
7
+ import { currentAppendOnly, loadPrivateRecords } from "./g2.js";
8
+ const HASH = /^sha1:[a-f0-9]{40}$/;
9
+ const COMMIT = /^[a-f0-9]{40}$/;
10
+ const ACTOR = /^(human|git|github|runner):[^\s]+$/i;
11
+ const ID = /^[a-z][a-z0-9_.-]{1,127}$/;
12
+ const encode = (value) => `${JSON.stringify(value, null, 2)}\n`;
13
+ export const EXPERIMENT_ANALYSIS_SPEC = {
14
+ implementation: "hunch-g3-experiment-report-v1",
15
+ binary_interval: "wilson-95",
16
+ small_cell_test: "fisher-exact-two-sided",
17
+ reviewer_rate_interval: "seeded-nonparametric-bootstrap-4000",
18
+ effect_sizes: ["absolute-difference", "relative-risk-or-rate"],
19
+ missingness: "all-assigned-denominators",
20
+ };
21
+ export const EXPERIMENT_ANALYSIS_HASH = canonicalHash(EXPERIMENT_ANALYSIS_SPEC);
22
+ const CommandSchema = z.object({
23
+ command: z.string().trim().min(1).max(4096),
24
+ args: z.array(z.string().max(8192)).max(128),
25
+ timeout_ms: z.number().int().min(1).max(1_800_000),
26
+ }).strict();
27
+ const LockedExternalCommandSchema = CommandSchema.extend({
28
+ artifact: z.string().trim().min(1).max(4096),
29
+ artifact_hash: z.string().regex(HASH),
30
+ }).strict();
31
+ const HiddenEvaluatorSchema = LockedExternalCommandSchema.extend({
32
+ visibility: z.literal("hidden_external"),
33
+ }).strict();
34
+ const StrataSchema = z.record(z.string().min(1).max(128), z.string().min(1).max(512));
35
+ const CommonCaseSchema = z.object({
36
+ id: z.string().regex(ID),
37
+ block: z.string().trim().min(1).max(256),
38
+ created_at: z.string().datetime({ offset: true }),
39
+ held_out: z.literal(true),
40
+ used_for_tuning: z.literal(false),
41
+ strata: StrataSchema,
42
+ }).strict();
43
+ export const Exp01CaseSchema = CommonCaseSchema.extend({
44
+ prompt: z.string().trim().min(1).max(40_000),
45
+ context: z.object({
46
+ decision: z.string().trim().min(1).max(10_000),
47
+ rationale: z.string().trim().min(1).max(10_000),
48
+ executable_policy: z.string().trim().min(1).max(10_000),
49
+ causal_incident: z.string().trim().min(1).max(10_000),
50
+ }).strict(),
51
+ setup: LockedExternalCommandSchema.nullable(),
52
+ evaluator: HiddenEvaluatorSchema,
53
+ }).strict();
54
+ export const Exp03CaseSchema = CommonCaseSchema.extend({
55
+ evidence: z.string().trim().min(1).max(100_000),
56
+ required_relationship: z.string().trim().min(1).max(20_000).optional(),
57
+ manual_brief: z.string().trim().min(1).max(20_000),
58
+ compiler_candidate: z.string().trim().min(1).max(100_000),
59
+ proof_card: z.string().trim().min(1).max(100_000),
60
+ editable_bindings: z.array(z.string().trim().min(1).max(5000)).min(1).max(256),
61
+ target_commitment_hash: z.string().regex(HASH),
62
+ }).strict();
63
+ const ExperimentCaseSchema = z.union([Exp01CaseSchema, Exp03CaseSchema]);
64
+ export const ExperimentCaseBankSchema = z.object({
65
+ id: z.string().regex(/^expbank_[a-f0-9]{10}$/),
66
+ content_hash: z.string().regex(HASH),
67
+ experiment: z.enum(["EXP-01", "EXP-03"]),
68
+ preregistration_id: z.string().regex(/^expreg_[a-f0-9]{10}$/),
69
+ preregistration_hash: z.string().regex(HASH),
70
+ repository_root: z.string().trim().min(1).max(4096),
71
+ base_commit: z.string().regex(COMMIT),
72
+ cases: z.array(ExperimentCaseSchema).min(1).max(100_000),
73
+ actor: z.string().regex(ACTOR),
74
+ reason: z.string().trim().min(1).max(4000),
75
+ data_class: z.literal("private"),
76
+ authority: z.literal("none"),
77
+ locked_at: z.string().datetime({ offset: true }),
78
+ }).strict().superRefine((bank, ctx) => {
79
+ const seen = new Set();
80
+ for (const [index, item] of bank.cases.entries()) {
81
+ if (seen.has(item.id))
82
+ ctx.addIssue({ code: "custom", path: ["cases", index, "id"], message: "case ids must be unique" });
83
+ seen.add(item.id);
84
+ const isExp01 = "prompt" in item;
85
+ if ((bank.experiment === "EXP-01") !== isExp01) {
86
+ ctx.addIssue({ code: "custom", path: ["cases", index], message: `case shape does not match ${bank.experiment}` });
87
+ }
88
+ }
89
+ });
90
+ export function experimentCaseBankContentHash(bank) {
91
+ const { id: _id, content_hash: _hash, ...body } = bank;
92
+ return canonicalHash(body);
93
+ }
94
+ export function compileExperimentCaseBank(input, preregistration, opts = {}) {
95
+ if (input.experiment !== preregistration.experiment)
96
+ throw new Error("case bank experiment does not match preregistration");
97
+ if (input.preregistration_id !== preregistration.id || input.preregistration_hash !== preregistration.content_hash) {
98
+ throw new Error("case bank must bind the exact current preregistration id and content hash");
99
+ }
100
+ const lockedAt = opts.now ?? new Date().toISOString();
101
+ if (Date.parse(lockedAt) < Date.parse(preregistration.registered_at))
102
+ throw new Error("case bank cannot be locked before preregistration");
103
+ for (const item of input.cases) {
104
+ if (Date.parse(item.created_at) < Date.parse(preregistration.registered_at)) {
105
+ throw new Error(`case ${item.id} predates preregistration and is excluded from the fresh sample`);
106
+ }
107
+ const missing = preregistration.strata.filter((stratum) => !item.strata[stratum]);
108
+ if (missing.length)
109
+ throw new Error(`case ${item.id} is missing preregistered strata: ${missing.join(", ")}`);
110
+ if (input.experiment === "EXP-03" && preregistration.revision >= 2
111
+ && (!("required_relationship" in item) || !item.required_relationship)) {
112
+ throw new Error(`case ${item.id} must state the durable required relationship in plain language for EXP-03 revision 2 or later`);
113
+ }
114
+ }
115
+ const body = {
116
+ experiment: input.experiment,
117
+ preregistration_id: input.preregistration_id,
118
+ preregistration_hash: input.preregistration_hash,
119
+ repository_root: input.repository_root,
120
+ base_commit: input.base_commit,
121
+ cases: input.cases,
122
+ actor: input.actor,
123
+ reason: input.reason.trim(),
124
+ data_class: "private",
125
+ authority: "none",
126
+ locked_at: lockedAt,
127
+ };
128
+ const contentHash = canonicalHash(body);
129
+ return ExperimentCaseBankSchema.parse({ id: `expbank_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
130
+ }
131
+ const AssignmentSchema = z.object({
132
+ id: z.string().regex(/^expassign_[a-f0-9]{10}$/),
133
+ case_id: z.string().regex(ID),
134
+ arm: z.string().regex(/^[A-Z][A-Z0-9_-]{0,15}$/),
135
+ block: z.string().min(1),
136
+ order: z.number().int().min(0),
137
+ treatment_hash: z.string().regex(HASH),
138
+ }).strict();
139
+ export const ExperimentRunSchema = z.object({
140
+ id: z.string().regex(/^exprun_[a-f0-9]{10}$/),
141
+ content_hash: z.string().regex(HASH),
142
+ experiment: z.enum(["EXP-01", "EXP-03"]),
143
+ preregistration_id: z.string().regex(/^expreg_[a-f0-9]{10}$/),
144
+ preregistration_hash: z.string().regex(HASH),
145
+ case_bank_id: z.string().regex(/^expbank_[a-f0-9]{10}$/),
146
+ case_bank_hash: z.string().regex(HASH),
147
+ seed: z.string().min(1),
148
+ sample_per_arm: z.number().int().min(1),
149
+ assignment_strategy: z.enum(["crossed_blocked", "exclusive_blocked"]),
150
+ assignments: z.array(AssignmentSchema).min(2).max(1_000_000),
151
+ runner: z.object({
152
+ implementation: z.literal("hunch-g3-experiment-v1"),
153
+ provider: z.enum(["claude-cli", "codex-cli"]).nullable(),
154
+ provider_version: z.string().min(1).nullable(),
155
+ model_version: z.string().min(1).nullable(),
156
+ max_turns: z.number().int().min(1).max(1000).nullable(),
157
+ }).strict(),
158
+ actor: z.string().regex(ACTOR),
159
+ reason: z.string().trim().min(1).max(4000),
160
+ data_class: z.literal("private"),
161
+ authority: z.literal("none"),
162
+ created_at: z.string().datetime({ offset: true }),
163
+ }).strict().superRefine((run, ctx) => {
164
+ const ids = run.assignments.map((item) => item.id);
165
+ if (new Set(ids).size !== ids.length)
166
+ ctx.addIssue({ code: "custom", path: ["assignments"], message: "assignment ids must be unique" });
167
+ const orders = [...run.assignments.map((item) => item.order)].sort((a, b) => a - b);
168
+ if (new Set(orders).size !== orders.length || orders.some((value, index) => value !== index)) {
169
+ ctx.addIssue({ code: "custom", path: ["assignments"], message: "assignment order must be one unique contiguous sequence" });
170
+ }
171
+ const arms = [...new Set(run.assignments.map((item) => item.arm))];
172
+ if (arms.some((arm) => run.assignments.filter((item) => item.arm === arm).length !== run.sample_per_arm)) {
173
+ ctx.addIssue({ code: "custom", path: ["assignments"], message: "every manifest arm must contain exactly sample_per_arm assignments" });
174
+ }
175
+ const byCase = new Map();
176
+ for (const assignment of run.assignments)
177
+ byCase.set(assignment.case_id, [...(byCase.get(assignment.case_id) ?? []), assignment]);
178
+ if (run.experiment === "EXP-01") {
179
+ if (run.assignment_strategy !== "crossed_blocked" || byCase.size !== run.sample_per_arm
180
+ || [...byCase.values()].some((items) => items.length !== arms.length || new Set(items.map((item) => item.arm)).size !== arms.length)) {
181
+ ctx.addIssue({ code: "custom", path: ["assignments"], message: "EXP-01 must cross every fresh case exactly once through every arm" });
182
+ }
183
+ }
184
+ else if (run.assignment_strategy !== "exclusive_blocked" || byCase.size !== run.assignments.length) {
185
+ ctx.addIssue({ code: "custom", path: ["assignments"], message: "EXP-03 must assign each fresh case exclusively to one arm" });
186
+ }
187
+ if (run.experiment === "EXP-01" && (!run.runner.provider || !run.runner.provider_version || !run.runner.model_version || !run.runner.max_turns)) {
188
+ ctx.addIssue({ code: "custom", path: ["runner"], message: "EXP-01 requires an exact subscription CLI, version, model, and max-turn limit" });
189
+ }
190
+ if (run.experiment === "EXP-03" && Object.values(run.runner).some((value) => value !== null && value !== "hunch-g3-experiment-v1")) {
191
+ ctx.addIssue({ code: "custom", path: ["runner"], message: "EXP-03 human review must not claim an automated model runner" });
192
+ }
193
+ });
194
+ function rank(seed, ...parts) {
195
+ return canonicalHash([seed, ...parts]);
196
+ }
197
+ function treatmentFor(experiment, arm, item) {
198
+ if (experiment === "EXP-01") {
199
+ const c = Exp01CaseSchema.parse(item);
200
+ if (arm === "A")
201
+ return { prompt: c.prompt, context: null };
202
+ if (arm === "B")
203
+ return { prompt: c.prompt, context: { decision: c.context.decision, rationale: c.context.rationale } };
204
+ return { prompt: c.prompt, context: c.context };
205
+ }
206
+ const c = Exp03CaseSchema.parse(item);
207
+ // Revision-1 cases have no required_relationship. Preserve their exact treatment
208
+ // bytes so the append-only pilot remains replayable under its original hash.
209
+ if (c.required_relationship) {
210
+ const review = {
211
+ required_relationship: c.required_relationship,
212
+ question: "Does the proposed rule accurately preserve the required relationship described above?",
213
+ choices: [
214
+ { value: "accept", label: "Yes — use it as written" },
215
+ { value: "edit", label: "Yes — after I correct the rule" },
216
+ { value: "reject", label: "No — the rule is wrong or unsupported" },
217
+ { value: "cannot_decide", label: "Cannot decide from this evidence" },
218
+ ],
219
+ response_template: {
220
+ choice: "accept | edit | reject | cannot_decide",
221
+ rule_text: "Required for accept or edit; otherwise leave blank.",
222
+ reason: "One plain-language sentence.",
223
+ },
224
+ };
225
+ if (arm === "A")
226
+ return { evidence: c.evidence, review, manual_brief: c.manual_brief };
227
+ if (arm === "B")
228
+ return { evidence: c.evidence, review, proposed_rule: c.compiler_candidate };
229
+ return { evidence: c.evidence, review, proposed_rule: c.compiler_candidate, supporting_checks: c.proof_card, editable_parts: c.editable_bindings };
230
+ }
231
+ if (arm === "A")
232
+ return { evidence: c.evidence, manual_brief: c.manual_brief };
233
+ if (arm === "B")
234
+ return { evidence: c.evidence, compiler_candidate: c.compiler_candidate };
235
+ return { evidence: c.evidence, compiler_candidate: c.compiler_candidate, proof_card: c.proof_card, editable_bindings: c.editable_bindings };
236
+ }
237
+ export function assignmentTreatment(bank, run, assignment) {
238
+ if (run.case_bank_id !== bank.id || run.case_bank_hash !== bank.content_hash)
239
+ throw new Error("run does not bind this exact case bank");
240
+ const item = bank.cases.find((candidate) => candidate.id === assignment.case_id);
241
+ if (!item)
242
+ throw new Error(`assignment ${assignment.id} references missing case ${assignment.case_id}`);
243
+ const treatment = treatmentFor(run.experiment, assignment.arm, item);
244
+ if (canonicalHash(treatment) !== assignment.treatment_hash)
245
+ throw new Error(`assignment ${assignment.id} treatment hash mismatch`);
246
+ return treatment;
247
+ }
248
+ export function experimentRunContentHash(run) {
249
+ const { id: _id, content_hash: _hash, ...body } = run;
250
+ return canonicalHash(body);
251
+ }
252
+ export function compileExperimentRun(input, preregistration, bank, opts = {}) {
253
+ if (bank.preregistration_id !== preregistration.id || bank.preregistration_hash !== preregistration.content_hash) {
254
+ throw new Error("run case bank does not bind the exact preregistration");
255
+ }
256
+ if (Date.parse(bank.locked_at) < Date.parse(preregistration.registered_at))
257
+ throw new Error("run case bank predates its preregistration");
258
+ for (const item of bank.cases) {
259
+ const missing = preregistration.strata.filter((stratum) => !item.strata[stratum]);
260
+ if (Date.parse(item.created_at) < Date.parse(preregistration.registered_at) || missing.length || !item.held_out || item.used_for_tuning) {
261
+ throw new Error(`case ${item.id} no longer satisfies the locked fresh-sample boundary`);
262
+ }
263
+ }
264
+ if (!Number.isInteger(input.sample_per_arm) || input.sample_per_arm !== preregistration.sample_plan.target_per_arm) {
265
+ throw new Error(`the immutable assignment manifest must carry the full preregistered target of ${preregistration.sample_plan.target_per_arm} samples per arm; inspect the first ${preregistration.sample_plan.minimum_per_arm} as a pilot checkpoint without stopping or replacing the run`);
266
+ }
267
+ const arms = preregistration.arms.map((arm) => arm.id);
268
+ if (arms.join(",") !== "A,B,C")
269
+ throw new Error(`${preregistration.experiment} execution requires the preregistered A/B/C arm contract in exact order`);
270
+ const assignments = [];
271
+ if (preregistration.experiment === "EXP-01") {
272
+ if (bank.cases.length !== input.sample_per_arm)
273
+ throw new Error("EXP-01 requires exactly sample_per_arm fresh case templates; every case is run once in every arm");
274
+ for (const item of bank.cases) {
275
+ const orderedArms = [...arms].sort((a, b) => rank(preregistration.assignment.seed, item.block, item.id, a).localeCompare(rank(preregistration.assignment.seed, item.block, item.id, b)));
276
+ for (const arm of orderedArms) {
277
+ const treatmentHash = canonicalHash(treatmentFor(preregistration.experiment, arm, item));
278
+ const id = `expassign_${shortHash(canonicalHash({ preregistration: preregistration.content_hash, bank: bank.content_hash, case_id: item.id, arm, treatment_hash: treatmentHash }))}`;
279
+ assignments.push({ id, case_id: item.id, arm, block: item.block, order: 0, treatment_hash: treatmentHash });
280
+ }
281
+ }
282
+ }
283
+ else {
284
+ if (bank.cases.length !== input.sample_per_arm * arms.length)
285
+ throw new Error("EXP-03 requires exactly sample_per_arm multiplied by arm count fresh cases");
286
+ const byBlock = new Map();
287
+ for (const item of bank.cases)
288
+ byBlock.set(item.block, [...(byBlock.get(item.block) ?? []), item]);
289
+ let offset = 0;
290
+ for (const [block, blockCases] of [...byBlock.entries()].sort(([a], [b]) => a.localeCompare(b))) {
291
+ const ordered = [...blockCases].sort((a, b) => rank(preregistration.assignment.seed, block, a.id).localeCompare(rank(preregistration.assignment.seed, block, b.id)));
292
+ const armOrder = [...arms].sort((a, b) => rank(preregistration.assignment.seed, block, "arm", a).localeCompare(rank(preregistration.assignment.seed, block, "arm", b)));
293
+ for (const [index, item] of ordered.entries()) {
294
+ const arm = armOrder[(offset + index) % armOrder.length];
295
+ const treatmentHash = canonicalHash(treatmentFor(preregistration.experiment, arm, item));
296
+ const id = `expassign_${shortHash(canonicalHash({ preregistration: preregistration.content_hash, bank: bank.content_hash, case_id: item.id, arm, treatment_hash: treatmentHash }))}`;
297
+ assignments.push({ id, case_id: item.id, arm, block: item.block, order: 0, treatment_hash: treatmentHash });
298
+ }
299
+ offset += ordered.length;
300
+ }
301
+ const counts = new Map(arms.map((arm) => [arm, assignments.filter((item) => item.arm === arm).length]));
302
+ if ([...counts.values()].some((count) => count !== input.sample_per_arm))
303
+ throw new Error("EXP-03 blocks cannot be assigned to an exactly balanced arm allocation; adjust block sizes");
304
+ }
305
+ assignments.sort((a, b) => rank(preregistration.assignment.seed, "execution", a.id).localeCompare(rank(preregistration.assignment.seed, "execution", b.id)));
306
+ assignments.forEach((assignment, order) => { assignment.order = order; });
307
+ if (assignments.length > preregistration.sample_plan.maximum_total)
308
+ throw new Error("assignment count exceeds preregistered maximum_total");
309
+ const isExp01 = preregistration.experiment === "EXP-01";
310
+ const body = {
311
+ experiment: preregistration.experiment,
312
+ preregistration_id: preregistration.id,
313
+ preregistration_hash: preregistration.content_hash,
314
+ case_bank_id: bank.id,
315
+ case_bank_hash: bank.content_hash,
316
+ seed: preregistration.assignment.seed,
317
+ sample_per_arm: input.sample_per_arm,
318
+ assignment_strategy: isExp01 ? "crossed_blocked" : "exclusive_blocked",
319
+ assignments,
320
+ runner: {
321
+ implementation: "hunch-g3-experiment-v1",
322
+ provider: isExp01 ? input.provider ?? null : null,
323
+ provider_version: isExp01 ? input.provider_version?.trim() ?? null : null,
324
+ model_version: isExp01 ? input.model_version?.trim() ?? null : null,
325
+ max_turns: isExp01 ? input.max_turns ?? null : null,
326
+ },
327
+ actor: input.actor,
328
+ reason: input.reason.trim(),
329
+ data_class: "private",
330
+ authority: "none",
331
+ created_at: opts.now ?? new Date().toISOString(),
332
+ };
333
+ const contentHash = canonicalHash(body);
334
+ return ExperimentRunSchema.parse({ id: `exprun_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
335
+ }
336
+ const Exp01MetricsSchema = z.object({
337
+ valid_completion: z.boolean(),
338
+ policy_violation: z.boolean().nullable(),
339
+ task_success: z.boolean(),
340
+ build_success: z.boolean(),
341
+ unknown_or_error: z.boolean(),
342
+ refusal: z.boolean(),
343
+ turns: z.number().int().min(0).nullable(),
344
+ edits: z.number().int().min(0).nullable(),
345
+ tokens: z.number().int().min(0).nullable(),
346
+ latency_ms: z.number().int().min(0),
347
+ }).strict().superRefine((metrics, ctx) => {
348
+ if (metrics.valid_completion !== (metrics.policy_violation !== null))
349
+ ctx.addIssue({ code: "custom", path: ["policy_violation"], message: "policy_violation must be present exactly for valid completions" });
350
+ });
351
+ const Exp03MetricsSchema = z.object({
352
+ decision: z.enum(["accepted_precise", "accepted_edited", "rejected", "uncompilable", "abandoned", "timeout"]),
353
+ precise: z.boolean(),
354
+ proof_inspected: z.boolean(),
355
+ result_hash: z.string().regex(HASH).nullable(),
356
+ semantic_edit_distance: z.number().min(0).max(1).nullable(),
357
+ silent_semantic_substitution: z.boolean(),
358
+ rejection_reason: z.string().trim().min(1).max(4000).nullable(),
359
+ duration_ms: z.number().int().min(1).max(86_400_000),
360
+ }).strict().superRefine((metrics, ctx) => {
361
+ if (metrics.decision.startsWith("accepted") && !metrics.precise)
362
+ ctx.addIssue({ code: "custom", path: ["precise"], message: "accepted outcomes must be precise" });
363
+ if (metrics.decision.startsWith("accepted") !== (metrics.result_hash !== null))
364
+ ctx.addIssue({ code: "custom", path: ["result_hash"], message: "an accepted result hash is required exactly for accepted outcomes" });
365
+ if (!metrics.decision.startsWith("accepted") && metrics.semantic_edit_distance !== null)
366
+ ctx.addIssue({ code: "custom", path: ["semantic_edit_distance"], message: "non-accepted outcomes cannot claim proposal edit distance" });
367
+ if (metrics.decision === "rejected" && !metrics.rejection_reason)
368
+ ctx.addIssue({ code: "custom", path: ["rejection_reason"], message: "rejected outcomes require a reason" });
369
+ });
370
+ export const ExperimentOutcomeSchema = z.object({
371
+ id: z.string().regex(/^expout_[a-f0-9]{10}$/),
372
+ content_hash: z.string().regex(HASH),
373
+ run_id: z.string().regex(/^exprun_[a-f0-9]{10}$/),
374
+ assignment_id: z.string().regex(/^expassign_[a-f0-9]{10}$/),
375
+ experiment: z.enum(["EXP-01", "EXP-03"]),
376
+ arm: z.string().regex(/^[A-Z][A-Z0-9_-]{0,15}$/),
377
+ status: z.enum(["completed", "invalid_completion", "infrastructure_failure", "refused", "aborted"]),
378
+ invocation_started: z.boolean(),
379
+ metrics: z.union([Exp01MetricsSchema, Exp03MetricsSchema]).nullable(),
380
+ output_hash: z.string().regex(HASH).nullable(),
381
+ diff_hash: z.string().regex(HASH).nullable(),
382
+ evaluator_hash: z.string().regex(HASH).nullable(),
383
+ error_code: z.string().trim().min(1).max(256).nullable(),
384
+ incidents: z.object({
385
+ confirmed_private_leak: z.boolean(),
386
+ data_loss_or_corruption: z.boolean(),
387
+ unsafe_evaluator_behavior: z.boolean(),
388
+ }).strict(),
389
+ recorder: z.string().regex(ACTOR),
390
+ reason: z.string().trim().min(1).max(4000),
391
+ supersedes: z.string().regex(/^expout_[a-f0-9]{10}$/).nullable(),
392
+ data_class: z.literal("private"),
393
+ authority: z.literal("none"),
394
+ recorded_at: z.string().datetime({ offset: true }),
395
+ }).strict().superRefine((outcome, ctx) => {
396
+ if (outcome.status === "completed" && !outcome.metrics)
397
+ ctx.addIssue({ code: "custom", path: ["metrics"], message: "completed outcomes require metrics" });
398
+ if (outcome.status !== "completed" && outcome.metrics)
399
+ ctx.addIssue({ code: "custom", path: ["metrics"], message: "non-completed outcomes cannot claim primary metrics" });
400
+ if (outcome.status === "completed" && !outcome.invocation_started)
401
+ ctx.addIssue({ code: "custom", path: ["invocation_started"], message: "completed outcomes require a recorded model/reviewer invocation" });
402
+ if (outcome.status === "infrastructure_failure" && outcome.invocation_started)
403
+ ctx.addIssue({ code: "custom", path: ["invocation_started"], message: "pre-invocation infrastructure exclusions must not claim model invocation" });
404
+ if ((outcome.status === "invalid_completion" || outcome.status === "refused") && !outcome.invocation_started)
405
+ ctx.addIssue({ code: "custom", path: ["invocation_started"], message: `${outcome.status} requires a recorded invocation` });
406
+ if (outcome.status === "completed" && outcome.error_code)
407
+ ctx.addIssue({ code: "custom", path: ["error_code"], message: "completed outcomes cannot carry an error code" });
408
+ if (outcome.status !== "completed" && !outcome.error_code)
409
+ ctx.addIssue({ code: "custom", path: ["error_code"], message: "non-completed outcomes require an explicit error code" });
410
+ });
411
+ export function experimentOutcomeContentHash(outcome) {
412
+ const { id: _id, content_hash: _hash, ...body } = outcome;
413
+ return canonicalHash(body);
414
+ }
415
+ export function normalizedEditDistance(from, to) {
416
+ if (from === to)
417
+ return 0;
418
+ if (!from.length || !to.length)
419
+ return 1;
420
+ let previous = Array.from({ length: to.length + 1 }, (_, index) => index);
421
+ for (let left = 1; left <= from.length; left++) {
422
+ const current = [left];
423
+ for (let right = 1; right <= to.length; right++) {
424
+ current[right] = Math.min(current[right - 1] + 1, previous[right] + 1, previous[right - 1] + (from[left - 1] === to[right - 1] ? 0 : 1));
425
+ }
426
+ previous = current;
427
+ }
428
+ return previous[to.length] / Math.max(from.length, to.length);
429
+ }
430
+ export function compileExperimentOutcome(input, run, opts = {}) {
431
+ const assignment = run.assignments.find((item) => item.id === input.assignment_id);
432
+ if (!assignment || input.run_id !== run.id)
433
+ throw new Error("outcome must bind an assignment in the exact run");
434
+ const body = {
435
+ ...input,
436
+ experiment: run.experiment,
437
+ arm: assignment.arm,
438
+ reason: input.reason.trim(),
439
+ data_class: "private",
440
+ authority: "none",
441
+ recorded_at: opts.now ?? new Date().toISOString(),
442
+ };
443
+ const contentHash = canonicalHash(body);
444
+ const parsed = ExperimentOutcomeSchema.parse({ id: `expout_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
445
+ if (parsed.experiment === "EXP-01" && parsed.metrics && !("valid_completion" in parsed.metrics))
446
+ throw new Error("EXP-01 outcome requires prevention metrics");
447
+ if (parsed.experiment === "EXP-03" && parsed.metrics && !("decision" in parsed.metrics))
448
+ throw new Error("EXP-03 outcome requires review metrics");
449
+ if (parsed.experiment === "EXP-01" && parsed.status === "completed" && parsed.metrics && "valid_completion" in parsed.metrics
450
+ && (!parsed.metrics.valid_completion || parsed.metrics.refusal || parsed.metrics.unknown_or_error)) {
451
+ throw new Error("completed EXP-01 outcomes require a valid, non-refused, known evaluator result");
452
+ }
453
+ return parsed;
454
+ }
455
+ export const ExperimentReviewStartSchema = z.object({
456
+ id: z.string().regex(/^expreview_[a-f0-9]{10}$/),
457
+ content_hash: z.string().regex(HASH),
458
+ run_id: z.string().regex(/^exprun_[a-f0-9]{10}$/),
459
+ assignment_id: z.string().regex(/^expassign_[a-f0-9]{10}$/),
460
+ reviewer: z.string().regex(/^human:[^\s]+$/i),
461
+ treatment_hash: z.string().regex(HASH),
462
+ data_class: z.literal("private"),
463
+ authority: z.literal("none"),
464
+ started_at: z.string().datetime({ offset: true }),
465
+ }).strict();
466
+ export function experimentReviewStartContentHash(record) {
467
+ const { id: _id, content_hash: _hash, ...body } = record;
468
+ return canonicalHash(body);
469
+ }
470
+ export function compileExperimentReviewStart(run, assignment, reviewer, opts = {}) {
471
+ if (run.experiment !== "EXP-03" || !run.assignments.some((item) => item.id === assignment.id))
472
+ throw new Error("review starts require an EXP-03 assignment in the exact run");
473
+ const body = {
474
+ run_id: run.id,
475
+ assignment_id: assignment.id,
476
+ reviewer,
477
+ treatment_hash: assignment.treatment_hash,
478
+ data_class: "private",
479
+ authority: "none",
480
+ started_at: opts.now ?? new Date().toISOString(),
481
+ };
482
+ const contentHash = canonicalHash(body);
483
+ return ExperimentReviewStartSchema.parse({ id: `expreview_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
484
+ }
485
+ export const ExperimentFollowupSchema = z.object({
486
+ id: z.string().regex(/^expfollow_[a-f0-9]{10}$/),
487
+ content_hash: z.string().regex(HASH),
488
+ run_id: z.string().regex(/^exprun_[a-f0-9]{10}$/),
489
+ assignment_id: z.string().regex(/^expassign_[a-f0-9]{10}$/),
490
+ outcome_id: z.string().regex(/^expout_[a-f0-9]{10}$/),
491
+ reviewer: z.string().regex(/^human:[^\s]+$/i),
492
+ reversed: z.boolean().nullable(),
493
+ missing_reason: z.string().trim().min(1).max(4000).nullable(),
494
+ notes: z.string().trim().min(1).max(4000),
495
+ supersedes: z.string().regex(/^expfollow_[a-f0-9]{10}$/).nullable(),
496
+ data_class: z.literal("private"),
497
+ authority: z.literal("none"),
498
+ measured_at: z.string().datetime({ offset: true }),
499
+ }).strict().superRefine((record, ctx) => {
500
+ if ((record.reversed === null) !== (record.missing_reason !== null))
501
+ ctx.addIssue({ code: "custom", path: ["missing_reason"], message: "missing reason is required exactly when reversal is unmeasured" });
502
+ });
503
+ export function experimentFollowupContentHash(record) {
504
+ const { id: _id, content_hash: _hash, ...body } = record;
505
+ return canonicalHash(body);
506
+ }
507
+ export function compileExperimentFollowup(input, run, outcome, opts = {}) {
508
+ if (run.experiment !== "EXP-03" || outcome.run_id !== run.id || outcome.status !== "completed")
509
+ throw new Error("seven-day follow-up requires a completed EXP-03 outcome in the exact run");
510
+ const measuredAt = opts.now ?? new Date().toISOString();
511
+ if (Date.parse(measuredAt) - Date.parse(outcome.recorded_at) < 7 * 24 * 60 * 60 * 1000)
512
+ throw new Error("seven-day follow-up cannot be recorded before seven full days");
513
+ const body = {
514
+ run_id: run.id,
515
+ assignment_id: outcome.assignment_id,
516
+ outcome_id: outcome.id,
517
+ reviewer: input.reviewer,
518
+ reversed: input.reversed,
519
+ missing_reason: input.missing_reason,
520
+ notes: input.notes.trim(),
521
+ supersedes: input.supersedes,
522
+ data_class: "private",
523
+ authority: "none",
524
+ measured_at: measuredAt,
525
+ };
526
+ const contentHash = canonicalHash(body);
527
+ return ExperimentFollowupSchema.parse({ id: `expfollow_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
528
+ }
529
+ export function currentExperimentOutcomes(records) {
530
+ return currentAppendOnly(records.map((item) => ExperimentOutcomeSchema.parse(item)), "experiment outcome", (item) => `${item.run_id}:${item.assignment_id}`);
531
+ }
532
+ export function currentExperimentFollowups(records) {
533
+ return currentAppendOnly(records.map((item) => ExperimentFollowupSchema.parse(item)), "experiment follow-up", (item) => `${item.run_id}:${item.assignment_id}`);
534
+ }
535
+ export const ExperimentStopSchema = z.object({
536
+ id: z.string().regex(/^expstop_[a-f0-9]{10}$/),
537
+ content_hash: z.string().regex(HASH),
538
+ run_id: z.string().regex(/^exprun_[a-f0-9]{10}$/),
539
+ category: z.enum(["confirmed_private_leak", "data_loss_or_corruption", "unsafe_evaluator_behavior", "unsafe_semantic_substitution", "provider_wide_unavailability"]),
540
+ actor: z.string().regex(/^(human|git|github):[^\s]+$/i),
541
+ reason: z.string().trim().min(1).max(4000),
542
+ evidence_hashes: z.array(z.string().regex(HASH)).min(1).max(256),
543
+ data_class: z.literal("private"),
544
+ authority: z.literal("none"),
545
+ effect: z.literal("irreversible_run_stop"),
546
+ recorded_at: z.string().datetime({ offset: true }),
547
+ }).strict();
548
+ export function experimentStopContentHash(record) {
549
+ const { id: _id, content_hash: _hash, ...body } = record;
550
+ return canonicalHash(body);
551
+ }
552
+ export function compileExperimentStop(input, run, opts = {}) {
553
+ const body = {
554
+ run_id: run.id,
555
+ category: input.category,
556
+ actor: input.actor,
557
+ reason: input.reason.trim(),
558
+ evidence_hashes: [...new Set(input.evidence_hashes)].sort(),
559
+ data_class: "private",
560
+ authority: "none",
561
+ effect: "irreversible_run_stop",
562
+ recorded_at: opts.now ?? new Date().toISOString(),
563
+ };
564
+ const contentHash = canonicalHash(body);
565
+ return ExperimentStopSchema.parse({ id: `expstop_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
566
+ }
567
+ function wilson(successes, total) {
568
+ if (!total)
569
+ return null;
570
+ const z = 1.959963984540054;
571
+ const p = successes / total;
572
+ const denom = 1 + z * z / total;
573
+ const center = (p + z * z / (2 * total)) / denom;
574
+ const half = z * Math.sqrt((p * (1 - p) + z * z / (4 * total)) / total) / denom;
575
+ return [Math.max(0, center - half), Math.min(1, center + half)];
576
+ }
577
+ function seededRandom(seed) {
578
+ let state = Number.parseInt(canonicalHash(seed).slice(5, 13), 16) >>> 0;
579
+ return () => {
580
+ state ^= state << 13;
581
+ state ^= state >>> 17;
582
+ state ^= state << 5;
583
+ return (state >>> 0) / 0x1_0000_0000;
584
+ };
585
+ }
586
+ function bootstrapReviewerRate(rows, seed, samples = 4000) {
587
+ if (!rows.length)
588
+ return null;
589
+ const random = seededRandom(seed);
590
+ const rates = [];
591
+ for (let sample = 0; sample < samples; sample++) {
592
+ let accepted = 0;
593
+ let duration = 0;
594
+ for (let index = 0; index < rows.length; index++) {
595
+ const row = rows[Math.floor(random() * rows.length)];
596
+ if (row.precise && row.decision.startsWith("accepted"))
597
+ accepted++;
598
+ duration += row.duration_ms;
599
+ }
600
+ if (duration)
601
+ rates.push(accepted / (duration / 3_600_000));
602
+ }
603
+ if (!rates.length)
604
+ return null;
605
+ rates.sort((a, b) => a - b);
606
+ return [rates[Math.floor(0.025 * (rates.length - 1))], rates[Math.floor(0.975 * (rates.length - 1))]];
607
+ }
608
+ function logCombination(n, k) {
609
+ if (k < 0 || k > n)
610
+ return Number.NEGATIVE_INFINITY;
611
+ let value = 0;
612
+ for (let index = 1; index <= k; index++)
613
+ value += Math.log(n - k + index) - Math.log(index);
614
+ return value;
615
+ }
616
+ function fisherTwoSided(a, b, c, d) {
617
+ const total = a + b + c + d;
618
+ if (!total)
619
+ return null;
620
+ const rowOne = a + b;
621
+ const columnOne = a + c;
622
+ const lower = Math.max(0, rowOne - (total - columnOne));
623
+ const upper = Math.min(rowOne, columnOne);
624
+ const probability = (x) => Math.exp(logCombination(columnOne, x) + logCombination(total - columnOne, rowOne - x) - logCombination(total, rowOne));
625
+ const observed = probability(a);
626
+ let p = 0;
627
+ for (let x = lower; x <= upper; x++) {
628
+ const candidate = probability(x);
629
+ if (candidate <= observed + 1e-12)
630
+ p += candidate;
631
+ }
632
+ return Math.min(1, p);
633
+ }
634
+ export function buildExperimentReport(run, bank, outcomes, followups, stops = []) {
635
+ if (run.case_bank_id !== bank.id || run.case_bank_hash !== bank.content_hash)
636
+ throw new Error("experiment report requires the exact run-bound case bank");
637
+ const currentOutcomes = currentExperimentOutcomes(outcomes).filter((item) => item.run_id === run.id);
638
+ const currentFollowups = currentExperimentFollowups(followups).filter((item) => item.run_id === run.id);
639
+ const runStops = stops.filter((item) => ExperimentStopSchema.parse(item).run_id === run.id).sort((a, b) => a.id.localeCompare(b.id));
640
+ const outcomeByAssignment = new Map(currentOutcomes.map((item) => [item.assignment_id, item]));
641
+ const validFollowups = currentFollowups.filter((item) => outcomeByAssignment.get(item.assignment_id)?.id === item.outcome_id);
642
+ const followupByAssignment = new Map(validFollowups.map((item) => [item.assignment_id, item]));
643
+ const armIds = [...new Set(run.assignments.map((item) => item.arm))].sort();
644
+ const arms = armIds.map((arm) => {
645
+ const assigned = run.assignments.filter((item) => item.arm === arm);
646
+ const terminal = assigned.flatMap((item) => outcomeByAssignment.get(item.id) ?? []);
647
+ const completed = terminal.filter((item) => item.status === "completed");
648
+ const prevention = completed.flatMap((item) => item.metrics && "valid_completion" in item.metrics ? [item.metrics] : []);
649
+ const reviews = completed.flatMap((item) => item.metrics && "decision" in item.metrics ? [item.metrics] : []);
650
+ const valid = prevention.filter((item) => item.valid_completion);
651
+ const violations = valid.filter((item) => item.policy_violation === true).length;
652
+ const accepted = reviews.filter((item) => item.precise && item.decision.startsWith("accepted")).length;
653
+ const reviewerHours = reviews.reduce((sum, item) => sum + item.duration_ms, 0) / 3_600_000;
654
+ const measuredFollowups = assigned.flatMap((item) => followupByAssignment.get(item.id) ?? []);
655
+ return {
656
+ arm,
657
+ assigned: assigned.length,
658
+ terminal: terminal.length,
659
+ infrastructure_failures: terminal.filter((item) => item.status === "infrastructure_failure").length,
660
+ invalid_or_refused: terminal.filter((item) => item.status === "invalid_completion" || item.status === "refused").length,
661
+ valid_completions: run.experiment === "EXP-01" ? valid.length : completed.length,
662
+ violations: run.experiment === "EXP-01" ? violations : null,
663
+ violation_rate: run.experiment === "EXP-01" && valid.length ? violations / valid.length : null,
664
+ wilson_95: run.experiment === "EXP-01" ? wilson(violations, valid.length) : null,
665
+ accepted_precise: run.experiment === "EXP-03" ? accepted : null,
666
+ reviewer_hours: run.experiment === "EXP-03" ? reviewerHours : null,
667
+ accepted_per_reviewer_hour: run.experiment === "EXP-03" && reviewerHours ? accepted / reviewerHours : null,
668
+ bootstrap_95: run.experiment === "EXP-03" ? bootstrapReviewerRate(reviews, `${run.seed}:${arm}:reviewer-rate`) : null,
669
+ reversals: run.experiment === "EXP-03" ? measuredFollowups.filter((item) => item.reversed === true).length : null,
670
+ followups_missing: run.experiment === "EXP-03" ? completed.length - measuredFollowups.length : null,
671
+ };
672
+ });
673
+ const caseById = new Map(bank.cases.map((item) => [item.id, item]));
674
+ const stratumKeys = [...new Set(bank.cases.flatMap((item) => Object.keys(item.strata)))].sort();
675
+ const strata = [];
676
+ for (const key of stratumKeys) {
677
+ const values = [...new Set(bank.cases.map((item) => item.strata[key]).filter((value) => !!value))].sort();
678
+ for (const value of values) {
679
+ for (const arm of armIds) {
680
+ const assigned = run.assignments.filter((assignment) => assignment.arm === arm && caseById.get(assignment.case_id)?.strata[key] === value);
681
+ const terminal = assigned.flatMap((assignment) => outcomeByAssignment.get(assignment.id) ?? []);
682
+ const completed = terminal.filter((outcome) => outcome.status === "completed");
683
+ const prevention = completed.flatMap((outcome) => outcome.metrics && "valid_completion" in outcome.metrics ? [outcome.metrics] : []);
684
+ const reviews = completed.flatMap((outcome) => outcome.metrics && "decision" in outcome.metrics ? [outcome.metrics] : []);
685
+ const valid = prevention.filter((metrics) => metrics.valid_completion);
686
+ strata.push({
687
+ key,
688
+ value,
689
+ arm,
690
+ assigned: assigned.length,
691
+ terminal: terminal.length,
692
+ valid_completions: run.experiment === "EXP-01" ? valid.length : completed.length,
693
+ violations: run.experiment === "EXP-01" ? valid.filter((metrics) => metrics.policy_violation === true).length : null,
694
+ accepted_precise: run.experiment === "EXP-03" ? reviews.filter((metrics) => metrics.precise && metrics.decision.startsWith("accepted")).length : null,
695
+ reviewer_ms: run.experiment === "EXP-03" ? reviews.reduce((sum, metrics) => sum + metrics.duration_ms, 0) : null,
696
+ });
697
+ }
698
+ }
699
+ }
700
+ const terminal = currentOutcomes.length;
701
+ const allTerminal = terminal === run.assignments.length;
702
+ const exp03Completed = currentOutcomes.filter((item) => item.status === "completed").length;
703
+ const followupsComplete = run.experiment !== "EXP-03" || validFollowups.length === exp03Completed;
704
+ const substitutions = currentOutcomes.filter((item) => item.metrics && "silent_semantic_substitution" in item.metrics && item.metrics.silent_semantic_substitution).length;
705
+ const privateLeaks = currentOutcomes.filter((item) => item.incidents.confirmed_private_leak).length;
706
+ const corruption = currentOutcomes.filter((item) => item.incidents.data_loss_or_corruption).length;
707
+ const unsafeEvaluator = currentOutcomes.filter((item) => item.incidents.unsafe_evaluator_behavior).length;
708
+ const guardrailStopped = substitutions + privateLeaks + corruption + unsafeEvaluator > 0 || runStops.length > 0;
709
+ const status = guardrailStopped ? "guardrail_stopped" : !terminal ? "registered" : !allTerminal ? "running" : !followupsComplete ? "awaiting_followup" : "completed";
710
+ const unresolved = run.assignments.filter((item) => !outcomeByAssignment.has(item.id)).map((item) => item.id);
711
+ const byArm = new Map(arms.map((item) => [item.arm, item]));
712
+ const a = byArm.get("A");
713
+ const b = byArm.get("B");
714
+ const c = byArm.get("C");
715
+ const contrasts = run.experiment === "EXP-01" ? {
716
+ "C_vs_A_risk_difference": c?.violation_rate != null && a?.violation_rate != null ? c.violation_rate - a.violation_rate : null,
717
+ "C_vs_A_relative_risk": c?.violation_rate != null && a?.violation_rate ? c.violation_rate / a.violation_rate : null,
718
+ "B_vs_A_risk_difference": b?.violation_rate != null && a?.violation_rate != null ? b.violation_rate - a.violation_rate : null,
719
+ "C_vs_B_risk_difference": c?.violation_rate != null && b?.violation_rate != null ? c.violation_rate - b.violation_rate : null,
720
+ "C_vs_A_fisher_exact_p": c?.violations != null && a?.violations != null ? fisherTwoSided(c.violations, c.valid_completions - c.violations, a.violations, a.valid_completions - a.violations) : null,
721
+ "B_vs_A_fisher_exact_p": b?.violations != null && a?.violations != null ? fisherTwoSided(b.violations, b.valid_completions - b.violations, a.violations, a.valid_completions - a.violations) : null,
722
+ "C_vs_B_fisher_exact_p": c?.violations != null && b?.violations != null ? fisherTwoSided(c.violations, c.valid_completions - c.violations, b.violations, b.valid_completions - b.violations) : null,
723
+ } : {
724
+ "C_vs_A_rate_difference": c?.accepted_per_reviewer_hour != null && a?.accepted_per_reviewer_hour != null ? c.accepted_per_reviewer_hour - a.accepted_per_reviewer_hour : null,
725
+ "C_vs_A_rate_ratio": c?.accepted_per_reviewer_hour != null && a?.accepted_per_reviewer_hour ? c.accepted_per_reviewer_hour / a.accepted_per_reviewer_hour : null,
726
+ "B_vs_A_rate_difference": b?.accepted_per_reviewer_hour != null && a?.accepted_per_reviewer_hour != null ? b.accepted_per_reviewer_hour - a.accepted_per_reviewer_hour : null,
727
+ "C_vs_B_rate_difference": c?.accepted_per_reviewer_hour != null && b?.accepted_per_reviewer_hour != null ? c.accepted_per_reviewer_hour - b.accepted_per_reviewer_hour : null,
728
+ };
729
+ const deviations = [
730
+ ...currentOutcomes.filter((item) => item.supersedes).map((item) => `corrected outcome ${item.id} supersedes ${item.supersedes}`),
731
+ ...currentFollowups.filter((item) => item.supersedes).map((item) => `corrected follow-up ${item.id} supersedes ${item.supersedes}`),
732
+ ...currentFollowups.filter((item) => outcomeByAssignment.get(item.assignment_id)?.id !== item.outcome_id).map((item) => `follow-up ${item.id} is stale because its bound initial outcome is no longer current`),
733
+ ];
734
+ const body = {
735
+ run_id: run.id,
736
+ preregistration_hash: run.preregistration_hash,
737
+ case_bank_hash: run.case_bank_hash,
738
+ experiment: run.experiment,
739
+ analysis: { ...EXPERIMENT_ANALYSIS_SPEC, deterministic_hash: EXPERIMENT_ANALYSIS_HASH },
740
+ status,
741
+ arms,
742
+ strata,
743
+ assignments: run.assignments.length,
744
+ terminal,
745
+ unresolved,
746
+ contrasts,
747
+ deviations,
748
+ guardrails: { confirmed_private_leaks: privateLeaks, silent_semantic_substitutions: substitutions, data_loss_or_corruption: corruption, unsafe_evaluator_behavior: unsafeEvaluator },
749
+ stop_receipts: runStops,
750
+ claim_allowed: false,
751
+ authority: "none",
752
+ };
753
+ const contentHash = canonicalHash(body);
754
+ return { id: `expreport_${shortHash(contentHash)}`, content_hash: contentHash, ...body };
755
+ }
756
+ /** Private-only, content-addressed experiment execution ledger. */
757
+ export class ExperimentRepository {
758
+ store;
759
+ constructor(store) {
760
+ this.store = store;
761
+ }
762
+ requirePrivate() {
763
+ if (!this.store.privateDir)
764
+ throw new Error("experiment execution requires a configured private overlay");
765
+ return this.store.privateDir;
766
+ }
767
+ load(dir, prefix, parse) {
768
+ return loadPrivateRecords(this.store.privateDir ? join(this.store.privateDir, dir) : undefined, prefix, parse, dir);
769
+ }
770
+ put(dir, id, value) {
771
+ const root = join(this.requirePrivate(), dir);
772
+ mkdirSync(root, { recursive: true });
773
+ writeFileAtomic(join(root, `${id}.json`), encode(value));
774
+ }
775
+ listCaseBanks() {
776
+ return this.load("experiment-case-banks", "expbank_", (raw) => {
777
+ const parsed = ExperimentCaseBankSchema.parse(raw);
778
+ if (experimentCaseBankContentHash(parsed) !== parsed.content_hash || parsed.id !== `expbank_${shortHash(parsed.content_hash)}`)
779
+ throw new Error(`experiment case bank ${parsed.id} content hash mismatch`);
780
+ return parsed;
781
+ }).sort((a, b) => a.id.localeCompare(b.id));
782
+ }
783
+ putCaseBank(bank) {
784
+ const parsed = ExperimentCaseBankSchema.parse(bank);
785
+ if (experimentCaseBankContentHash(parsed) !== parsed.content_hash || parsed.id !== `expbank_${shortHash(parsed.content_hash)}`)
786
+ throw new Error(`experiment case bank ${parsed.id} content hash mismatch`);
787
+ if (!this.listCaseBanks().some((item) => item.id === parsed.id))
788
+ this.put("experiment-case-banks", parsed.id, parsed);
789
+ return parsed;
790
+ }
791
+ listRuns() {
792
+ const records = this.load("experiment-runs", "exprun_", (raw) => {
793
+ const parsed = ExperimentRunSchema.parse(raw);
794
+ if (experimentRunContentHash(parsed) !== parsed.content_hash || parsed.id !== `exprun_${shortHash(parsed.content_hash)}`)
795
+ throw new Error(`experiment run ${parsed.id} content hash mismatch`);
796
+ return parsed;
797
+ }).sort((a, b) => a.id.localeCompare(b.id));
798
+ const banks = new Map(this.listCaseBanks().map((item) => [item.id, item]));
799
+ for (const run of records) {
800
+ const bank = banks.get(run.case_bank_id);
801
+ if (!bank || bank.content_hash !== run.case_bank_hash)
802
+ throw new Error(`experiment run ${run.id} is missing its exact case bank`);
803
+ for (const assignment of run.assignments)
804
+ assignmentTreatment(bank, run, assignment);
805
+ }
806
+ return records;
807
+ }
808
+ putRun(run) {
809
+ const parsed = ExperimentRunSchema.parse(run);
810
+ if (experimentRunContentHash(parsed) !== parsed.content_hash || parsed.id !== `exprun_${shortHash(parsed.content_hash)}`)
811
+ throw new Error(`experiment run ${parsed.id} content hash mismatch`);
812
+ const runs = this.listRuns();
813
+ const incumbent = runs.find((item) => item.preregistration_id === parsed.preregistration_id);
814
+ if (incumbent && incumbent.id !== parsed.id)
815
+ throw new Error(`preregistration ${parsed.preregistration_id} already has immutable run ${incumbent.id}`);
816
+ if (!incumbent)
817
+ this.put("experiment-runs", parsed.id, parsed);
818
+ return parsed;
819
+ }
820
+ listOutcomes() {
821
+ const records = this.load("experiment-outcomes", "expout_", (raw) => {
822
+ const parsed = ExperimentOutcomeSchema.parse(raw);
823
+ if (experimentOutcomeContentHash(parsed) !== parsed.content_hash || parsed.id !== `expout_${shortHash(parsed.content_hash)}`)
824
+ throw new Error(`experiment outcome ${parsed.id} content hash mismatch`);
825
+ return parsed;
826
+ });
827
+ currentExperimentOutcomes(records);
828
+ const runs = new Map(this.listRuns().map((item) => [item.id, item]));
829
+ for (const outcome of records) {
830
+ const run = runs.get(outcome.run_id);
831
+ const assignment = run?.assignments.find((item) => item.id === outcome.assignment_id);
832
+ if (!run || !assignment || outcome.experiment !== run.experiment || outcome.arm !== assignment.arm)
833
+ throw new Error(`experiment outcome ${outcome.id} has no exact run assignment binding`);
834
+ }
835
+ return records.sort((a, b) => a.id.localeCompare(b.id));
836
+ }
837
+ putOutcome(outcome) {
838
+ const parsed = ExperimentOutcomeSchema.parse(outcome);
839
+ if (experimentOutcomeContentHash(parsed) !== parsed.content_hash || parsed.id !== `expout_${shortHash(parsed.content_hash)}`)
840
+ throw new Error(`experiment outcome ${parsed.id} content hash mismatch`);
841
+ const records = this.listOutcomes();
842
+ if (records.some((item) => item.id === parsed.id))
843
+ return parsed;
844
+ const current = currentExperimentOutcomes(records).find((item) => item.run_id === parsed.run_id && item.assignment_id === parsed.assignment_id);
845
+ if (current && parsed.supersedes !== current.id)
846
+ throw new Error(`assignment ${parsed.assignment_id} already has current outcome ${current.id}; corrections must supersede it explicitly`);
847
+ if (!current && parsed.supersedes)
848
+ throw new Error(`outcome ${parsed.id} supersedes no current assignment outcome`);
849
+ currentExperimentOutcomes([...records, parsed]);
850
+ this.put("experiment-outcomes", parsed.id, parsed);
851
+ return parsed;
852
+ }
853
+ listReviewStarts() {
854
+ const records = this.load("experiment-review-starts", "expreview_", (raw) => {
855
+ const parsed = ExperimentReviewStartSchema.parse(raw);
856
+ if (experimentReviewStartContentHash(parsed) !== parsed.content_hash || parsed.id !== `expreview_${shortHash(parsed.content_hash)}`)
857
+ throw new Error(`experiment review start ${parsed.id} content hash mismatch`);
858
+ return parsed;
859
+ }).sort((a, b) => a.id.localeCompare(b.id));
860
+ const runs = new Map(this.listRuns().map((item) => [item.id, item]));
861
+ for (const record of records) {
862
+ const run = runs.get(record.run_id);
863
+ const assignment = run?.assignments.find((item) => item.id === record.assignment_id);
864
+ if (!run || run.experiment !== "EXP-03" || !assignment || assignment.treatment_hash !== record.treatment_hash)
865
+ throw new Error(`experiment review start ${record.id} has no exact EXP-03 assignment binding`);
866
+ }
867
+ return records;
868
+ }
869
+ putReviewStart(record) {
870
+ const parsed = ExperimentReviewStartSchema.parse(record);
871
+ if (experimentReviewStartContentHash(parsed) !== parsed.content_hash || parsed.id !== `expreview_${shortHash(parsed.content_hash)}`)
872
+ throw new Error(`experiment review start ${parsed.id} content hash mismatch`);
873
+ const records = this.listReviewStarts();
874
+ const incumbent = records.find((item) => item.run_id === parsed.run_id && item.assignment_id === parsed.assignment_id);
875
+ if (incumbent) {
876
+ if (incumbent.reviewer !== parsed.reviewer)
877
+ throw new Error(`assignment ${parsed.assignment_id} review already started by ${incumbent.reviewer}`);
878
+ return incumbent;
879
+ }
880
+ const completed = new Set(currentExperimentOutcomes(this.listOutcomes()).map((item) => `${item.run_id}:${item.assignment_id}`));
881
+ const openForReviewer = records.find((item) => item.run_id === parsed.run_id && item.reviewer === parsed.reviewer && !completed.has(`${item.run_id}:${item.assignment_id}`));
882
+ if (openForReviewer)
883
+ throw new Error(`${parsed.reviewer} already has open review ${openForReviewer.id}; complete it before starting another assignment`);
884
+ this.put("experiment-review-starts", parsed.id, parsed);
885
+ return parsed;
886
+ }
887
+ listFollowups() {
888
+ const records = this.load("experiment-followups", "expfollow_", (raw) => {
889
+ const parsed = ExperimentFollowupSchema.parse(raw);
890
+ if (experimentFollowupContentHash(parsed) !== parsed.content_hash || parsed.id !== `expfollow_${shortHash(parsed.content_hash)}`)
891
+ throw new Error(`experiment follow-up ${parsed.id} content hash mismatch`);
892
+ return parsed;
893
+ });
894
+ currentExperimentFollowups(records);
895
+ const runs = new Map(this.listRuns().map((item) => [item.id, item]));
896
+ const outcomes = new Map(this.listOutcomes().map((item) => [item.id, item]));
897
+ for (const record of records) {
898
+ const run = runs.get(record.run_id);
899
+ const outcome = outcomes.get(record.outcome_id);
900
+ if (!run || run.experiment !== "EXP-03" || !outcome || outcome.run_id !== run.id || outcome.assignment_id !== record.assignment_id)
901
+ throw new Error(`experiment follow-up ${record.id} has no exact EXP-03 outcome binding`);
902
+ }
903
+ return records.sort((a, b) => a.id.localeCompare(b.id));
904
+ }
905
+ putFollowup(record) {
906
+ const parsed = ExperimentFollowupSchema.parse(record);
907
+ if (experimentFollowupContentHash(parsed) !== parsed.content_hash || parsed.id !== `expfollow_${shortHash(parsed.content_hash)}`)
908
+ throw new Error(`experiment follow-up ${parsed.id} content hash mismatch`);
909
+ const records = this.listFollowups();
910
+ if (records.some((item) => item.id === parsed.id))
911
+ return parsed;
912
+ const current = currentExperimentFollowups(records).find((item) => item.run_id === parsed.run_id && item.assignment_id === parsed.assignment_id);
913
+ if (current && parsed.supersedes !== current.id)
914
+ throw new Error(`assignment ${parsed.assignment_id} already has current follow-up ${current.id}`);
915
+ if (!current && parsed.supersedes)
916
+ throw new Error(`follow-up ${parsed.id} supersedes no current record`);
917
+ currentExperimentFollowups([...records, parsed]);
918
+ this.put("experiment-followups", parsed.id, parsed);
919
+ return parsed;
920
+ }
921
+ listStops() {
922
+ const records = this.load("experiment-stops", "expstop_", (raw) => {
923
+ const parsed = ExperimentStopSchema.parse(raw);
924
+ if (experimentStopContentHash(parsed) !== parsed.content_hash || parsed.id !== `expstop_${shortHash(parsed.content_hash)}`)
925
+ throw new Error(`experiment stop ${parsed.id} content hash mismatch`);
926
+ return parsed;
927
+ }).sort((a, b) => a.id.localeCompare(b.id));
928
+ const runIds = new Set(this.listRuns().map((item) => item.id));
929
+ for (const record of records)
930
+ if (!runIds.has(record.run_id))
931
+ throw new Error(`experiment stop ${record.id} has no exact run binding`);
932
+ return records;
933
+ }
934
+ putStop(record) {
935
+ const parsed = ExperimentStopSchema.parse(record);
936
+ if (experimentStopContentHash(parsed) !== parsed.content_hash || parsed.id !== `expstop_${shortHash(parsed.content_hash)}`)
937
+ throw new Error(`experiment stop ${parsed.id} content hash mismatch`);
938
+ const incumbent = this.listStops().find((item) => item.run_id === parsed.run_id);
939
+ if (incumbent) {
940
+ if (incumbent.id !== parsed.id)
941
+ throw new Error(`run ${parsed.run_id} is already irreversibly stopped by ${incumbent.id}`);
942
+ return incumbent;
943
+ }
944
+ this.put("experiment-stops", parsed.id, parsed);
945
+ return parsed;
946
+ }
947
+ }
948
+ //# sourceMappingURL=experiment.js.map