@davesheffer/hunch 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +242 -0
  2. package/bench/constitution-exp03-v1.json +70 -0
  3. package/dist/cli/index.js +1630 -107
  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 +1007 -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 +132 -0
  31. package/dist/constitution/lifecycle.js +224 -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/repairPolicies.js +78 -0
  38. package/dist/constitution/replay.js +361 -0
  39. package/dist/constitution/replayCache.js +89 -0
  40. package/dist/constitution/replayWorker.js +34 -0
  41. package/dist/constitution/repository.js +533 -0
  42. package/dist/constitution/schema.js +545 -0
  43. package/dist/constitution/scorecard.js +106 -0
  44. package/dist/constitution/service.js +1211 -0
  45. package/dist/constitution/shadow.js +235 -0
  46. package/dist/constitution/sourceMutation.js +316 -0
  47. package/dist/constitution/structural.js +601 -0
  48. package/dist/core/autoreview.js +27 -3
  49. package/dist/core/dupdetect.js +10 -3
  50. package/dist/core/escalations.js +65 -0
  51. package/dist/core/events.js +61 -0
  52. package/dist/core/externalImports.js +24 -0
  53. package/dist/core/hookpolicy.js +3 -0
  54. package/dist/core/memorylog.js +69 -0
  55. package/dist/core/relativeImports.js +33 -0
  56. package/dist/core/repair.js +71 -0
  57. package/dist/core/reviewqueue.js +11 -0
  58. package/dist/core/stats.js +115 -0
  59. package/dist/extractors/git.js +120 -0
  60. package/dist/extractors/indexer.js +39 -38
  61. package/dist/extractors/nativeTreeSitter.js +108 -0
  62. package/dist/extractors/parse.js +5 -15
  63. package/dist/integrations/claudemd.js +8 -1
  64. package/dist/integrations/gitignore.js +8 -0
  65. package/dist/integrations/providers.js +32 -10
  66. package/dist/integrations/sync.js +16 -1
  67. package/dist/mcp/server.js +317 -1
  68. package/dist/synthesis/synthesize.js +8 -1
  69. package/dist/wiki/graph.js +301 -0
  70. package/dist/wiki/wiki.js +31 -3
  71. package/package.json +5 -1
