@dzhechkov/harness-core 0.3.142 → 0.3.144

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.
@@ -0,0 +1,695 @@
1
+ /**
2
+ * `dz epoch-replay` — the executable cold-vs-warm EPOCH RUNNER (feature epoch-replay, scout idea #4).
3
+ *
4
+ * `dz compounding` answers READINESS ("N unique prompt events recorded — a replay can now be RUN").
5
+ * This module answers the RESULT: Epoch-0 (cold, no injected lessons) vs Epoch-1 (warm, the SAME
6
+ * instances plus exactly the lessons the apply leg injected), scored into a three-valued verdict
7
+ * whose positive branch requires two DISJOINT Wilson confidence intervals.
8
+ *
9
+ * ── The honesty boundary (ADR-002) ─────────────────────────────────────────────────────────────
10
+ * This runner ORCHESTRATES and SCORES. It NEVER calls a model. Real mode is a three-stage protocol
11
+ * over files:
12
+ * 1. `buildWorkOrder` — emits the instances + per-arm generation instructions + the
13
+ * PRE-REGISTERED blind A/B assignment (seeded, decided before any plan
14
+ * text exists) + an integrity `digest` over that pre-registered core.
15
+ * 2. `buildJudgePrompts`— renders the blind judge prompts from the filled plans. The judge-facing
16
+ * payload is `{id, prompt}` and NOTHING else.
17
+ * 3. `verifyWorkOrder` + `unblindJudgments` + `scoreEpochReplay` — check the order really is the
18
+ * pre-registered one (digest + seed-derived assignment), un-blind against
19
+ * it (never against a field the judge wrote), and compute the verdict.
20
+ * Every stage is pure and deterministic, so the protocol is testable with zero LLM dependency.
21
+ *
22
+ * ── What "blind" has to mean ───────────────────────────────────────────────────────────────────
23
+ * The first version shipped `warmIsA` INSIDE the judge artifact: the judge could read the answer
24
+ * key, so the blinding was theatre (Codex QE CRITICAL-1). `warmIsA` now exists only in the work
25
+ * order, which `--score` consumes and the judge never sees — and the artifact is byte-identical
26
+ * whichever way the assignment fell.
27
+ *
28
+ * ── The conformance firewall ───────────────────────────────────────────────────────────────────
29
+ * The warm arm's only delta is the lessons the apply leg ALREADY injects for that prompt. Gold
30
+ * answers, judge verdicts, and outcome labels never enter the warm context — feedback flows from
31
+ * SOLVE OUTCOMES ONLY. `buildWorkOrder` therefore reads instances, not results, and there is no
32
+ * code path from an `EpochOutcome` back into a work order.
33
+ *
34
+ * ── `--mock` ───────────────────────────────────────────────────────────────────────────────────
35
+ * A seeded synthetic outcome generator (reusing `mulberry32` — no second RNG in this repo) with a
36
+ * configurable TRUE effect, so the verdict math is exercised at $0 before any real data exists.
37
+ *
38
+ * Everything here is PURE: callers read/write files; this module only computes.
39
+ */
40
+ // One-way dependency: epoch-replay → compounding. `mulberry32` (the repo's only PRNG), the darwin
41
+ // min-n and the single `replayableInstances` definition all live there; importing them keeps this
42
+ // module free of a second RNG and of a second definition of "a replayable pair".
43
+ import { createHash } from 'node:crypto';
44
+ import { mulberry32, MIN_SAMPLES_PER_ARM, replayableInstances, } from './compounding.js';
45
+ export { replayableInstances };
46
+ // ── Wilson score interval (ADR-003) ────────────────────────────────────────────────────────────
47
+ /** 95% two-sided normal quantile. Named so a future 90%/99% run is a parameter, not a fork. */
48
+ export const WILSON_Z = 1.96;
49
+ /** Per-arm minimum. Shared with the darwin FDR discipline already pinned in compounding.ts. */
50
+ export const MIN_INSTANCES = MIN_SAMPLES_PER_ARM;
51
+ /**
52
+ * Floor of DECISIVE pairs for the no-lift branch. Necessary, NOT sufficient: reaching it only makes
53
+ * the non-superiority test eligible — the test itself must still pass (see {@link NO_LIFT_MARGIN}).
54
+ *
55
+ * The first draft FALSIFIED on `warmWins <= coldWins` at this n, which made 6/12 vs 6/12 read as
56
+ * "refuted". That is indefensible: a tie at n=12 is UNDER-POWERED, not evidence of no effect
57
+ * (Codex QE HIGH-3).
58
+ */
59
+ export const FALSIFY_NO_LIFT_MIN_N = 2 * MIN_SAMPLES_PER_ARM;
60
+ /**
61
+ * Pre-registered NON-SUPERIORITY margin, on the LIFT scale (see {@link liftInterval}). "No lift" is
62
+ * claimed only when the UPPER bound of the lift interval sits below this — i.e. the data EXCLUDE
63
+ * any lift worth having, rather than merely failing to show one.
64
+ *
65
+ * Consequence, stated plainly: at this margin the branch needs ~1200 decisive pairs. That is the
66
+ * honest price of an equivalence-style claim, and it is exactly why a 6/6 tie at n=12 is
67
+ * INCONCLUSIVE rather than FALSIFIED.
68
+ */
69
+ export const NO_LIFT_MARGIN = 0.05;
70
+ /** A margin outside this range is REFUSED, never clamped: `--margin 99` must not buy FALSIFIED. */
71
+ export const MARGIN_MIN_EXCLUSIVE = 0;
72
+ export const MARGIN_MAX = 0.5;
73
+ /** True for a margin that may be pre-registered — finite and in `(0, 0.5]`. */
74
+ export function isValidMargin(value) {
75
+ return typeof value === 'number' && Number.isFinite(value) && value > MARGIN_MIN_EXCLUSIVE && value <= MARGIN_MAX;
76
+ }
77
+ /**
78
+ * Wilson score interval for a binomial proportion. Returns `null` — never a fabricated interval —
79
+ * for any input that is not a real (k, n) pair: n <= 0, non-integers, k out of [0, n], non-finite
80
+ * numbers, or a non-finite/non-positive z. A `null` interval can only ever produce INCONCLUSIVE.
81
+ */
82
+ export function wilsonInterval(k, n, z = WILSON_Z) {
83
+ if (!Number.isFinite(k) || !Number.isFinite(n) || !Number.isFinite(z))
84
+ return null;
85
+ if (!Number.isInteger(k) || !Number.isInteger(n))
86
+ return null;
87
+ if (n <= 0 || k < 0 || k > n)
88
+ return null;
89
+ if (z <= 0)
90
+ return null;
91
+ const p = k / n;
92
+ const z2 = z * z;
93
+ const denom = 1 + z2 / n;
94
+ const centre = (p + z2 / (2 * n)) / denom;
95
+ const half = (z / denom) * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n));
96
+ if (!Number.isFinite(centre) || !Number.isFinite(half))
97
+ return null;
98
+ return {
99
+ k,
100
+ n,
101
+ p,
102
+ lower: Math.max(0, Math.min(1, centre - half)),
103
+ upper: Math.max(0, Math.min(1, centre + half)),
104
+ z,
105
+ };
106
+ }
107
+ /**
108
+ * Map the Wilson interval for `p̂ = P(warm wins | decisive)` onto the LIFT scale, `2p − 1`.
109
+ *
110
+ * WHY THE SCALE MATTERS (and why it is not cosmetic): `margin` is stated as "a lift worth having",
111
+ * which is what a reader reasons about, and it kept exactly the meaning it had under the previous
112
+ * (wrong) two-proportion model. On the raw `p̂` scale the equivalent threshold is `0.5 + margin/2`,
113
+ * NOT `0.5 + margin` — reading the margin on the `p̂` scale would silently DOUBLE the strictness of
114
+ * the non-superiority branch, i.e. make FALSIFIED easier. That is the anti-conservative direction,
115
+ * which is precisely the class of error the paired rewrite exists to remove.
116
+ *
117
+ * Worked check (the case that drove the rewrite): 500 warm / 500 cold over 1000 decisive pairs gives
118
+ * `p̂` CI `[0.4691, 0.5309]` → lift CI `[-0.0619, +0.0619]`. Upper `0.0619` exceeds the default
119
+ * margin `0.05`, so it reads INCONCLUSIVE. The discarded two-proportion Newcombe interval put the
120
+ * upper bound at `0.0437` and called the same data FALSIFIED.
121
+ */
122
+ export function liftInterval(pWarm) {
123
+ if (pWarm === null)
124
+ return null;
125
+ const d = 2 * pWarm.p - 1;
126
+ const lower = 2 * pWarm.lower - 1;
127
+ const upper = 2 * pWarm.upper - 1;
128
+ if (!Number.isFinite(d) || !Number.isFinite(lower) || !Number.isFinite(upper))
129
+ return null;
130
+ return { d, lower, upper };
131
+ }
132
+ // ── Stage 1: the work order (ADR-002) ──────────────────────────────────────────────────────────
133
+ export const WORK_ORDER_KIND = 'dz-epoch-replay-work-order';
134
+ /**
135
+ * v3: the integrity `digest` (v2) plus the PRE-REGISTERED `margin` and `corpusFingerprint`, and an
136
+ * unambiguous JSON digest input. An older order cannot be verified under these rules, so it is
137
+ * refused rather than half-trusted.
138
+ */
139
+ export const WORK_ORDER_VERSION = 3;
140
+ function clampInt(value, fallback, min, max) {
141
+ if (typeof value !== 'number' || !Number.isFinite(value))
142
+ return fallback;
143
+ return Math.max(min, Math.min(max, Math.trunc(value)));
144
+ }
145
+ function clampNumber(value, fallback, min, max) {
146
+ if (typeof value !== 'number' || !Number.isFinite(value))
147
+ return fallback;
148
+ return Math.max(min, Math.min(max, value));
149
+ }
150
+ export const DEFAULT_WORD_MIN = 80;
151
+ export const DEFAULT_WORD_MAX = 150;
152
+ /**
153
+ * The digest input is the PRE-REGISTERED core and nothing else: version, seed, margin, the corpus
154
+ * fingerprint, and every `[id, warmIsA]` pair in order. Deliberately EXCLUDED are the fields the
155
+ * protocol requires a human to fill after emit — `coldPlan`, `warmPlan` and `class` — so filling
156
+ * them keeps the order verifiable. (Limitation stated in ADR-002: `class` is NOT covered.)
157
+ *
158
+ * Serialization is `JSON.stringify` over an array of TUPLES, not a delimiter-joined string. The
159
+ * first version used `${id}:${flag}` joined by `,`, so an id containing `:` or `,` could make two
160
+ * DIFFERENT orders hash identically — e.g. `[['a:1,b', false]]` and `[['a', true], ['b', false]]`
161
+ * both flattened to `a:1,b:0` (Codex QE MED-D). JSON escaping removes the ambiguity.
162
+ */
163
+ function workOrderDigestInput(core) {
164
+ return JSON.stringify({
165
+ v: core.version,
166
+ seed: core.seed,
167
+ margin: core.margin,
168
+ corpus: core.corpusFingerprint,
169
+ items: core.items.map((i) => [i.id, i.warmIsA]),
170
+ });
171
+ }
172
+ /** sha256 over the ordered instance identity — lets a reviewer see two orders share a corpus. */
173
+ export function corpusFingerprint(instances) {
174
+ return createHash('sha256')
175
+ .update(JSON.stringify(instances.map((i) => [i.id, i.query])))
176
+ .digest('hex');
177
+ }
178
+ /**
179
+ * sha256 over {@link workOrderDigestInput}. Pure computation — no IO, no key material.
180
+ *
181
+ * HONEST SCOPE — read this before describing what it proves. This is an integrity check against
182
+ * ACCIDENTAL corruption and mismatch; it is NOT a cryptographic commitment. The digest is
183
+ * self-contained, so a determined operator can re-forge it (at n=12 a seed search finds a matching
184
+ * assignment in a few thousand tries). The threat model is US making mistakes — a hand-edited file,
185
+ * a stale order paired with fresh judgments — exactly the corruption-detection scoping the
186
+ * hash-chain backlog idea already carries. The honest-use contract is procedural: emit once, then
187
+ * judge, and keep the emitted file.
188
+ */
189
+ export function workOrderDigest(order) {
190
+ return createHash('sha256')
191
+ .update(workOrderDigestInput({
192
+ version: order.version,
193
+ seed: order.seed,
194
+ margin: order.margin,
195
+ corpusFingerprint: order.corpusFingerprint,
196
+ items: order.items,
197
+ }))
198
+ .digest('hex');
199
+ }
200
+ /**
201
+ * The one sentence that states what the digest is and is not. Held in a constant so the CLI error
202
+ * text, the module documentation and the honest-scope regression test all read the SAME words —
203
+ * this promise must not quietly regrow into "commitment" language (Codex QE HIGH-C).
204
+ */
205
+ export const DIGEST_HONEST_SCOPE = 'integrity check against accidental corruption/mismatch — not a cryptographic commitment; ' +
206
+ 'a determined operator can re-forge it, and the honest-use contract is emit-once-then-judge';
207
+ /**
208
+ * Integrity-check a work order before ANY verdict may depend on it (Codex QE HIGH-2).
209
+ *
210
+ * Checking `kind` and `Array.isArray(items)` was vacuous: a hand-written file with the right two
211
+ * fields and an invented `warmIsA` un-blinded into whatever verdict its author wanted. Four checks
212
+ * now have to agree:
213
+ * 1. the `digest` recomputes over (version, seed, margin, corpus fingerprint, `[id, warmIsA]`…);
214
+ * 2. every `warmIsA` is REDERIVABLE from the stated `seed` — the same `mulberry32` stream that
215
+ * emitted it;
216
+ * 3. the pre-registered `margin` is in range;
217
+ * 4. structural sanity — unique non-empty ids, boolean assignments, integer seed.
218
+ *
219
+ * WHAT THIS IS NOT: see {@link DIGEST_HONEST_SCOPE}. Re-deriving from the seed raises the bar from
220
+ * "edit one field" to "search for a seed", which at n=12 is a few thousand tries — a deterrent
221
+ * against slips, not a defence against intent. Nothing here is a cryptographic commitment, and no
222
+ * amount of hashing inside the file itself could make it one.
223
+ */
224
+ export function verifyWorkOrder(value) {
225
+ const problems = [];
226
+ if (typeof value !== 'object' || value === null)
227
+ return { ok: false, problems: ['not an object'] };
228
+ const o = value;
229
+ if (o.kind !== WORK_ORDER_KIND)
230
+ problems.push(`kind is not ${WORK_ORDER_KIND}`);
231
+ if (o.version !== WORK_ORDER_VERSION)
232
+ problems.push(`version is not ${WORK_ORDER_VERSION} (older orders cannot be verified under these rules)`);
233
+ if (!Number.isInteger(o.seed) || o.seed < 0)
234
+ problems.push('seed is not a non-negative integer');
235
+ if (!isValidMargin(o.margin)) {
236
+ problems.push(`pre-registered margin ${JSON.stringify(o.margin)} is not a number in (0, ${MARGIN_MAX}]`);
237
+ }
238
+ if (typeof o.corpusFingerprint !== 'string' || o.corpusFingerprint === '')
239
+ problems.push('missing corpusFingerprint');
240
+ if (!Array.isArray(o.items)) {
241
+ problems.push('items is not an array');
242
+ return { ok: false, problems };
243
+ }
244
+ const items = o.items;
245
+ const seen = new Set();
246
+ const core = [];
247
+ for (let i = 0; i < items.length; i++) {
248
+ const it = (typeof items[i] === 'object' && items[i] !== null ? items[i] : {});
249
+ const id = typeof it.id === 'string' ? it.id : '';
250
+ if (id.trim() === '')
251
+ problems.push(`item ${i}: missing id`);
252
+ else if (seen.has(id))
253
+ problems.push(`item ${i}: duplicate id ${JSON.stringify(id)}`);
254
+ seen.add(id);
255
+ if (typeof it.warmIsA !== 'boolean')
256
+ problems.push(`item ${i}: warmIsA is not a boolean`);
257
+ core.push({ id, warmIsA: it.warmIsA === true });
258
+ }
259
+ if (problems.length > 0)
260
+ return { ok: false, problems };
261
+ const expected = workOrderDigest({
262
+ seed: o.seed,
263
+ version: o.version,
264
+ margin: o.margin,
265
+ corpusFingerprint: o.corpusFingerprint,
266
+ items: core,
267
+ });
268
+ if (typeof o.digest !== 'string' || o.digest !== expected) {
269
+ problems.push(`digest mismatch — the pre-registered core (seed / margin / corpus / ids / assignments) was altered after emit. ` +
270
+ `NOTE: ${DIGEST_HONEST_SCOPE}`);
271
+ }
272
+ // Re-derive the assignment from the stated seed: the same one call per item, in item order.
273
+ const rand = mulberry32(o.seed);
274
+ for (let i = 0; i < core.length; i++) {
275
+ const derived = rand() < 0.5;
276
+ if (derived !== core[i].warmIsA) {
277
+ problems.push(`item ${i} (${core[i].id}): warmIsA does not derive from seed ${o.seed} — the assignment was not pre-registered`);
278
+ break; // one is enough; listing all would just be noise
279
+ }
280
+ }
281
+ return { ok: problems.length === 0, problems };
282
+ }
283
+ /**
284
+ * Emit the generation work order. Deterministic in (instances, seed): the same corpus and seed
285
+ * produce the same blind assignment, which is what makes "pre-registered" checkable after the fact.
286
+ */
287
+ export function buildWorkOrder(instances, options = {}) {
288
+ // A margin is a PRE-REGISTRATION, so a bad one is an error, not something to quietly round into
289
+ // range: `margin: 99` clamped to 0.5 would silently pre-register a bar nothing can fail.
290
+ if (options.margin !== undefined && !isValidMargin(options.margin)) {
291
+ throw new RangeError(`margin ${JSON.stringify(options.margin)} must be a number in (${MARGIN_MIN_EXCLUSIVE}, ${MARGIN_MAX}]`);
292
+ }
293
+ const margin = options.margin ?? NO_LIFT_MARGIN;
294
+ const seed = clampInt(options.seed, 20260729, 0, 2 ** 31 - 1);
295
+ const wordMin = clampInt(options.wordMin, DEFAULT_WORD_MIN, 10, 5000);
296
+ const wordMax = Math.max(wordMin, clampInt(options.wordMax, DEFAULT_WORD_MAX, 10, 5000));
297
+ const limit = clampInt(options.limit, 0, 0, 100000);
298
+ const picked = limit > 0 ? instances.slice(0, limit) : instances.slice();
299
+ const rand = mulberry32(seed);
300
+ const items = picked.map((inst) => {
301
+ const warmIsA = rand() < 0.5;
302
+ const shared = `Answer the user prompt below as a first-response PLAN of ${wordMin}-${wordMax} words. ` +
303
+ 'Plain prose, no headings, no lists. Say what you would DO first and why.';
304
+ return {
305
+ id: inst.id,
306
+ query: inst.query,
307
+ class: inst.class,
308
+ warmIsA,
309
+ cold: { instruction: shared, lessons: [] },
310
+ warm: {
311
+ instruction: `${shared} The following learned lessons were surfaced for this prompt; use them if relevant.`,
312
+ lessons: inst.lessons.slice(),
313
+ },
314
+ };
315
+ });
316
+ const emittedAt = typeof options.nowTs === 'string' ? options.nowTs : new Date().toISOString();
317
+ const fingerprint = corpusFingerprint(picked.map((i) => ({ id: i.id, query: i.query })));
318
+ return {
319
+ kind: WORK_ORDER_KIND,
320
+ version: WORK_ORDER_VERSION,
321
+ seed,
322
+ generatedAt: emittedAt,
323
+ emittedAt,
324
+ margin,
325
+ corpusFingerprint: fingerprint,
326
+ wordMin,
327
+ wordMax,
328
+ protocol: [
329
+ 'PRE-REGISTERED before any plan text exists. Do not edit `warmIsA`, `seed`, `margin`, or `id`.',
330
+ 'COLD arm = the prompt alone. WARM arm = the same prompt plus exactly the lessons the apply leg injected.',
331
+ 'CONFORMANCE FIREWALL: never place a gold answer, a judge verdict, or an outcome label in either arm.',
332
+ `Fill coldPlan/warmPlan for each item (${wordMin}-${wordMax} words, symmetric length).`,
333
+ 'Assign `class` NOW if you intend to slice — a class chosen after outcomes are known is not a pre-registration.',
334
+ 'Then: `dz epoch-replay --judge <this file>` → judge each prompt with an EXTERNAL model → `dz epoch-replay --score <judgments> --work-order <this file>`.',
335
+ `The non-superiority margin (${margin}) is PRE-REGISTERED HERE. --score reads it from this file and rejects a --margin flag.`,
336
+ 'PRIVACY: this file contains raw local prompt texts. Keep it out of version control.',
337
+ `INTEGRITY: \`digest\` covers version + seed + margin + corpus + every [id, warmIsA]. Editing any of them makes --judge/--score refuse. It is an ${DIGEST_HONEST_SCOPE}.`,
338
+ ],
339
+ items,
340
+ digest: workOrderDigest({ seed, version: WORK_ORDER_VERSION, margin, corpusFingerprint: fingerprint, items }),
341
+ };
342
+ }
343
+ /**
344
+ * Render blind A/B judge prompts from a FILLED work order. An item missing either plan is skipped
345
+ * with a reason: half a pair is not a comparison, and substituting an empty string would hand the
346
+ * judge a rigged contest.
347
+ */
348
+ export function buildJudgePrompts(order) {
349
+ const prompts = [];
350
+ const skipped = [];
351
+ for (const item of order.items ?? []) {
352
+ const cold = typeof item.coldPlan === 'string' ? item.coldPlan.trim() : '';
353
+ const warm = typeof item.warmPlan === 'string' ? item.warmPlan.trim() : '';
354
+ if (cold === '' || warm === '') {
355
+ skipped.push({
356
+ id: item.id,
357
+ reason: cold === '' && warm === '' ? 'both plans missing' : cold === '' ? 'coldPlan missing' : 'warmPlan missing',
358
+ });
359
+ continue;
360
+ }
361
+ const a = item.warmIsA ? warm : cold;
362
+ const b = item.warmIsA ? cold : warm;
363
+ prompts.push({
364
+ id: item.id,
365
+ prompt: [
366
+ 'You are a blind judge. An assistant received this user prompt:',
367
+ `PROMPT: ${item.query}`,
368
+ '',
369
+ 'Two candidate first-response plans. Judge which better serves the user: concreteness,',
370
+ 'correct first actions, avoiding known failure modes. Ignore verbosity and style.',
371
+ '',
372
+ `PLAN A: ${a}`,
373
+ '',
374
+ `PLAN B: ${b}`,
375
+ '',
376
+ 'Reply with EXACTLY 3 lines:',
377
+ 'WINNER: A|B|TIE',
378
+ 'DECISIVE: <the single concrete element that made the winner better, one line>',
379
+ 'CONFIDENCE: high|medium|low',
380
+ ].join('\n'),
381
+ });
382
+ }
383
+ return { prompts, skipped };
384
+ }
385
+ /**
386
+ * Map blind judgments back to arms using the work order's PRE-REGISTERED `warmIsA`. The judgments
387
+ * file deliberately carries no arm labels: if un-blinding read a label the judge (or a later hand
388
+ * edit) supplied, the blinding would be decorative.
389
+ *
390
+ * Unknown ids and unparseable winners are SKIPPED with a reason, never guessed. DUPLICATE ids are
391
+ * different: they are a CORRUPT input, not a skippable row, so the whole call is REFUSED. Skipping
392
+ * the second copy silently accepted a file in which one judgment had been pasted five times — which
393
+ * scored as n=5 and reached SUPPORTED off a single opinion (Codex QE MED-4).
394
+ */
395
+ export function unblindJudgments(order, judgments) {
396
+ const byId = new Map();
397
+ for (const item of order.items ?? [])
398
+ byId.set(item.id, item);
399
+ const outcomes = [];
400
+ const skipped = [];
401
+ const seen = new Set();
402
+ // Refuse BEFORE interpreting anything: a duplicated id makes the whole file untrustworthy.
403
+ const dupes = new Set();
404
+ const walked = new Set();
405
+ for (const j of judgments ?? []) {
406
+ const id = typeof j?.id === 'string' ? j.id : '';
407
+ if (id === '')
408
+ continue;
409
+ if (walked.has(id))
410
+ dupes.add(id);
411
+ walked.add(id);
412
+ }
413
+ if (dupes.size > 0) {
414
+ return {
415
+ ok: false,
416
+ error: `corrupt judgments: duplicate id(s) ${[...dupes].sort().map((d) => JSON.stringify(d)).join(', ')} — one instance may be judged exactly once`,
417
+ outcomes: [],
418
+ skipped: [],
419
+ };
420
+ }
421
+ for (const j of judgments ?? []) {
422
+ const id = typeof j?.id === 'string' ? j.id : '';
423
+ if (id === '') {
424
+ skipped.push({ id: String(j?.id ?? ''), reason: 'missing id' });
425
+ continue;
426
+ }
427
+ const item = byId.get(id);
428
+ if (!item) {
429
+ skipped.push({ id, reason: 'id not in the work order (cannot un-blind)' });
430
+ continue;
431
+ }
432
+ const raw = typeof j.winner === 'string' ? j.winner.trim().toUpperCase() : '';
433
+ let winner;
434
+ if (raw === 'TIE')
435
+ winner = 'tie';
436
+ else if (raw === 'A')
437
+ winner = item.warmIsA ? 'warm' : 'cold';
438
+ else if (raw === 'B')
439
+ winner = item.warmIsA ? 'cold' : 'warm';
440
+ else {
441
+ skipped.push({ id, reason: `unparseable winner ${JSON.stringify(j.winner)} (expected A|B|TIE)` });
442
+ continue;
443
+ }
444
+ seen.add(id);
445
+ outcomes.push({ id, class: item.class ?? null, winner });
446
+ }
447
+ return { ok: true, error: null, outcomes, skipped };
448
+ }
449
+ /**
450
+ * The three-valued verdict (ADR-003, as amended).
451
+ *
452
+ * THE MODEL — a SINGLE binomial over DECISIVE pairs. Each instance yields ONE judgment about ONE
453
+ * prompt, so the arms are PAIRED, not two independent samples. Let `W` = warm wins, `C` = cold
454
+ * wins, `D = W + C` (ties are excluded from the test and reported separately). The statistic is
455
+ * `p̂ = W/D` with a Wilson interval, mapped to the lift scale by {@link liftInterval}:
456
+ *
457
+ * SUPPORTED lift lower bound > 0 (equivalently: Wilson lower on p̂ > 0.5)
458
+ * FALSIFIED harm — lift upper bound < 0 (Wilson upper on p̂ < 0.5); OR non-superiority —
459
+ * lift upper bound < `margin` with `D >= FALSIFY_NO_LIFT_MIN_N`
460
+ * INCONCLUSIVE everything else, including `D < MIN_INSTANCES`. A first-class honest outcome.
461
+ *
462
+ * This is exactly the statistic the manual 2026-07-29 experiment used, and it is the correction the
463
+ * re-QE demanded: the previous two-proportion (Newcombe) framing treated the paired judgments as
464
+ * independent samples and was ANTI-CONSERVATIVE — 500/500 over 1000 decisive pairs produced an
465
+ * upper bound of 0.0437 and a FALSIFIED verdict where the paired form gives 0.0619 and INCONCLUSIVE
466
+ * (Codex QE HIGH-A).
467
+ *
468
+ * REFUSALS (verdict is a placeholder, `refusal` is set): duplicate instance ids, an invalid `z`, or
469
+ * an out-of-range `margin`.
470
+ */
471
+ export function scoreEpochReplay(outcomes, options = {}) {
472
+ const slice = typeof options.slice === 'string' && options.slice.trim() !== '' ? options.slice.trim() : 'all';
473
+ // HIGH-B: a margin is a PRE-REGISTRATION. Out of range is an error, not something to round into
474
+ // range — clamping `--margin 99` down to a legal value would buy FALSIFIED for free.
475
+ const marginGiven = options.margin !== undefined;
476
+ const marginValid = !marginGiven || isValidMargin(options.margin);
477
+ const margin = marginGiven && marginValid ? options.margin : NO_LIFT_MARGIN;
478
+ // MED-5: `z` used to be CLAMPED into [0.0001, 10]. A caller passing 0 therefore got a near-zero
479
+ // -width interval, which is trivially disjoint from anything — SUPPORTED fabricated out of a bad
480
+ // argument. An invalid z is refused, consistent with `wilsonInterval`'s null discipline.
481
+ const zGiven = options.z !== undefined;
482
+ const zValid = !zGiven || (typeof options.z === 'number' && Number.isFinite(options.z) && options.z > 0 && options.z <= 10);
483
+ const z = zGiven && zValid ? options.z : WILSON_Z;
484
+ const pool = (outcomes ?? []).filter((o) => {
485
+ if (!o || typeof o.id !== 'string')
486
+ return false;
487
+ if (o.winner !== 'cold' && o.winner !== 'warm' && o.winner !== 'tie')
488
+ return false;
489
+ return slice === 'all' ? true : (o.class ?? null) === slice;
490
+ });
491
+ // MED-4: five copies of one judgement are not five observations. Refuse — a duplicated instance
492
+ // id means the input is corrupt, and D must never be inflated by a paste.
493
+ const dupes = new Set();
494
+ const walkedIds = new Set();
495
+ for (const o of pool) {
496
+ if (walkedIds.has(o.id))
497
+ dupes.add(o.id);
498
+ walkedIds.add(o.id);
499
+ }
500
+ const n = pool.length;
501
+ const warmWins = pool.filter((o) => o.winner === 'warm').length;
502
+ const coldWins = pool.filter((o) => o.winner === 'cold').length;
503
+ const ties = pool.filter((o) => o.winner === 'tie').length;
504
+ // HIGH-A: ONE binomial over DECISIVE pairs. Ties carry no directional information, so they are
505
+ // excluded from the denominator (and reported); `p̂ = W/D` is the paired statistic.
506
+ const decisive = warmWins + coldWins;
507
+ const pWarm = wilsonInterval(warmWins, decisive, z);
508
+ const lift = liftInterval(pWarm);
509
+ // Cold's interval is the exact complement of warm's — reported for readability, never a second test.
510
+ const coldCi = wilsonInterval(coldWins, decisive, z);
511
+ const warm = { arm: 'warm', wins: warmWins, n: decisive, ci: pWarm };
512
+ const cold = { arm: 'cold', wins: coldWins, n: decisive, ci: coldCi };
513
+ const base = {
514
+ slice,
515
+ n,
516
+ ties,
517
+ decisive,
518
+ cold,
519
+ warm,
520
+ lift,
521
+ z,
522
+ minN: MIN_INSTANCES,
523
+ falsifyNoLiftMinN: FALSIFY_NO_LIFT_MIN_N,
524
+ noLiftMargin: margin,
525
+ };
526
+ // ── refusals: a corrupt input yields NO measurement ──
527
+ if (!zValid) {
528
+ return {
529
+ ...base,
530
+ z: WILSON_Z,
531
+ verdict: 'INCONCLUSIVE',
532
+ refusal: `invalid z ${JSON.stringify(options.z)} — must be a finite number in (0, 10]; refused rather than clamped, because a tiny z manufactures a significant interval`,
533
+ reason: 'REFUSED: invalid confidence parameter — no verdict was computed',
534
+ };
535
+ }
536
+ if (!marginValid) {
537
+ return {
538
+ ...base,
539
+ noLiftMargin: NO_LIFT_MARGIN,
540
+ verdict: 'INCONCLUSIVE',
541
+ refusal: `invalid margin ${JSON.stringify(options.margin)} — must be a number in (${MARGIN_MIN_EXCLUSIVE}, ${MARGIN_MAX}]; refused rather than clamped, because an oversized margin buys FALSIFIED`,
542
+ reason: 'REFUSED: invalid non-superiority margin — no verdict was computed',
543
+ };
544
+ }
545
+ if (dupes.size > 0) {
546
+ return {
547
+ ...base,
548
+ verdict: 'INCONCLUSIVE',
549
+ refusal: `corrupt outcomes: duplicate instance id(s) ${[...dupes].sort().map((d) => JSON.stringify(d)).join(', ')} — one instance counts exactly once`,
550
+ reason: 'REFUSED: duplicate instance ids would inflate the denominator — no verdict was computed',
551
+ };
552
+ }
553
+ if (decisive < MIN_INSTANCES) {
554
+ return {
555
+ ...base,
556
+ verdict: 'INCONCLUSIVE',
557
+ refusal: null,
558
+ reason: `insufficient data: ${decisive} DECISIVE pair(s) in slice "${slice}"` +
559
+ `${ties > 0 ? ` (${ties} tie(s) carry no direction and are excluded)` : ''}, ` +
560
+ `${MIN_INSTANCES} needed (darwin FDR discipline — n=3 gave a 33% false-discovery rate)`,
561
+ };
562
+ }
563
+ if (pWarm === null || lift === null) {
564
+ return {
565
+ ...base,
566
+ verdict: 'INCONCLUSIVE',
567
+ refusal: null,
568
+ reason: `no valid confidence interval could be computed for slice "${slice}" (degenerate counts)`,
569
+ };
570
+ }
571
+ const shape = `warm ${warmWins}/${decisive} decisive (p=${pWarm.p.toFixed(3)}, CI [${pWarm.lower.toFixed(3)}, ${pWarm.upper.toFixed(3)}]); ` +
572
+ `lift [${lift.lower.toFixed(3)}, ${lift.upper.toFixed(3)}]`;
573
+ // Belt-and-braces: `lift.lower > 0` already implies warm won more, but asserting it makes a
574
+ // SUPPORTED unreachable through a single edited comparison.
575
+ if (lift.lower > 0 && warmWins > coldWins) {
576
+ return {
577
+ ...base,
578
+ verdict: 'SUPPORTED',
579
+ refusal: null,
580
+ reason: `${shape} — the lift interval lies ENTIRELY above zero (warm wins more than half of decisive pairs)`,
581
+ };
582
+ }
583
+ if (lift.upper < 0 && coldWins > warmWins) {
584
+ return {
585
+ ...base,
586
+ verdict: 'FALSIFIED',
587
+ refusal: null,
588
+ reason: `harm: ${shape} — the lift interval lies ENTIRELY below zero (cold wins more than half of decisive pairs)`,
589
+ };
590
+ }
591
+ // NON-SUPERIORITY, not "warm didn't win". The data must EXCLUDE a lift as large as the margin;
592
+ // an equal split at small D excludes nothing (Codex QE HIGH-3).
593
+ if (decisive >= FALSIFY_NO_LIFT_MIN_N && lift.upper < margin) {
594
+ return {
595
+ ...base,
596
+ verdict: 'FALSIFIED',
597
+ refusal: null,
598
+ reason: `no lift: ${shape} — the lift UPPER bound is below the pre-registered margin ${margin} over ${decisive} decisive pair(s), so a lift worth having is excluded`,
599
+ };
600
+ }
601
+ return {
602
+ ...base,
603
+ verdict: 'INCONCLUSIVE',
604
+ refusal: null,
605
+ reason: `${shape} — neither excludes zero nor excludes a ${margin} lift; under-powered, keep the corpus growing`,
606
+ };
607
+ }
608
+ export const DEFAULT_MOCK_N = 12;
609
+ export const DEFAULT_MOCK_SEED = 20260729;
610
+ /**
611
+ * Synthetic judge outcomes from ONE seeded stream (`mulberry32` — the repo's only PRNG). Same
612
+ * (n, effect, tieRate, seed) → byte-identical outcomes, so a `--mock` demo is a reproducer.
613
+ */
614
+ export function generateMockOutcomes(options = {}) {
615
+ const n = clampInt(options.n, DEFAULT_MOCK_N, 0, 100000);
616
+ const effect = clampNumber(options.effect, 0, -1, 1);
617
+ const tieRate = clampNumber(options.tieRate, 0, 0, 1);
618
+ const seed = clampInt(options.seed, DEFAULT_MOCK_SEED, 0, 2 ** 31 - 1);
619
+ const pWarm = Math.max(0, Math.min(1, 0.5 + effect / 2));
620
+ const rand = mulberry32(seed);
621
+ const out = [];
622
+ for (let i = 0; i < n; i++) {
623
+ const tieRoll = rand();
624
+ const armRoll = rand();
625
+ const winner = tieRoll < tieRate ? 'tie' : armRoll < pWarm ? 'warm' : 'cold';
626
+ out.push({ id: `mock-${i}`, class: options.class ?? null, winner });
627
+ }
628
+ return out;
629
+ }
630
+ // ── Rendering ──────────────────────────────────────────────────────────────────────────────────
631
+ function fmtCi(ci) {
632
+ return ci === null ? 'n/a' : `[${ci.lower.toFixed(3)}, ${ci.upper.toFixed(3)}]`;
633
+ }
634
+ export function renderEpochReplayResult(r) {
635
+ const out = [];
636
+ out.push('dz epoch-replay — cold (epoch 0) vs warm (epoch 1), Wilson-CI three-valued verdict');
637
+ out.push('');
638
+ if (r.refusal !== null) {
639
+ out.push(' REFUSED — the input is corrupt, so NO measurement was made:');
640
+ out.push(` ${r.refusal}`);
641
+ return out.join('\n');
642
+ }
643
+ out.push(` SLICE: ${r.slice} · ${r.n} scored instance(s) · ${r.decisive} DECISIVE pair(s)` +
644
+ `${r.ties > 0 ? ` · ${r.ties} tie(s) excluded from the test` : ''}`);
645
+ const ciLabel = r.z === WILSON_Z ? 'CI95' : `CI(z=${r.z})`;
646
+ out.push(` COLD (epoch 0, no injected lessons): ${r.cold.wins}/${r.cold.n} decisive ${ciLabel} ${fmtCi(r.cold.ci)}`);
647
+ out.push(` WARM (epoch 1, apply-leg lessons): ${r.warm.wins}/${r.warm.n} decisive ${ciLabel} ${fmtCi(r.warm.ci)}`);
648
+ out.push(` LIFT (paired, 2p−1 over decisive pairs): ${r.lift === null ? 'n/a' : `${r.lift.d >= 0 ? '+' : ''}${r.lift.d.toFixed(3)} [${r.lift.lower.toFixed(3)}, ${r.lift.upper.toFixed(3)}]`}`);
649
+ out.push('');
650
+ out.push(` VERDICT: ${r.verdict}`);
651
+ out.push(` ${r.reason}`);
652
+ out.push('');
653
+ out.push(` rule: ONE binomial over DECISIVE pairs (ties carry no direction and are excluded).`);
654
+ out.push(` SUPPORTED only when the lift interval lies ENTIRELY above zero.`);
655
+ out.push(` FALSIFIED only on HARM (entirely below zero) or on a passed NON-SUPERIORITY test — the lift UPPER bound below the pre-registered margin ${r.noLiftMargin} at D >= ${r.falsifyNoLiftMinN}.`);
656
+ out.push(` Everything else is INCONCLUSIVE (min ${r.minN} decisive pairs). A tie is UNDER-POWERED, never "refuted".`);
657
+ return out.join('\n');
658
+ }
659
+ export function renderWorkOrderSummary(order, outPath) {
660
+ const withClass = order.items.filter((i) => typeof i.class === 'string' && i.class !== '').length;
661
+ const out = [];
662
+ out.push(`dz epoch-replay --emit → ${outPath}`);
663
+ out.push('');
664
+ out.push(` ${order.items.length} instance(s) · seed ${order.seed} · blind A/B assignment PRE-REGISTERED`);
665
+ out.push(` integrity digest: ${order.digest.slice(0, 16)}… (covers version + seed + every id:warmIsA)`);
666
+ out.push(` slice labels assigned: ${withClass}/${order.items.length}`);
667
+ out.push(` plan length target: ${order.wordMin}-${order.wordMax} words per arm`);
668
+ out.push('');
669
+ out.push(' NEXT (this runner never calls a model):');
670
+ out.push(' 1. Fill coldPlan/warmPlan for every item with an agent, arms generated symmetrically.');
671
+ out.push(` 2. dz epoch-replay --judge ${outPath} → blind judge prompts`);
672
+ out.push(' 3. Have an EXTERNAL (cross-model) judge answer each prompt; collect {id, winner} rows.');
673
+ out.push(` 4. dz epoch-replay --score <judgments.json> --work-order ${outPath}`);
674
+ out.push('');
675
+ out.push(' PRIVACY: the work order embeds raw local prompt texts — keep it out of version control.');
676
+ return out.join('\n');
677
+ }
678
+ export function renderJudgePromptsSummary(result, outPath) {
679
+ const out = [];
680
+ out.push(`dz epoch-replay --judge → ${outPath}`);
681
+ out.push('');
682
+ out.push(` ${result.prompts.length} blind judge prompt(s) rendered — the file carries {id, prompt} and NOTHING else`);
683
+ if (result.skipped.length > 0) {
684
+ out.push(` ${result.skipped.length} item(s) SKIPPED (half a pair is not a comparison):`);
685
+ for (const s of result.skipped)
686
+ out.push(` · ${s.id}: ${s.reason}`);
687
+ out.push(' (this list stays HERE — its reasons name arms, so it is never written to the judge file)');
688
+ }
689
+ out.push('');
690
+ out.push(' Give each prompt to an EXTERNAL judge model (cross-model: not the generator).');
691
+ out.push(' Collect the answers as [{ "id": "<id>", "winner": "A|B|TIE" }, ...] — no arm labels:');
692
+ out.push(' un-blinding uses the work order\'s pre-registered assignment, not anything the judge wrote.');
693
+ return out.join('\n');
694
+ }
695
+ //# sourceMappingURL=epoch-replay.js.map