@@ -0,0 +1,1007 @@
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
+ // "uncompilable" is the revision-1 vocabulary; "cannot_decide" is its revision-2
353
+ // successor (expreg_ba6aef4ecd counts cannot-decide as its own raw category, so
354
+ // the recorded token must be what the reviewer actually chose — never folded).
355
+ decision: z.enum(["accepted_precise", "accepted_edited", "rejected", "uncompilable", "cannot_decide", "abandoned", "timeout"]),
356
+ precise: z.boolean(),
357
+ proof_inspected: z.boolean(),
358
+ result_hash: z.string().regex(HASH).nullable(),
359
+ semantic_edit_distance: z.number().min(0).max(1).nullable(),
360
+ silent_semantic_substitution: z.boolean(),
361
+ rejection_reason: z.string().trim().min(1).max(4000).nullable(),
362
+ duration_ms: z.number().int().min(1).max(86_400_000),
363
+ }).strict().superRefine((metrics, ctx) => {
364
+ if (metrics.decision.startsWith("accepted") && !metrics.precise)
365
+ ctx.addIssue({ code: "custom", path: ["precise"], message: "accepted outcomes must be precise" });
366
+ if (metrics.decision.startsWith("accepted") !== (metrics.result_hash !== null))
367
+ ctx.addIssue({ code: "custom", path: ["result_hash"], message: "an accepted result hash is required exactly for accepted outcomes" });
368
+ if (!metrics.decision.startsWith("accepted") && metrics.semantic_edit_distance !== null)
369
+ ctx.addIssue({ code: "custom", path: ["semantic_edit_distance"], message: "non-accepted outcomes cannot claim proposal edit distance" });
370
+ if (metrics.decision === "rejected" && !metrics.rejection_reason)
371
+ ctx.addIssue({ code: "custom", path: ["rejection_reason"], message: "rejected outcomes require a reason" });
372
+ });
373
+ export const ExperimentOutcomeSchema = z.object({
374
+ id: z.string().regex(/^expout_[a-f0-9]{10}$/),
375
+ content_hash: z.string().regex(HASH),
376
+ run_id: z.string().regex(/^exprun_[a-f0-9]{10}$/),
377
+ assignment_id: z.string().regex(/^expassign_[a-f0-9]{10}$/),
378
+ experiment: z.enum(["EXP-01", "EXP-03"]),
379
+ arm: z.string().regex(/^[A-Z][A-Z0-9_-]{0,15}$/),
380
+ status: z.enum(["completed", "invalid_completion", "infrastructure_failure", "refused", "aborted"]),
381
+ invocation_started: z.boolean(),
382
+ metrics: z.union([Exp01MetricsSchema, Exp03MetricsSchema]).nullable(),
383
+ output_hash: z.string().regex(HASH).nullable(),
384
+ diff_hash: z.string().regex(HASH).nullable(),
385
+ evaluator_hash: z.string().regex(HASH).nullable(),
386
+ error_code: z.string().trim().min(1).max(256).nullable(),
387
+ incidents: z.object({
388
+ confirmed_private_leak: z.boolean(),
389
+ data_loss_or_corruption: z.boolean(),
390
+ unsafe_evaluator_behavior: z.boolean(),
391
+ }).strict(),
392
+ recorder: z.string().regex(ACTOR),
393
+ reason: z.string().trim().min(1).max(4000),
394
+ supersedes: z.string().regex(/^expout_[a-f0-9]{10}$/).nullable(),
395
+ data_class: z.literal("private"),
396
+ authority: z.literal("none"),
397
+ recorded_at: z.string().datetime({ offset: true }),
398
+ }).strict().superRefine((outcome, ctx) => {
399
+ if (outcome.status === "completed" && !outcome.metrics)
400
+ ctx.addIssue({ code: "custom", path: ["metrics"], message: "completed outcomes require metrics" });
401
+ if (outcome.status !== "completed" && outcome.metrics)
402
+ ctx.addIssue({ code: "custom", path: ["metrics"], message: "non-completed outcomes cannot claim primary metrics" });
403
+ if (outcome.status === "completed" && !outcome.invocation_started)
404
+ ctx.addIssue({ code: "custom", path: ["invocation_started"], message: "completed outcomes require a recorded model/reviewer invocation" });
405
+ if (outcome.status === "infrastructure_failure" && outcome.invocation_started)
406
+ ctx.addIssue({ code: "custom", path: ["invocation_started"], message: "pre-invocation infrastructure exclusions must not claim model invocation" });
407
+ if ((outcome.status === "invalid_completion" || outcome.status === "refused") && !outcome.invocation_started)
408
+ ctx.addIssue({ code: "custom", path: ["invocation_started"], message: `${outcome.status} requires a recorded invocation` });
409
+ if (outcome.status === "completed" && outcome.error_code)
410
+ ctx.addIssue({ code: "custom", path: ["error_code"], message: "completed outcomes cannot carry an error code" });
411
+ if (outcome.status !== "completed" && !outcome.error_code)
412
+ ctx.addIssue({ code: "custom", path: ["error_code"], message: "non-completed outcomes require an explicit error code" });
413
+ });
414
+ export function experimentOutcomeContentHash(outcome) {
415
+ const { id: _id, content_hash: _hash, ...body } = outcome;
416
+ return canonicalHash(body);
417
+ }
418
+ export function normalizedEditDistance(from, to) {
419
+ if (from === to)
420
+ return 0;
421
+ if (!from.length || !to.length)
422
+ return 1;
423
+ let previous = Array.from({ length: to.length + 1 }, (_, index) => index);
424
+ for (let left = 1; left <= from.length; left++) {
425
+ const current = [left];
426
+ for (let right = 1; right <= to.length; right++) {
427
+ current[right] = Math.min(current[right - 1] + 1, previous[right] + 1, previous[right - 1] + (from[left - 1] === to[right - 1] ? 0 : 1));
428
+ }
429
+ previous = current;
430
+ }
431
+ return previous[to.length] / Math.max(from.length, to.length);
432
+ }
433
+ export function compileExperimentOutcome(input, run, opts = {}) {
434
+ const assignment = run.assignments.find((item) => item.id === input.assignment_id);
435
+ if (!assignment || input.run_id !== run.id)
436
+ throw new Error("outcome must bind an assignment in the exact run");
437
+ const body = {
438
+ ...input,
439
+ experiment: run.experiment,
440
+ arm: assignment.arm,
441
+ reason: input.reason.trim(),
442
+ data_class: "private",
443
+ authority: "none",
444
+ recorded_at: opts.now ?? new Date().toISOString(),
445
+ };
446
+ const contentHash = canonicalHash(body);
447
+ const parsed = ExperimentOutcomeSchema.parse({ id: `expout_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
448
+ if (parsed.experiment === "EXP-01" && parsed.metrics && !("valid_completion" in parsed.metrics))
449
+ throw new Error("EXP-01 outcome requires prevention metrics");
450
+ if (parsed.experiment === "EXP-03" && parsed.metrics && !("decision" in parsed.metrics))
451
+ throw new Error("EXP-03 outcome requires review metrics");
452
+ if (parsed.experiment === "EXP-01" && parsed.status === "completed" && parsed.metrics && "valid_completion" in parsed.metrics
453
+ && (!parsed.metrics.valid_completion || parsed.metrics.refusal || parsed.metrics.unknown_or_error)) {
454
+ throw new Error("completed EXP-01 outcomes require a valid, non-refused, known evaluator result");
455
+ }
456
+ return parsed;
457
+ }
458
+ // ---- EXP-03 revision-2 standardized response (dec_0be4fd3717) --------------
459
+ // Revision-2 reviews are SUBMITTED in the same plain-language vocabulary they are
460
+ // PRESENTED in: one choice out of four, the rule text when one is kept, and one
461
+ // plain sentence. The mapper below is the single deterministic translation from
462
+ // that template into the canonical Exp03 metrics vocabulary — the reviewer never
463
+ // hand-crafts metrics, so the presented contract and the recorded outcome cannot
464
+ // drift apart. Revision-1 cases are refused here (their original raw submission
465
+ // path stays byte-identical for the append-only pilot).
466
+ export const EXP03_REVIEW_CHOICES = ["accept", "edit", "reject", "cannot_decide"];
467
+ const CHOICE_TO_DECISION = {
468
+ accept: "accepted_precise",
469
+ edit: "accepted_edited",
470
+ reject: "rejected",
471
+ cannot_decide: "cannot_decide", // its own raw category per expreg_ba6aef4ecd — never folded into uncompilable
472
+ };
473
+ export function compileExp03ReviewResponse(item, arm, response) {
474
+ if (!item.required_relationship) {
475
+ throw new Error(`case ${item.id} is a revision-1 pilot case and keeps its original submission contract; use the raw review submission, not the standardized template`);
476
+ }
477
+ if (!EXP03_REVIEW_CHOICES.includes(response.choice)) {
478
+ throw new Error(`choice must be one of: ${EXP03_REVIEW_CHOICES.join(" | ")}`);
479
+ }
480
+ const reason = response.reason?.trim();
481
+ if (!reason)
482
+ throw new Error("the response requires one plain-language sentence of reasoning");
483
+ const accepted = response.choice === "accept" || response.choice === "edit";
484
+ const rule = response.rule_text?.trim() || null;
485
+ if (accepted && !rule)
486
+ throw new Error(`choice "${response.choice}" requires the rule text it keeps`);
487
+ if (!accepted && rule)
488
+ throw new Error(`choice "${response.choice}" must leave the rule text blank`);
489
+ // Arms B/C present a proposed rule: "use it as written" must be byte-faithful to
490
+ // what was shown, and "after I correct it" must actually change it — the choice
491
+ // and the submitted text can never contradict each other.
492
+ if (arm !== "A" && response.choice === "accept" && rule !== item.compiler_candidate) {
493
+ throw new Error('the submitted rule differs from the one presented; use choice "edit"');
494
+ }
495
+ if (arm !== "A" && response.choice === "edit" && rule === item.compiler_candidate) {
496
+ throw new Error('the submitted rule is unchanged from the one presented; use choice "accept"');
497
+ }
498
+ if (response.inspected_supporting_checks && arm !== "C") {
499
+ throw new Error("supporting checks are only shown in arm C; this review cannot claim to have inspected them");
500
+ }
501
+ return {
502
+ decision: CHOICE_TO_DECISION[response.choice],
503
+ precise: accepted, // schema invariant: accepted outcomes are precise; graded later against the target commitment
504
+ proof_inspected: arm === "C" && !!response.inspected_supporting_checks,
505
+ result: accepted ? rule : null,
506
+ silent_semantic_substitution: false, // graded post-hoc via the append-only correction workflow, never self-declared
507
+ rejection_reason: response.choice === "reject" ? reason : null,
508
+ };
509
+ }
510
+ export const ExperimentReviewStartSchema = z.object({
511
+ id: z.string().regex(/^expreview_[a-f0-9]{10}$/),
512
+ content_hash: z.string().regex(HASH),
513
+ run_id: z.string().regex(/^exprun_[a-f0-9]{10}$/),
514
+ assignment_id: z.string().regex(/^expassign_[a-f0-9]{10}$/),
515
+ reviewer: z.string().regex(/^human:[^\s]+$/i),
516
+ treatment_hash: z.string().regex(HASH),
517
+ data_class: z.literal("private"),
518
+ authority: z.literal("none"),
519
+ started_at: z.string().datetime({ offset: true }),
520
+ }).strict();
521
+ export function experimentReviewStartContentHash(record) {
522
+ const { id: _id, content_hash: _hash, ...body } = record;
523
+ return canonicalHash(body);
524
+ }
525
+ export function compileExperimentReviewStart(run, assignment, reviewer, opts = {}) {
526
+ if (run.experiment !== "EXP-03" || !run.assignments.some((item) => item.id === assignment.id))
527
+ throw new Error("review starts require an EXP-03 assignment in the exact run");
528
+ const body = {
529
+ run_id: run.id,
530
+ assignment_id: assignment.id,
531
+ reviewer,
532
+ treatment_hash: assignment.treatment_hash,
533
+ data_class: "private",
534
+ authority: "none",
535
+ started_at: opts.now ?? new Date().toISOString(),
536
+ };
537
+ const contentHash = canonicalHash(body);
538
+ return ExperimentReviewStartSchema.parse({ id: `expreview_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
539
+ }
540
+ export const ExperimentFollowupSchema = z.object({
541
+ id: z.string().regex(/^expfollow_[a-f0-9]{10}$/),
542
+ content_hash: z.string().regex(HASH),
543
+ run_id: z.string().regex(/^exprun_[a-f0-9]{10}$/),
544
+ assignment_id: z.string().regex(/^expassign_[a-f0-9]{10}$/),
545
+ outcome_id: z.string().regex(/^expout_[a-f0-9]{10}$/),
546
+ reviewer: z.string().regex(/^human:[^\s]+$/i),
547
+ reversed: z.boolean().nullable(),
548
+ missing_reason: z.string().trim().min(1).max(4000).nullable(),
549
+ notes: z.string().trim().min(1).max(4000),
550
+ supersedes: z.string().regex(/^expfollow_[a-f0-9]{10}$/).nullable(),
551
+ data_class: z.literal("private"),
552
+ authority: z.literal("none"),
553
+ measured_at: z.string().datetime({ offset: true }),
554
+ }).strict().superRefine((record, ctx) => {
555
+ if ((record.reversed === null) !== (record.missing_reason !== null))
556
+ ctx.addIssue({ code: "custom", path: ["missing_reason"], message: "missing reason is required exactly when reversal is unmeasured" });
557
+ });
558
+ export function experimentFollowupContentHash(record) {
559
+ const { id: _id, content_hash: _hash, ...body } = record;
560
+ return canonicalHash(body);
561
+ }
562
+ export function compileExperimentFollowup(input, run, outcome, opts = {}) {
563
+ if (run.experiment !== "EXP-03" || outcome.run_id !== run.id || outcome.status !== "completed")
564
+ throw new Error("seven-day follow-up requires a completed EXP-03 outcome in the exact run");
565
+ const measuredAt = opts.now ?? new Date().toISOString();
566
+ if (Date.parse(measuredAt) - Date.parse(outcome.recorded_at) < 7 * 24 * 60 * 60 * 1000)
567
+ throw new Error("seven-day follow-up cannot be recorded before seven full days");
568
+ const body = {
569
+ run_id: run.id,
570
+ assignment_id: outcome.assignment_id,
571
+ outcome_id: outcome.id,
572
+ reviewer: input.reviewer,
573
+ reversed: input.reversed,
574
+ missing_reason: input.missing_reason,
575
+ notes: input.notes.trim(),
576
+ supersedes: input.supersedes,
577
+ data_class: "private",
578
+ authority: "none",
579
+ measured_at: measuredAt,
580
+ };
581
+ const contentHash = canonicalHash(body);
582
+ return ExperimentFollowupSchema.parse({ id: `expfollow_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
583
+ }
584
+ export function currentExperimentOutcomes(records) {
585
+ return currentAppendOnly(records.map((item) => ExperimentOutcomeSchema.parse(item)), "experiment outcome", (item) => `${item.run_id}:${item.assignment_id}`);
586
+ }
587
+ export function currentExperimentFollowups(records) {
588
+ return currentAppendOnly(records.map((item) => ExperimentFollowupSchema.parse(item)), "experiment follow-up", (item) => `${item.run_id}:${item.assignment_id}`);
589
+ }
590
+ export const ExperimentStopSchema = z.object({
591
+ id: z.string().regex(/^expstop_[a-f0-9]{10}$/),
592
+ content_hash: z.string().regex(HASH),
593
+ run_id: z.string().regex(/^exprun_[a-f0-9]{10}$/),
594
+ category: z.enum(["confirmed_private_leak", "data_loss_or_corruption", "unsafe_evaluator_behavior", "unsafe_semantic_substitution", "provider_wide_unavailability"]),
595
+ actor: z.string().regex(/^(human|git|github):[^\s]+$/i),
596
+ reason: z.string().trim().min(1).max(4000),
597
+ evidence_hashes: z.array(z.string().regex(HASH)).min(1).max(256),
598
+ data_class: z.literal("private"),
599
+ authority: z.literal("none"),
600
+ effect: z.literal("irreversible_run_stop"),
601
+ recorded_at: z.string().datetime({ offset: true }),
602
+ }).strict();
603
+ export function experimentStopContentHash(record) {
604
+ const { id: _id, content_hash: _hash, ...body } = record;
605
+ return canonicalHash(body);
606
+ }
607
+ export function compileExperimentStop(input, run, opts = {}) {
608
+ const body = {
609
+ run_id: run.id,
610
+ category: input.category,
611
+ actor: input.actor,
612
+ reason: input.reason.trim(),
613
+ evidence_hashes: [...new Set(input.evidence_hashes)].sort(),
614
+ data_class: "private",
615
+ authority: "none",
616
+ effect: "irreversible_run_stop",
617
+ recorded_at: opts.now ?? new Date().toISOString(),
618
+ };
619
+ const contentHash = canonicalHash(body);
620
+ return ExperimentStopSchema.parse({ id: `expstop_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
621
+ }
622
+ const EXP03_DECISION_TOKENS = ["accepted_precise", "accepted_edited", "rejected", "uncompilable", "cannot_decide", "abandoned", "timeout"];
623
+ function wilson(successes, total) {
624
+ if (!total)
625
+ return null;
626
+ const z = 1.959963984540054;
627
+ const p = successes / total;
628
+ const denom = 1 + z * z / total;
629
+ const center = (p + z * z / (2 * total)) / denom;
630
+ const half = z * Math.sqrt((p * (1 - p) + z * z / (4 * total)) / total) / denom;
631
+ return [Math.max(0, center - half), Math.min(1, center + half)];
632
+ }
633
+ function seededRandom(seed) {
634
+ let state = Number.parseInt(canonicalHash(seed).slice(5, 13), 16) >>> 0;
635
+ return () => {
636
+ state ^= state << 13;
637
+ state ^= state >>> 17;
638
+ state ^= state << 5;
639
+ return (state >>> 0) / 0x1_0000_0000;
640
+ };
641
+ }
642
+ function bootstrapReviewerRate(rows, seed, samples = 4000) {
643
+ if (!rows.length)
644
+ return null;
645
+ const random = seededRandom(seed);
646
+ const rates = [];
647
+ for (let sample = 0; sample < samples; sample++) {
648
+ let accepted = 0;
649
+ let duration = 0;
650
+ for (let index = 0; index < rows.length; index++) {
651
+ const row = rows[Math.floor(random() * rows.length)];
652
+ if (row.precise && row.decision.startsWith("accepted"))
653
+ accepted++;
654
+ duration += row.duration_ms;
655
+ }
656
+ if (duration)
657
+ rates.push(accepted / (duration / 3_600_000));
658
+ }
659
+ if (!rates.length)
660
+ return null;
661
+ rates.sort((a, b) => a - b);
662
+ return [rates[Math.floor(0.025 * (rates.length - 1))], rates[Math.floor(0.975 * (rates.length - 1))]];
663
+ }
664
+ function logCombination(n, k) {
665
+ if (k < 0 || k > n)
666
+ return Number.NEGATIVE_INFINITY;
667
+ let value = 0;
668
+ for (let index = 1; index <= k; index++)
669
+ value += Math.log(n - k + index) - Math.log(index);
670
+ return value;
671
+ }
672
+ function fisherTwoSided(a, b, c, d) {
673
+ const total = a + b + c + d;
674
+ if (!total)
675
+ return null;
676
+ const rowOne = a + b;
677
+ const columnOne = a + c;
678
+ const lower = Math.max(0, rowOne - (total - columnOne));
679
+ const upper = Math.min(rowOne, columnOne);
680
+ const probability = (x) => Math.exp(logCombination(columnOne, x) + logCombination(total - columnOne, rowOne - x) - logCombination(total, rowOne));
681
+ const observed = probability(a);
682
+ let p = 0;
683
+ for (let x = lower; x <= upper; x++) {
684
+ const candidate = probability(x);
685
+ if (candidate <= observed + 1e-12)
686
+ p += candidate;
687
+ }
688
+ return Math.min(1, p);
689
+ }
690
+ export function buildExperimentReport(run, bank, outcomes, followups, stops = []) {
691
+ if (run.case_bank_id !== bank.id || run.case_bank_hash !== bank.content_hash)
692
+ throw new Error("experiment report requires the exact run-bound case bank");
693
+ const currentOutcomes = currentExperimentOutcomes(outcomes).filter((item) => item.run_id === run.id);
694
+ const currentFollowups = currentExperimentFollowups(followups).filter((item) => item.run_id === run.id);
695
+ const runStops = stops.filter((item) => ExperimentStopSchema.parse(item).run_id === run.id).sort((a, b) => a.id.localeCompare(b.id));
696
+ const outcomeByAssignment = new Map(currentOutcomes.map((item) => [item.assignment_id, item]));
697
+ const validFollowups = currentFollowups.filter((item) => outcomeByAssignment.get(item.assignment_id)?.id === item.outcome_id);
698
+ const followupByAssignment = new Map(validFollowups.map((item) => [item.assignment_id, item]));
699
+ const armIds = [...new Set(run.assignments.map((item) => item.arm))].sort();
700
+ const arms = armIds.map((arm) => {
701
+ const assigned = run.assignments.filter((item) => item.arm === arm);
702
+ const terminal = assigned.flatMap((item) => outcomeByAssignment.get(item.id) ?? []);
703
+ const completed = terminal.filter((item) => item.status === "completed");
704
+ const prevention = completed.flatMap((item) => item.metrics && "valid_completion" in item.metrics ? [item.metrics] : []);
705
+ const reviews = completed.flatMap((item) => item.metrics && "decision" in item.metrics ? [item.metrics] : []);
706
+ const valid = prevention.filter((item) => item.valid_completion);
707
+ const violations = valid.filter((item) => item.policy_violation === true).length;
708
+ const accepted = reviews.filter((item) => item.precise && item.decision.startsWith("accepted")).length;
709
+ const reviewerHours = reviews.reduce((sum, item) => sum + item.duration_ms, 0) / 3_600_000;
710
+ const measuredFollowups = assigned.flatMap((item) => followupByAssignment.get(item.id) ?? []);
711
+ return {
712
+ arm,
713
+ assigned: assigned.length,
714
+ terminal: terminal.length,
715
+ infrastructure_failures: terminal.filter((item) => item.status === "infrastructure_failure").length,
716
+ invalid_or_refused: terminal.filter((item) => item.status === "invalid_completion" || item.status === "refused").length,
717
+ valid_completions: run.experiment === "EXP-01" ? valid.length : completed.length,
718
+ violations: run.experiment === "EXP-01" ? violations : null,
719
+ violation_rate: run.experiment === "EXP-01" && valid.length ? violations / valid.length : null,
720
+ wilson_95: run.experiment === "EXP-01" ? wilson(violations, valid.length) : null,
721
+ accepted_precise: run.experiment === "EXP-03" ? accepted : null,
722
+ reviewer_hours: run.experiment === "EXP-03" ? reviewerHours : null,
723
+ accepted_per_reviewer_hour: run.experiment === "EXP-03" && reviewerHours ? accepted / reviewerHours : null,
724
+ bootstrap_95: run.experiment === "EXP-03" ? bootstrapReviewerRate(reviews, `${run.seed}:${arm}:reviewer-rate`) : null,
725
+ reversals: run.experiment === "EXP-03" ? measuredFollowups.filter((item) => item.reversed === true).length : null,
726
+ followups_missing: run.experiment === "EXP-03" ? completed.length - measuredFollowups.length : null,
727
+ decisions: run.experiment === "EXP-03"
728
+ ? Object.fromEntries(EXP03_DECISION_TOKENS.map((token) => [token, reviews.filter((item) => item.decision === token).length]))
729
+ : null,
730
+ };
731
+ });
732
+ const caseById = new Map(bank.cases.map((item) => [item.id, item]));
733
+ const stratumKeys = [...new Set(bank.cases.flatMap((item) => Object.keys(item.strata)))].sort();
734
+ const strata = [];
735
+ for (const key of stratumKeys) {
736
+ const values = [...new Set(bank.cases.map((item) => item.strata[key]).filter((value) => !!value))].sort();
737
+ for (const value of values) {
738
+ for (const arm of armIds) {
739
+ const assigned = run.assignments.filter((assignment) => assignment.arm === arm && caseById.get(assignment.case_id)?.strata[key] === value);
740
+ const terminal = assigned.flatMap((assignment) => outcomeByAssignment.get(assignment.id) ?? []);
741
+ const completed = terminal.filter((outcome) => outcome.status === "completed");
742
+ const prevention = completed.flatMap((outcome) => outcome.metrics && "valid_completion" in outcome.metrics ? [outcome.metrics] : []);
743
+ const reviews = completed.flatMap((outcome) => outcome.metrics && "decision" in outcome.metrics ? [outcome.metrics] : []);
744
+ const valid = prevention.filter((metrics) => metrics.valid_completion);
745
+ strata.push({
746
+ key,
747
+ value,
748
+ arm,
749
+ assigned: assigned.length,
750
+ terminal: terminal.length,
751
+ valid_completions: run.experiment === "EXP-01" ? valid.length : completed.length,
752
+ violations: run.experiment === "EXP-01" ? valid.filter((metrics) => metrics.policy_violation === true).length : null,
753
+ accepted_precise: run.experiment === "EXP-03" ? reviews.filter((metrics) => metrics.precise && metrics.decision.startsWith("accepted")).length : null,
754
+ reviewer_ms: run.experiment === "EXP-03" ? reviews.reduce((sum, metrics) => sum + metrics.duration_ms, 0) : null,
755
+ });
756
+ }
757
+ }
758
+ }
759
+ const terminal = currentOutcomes.length;
760
+ const allTerminal = terminal === run.assignments.length;
761
+ const exp03Completed = currentOutcomes.filter((item) => item.status === "completed").length;
762
+ const followupsComplete = run.experiment !== "EXP-03" || validFollowups.length === exp03Completed;
763
+ const substitutions = currentOutcomes.filter((item) => item.metrics && "silent_semantic_substitution" in item.metrics && item.metrics.silent_semantic_substitution).length;
764
+ const privateLeaks = currentOutcomes.filter((item) => item.incidents.confirmed_private_leak).length;
765
+ const corruption = currentOutcomes.filter((item) => item.incidents.data_loss_or_corruption).length;
766
+ const unsafeEvaluator = currentOutcomes.filter((item) => item.incidents.unsafe_evaluator_behavior).length;
767
+ const guardrailStopped = substitutions + privateLeaks + corruption + unsafeEvaluator > 0 || runStops.length > 0;
768
+ const status = guardrailStopped ? "guardrail_stopped" : !terminal ? "registered" : !allTerminal ? "running" : !followupsComplete ? "awaiting_followup" : "completed";
769
+ const unresolved = run.assignments.filter((item) => !outcomeByAssignment.has(item.id)).map((item) => item.id);
770
+ const byArm = new Map(arms.map((item) => [item.arm, item]));
771
+ const a = byArm.get("A");
772
+ const b = byArm.get("B");
773
+ const c = byArm.get("C");
774
+ const contrasts = run.experiment === "EXP-01" ? {
775
+ "C_vs_A_risk_difference": c?.violation_rate != null && a?.violation_rate != null ? c.violation_rate - a.violation_rate : null,
776
+ "C_vs_A_relative_risk": c?.violation_rate != null && a?.violation_rate ? c.violation_rate / a.violation_rate : null,
777
+ "B_vs_A_risk_difference": b?.violation_rate != null && a?.violation_rate != null ? b.violation_rate - a.violation_rate : null,
778
+ "C_vs_B_risk_difference": c?.violation_rate != null && b?.violation_rate != null ? c.violation_rate - b.violation_rate : null,
779
+ "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,
780
+ "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,
781
+ "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,
782
+ } : {
783
+ "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,
784
+ "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,
785
+ "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,
786
+ "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,
787
+ };
788
+ const deviations = [
789
+ ...currentOutcomes.filter((item) => item.supersedes).map((item) => `corrected outcome ${item.id} supersedes ${item.supersedes}`),
790
+ ...currentFollowups.filter((item) => item.supersedes).map((item) => `corrected follow-up ${item.id} supersedes ${item.supersedes}`),
791
+ ...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`),
792
+ ];
793
+ const body = {
794
+ run_id: run.id,
795
+ preregistration_hash: run.preregistration_hash,
796
+ case_bank_hash: run.case_bank_hash,
797
+ experiment: run.experiment,
798
+ analysis: { ...EXPERIMENT_ANALYSIS_SPEC, deterministic_hash: EXPERIMENT_ANALYSIS_HASH },
799
+ status,
800
+ arms,
801
+ strata,
802
+ assignments: run.assignments.length,
803
+ terminal,
804
+ unresolved,
805
+ contrasts,
806
+ deviations,
807
+ guardrails: { confirmed_private_leaks: privateLeaks, silent_semantic_substitutions: substitutions, data_loss_or_corruption: corruption, unsafe_evaluator_behavior: unsafeEvaluator },
808
+ stop_receipts: runStops,
809
+ claim_allowed: false,
810
+ authority: "none",
811
+ };
812
+ const contentHash = canonicalHash(body);
813
+ return { id: `expreport_${shortHash(contentHash)}`, content_hash: contentHash, ...body };
814
+ }
815
+ /** Private-only, content-addressed experiment execution ledger. */
816
+ export class ExperimentRepository {
817
+ store;
818
+ constructor(store) {
819
+ this.store = store;
820
+ }
821
+ requirePrivate() {
822
+ if (!this.store.privateDir)
823
+ throw new Error("experiment execution requires a configured private overlay");
824
+ return this.store.privateDir;
825
+ }
826
+ load(dir, prefix, parse) {
827
+ return loadPrivateRecords(this.store.privateDir ? join(this.store.privateDir, dir) : undefined, prefix, parse, dir);
828
+ }
829
+ put(dir, id, value) {
830
+ const root = join(this.requirePrivate(), dir);
831
+ mkdirSync(root, { recursive: true });
832
+ writeFileAtomic(join(root, `${id}.json`), encode(value));
833
+ }
834
+ listCaseBanks() {
835
+ return this.load("experiment-case-banks", "expbank_", (raw) => {
836
+ const parsed = ExperimentCaseBankSchema.parse(raw);
837
+ if (experimentCaseBankContentHash(parsed) !== parsed.content_hash || parsed.id !== `expbank_${shortHash(parsed.content_hash)}`)
838
+ throw new Error(`experiment case bank ${parsed.id} content hash mismatch`);
839
+ return parsed;
840
+ }).sort((a, b) => a.id.localeCompare(b.id));
841
+ }
842
+ putCaseBank(bank) {
843
+ const parsed = ExperimentCaseBankSchema.parse(bank);
844
+ if (experimentCaseBankContentHash(parsed) !== parsed.content_hash || parsed.id !== `expbank_${shortHash(parsed.content_hash)}`)
845
+ throw new Error(`experiment case bank ${parsed.id} content hash mismatch`);
846
+ if (!this.listCaseBanks().some((item) => item.id === parsed.id))
847
+ this.put("experiment-case-banks", parsed.id, parsed);
848
+ return parsed;
849
+ }
850
+ listRuns() {
851
+ const records = this.load("experiment-runs", "exprun_", (raw) => {
852
+ const parsed = ExperimentRunSchema.parse(raw);
853
+ if (experimentRunContentHash(parsed) !== parsed.content_hash || parsed.id !== `exprun_${shortHash(parsed.content_hash)}`)
854
+ throw new Error(`experiment run ${parsed.id} content hash mismatch`);
855
+ return parsed;
856
+ }).sort((a, b) => a.id.localeCompare(b.id));
857
+ const banks = new Map(this.listCaseBanks().map((item) => [item.id, item]));
858
+ for (const run of records) {
859
+ const bank = banks.get(run.case_bank_id);
860
+ if (!bank || bank.content_hash !== run.case_bank_hash)
861
+ throw new Error(`experiment run ${run.id} is missing its exact case bank`);
862
+ for (const assignment of run.assignments)
863
+ assignmentTreatment(bank, run, assignment);
864
+ }
865
+ return records;
866
+ }
867
+ putRun(run) {
868
+ const parsed = ExperimentRunSchema.parse(run);
869
+ if (experimentRunContentHash(parsed) !== parsed.content_hash || parsed.id !== `exprun_${shortHash(parsed.content_hash)}`)
870
+ throw new Error(`experiment run ${parsed.id} content hash mismatch`);
871
+ const runs = this.listRuns();
872
+ const incumbent = runs.find((item) => item.preregistration_id === parsed.preregistration_id);
873
+ if (incumbent && incumbent.id !== parsed.id)
874
+ throw new Error(`preregistration ${parsed.preregistration_id} already has immutable run ${incumbent.id}`);
875
+ if (!incumbent)
876
+ this.put("experiment-runs", parsed.id, parsed);
877
+ return parsed;
878
+ }
879
+ listOutcomes() {
880
+ const records = this.load("experiment-outcomes", "expout_", (raw) => {
881
+ const parsed = ExperimentOutcomeSchema.parse(raw);
882
+ if (experimentOutcomeContentHash(parsed) !== parsed.content_hash || parsed.id !== `expout_${shortHash(parsed.content_hash)}`)
883
+ throw new Error(`experiment outcome ${parsed.id} content hash mismatch`);
884
+ return parsed;
885
+ });
886
+ currentExperimentOutcomes(records);
887
+ const runs = new Map(this.listRuns().map((item) => [item.id, item]));
888
+ for (const outcome of records) {
889
+ const run = runs.get(outcome.run_id);
890
+ const assignment = run?.assignments.find((item) => item.id === outcome.assignment_id);
891
+ if (!run || !assignment || outcome.experiment !== run.experiment || outcome.arm !== assignment.arm)
892
+ throw new Error(`experiment outcome ${outcome.id} has no exact run assignment binding`);
893
+ }
894
+ return records.sort((a, b) => a.id.localeCompare(b.id));
895
+ }
896
+ putOutcome(outcome) {
897
+ const parsed = ExperimentOutcomeSchema.parse(outcome);
898
+ if (experimentOutcomeContentHash(parsed) !== parsed.content_hash || parsed.id !== `expout_${shortHash(parsed.content_hash)}`)
899
+ throw new Error(`experiment outcome ${parsed.id} content hash mismatch`);
900
+ const records = this.listOutcomes();
901
+ if (records.some((item) => item.id === parsed.id))
902
+ return parsed;
903
+ const current = currentExperimentOutcomes(records).find((item) => item.run_id === parsed.run_id && item.assignment_id === parsed.assignment_id);
904
+ if (current && parsed.supersedes !== current.id)
905
+ throw new Error(`assignment ${parsed.assignment_id} already has current outcome ${current.id}; corrections must supersede it explicitly`);
906
+ if (!current && parsed.supersedes)
907
+ throw new Error(`outcome ${parsed.id} supersedes no current assignment outcome`);
908
+ currentExperimentOutcomes([...records, parsed]);
909
+ this.put("experiment-outcomes", parsed.id, parsed);
910
+ return parsed;
911
+ }
912
+ listReviewStarts() {
913
+ const records = this.load("experiment-review-starts", "expreview_", (raw) => {
914
+ const parsed = ExperimentReviewStartSchema.parse(raw);
915
+ if (experimentReviewStartContentHash(parsed) !== parsed.content_hash || parsed.id !== `expreview_${shortHash(parsed.content_hash)}`)
916
+ throw new Error(`experiment review start ${parsed.id} content hash mismatch`);
917
+ return parsed;
918
+ }).sort((a, b) => a.id.localeCompare(b.id));
919
+ const runs = new Map(this.listRuns().map((item) => [item.id, item]));
920
+ for (const record of records) {
921
+ const run = runs.get(record.run_id);
922
+ const assignment = run?.assignments.find((item) => item.id === record.assignment_id);
923
+ if (!run || run.experiment !== "EXP-03" || !assignment || assignment.treatment_hash !== record.treatment_hash)
924
+ throw new Error(`experiment review start ${record.id} has no exact EXP-03 assignment binding`);
925
+ }
926
+ return records;
927
+ }
928
+ putReviewStart(record) {
929
+ const parsed = ExperimentReviewStartSchema.parse(record);
930
+ if (experimentReviewStartContentHash(parsed) !== parsed.content_hash || parsed.id !== `expreview_${shortHash(parsed.content_hash)}`)
931
+ throw new Error(`experiment review start ${parsed.id} content hash mismatch`);
932
+ const records = this.listReviewStarts();
933
+ const incumbent = records.find((item) => item.run_id === parsed.run_id && item.assignment_id === parsed.assignment_id);
934
+ if (incumbent) {
935
+ if (incumbent.reviewer !== parsed.reviewer)
936
+ throw new Error(`assignment ${parsed.assignment_id} review already started by ${incumbent.reviewer}`);
937
+ return incumbent;
938
+ }
939
+ const completed = new Set(currentExperimentOutcomes(this.listOutcomes()).map((item) => `${item.run_id}:${item.assignment_id}`));
940
+ const openForReviewer = records.find((item) => item.run_id === parsed.run_id && item.reviewer === parsed.reviewer && !completed.has(`${item.run_id}:${item.assignment_id}`));
941
+ if (openForReviewer)
942
+ throw new Error(`${parsed.reviewer} already has open review ${openForReviewer.id}; complete it before starting another assignment`);
943
+ this.put("experiment-review-starts", parsed.id, parsed);
944
+ return parsed;
945
+ }
946
+ listFollowups() {
947
+ const records = this.load("experiment-followups", "expfollow_", (raw) => {
948
+ const parsed = ExperimentFollowupSchema.parse(raw);
949
+ if (experimentFollowupContentHash(parsed) !== parsed.content_hash || parsed.id !== `expfollow_${shortHash(parsed.content_hash)}`)
950
+ throw new Error(`experiment follow-up ${parsed.id} content hash mismatch`);
951
+ return parsed;
952
+ });
953
+ currentExperimentFollowups(records);
954
+ const runs = new Map(this.listRuns().map((item) => [item.id, item]));
955
+ const outcomes = new Map(this.listOutcomes().map((item) => [item.id, item]));
956
+ for (const record of records) {
957
+ const run = runs.get(record.run_id);
958
+ const outcome = outcomes.get(record.outcome_id);
959
+ if (!run || run.experiment !== "EXP-03" || !outcome || outcome.run_id !== run.id || outcome.assignment_id !== record.assignment_id)
960
+ throw new Error(`experiment follow-up ${record.id} has no exact EXP-03 outcome binding`);
961
+ }
962
+ return records.sort((a, b) => a.id.localeCompare(b.id));
963
+ }
964
+ putFollowup(record) {
965
+ const parsed = ExperimentFollowupSchema.parse(record);
966
+ if (experimentFollowupContentHash(parsed) !== parsed.content_hash || parsed.id !== `expfollow_${shortHash(parsed.content_hash)}`)
967
+ throw new Error(`experiment follow-up ${parsed.id} content hash mismatch`);
968
+ const records = this.listFollowups();
969
+ if (records.some((item) => item.id === parsed.id))
970
+ return parsed;
971
+ const current = currentExperimentFollowups(records).find((item) => item.run_id === parsed.run_id && item.assignment_id === parsed.assignment_id);
972
+ if (current && parsed.supersedes !== current.id)
973
+ throw new Error(`assignment ${parsed.assignment_id} already has current follow-up ${current.id}`);
974
+ if (!current && parsed.supersedes)
975
+ throw new Error(`follow-up ${parsed.id} supersedes no current record`);
976
+ currentExperimentFollowups([...records, parsed]);
977
+ this.put("experiment-followups", parsed.id, parsed);
978
+ return parsed;
979
+ }
980
+ listStops() {
981
+ const records = this.load("experiment-stops", "expstop_", (raw) => {
982
+ const parsed = ExperimentStopSchema.parse(raw);
983
+ if (experimentStopContentHash(parsed) !== parsed.content_hash || parsed.id !== `expstop_${shortHash(parsed.content_hash)}`)
984
+ throw new Error(`experiment stop ${parsed.id} content hash mismatch`);
985
+ return parsed;
986
+ }).sort((a, b) => a.id.localeCompare(b.id));
987
+ const runIds = new Set(this.listRuns().map((item) => item.id));
988
+ for (const record of records)
989
+ if (!runIds.has(record.run_id))
990
+ throw new Error(`experiment stop ${record.id} has no exact run binding`);
991
+ return records;
992
+ }
993
+ putStop(record) {
994
+ const parsed = ExperimentStopSchema.parse(record);
995
+ if (experimentStopContentHash(parsed) !== parsed.content_hash || parsed.id !== `expstop_${shortHash(parsed.content_hash)}`)
996
+ throw new Error(`experiment stop ${parsed.id} content hash mismatch`);
997
+ const incumbent = this.listStops().find((item) => item.run_id === parsed.run_id);
998
+ if (incumbent) {
999
+ if (incumbent.id !== parsed.id)
1000
+ throw new Error(`run ${parsed.run_id} is already irreversibly stopped by ${incumbent.id}`);
1001
+ return incumbent;
1002
+ }
1003
+ this.put("experiment-stops", parsed.id, parsed);
1004
+ return parsed;
1005
+ }
1006
+ }
1007
+ //# sourceMappingURL=experiment.js.map