@dzhechkov/harness-core 0.3.143 → 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.
- package/.dz-manifest.json +36 -12
- package/dist/compounding.d.ts +22 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +29 -10
- package/dist/compounding.js.map +1 -1
- package/dist/epoch-replay.d.ts +399 -0
- package/dist/epoch-replay.d.ts.map +1 -0
- package/dist/epoch-replay.js +695 -0
- package/dist/epoch-replay.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/sbom.json +71 -11
- package/src/compounding.ts +45 -8
- package/src/epoch-replay.ts +955 -0
- package/src/index.ts +52 -0
|
@@ -0,0 +1,955 @@
|
|
|
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
|
+
|
|
41
|
+
// One-way dependency: epoch-replay → compounding. `mulberry32` (the repo's only PRNG), the darwin
|
|
42
|
+
// min-n and the single `replayableInstances` definition all live there; importing them keeps this
|
|
43
|
+
// module free of a second RNG and of a second definition of "a replayable pair".
|
|
44
|
+
import { createHash } from 'node:crypto';
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
mulberry32,
|
|
48
|
+
MIN_SAMPLES_PER_ARM,
|
|
49
|
+
replayableInstances,
|
|
50
|
+
type ReplayInstance,
|
|
51
|
+
} from './compounding.js';
|
|
52
|
+
|
|
53
|
+
export { replayableInstances, type ReplayInstance };
|
|
54
|
+
|
|
55
|
+
// ── Wilson score interval (ADR-003) ────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** 95% two-sided normal quantile. Named so a future 90%/99% run is a parameter, not a fork. */
|
|
58
|
+
export const WILSON_Z = 1.96;
|
|
59
|
+
|
|
60
|
+
/** Per-arm minimum. Shared with the darwin FDR discipline already pinned in compounding.ts. */
|
|
61
|
+
export const MIN_INSTANCES = MIN_SAMPLES_PER_ARM;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Floor of DECISIVE pairs for the no-lift branch. Necessary, NOT sufficient: reaching it only makes
|
|
65
|
+
* the non-superiority test eligible — the test itself must still pass (see {@link NO_LIFT_MARGIN}).
|
|
66
|
+
*
|
|
67
|
+
* The first draft FALSIFIED on `warmWins <= coldWins` at this n, which made 6/12 vs 6/12 read as
|
|
68
|
+
* "refuted". That is indefensible: a tie at n=12 is UNDER-POWERED, not evidence of no effect
|
|
69
|
+
* (Codex QE HIGH-3).
|
|
70
|
+
*/
|
|
71
|
+
export const FALSIFY_NO_LIFT_MIN_N = 2 * MIN_SAMPLES_PER_ARM;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pre-registered NON-SUPERIORITY margin, on the LIFT scale (see {@link liftInterval}). "No lift" is
|
|
75
|
+
* claimed only when the UPPER bound of the lift interval sits below this — i.e. the data EXCLUDE
|
|
76
|
+
* any lift worth having, rather than merely failing to show one.
|
|
77
|
+
*
|
|
78
|
+
* Consequence, stated plainly: at this margin the branch needs ~1200 decisive pairs. That is the
|
|
79
|
+
* honest price of an equivalence-style claim, and it is exactly why a 6/6 tie at n=12 is
|
|
80
|
+
* INCONCLUSIVE rather than FALSIFIED.
|
|
81
|
+
*/
|
|
82
|
+
export const NO_LIFT_MARGIN = 0.05;
|
|
83
|
+
|
|
84
|
+
/** A margin outside this range is REFUSED, never clamped: `--margin 99` must not buy FALSIFIED. */
|
|
85
|
+
export const MARGIN_MIN_EXCLUSIVE = 0;
|
|
86
|
+
export const MARGIN_MAX = 0.5;
|
|
87
|
+
|
|
88
|
+
/** True for a margin that may be pre-registered — finite and in `(0, 0.5]`. */
|
|
89
|
+
export function isValidMargin(value: unknown): value is number {
|
|
90
|
+
return typeof value === 'number' && Number.isFinite(value) && value > MARGIN_MIN_EXCLUSIVE && value <= MARGIN_MAX;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface WilsonInterval {
|
|
94
|
+
readonly k: number;
|
|
95
|
+
readonly n: number;
|
|
96
|
+
/** Point estimate k/n. */
|
|
97
|
+
readonly p: number;
|
|
98
|
+
readonly lower: number;
|
|
99
|
+
readonly upper: number;
|
|
100
|
+
readonly z: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Wilson score interval for a binomial proportion. Returns `null` — never a fabricated interval —
|
|
105
|
+
* for any input that is not a real (k, n) pair: n <= 0, non-integers, k out of [0, n], non-finite
|
|
106
|
+
* numbers, or a non-finite/non-positive z. A `null` interval can only ever produce INCONCLUSIVE.
|
|
107
|
+
*/
|
|
108
|
+
export function wilsonInterval(k: number, n: number, z: number = WILSON_Z): WilsonInterval | null {
|
|
109
|
+
if (!Number.isFinite(k) || !Number.isFinite(n) || !Number.isFinite(z)) return null;
|
|
110
|
+
if (!Number.isInteger(k) || !Number.isInteger(n)) return null;
|
|
111
|
+
if (n <= 0 || k < 0 || k > n) return null;
|
|
112
|
+
if (z <= 0) return null;
|
|
113
|
+
const p = k / n;
|
|
114
|
+
const z2 = z * z;
|
|
115
|
+
const denom = 1 + z2 / n;
|
|
116
|
+
const centre = (p + z2 / (2 * n)) / denom;
|
|
117
|
+
const half = (z / denom) * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n));
|
|
118
|
+
if (!Number.isFinite(centre) || !Number.isFinite(half)) return null;
|
|
119
|
+
return {
|
|
120
|
+
k,
|
|
121
|
+
n,
|
|
122
|
+
p,
|
|
123
|
+
lower: Math.max(0, Math.min(1, centre - half)),
|
|
124
|
+
upper: Math.max(0, Math.min(1, centre + half)),
|
|
125
|
+
z,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** An interval on the LIFT scale: warm's advantage over cold among DECISIVE pairs, in `[-1, +1]`. */
|
|
130
|
+
export interface LiftInterval {
|
|
131
|
+
/** Point estimate `2·p̂ − 1`: `0` = a coin flip, `+1` = warm wins every decisive pair. */
|
|
132
|
+
readonly d: number;
|
|
133
|
+
readonly lower: number;
|
|
134
|
+
readonly upper: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Map the Wilson interval for `p̂ = P(warm wins | decisive)` onto the LIFT scale, `2p − 1`.
|
|
139
|
+
*
|
|
140
|
+
* WHY THE SCALE MATTERS (and why it is not cosmetic): `margin` is stated as "a lift worth having",
|
|
141
|
+
* which is what a reader reasons about, and it kept exactly the meaning it had under the previous
|
|
142
|
+
* (wrong) two-proportion model. On the raw `p̂` scale the equivalent threshold is `0.5 + margin/2`,
|
|
143
|
+
* NOT `0.5 + margin` — reading the margin on the `p̂` scale would silently DOUBLE the strictness of
|
|
144
|
+
* the non-superiority branch, i.e. make FALSIFIED easier. That is the anti-conservative direction,
|
|
145
|
+
* which is precisely the class of error the paired rewrite exists to remove.
|
|
146
|
+
*
|
|
147
|
+
* Worked check (the case that drove the rewrite): 500 warm / 500 cold over 1000 decisive pairs gives
|
|
148
|
+
* `p̂` CI `[0.4691, 0.5309]` → lift CI `[-0.0619, +0.0619]`. Upper `0.0619` exceeds the default
|
|
149
|
+
* margin `0.05`, so it reads INCONCLUSIVE. The discarded two-proportion Newcombe interval put the
|
|
150
|
+
* upper bound at `0.0437` and called the same data FALSIFIED.
|
|
151
|
+
*/
|
|
152
|
+
export function liftInterval(pWarm: WilsonInterval | null): LiftInterval | null {
|
|
153
|
+
if (pWarm === null) return null;
|
|
154
|
+
const d = 2 * pWarm.p - 1;
|
|
155
|
+
const lower = 2 * pWarm.lower - 1;
|
|
156
|
+
const upper = 2 * pWarm.upper - 1;
|
|
157
|
+
if (!Number.isFinite(d) || !Number.isFinite(lower) || !Number.isFinite(upper)) return null;
|
|
158
|
+
return { d, lower, upper };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Stage 1: the work order (ADR-002) ──────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export const WORK_ORDER_KIND = 'dz-epoch-replay-work-order';
|
|
164
|
+
/**
|
|
165
|
+
* v3: the integrity `digest` (v2) plus the PRE-REGISTERED `margin` and `corpusFingerprint`, and an
|
|
166
|
+
* unambiguous JSON digest input. An older order cannot be verified under these rules, so it is
|
|
167
|
+
* refused rather than half-trusted.
|
|
168
|
+
*/
|
|
169
|
+
export const WORK_ORDER_VERSION = 3;
|
|
170
|
+
|
|
171
|
+
export interface WorkOrderItem {
|
|
172
|
+
readonly id: string;
|
|
173
|
+
readonly query: string;
|
|
174
|
+
readonly class: string | null;
|
|
175
|
+
/**
|
|
176
|
+
* PRE-REGISTERED blind assignment: does the WARM plan appear as "PLAN A"? Decided by the seeded
|
|
177
|
+
* PRNG before any plan text exists, and it is the ONLY authority for un-blinding.
|
|
178
|
+
*/
|
|
179
|
+
readonly warmIsA: boolean;
|
|
180
|
+
/** Epoch-0 arm: the prompt with NO injected lessons. */
|
|
181
|
+
readonly cold: { readonly instruction: string; readonly lessons: readonly string[] };
|
|
182
|
+
/** Epoch-1 arm: the SAME prompt plus exactly what the apply leg injected. */
|
|
183
|
+
readonly warm: { readonly instruction: string; readonly lessons: readonly string[] };
|
|
184
|
+
/** Filled by the generating agent — absent in a freshly emitted order. */
|
|
185
|
+
readonly coldPlan?: string;
|
|
186
|
+
readonly warmPlan?: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface WorkOrder {
|
|
190
|
+
readonly kind: typeof WORK_ORDER_KIND;
|
|
191
|
+
readonly version: typeof WORK_ORDER_VERSION;
|
|
192
|
+
readonly seed: number;
|
|
193
|
+
readonly generatedAt: string;
|
|
194
|
+
readonly wordMin: number;
|
|
195
|
+
readonly wordMax: number;
|
|
196
|
+
/** Human-readable pre-registration notes, written BEFORE the run. */
|
|
197
|
+
readonly protocol: readonly string[];
|
|
198
|
+
readonly items: readonly WorkOrderItem[];
|
|
199
|
+
/**
|
|
200
|
+
* The PRE-REGISTERED non-superiority margin, on the lift scale. It lives HERE, not on `--score`:
|
|
201
|
+
* a margin chosen after the counts are known is not a pre-registration, and `--margin 99` at
|
|
202
|
+
* scoring time would simply buy FALSIFIED (Codex QE HIGH-B).
|
|
203
|
+
*/
|
|
204
|
+
readonly margin: number;
|
|
205
|
+
/** sha256 over the ordered `[id, query]` corpus — lets a reviewer recognise the same corpus. */
|
|
206
|
+
readonly corpusFingerprint: string;
|
|
207
|
+
/** When this order was emitted. Recorded so a reviewer can ask for the original file. */
|
|
208
|
+
readonly emittedAt: string;
|
|
209
|
+
/**
|
|
210
|
+
* Integrity digest over the PRE-REGISTERED core (version, seed, margin, corpus fingerprint and
|
|
211
|
+
* every `[id, warmIsA]`). `--judge`/`--score` recompute it and refuse on mismatch: without this,
|
|
212
|
+
* a forged order — the right `kind`, a fabricated assignment — bought a SUPPORTED verdict for an
|
|
213
|
+
* experiment that never happened (Codex QE HIGH-2).
|
|
214
|
+
*
|
|
215
|
+
* NOT a cryptographic commitment — see {@link workOrderDigest} for the honest scope.
|
|
216
|
+
*/
|
|
217
|
+
readonly digest: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface WorkOrderOptions {
|
|
221
|
+
readonly seed?: number;
|
|
222
|
+
readonly nowTs?: string;
|
|
223
|
+
readonly wordMin?: number;
|
|
224
|
+
readonly wordMax?: number;
|
|
225
|
+
/** Cap the number of instances (0/absent = all). */
|
|
226
|
+
readonly limit?: number;
|
|
227
|
+
/**
|
|
228
|
+
* The PRE-REGISTERED non-superiority margin (lift scale), stored in the order and digest-covered.
|
|
229
|
+
* Must be in `(0, 0.5]`; anything else is REFUSED by {@link buildWorkOrder}, never clamped.
|
|
230
|
+
*/
|
|
231
|
+
readonly margin?: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
|
|
235
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
|
236
|
+
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
240
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
|
241
|
+
return Math.max(min, Math.min(max, value));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const DEFAULT_WORD_MIN = 80;
|
|
245
|
+
export const DEFAULT_WORD_MAX = 150;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The digest input is the PRE-REGISTERED core and nothing else: version, seed, margin, the corpus
|
|
249
|
+
* fingerprint, and every `[id, warmIsA]` pair in order. Deliberately EXCLUDED are the fields the
|
|
250
|
+
* protocol requires a human to fill after emit — `coldPlan`, `warmPlan` and `class` — so filling
|
|
251
|
+
* them keeps the order verifiable. (Limitation stated in ADR-002: `class` is NOT covered.)
|
|
252
|
+
*
|
|
253
|
+
* Serialization is `JSON.stringify` over an array of TUPLES, not a delimiter-joined string. The
|
|
254
|
+
* first version used `${id}:${flag}` joined by `,`, so an id containing `:` or `,` could make two
|
|
255
|
+
* DIFFERENT orders hash identically — e.g. `[['a:1,b', false]]` and `[['a', true], ['b', false]]`
|
|
256
|
+
* both flattened to `a:1,b:0` (Codex QE MED-D). JSON escaping removes the ambiguity.
|
|
257
|
+
*/
|
|
258
|
+
function workOrderDigestInput(core: {
|
|
259
|
+
version: number;
|
|
260
|
+
seed: number;
|
|
261
|
+
margin: number;
|
|
262
|
+
corpusFingerprint: string;
|
|
263
|
+
items: readonly { id: string; warmIsA: boolean }[];
|
|
264
|
+
}): string {
|
|
265
|
+
return JSON.stringify({
|
|
266
|
+
v: core.version,
|
|
267
|
+
seed: core.seed,
|
|
268
|
+
margin: core.margin,
|
|
269
|
+
corpus: core.corpusFingerprint,
|
|
270
|
+
items: core.items.map((i) => [i.id, i.warmIsA] as [string, boolean]),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** sha256 over the ordered instance identity — lets a reviewer see two orders share a corpus. */
|
|
275
|
+
export function corpusFingerprint(instances: readonly { id: string; query: string }[]): string {
|
|
276
|
+
return createHash('sha256')
|
|
277
|
+
.update(JSON.stringify(instances.map((i) => [i.id, i.query] as [string, string])))
|
|
278
|
+
.digest('hex');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* sha256 over {@link workOrderDigestInput}. Pure computation — no IO, no key material.
|
|
283
|
+
*
|
|
284
|
+
* HONEST SCOPE — read this before describing what it proves. This is an integrity check against
|
|
285
|
+
* ACCIDENTAL corruption and mismatch; it is NOT a cryptographic commitment. The digest is
|
|
286
|
+
* self-contained, so a determined operator can re-forge it (at n=12 a seed search finds a matching
|
|
287
|
+
* assignment in a few thousand tries). The threat model is US making mistakes — a hand-edited file,
|
|
288
|
+
* a stale order paired with fresh judgments — exactly the corruption-detection scoping the
|
|
289
|
+
* hash-chain backlog idea already carries. The honest-use contract is procedural: emit once, then
|
|
290
|
+
* judge, and keep the emitted file.
|
|
291
|
+
*/
|
|
292
|
+
export function workOrderDigest(order: {
|
|
293
|
+
seed: number;
|
|
294
|
+
version: number;
|
|
295
|
+
margin: number;
|
|
296
|
+
corpusFingerprint: string;
|
|
297
|
+
items: readonly { id: string; warmIsA: boolean }[];
|
|
298
|
+
}): string {
|
|
299
|
+
return createHash('sha256')
|
|
300
|
+
.update(
|
|
301
|
+
workOrderDigestInput({
|
|
302
|
+
version: order.version,
|
|
303
|
+
seed: order.seed,
|
|
304
|
+
margin: order.margin,
|
|
305
|
+
corpusFingerprint: order.corpusFingerprint,
|
|
306
|
+
items: order.items,
|
|
307
|
+
}),
|
|
308
|
+
)
|
|
309
|
+
.digest('hex');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface WorkOrderVerification {
|
|
313
|
+
readonly ok: boolean;
|
|
314
|
+
/** Every problem found, not just the first — a forged order usually trips several. */
|
|
315
|
+
readonly problems: readonly string[];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The one sentence that states what the digest is and is not. Held in a constant so the CLI error
|
|
320
|
+
* text, the module documentation and the honest-scope regression test all read the SAME words —
|
|
321
|
+
* this promise must not quietly regrow into "commitment" language (Codex QE HIGH-C).
|
|
322
|
+
*/
|
|
323
|
+
export const DIGEST_HONEST_SCOPE =
|
|
324
|
+
'integrity check against accidental corruption/mismatch — not a cryptographic commitment; ' +
|
|
325
|
+
'a determined operator can re-forge it, and the honest-use contract is emit-once-then-judge';
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Integrity-check a work order before ANY verdict may depend on it (Codex QE HIGH-2).
|
|
329
|
+
*
|
|
330
|
+
* Checking `kind` and `Array.isArray(items)` was vacuous: a hand-written file with the right two
|
|
331
|
+
* fields and an invented `warmIsA` un-blinded into whatever verdict its author wanted. Four checks
|
|
332
|
+
* now have to agree:
|
|
333
|
+
* 1. the `digest` recomputes over (version, seed, margin, corpus fingerprint, `[id, warmIsA]`…);
|
|
334
|
+
* 2. every `warmIsA` is REDERIVABLE from the stated `seed` — the same `mulberry32` stream that
|
|
335
|
+
* emitted it;
|
|
336
|
+
* 3. the pre-registered `margin` is in range;
|
|
337
|
+
* 4. structural sanity — unique non-empty ids, boolean assignments, integer seed.
|
|
338
|
+
*
|
|
339
|
+
* WHAT THIS IS NOT: see {@link DIGEST_HONEST_SCOPE}. Re-deriving from the seed raises the bar from
|
|
340
|
+
* "edit one field" to "search for a seed", which at n=12 is a few thousand tries — a deterrent
|
|
341
|
+
* against slips, not a defence against intent. Nothing here is a cryptographic commitment, and no
|
|
342
|
+
* amount of hashing inside the file itself could make it one.
|
|
343
|
+
*/
|
|
344
|
+
export function verifyWorkOrder(value: unknown): WorkOrderVerification {
|
|
345
|
+
const problems: string[] = [];
|
|
346
|
+
if (typeof value !== 'object' || value === null) return { ok: false, problems: ['not an object'] };
|
|
347
|
+
const o = value as Record<string, unknown>;
|
|
348
|
+
if (o.kind !== WORK_ORDER_KIND) problems.push(`kind is not ${WORK_ORDER_KIND}`);
|
|
349
|
+
if (o.version !== WORK_ORDER_VERSION) problems.push(`version is not ${WORK_ORDER_VERSION} (older orders cannot be verified under these rules)`);
|
|
350
|
+
if (!Number.isInteger(o.seed) || (o.seed as number) < 0) problems.push('seed is not a non-negative integer');
|
|
351
|
+
if (!isValidMargin(o.margin)) {
|
|
352
|
+
problems.push(`pre-registered margin ${JSON.stringify(o.margin)} is not a number in (0, ${MARGIN_MAX}]`);
|
|
353
|
+
}
|
|
354
|
+
if (typeof o.corpusFingerprint !== 'string' || o.corpusFingerprint === '') problems.push('missing corpusFingerprint');
|
|
355
|
+
if (!Array.isArray(o.items)) {
|
|
356
|
+
problems.push('items is not an array');
|
|
357
|
+
return { ok: false, problems };
|
|
358
|
+
}
|
|
359
|
+
const items = o.items as unknown[];
|
|
360
|
+
const seen = new Set<string>();
|
|
361
|
+
const core: { id: string; warmIsA: boolean }[] = [];
|
|
362
|
+
for (let i = 0; i < items.length; i++) {
|
|
363
|
+
const it = (typeof items[i] === 'object' && items[i] !== null ? items[i] : {}) as Record<string, unknown>;
|
|
364
|
+
const id = typeof it.id === 'string' ? it.id : '';
|
|
365
|
+
if (id.trim() === '') problems.push(`item ${i}: missing id`);
|
|
366
|
+
else if (seen.has(id)) problems.push(`item ${i}: duplicate id ${JSON.stringify(id)}`);
|
|
367
|
+
seen.add(id);
|
|
368
|
+
if (typeof it.warmIsA !== 'boolean') problems.push(`item ${i}: warmIsA is not a boolean`);
|
|
369
|
+
core.push({ id, warmIsA: it.warmIsA === true });
|
|
370
|
+
}
|
|
371
|
+
if (problems.length > 0) return { ok: false, problems };
|
|
372
|
+
|
|
373
|
+
const expected = workOrderDigest({
|
|
374
|
+
seed: o.seed as number,
|
|
375
|
+
version: o.version as number,
|
|
376
|
+
margin: o.margin as number,
|
|
377
|
+
corpusFingerprint: o.corpusFingerprint as string,
|
|
378
|
+
items: core,
|
|
379
|
+
});
|
|
380
|
+
if (typeof o.digest !== 'string' || o.digest !== expected) {
|
|
381
|
+
problems.push(
|
|
382
|
+
`digest mismatch — the pre-registered core (seed / margin / corpus / ids / assignments) was altered after emit. ` +
|
|
383
|
+
`NOTE: ${DIGEST_HONEST_SCOPE}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
// Re-derive the assignment from the stated seed: the same one call per item, in item order.
|
|
387
|
+
const rand = mulberry32(o.seed as number);
|
|
388
|
+
for (let i = 0; i < core.length; i++) {
|
|
389
|
+
const derived = rand() < 0.5;
|
|
390
|
+
if (derived !== core[i]!.warmIsA) {
|
|
391
|
+
problems.push(`item ${i} (${core[i]!.id}): warmIsA does not derive from seed ${o.seed as number} — the assignment was not pre-registered`);
|
|
392
|
+
break; // one is enough; listing all would just be noise
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return { ok: problems.length === 0, problems };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Emit the generation work order. Deterministic in (instances, seed): the same corpus and seed
|
|
400
|
+
* produce the same blind assignment, which is what makes "pre-registered" checkable after the fact.
|
|
401
|
+
*/
|
|
402
|
+
export function buildWorkOrder(
|
|
403
|
+
instances: readonly ReplayInstance[],
|
|
404
|
+
options: WorkOrderOptions = {},
|
|
405
|
+
): WorkOrder {
|
|
406
|
+
// A margin is a PRE-REGISTRATION, so a bad one is an error, not something to quietly round into
|
|
407
|
+
// range: `margin: 99` clamped to 0.5 would silently pre-register a bar nothing can fail.
|
|
408
|
+
if (options.margin !== undefined && !isValidMargin(options.margin)) {
|
|
409
|
+
throw new RangeError(`margin ${JSON.stringify(options.margin)} must be a number in (${MARGIN_MIN_EXCLUSIVE}, ${MARGIN_MAX}]`);
|
|
410
|
+
}
|
|
411
|
+
const margin = options.margin ?? NO_LIFT_MARGIN;
|
|
412
|
+
const seed = clampInt(options.seed, 20260729, 0, 2 ** 31 - 1);
|
|
413
|
+
const wordMin = clampInt(options.wordMin, DEFAULT_WORD_MIN, 10, 5000);
|
|
414
|
+
const wordMax = Math.max(wordMin, clampInt(options.wordMax, DEFAULT_WORD_MAX, 10, 5000));
|
|
415
|
+
const limit = clampInt(options.limit, 0, 0, 100000);
|
|
416
|
+
const picked = limit > 0 ? instances.slice(0, limit) : instances.slice();
|
|
417
|
+
const rand = mulberry32(seed);
|
|
418
|
+
const items: WorkOrderItem[] = picked.map((inst) => {
|
|
419
|
+
const warmIsA = rand() < 0.5;
|
|
420
|
+
const shared =
|
|
421
|
+
`Answer the user prompt below as a first-response PLAN of ${wordMin}-${wordMax} words. ` +
|
|
422
|
+
'Plain prose, no headings, no lists. Say what you would DO first and why.';
|
|
423
|
+
return {
|
|
424
|
+
id: inst.id,
|
|
425
|
+
query: inst.query,
|
|
426
|
+
class: inst.class,
|
|
427
|
+
warmIsA,
|
|
428
|
+
cold: { instruction: shared, lessons: [] },
|
|
429
|
+
warm: {
|
|
430
|
+
instruction: `${shared} The following learned lessons were surfaced for this prompt; use them if relevant.`,
|
|
431
|
+
lessons: inst.lessons.slice(),
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
const emittedAt = typeof options.nowTs === 'string' ? options.nowTs : new Date().toISOString();
|
|
436
|
+
const fingerprint = corpusFingerprint(picked.map((i) => ({ id: i.id, query: i.query })));
|
|
437
|
+
return {
|
|
438
|
+
kind: WORK_ORDER_KIND,
|
|
439
|
+
version: WORK_ORDER_VERSION,
|
|
440
|
+
seed,
|
|
441
|
+
generatedAt: emittedAt,
|
|
442
|
+
emittedAt,
|
|
443
|
+
margin,
|
|
444
|
+
corpusFingerprint: fingerprint,
|
|
445
|
+
wordMin,
|
|
446
|
+
wordMax,
|
|
447
|
+
protocol: [
|
|
448
|
+
'PRE-REGISTERED before any plan text exists. Do not edit `warmIsA`, `seed`, `margin`, or `id`.',
|
|
449
|
+
'COLD arm = the prompt alone. WARM arm = the same prompt plus exactly the lessons the apply leg injected.',
|
|
450
|
+
'CONFORMANCE FIREWALL: never place a gold answer, a judge verdict, or an outcome label in either arm.',
|
|
451
|
+
`Fill coldPlan/warmPlan for each item (${wordMin}-${wordMax} words, symmetric length).`,
|
|
452
|
+
'Assign `class` NOW if you intend to slice — a class chosen after outcomes are known is not a pre-registration.',
|
|
453
|
+
'Then: `dz epoch-replay --judge <this file>` → judge each prompt with an EXTERNAL model → `dz epoch-replay --score <judgments> --work-order <this file>`.',
|
|
454
|
+
`The non-superiority margin (${margin}) is PRE-REGISTERED HERE. --score reads it from this file and rejects a --margin flag.`,
|
|
455
|
+
'PRIVACY: this file contains raw local prompt texts. Keep it out of version control.',
|
|
456
|
+
`INTEGRITY: \`digest\` covers version + seed + margin + corpus + every [id, warmIsA]. Editing any of them makes --judge/--score refuse. It is an ${DIGEST_HONEST_SCOPE}.`,
|
|
457
|
+
],
|
|
458
|
+
items,
|
|
459
|
+
digest: workOrderDigest({ seed, version: WORK_ORDER_VERSION, margin, corpusFingerprint: fingerprint, items }),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ── Stage 2: blind judge prompts ───────────────────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* ONE judge-facing item. These two fields are the WHOLE artifact, by design.
|
|
467
|
+
*
|
|
468
|
+
* The first version also carried `warmIsA` and `class`, which handed the judge the answer key: the
|
|
469
|
+
* blinding was theatre (Codex QE CRITICAL-1). `warmIsA` now lives ONLY in the pre-registered work
|
|
470
|
+
* order, which `--score` consumes and the judge never sees. `class` went too — nothing the judge
|
|
471
|
+
* does not need may travel with the prompt.
|
|
472
|
+
*/
|
|
473
|
+
export interface JudgePrompt {
|
|
474
|
+
readonly id: string;
|
|
475
|
+
readonly prompt: string;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export interface JudgePromptsResult {
|
|
479
|
+
/** The judge-facing payload — `{id, prompt}` only. Nothing else may be written to the judge. */
|
|
480
|
+
readonly prompts: readonly JudgePrompt[];
|
|
481
|
+
/**
|
|
482
|
+
* Items that could NOT be judged, with the reason — never silently dropped. OPERATOR-facing:
|
|
483
|
+
* the reasons name arms ("warmPlan missing"), so this must not be written into the judge file.
|
|
484
|
+
*/
|
|
485
|
+
readonly skipped: readonly { readonly id: string; readonly reason: string }[];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Render blind A/B judge prompts from a FILLED work order. An item missing either plan is skipped
|
|
490
|
+
* with a reason: half a pair is not a comparison, and substituting an empty string would hand the
|
|
491
|
+
* judge a rigged contest.
|
|
492
|
+
*/
|
|
493
|
+
export function buildJudgePrompts(order: WorkOrder): JudgePromptsResult {
|
|
494
|
+
const prompts: JudgePrompt[] = [];
|
|
495
|
+
const skipped: { id: string; reason: string }[] = [];
|
|
496
|
+
for (const item of order.items ?? []) {
|
|
497
|
+
const cold = typeof item.coldPlan === 'string' ? item.coldPlan.trim() : '';
|
|
498
|
+
const warm = typeof item.warmPlan === 'string' ? item.warmPlan.trim() : '';
|
|
499
|
+
if (cold === '' || warm === '') {
|
|
500
|
+
skipped.push({
|
|
501
|
+
id: item.id,
|
|
502
|
+
reason: cold === '' && warm === '' ? 'both plans missing' : cold === '' ? 'coldPlan missing' : 'warmPlan missing',
|
|
503
|
+
});
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const a = item.warmIsA ? warm : cold;
|
|
507
|
+
const b = item.warmIsA ? cold : warm;
|
|
508
|
+
prompts.push({
|
|
509
|
+
id: item.id,
|
|
510
|
+
prompt: [
|
|
511
|
+
'You are a blind judge. An assistant received this user prompt:',
|
|
512
|
+
`PROMPT: ${item.query}`,
|
|
513
|
+
'',
|
|
514
|
+
'Two candidate first-response plans. Judge which better serves the user: concreteness,',
|
|
515
|
+
'correct first actions, avoiding known failure modes. Ignore verbosity and style.',
|
|
516
|
+
'',
|
|
517
|
+
`PLAN A: ${a}`,
|
|
518
|
+
'',
|
|
519
|
+
`PLAN B: ${b}`,
|
|
520
|
+
'',
|
|
521
|
+
'Reply with EXACTLY 3 lines:',
|
|
522
|
+
'WINNER: A|B|TIE',
|
|
523
|
+
'DECISIVE: <the single concrete element that made the winner better, one line>',
|
|
524
|
+
'CONFIDENCE: high|medium|low',
|
|
525
|
+
].join('\n'),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
return { prompts, skipped };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── Stage 3: un-blind + score ──────────────────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
export type Arm = 'cold' | 'warm';
|
|
534
|
+
|
|
535
|
+
export interface EpochOutcome {
|
|
536
|
+
readonly id: string;
|
|
537
|
+
readonly class: string | null;
|
|
538
|
+
/** Which epoch solved the instance better. `tie` counts in the denominator, for neither arm. */
|
|
539
|
+
readonly winner: Arm | 'tie';
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export interface Judgment {
|
|
543
|
+
readonly id: string;
|
|
544
|
+
/** The judge's blind answer: `A`, `B` or `TIE` (case-insensitive). */
|
|
545
|
+
readonly winner: string;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export interface UnblindResult {
|
|
549
|
+
/** False ⇒ the input is CORRUPT and no verdict may be computed from it. */
|
|
550
|
+
readonly ok: boolean;
|
|
551
|
+
/** Populated exactly when `ok` is false. */
|
|
552
|
+
readonly error: string | null;
|
|
553
|
+
readonly outcomes: readonly EpochOutcome[];
|
|
554
|
+
readonly skipped: readonly { readonly id: string; readonly reason: string }[];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Map blind judgments back to arms using the work order's PRE-REGISTERED `warmIsA`. The judgments
|
|
559
|
+
* file deliberately carries no arm labels: if un-blinding read a label the judge (or a later hand
|
|
560
|
+
* edit) supplied, the blinding would be decorative.
|
|
561
|
+
*
|
|
562
|
+
* Unknown ids and unparseable winners are SKIPPED with a reason, never guessed. DUPLICATE ids are
|
|
563
|
+
* different: they are a CORRUPT input, not a skippable row, so the whole call is REFUSED. Skipping
|
|
564
|
+
* the second copy silently accepted a file in which one judgment had been pasted five times — which
|
|
565
|
+
* scored as n=5 and reached SUPPORTED off a single opinion (Codex QE MED-4).
|
|
566
|
+
*/
|
|
567
|
+
export function unblindJudgments(order: WorkOrder, judgments: readonly Judgment[]): UnblindResult {
|
|
568
|
+
const byId = new Map<string, WorkOrderItem>();
|
|
569
|
+
for (const item of order.items ?? []) byId.set(item.id, item);
|
|
570
|
+
const outcomes: EpochOutcome[] = [];
|
|
571
|
+
const skipped: { id: string; reason: string }[] = [];
|
|
572
|
+
const seen = new Set<string>();
|
|
573
|
+
|
|
574
|
+
// Refuse BEFORE interpreting anything: a duplicated id makes the whole file untrustworthy.
|
|
575
|
+
const dupes = new Set<string>();
|
|
576
|
+
const walked = new Set<string>();
|
|
577
|
+
for (const j of judgments ?? []) {
|
|
578
|
+
const id = typeof j?.id === 'string' ? j.id : '';
|
|
579
|
+
if (id === '') continue;
|
|
580
|
+
if (walked.has(id)) dupes.add(id);
|
|
581
|
+
walked.add(id);
|
|
582
|
+
}
|
|
583
|
+
if (dupes.size > 0) {
|
|
584
|
+
return {
|
|
585
|
+
ok: false,
|
|
586
|
+
error: `corrupt judgments: duplicate id(s) ${[...dupes].sort().map((d) => JSON.stringify(d)).join(', ')} — one instance may be judged exactly once`,
|
|
587
|
+
outcomes: [],
|
|
588
|
+
skipped: [],
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
for (const j of judgments ?? []) {
|
|
593
|
+
const id = typeof j?.id === 'string' ? j.id : '';
|
|
594
|
+
if (id === '') {
|
|
595
|
+
skipped.push({ id: String(j?.id ?? ''), reason: 'missing id' });
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const item = byId.get(id);
|
|
599
|
+
if (!item) {
|
|
600
|
+
skipped.push({ id, reason: 'id not in the work order (cannot un-blind)' });
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const raw = typeof j.winner === 'string' ? j.winner.trim().toUpperCase() : '';
|
|
604
|
+
let winner: Arm | 'tie';
|
|
605
|
+
if (raw === 'TIE') winner = 'tie';
|
|
606
|
+
else if (raw === 'A') winner = item.warmIsA ? 'warm' : 'cold';
|
|
607
|
+
else if (raw === 'B') winner = item.warmIsA ? 'cold' : 'warm';
|
|
608
|
+
else {
|
|
609
|
+
skipped.push({ id, reason: `unparseable winner ${JSON.stringify(j.winner)} (expected A|B|TIE)` });
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
seen.add(id);
|
|
613
|
+
outcomes.push({ id, class: item.class ?? null, winner });
|
|
614
|
+
}
|
|
615
|
+
return { ok: true, error: null, outcomes, skipped };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export type EpochVerdict = 'SUPPORTED' | 'FALSIFIED' | 'INCONCLUSIVE';
|
|
619
|
+
|
|
620
|
+
export interface ArmResult {
|
|
621
|
+
readonly arm: Arm;
|
|
622
|
+
readonly wins: number;
|
|
623
|
+
/** DECISIVE pairs — the binomial denominator. Ties are excluded from the test (but reported). */
|
|
624
|
+
readonly n: number;
|
|
625
|
+
readonly ci: WilsonInterval | null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export interface EpochReplayResult {
|
|
629
|
+
readonly verdict: EpochVerdict;
|
|
630
|
+
/** Always populated — a bare label is not a finding. */
|
|
631
|
+
readonly reason: string;
|
|
632
|
+
/**
|
|
633
|
+
* Non-null ⇒ the INPUT was refused and the verdict is a placeholder INCONCLUSIVE, not a
|
|
634
|
+
* measurement. Callers must surface this and exit non-zero.
|
|
635
|
+
*/
|
|
636
|
+
readonly refusal: string | null;
|
|
637
|
+
readonly slice: string;
|
|
638
|
+
/** Every scored instance in the slice, ties included. Context, not the denominator. */
|
|
639
|
+
readonly n: number;
|
|
640
|
+
readonly ties: number;
|
|
641
|
+
/** DECISIVE pairs, `D = warm.wins + cold.wins` — the denominator the test actually uses. */
|
|
642
|
+
readonly decisive: number;
|
|
643
|
+
readonly cold: ArmResult;
|
|
644
|
+
readonly warm: ArmResult;
|
|
645
|
+
/**
|
|
646
|
+
* The test statistic on the LIFT scale (`2p̂ − 1`, where `p̂ = P(warm wins | decisive)`).
|
|
647
|
+
* `margin` is stated on this scale.
|
|
648
|
+
*/
|
|
649
|
+
readonly lift: LiftInterval | null;
|
|
650
|
+
readonly z: number;
|
|
651
|
+
/** Minimum DECISIVE pairs before any verdict exists. */
|
|
652
|
+
readonly minN: number;
|
|
653
|
+
readonly falsifyNoLiftMinN: number;
|
|
654
|
+
readonly noLiftMargin: number;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export interface ScoreOptions {
|
|
658
|
+
/** `all` (default) or a pre-registered class label. */
|
|
659
|
+
readonly slice?: string;
|
|
660
|
+
/** Must be finite and > 0 if given. Anything else is REFUSED — never clamped (Codex QE MED-5). */
|
|
661
|
+
readonly z?: number;
|
|
662
|
+
/**
|
|
663
|
+
* Non-superiority margin on the LIFT scale, in `(0, 0.5]`. In real mode this comes from the WORK
|
|
664
|
+
* ORDER (pre-registered); out-of-range is REFUSED, never clamped (Codex QE HIGH-B).
|
|
665
|
+
*/
|
|
666
|
+
readonly margin?: number;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* The three-valued verdict (ADR-003, as amended).
|
|
671
|
+
*
|
|
672
|
+
* THE MODEL — a SINGLE binomial over DECISIVE pairs. Each instance yields ONE judgment about ONE
|
|
673
|
+
* prompt, so the arms are PAIRED, not two independent samples. Let `W` = warm wins, `C` = cold
|
|
674
|
+
* wins, `D = W + C` (ties are excluded from the test and reported separately). The statistic is
|
|
675
|
+
* `p̂ = W/D` with a Wilson interval, mapped to the lift scale by {@link liftInterval}:
|
|
676
|
+
*
|
|
677
|
+
* SUPPORTED lift lower bound > 0 (equivalently: Wilson lower on p̂ > 0.5)
|
|
678
|
+
* FALSIFIED harm — lift upper bound < 0 (Wilson upper on p̂ < 0.5); OR non-superiority —
|
|
679
|
+
* lift upper bound < `margin` with `D >= FALSIFY_NO_LIFT_MIN_N`
|
|
680
|
+
* INCONCLUSIVE everything else, including `D < MIN_INSTANCES`. A first-class honest outcome.
|
|
681
|
+
*
|
|
682
|
+
* This is exactly the statistic the manual 2026-07-29 experiment used, and it is the correction the
|
|
683
|
+
* re-QE demanded: the previous two-proportion (Newcombe) framing treated the paired judgments as
|
|
684
|
+
* independent samples and was ANTI-CONSERVATIVE — 500/500 over 1000 decisive pairs produced an
|
|
685
|
+
* upper bound of 0.0437 and a FALSIFIED verdict where the paired form gives 0.0619 and INCONCLUSIVE
|
|
686
|
+
* (Codex QE HIGH-A).
|
|
687
|
+
*
|
|
688
|
+
* REFUSALS (verdict is a placeholder, `refusal` is set): duplicate instance ids, an invalid `z`, or
|
|
689
|
+
* an out-of-range `margin`.
|
|
690
|
+
*/
|
|
691
|
+
export function scoreEpochReplay(
|
|
692
|
+
outcomes: readonly EpochOutcome[],
|
|
693
|
+
options: ScoreOptions = {},
|
|
694
|
+
): EpochReplayResult {
|
|
695
|
+
const slice = typeof options.slice === 'string' && options.slice.trim() !== '' ? options.slice.trim() : 'all';
|
|
696
|
+
|
|
697
|
+
// HIGH-B: a margin is a PRE-REGISTRATION. Out of range is an error, not something to round into
|
|
698
|
+
// range — clamping `--margin 99` down to a legal value would buy FALSIFIED for free.
|
|
699
|
+
const marginGiven = options.margin !== undefined;
|
|
700
|
+
const marginValid = !marginGiven || isValidMargin(options.margin);
|
|
701
|
+
const margin = marginGiven && marginValid ? (options.margin as number) : NO_LIFT_MARGIN;
|
|
702
|
+
|
|
703
|
+
// MED-5: `z` used to be CLAMPED into [0.0001, 10]. A caller passing 0 therefore got a near-zero
|
|
704
|
+
// -width interval, which is trivially disjoint from anything — SUPPORTED fabricated out of a bad
|
|
705
|
+
// argument. An invalid z is refused, consistent with `wilsonInterval`'s null discipline.
|
|
706
|
+
const zGiven = options.z !== undefined;
|
|
707
|
+
const zValid = !zGiven || (typeof options.z === 'number' && Number.isFinite(options.z) && options.z > 0 && options.z <= 10);
|
|
708
|
+
const z = zGiven && zValid ? (options.z as number) : WILSON_Z;
|
|
709
|
+
|
|
710
|
+
const pool = (outcomes ?? []).filter((o) => {
|
|
711
|
+
if (!o || typeof o.id !== 'string') return false;
|
|
712
|
+
if (o.winner !== 'cold' && o.winner !== 'warm' && o.winner !== 'tie') return false;
|
|
713
|
+
return slice === 'all' ? true : (o.class ?? null) === slice;
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// MED-4: five copies of one judgement are not five observations. Refuse — a duplicated instance
|
|
717
|
+
// id means the input is corrupt, and D must never be inflated by a paste.
|
|
718
|
+
const dupes = new Set<string>();
|
|
719
|
+
const walkedIds = new Set<string>();
|
|
720
|
+
for (const o of pool) {
|
|
721
|
+
if (walkedIds.has(o.id)) dupes.add(o.id);
|
|
722
|
+
walkedIds.add(o.id);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const n = pool.length;
|
|
726
|
+
const warmWins = pool.filter((o) => o.winner === 'warm').length;
|
|
727
|
+
const coldWins = pool.filter((o) => o.winner === 'cold').length;
|
|
728
|
+
const ties = pool.filter((o) => o.winner === 'tie').length;
|
|
729
|
+
|
|
730
|
+
// HIGH-A: ONE binomial over DECISIVE pairs. Ties carry no directional information, so they are
|
|
731
|
+
// excluded from the denominator (and reported); `p̂ = W/D` is the paired statistic.
|
|
732
|
+
const decisive = warmWins + coldWins;
|
|
733
|
+
const pWarm = wilsonInterval(warmWins, decisive, z);
|
|
734
|
+
const lift = liftInterval(pWarm);
|
|
735
|
+
// Cold's interval is the exact complement of warm's — reported for readability, never a second test.
|
|
736
|
+
const coldCi = wilsonInterval(coldWins, decisive, z);
|
|
737
|
+
const warm: ArmResult = { arm: 'warm', wins: warmWins, n: decisive, ci: pWarm };
|
|
738
|
+
const cold: ArmResult = { arm: 'cold', wins: coldWins, n: decisive, ci: coldCi };
|
|
739
|
+
const base = {
|
|
740
|
+
slice,
|
|
741
|
+
n,
|
|
742
|
+
ties,
|
|
743
|
+
decisive,
|
|
744
|
+
cold,
|
|
745
|
+
warm,
|
|
746
|
+
lift,
|
|
747
|
+
z,
|
|
748
|
+
minN: MIN_INSTANCES,
|
|
749
|
+
falsifyNoLiftMinN: FALSIFY_NO_LIFT_MIN_N,
|
|
750
|
+
noLiftMargin: margin,
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
// ── refusals: a corrupt input yields NO measurement ──
|
|
754
|
+
if (!zValid) {
|
|
755
|
+
return {
|
|
756
|
+
...base,
|
|
757
|
+
z: WILSON_Z,
|
|
758
|
+
verdict: 'INCONCLUSIVE',
|
|
759
|
+
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`,
|
|
760
|
+
reason: 'REFUSED: invalid confidence parameter — no verdict was computed',
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
if (!marginValid) {
|
|
764
|
+
return {
|
|
765
|
+
...base,
|
|
766
|
+
noLiftMargin: NO_LIFT_MARGIN,
|
|
767
|
+
verdict: 'INCONCLUSIVE',
|
|
768
|
+
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`,
|
|
769
|
+
reason: 'REFUSED: invalid non-superiority margin — no verdict was computed',
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
if (dupes.size > 0) {
|
|
773
|
+
return {
|
|
774
|
+
...base,
|
|
775
|
+
verdict: 'INCONCLUSIVE',
|
|
776
|
+
refusal: `corrupt outcomes: duplicate instance id(s) ${[...dupes].sort().map((d) => JSON.stringify(d)).join(', ')} — one instance counts exactly once`,
|
|
777
|
+
reason: 'REFUSED: duplicate instance ids would inflate the denominator — no verdict was computed',
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (decisive < MIN_INSTANCES) {
|
|
782
|
+
return {
|
|
783
|
+
...base,
|
|
784
|
+
verdict: 'INCONCLUSIVE',
|
|
785
|
+
refusal: null,
|
|
786
|
+
reason:
|
|
787
|
+
`insufficient data: ${decisive} DECISIVE pair(s) in slice "${slice}"` +
|
|
788
|
+
`${ties > 0 ? ` (${ties} tie(s) carry no direction and are excluded)` : ''}, ` +
|
|
789
|
+
`${MIN_INSTANCES} needed (darwin FDR discipline — n=3 gave a 33% false-discovery rate)`,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
if (pWarm === null || lift === null) {
|
|
793
|
+
return {
|
|
794
|
+
...base,
|
|
795
|
+
verdict: 'INCONCLUSIVE',
|
|
796
|
+
refusal: null,
|
|
797
|
+
reason: `no valid confidence interval could be computed for slice "${slice}" (degenerate counts)`,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const shape =
|
|
802
|
+
`warm ${warmWins}/${decisive} decisive (p=${pWarm.p.toFixed(3)}, CI [${pWarm.lower.toFixed(3)}, ${pWarm.upper.toFixed(3)}]); ` +
|
|
803
|
+
`lift [${lift.lower.toFixed(3)}, ${lift.upper.toFixed(3)}]`;
|
|
804
|
+
|
|
805
|
+
// Belt-and-braces: `lift.lower > 0` already implies warm won more, but asserting it makes a
|
|
806
|
+
// SUPPORTED unreachable through a single edited comparison.
|
|
807
|
+
if (lift.lower > 0 && warmWins > coldWins) {
|
|
808
|
+
return {
|
|
809
|
+
...base,
|
|
810
|
+
verdict: 'SUPPORTED',
|
|
811
|
+
refusal: null,
|
|
812
|
+
reason: `${shape} — the lift interval lies ENTIRELY above zero (warm wins more than half of decisive pairs)`,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
if (lift.upper < 0 && coldWins > warmWins) {
|
|
816
|
+
return {
|
|
817
|
+
...base,
|
|
818
|
+
verdict: 'FALSIFIED',
|
|
819
|
+
refusal: null,
|
|
820
|
+
reason: `harm: ${shape} — the lift interval lies ENTIRELY below zero (cold wins more than half of decisive pairs)`,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
// NON-SUPERIORITY, not "warm didn't win". The data must EXCLUDE a lift as large as the margin;
|
|
824
|
+
// an equal split at small D excludes nothing (Codex QE HIGH-3).
|
|
825
|
+
if (decisive >= FALSIFY_NO_LIFT_MIN_N && lift.upper < margin) {
|
|
826
|
+
return {
|
|
827
|
+
...base,
|
|
828
|
+
verdict: 'FALSIFIED',
|
|
829
|
+
refusal: null,
|
|
830
|
+
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`,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
return {
|
|
834
|
+
...base,
|
|
835
|
+
verdict: 'INCONCLUSIVE',
|
|
836
|
+
refusal: null,
|
|
837
|
+
reason: `${shape} — neither excludes zero nor excludes a ${margin} lift; under-powered, keep the corpus growing`,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ── `--mock`: seeded synthetic outcomes ────────────────────────────────────────────────────────
|
|
842
|
+
|
|
843
|
+
export interface MockOptions {
|
|
844
|
+
readonly n?: number;
|
|
845
|
+
/**
|
|
846
|
+
* TRUE effect in [-1, 1]. P(warm wins | not a tie) = clamp(0.5 + effect / 2), so 0 is a fair
|
|
847
|
+
* coin, +1 is "warm always wins", -1 is "cold always wins".
|
|
848
|
+
*/
|
|
849
|
+
readonly effect?: number;
|
|
850
|
+
readonly tieRate?: number;
|
|
851
|
+
readonly seed?: number;
|
|
852
|
+
/** Class label stamped on every synthetic outcome (so `--slice` is exercisable). */
|
|
853
|
+
readonly class?: string | null;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
export const DEFAULT_MOCK_N = 12;
|
|
857
|
+
export const DEFAULT_MOCK_SEED = 20260729;
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Synthetic judge outcomes from ONE seeded stream (`mulberry32` — the repo's only PRNG). Same
|
|
861
|
+
* (n, effect, tieRate, seed) → byte-identical outcomes, so a `--mock` demo is a reproducer.
|
|
862
|
+
*/
|
|
863
|
+
export function generateMockOutcomes(options: MockOptions = {}): EpochOutcome[] {
|
|
864
|
+
const n = clampInt(options.n, DEFAULT_MOCK_N, 0, 100000);
|
|
865
|
+
const effect = clampNumber(options.effect, 0, -1, 1);
|
|
866
|
+
const tieRate = clampNumber(options.tieRate, 0, 0, 1);
|
|
867
|
+
const seed = clampInt(options.seed, DEFAULT_MOCK_SEED, 0, 2 ** 31 - 1);
|
|
868
|
+
const pWarm = Math.max(0, Math.min(1, 0.5 + effect / 2));
|
|
869
|
+
const rand = mulberry32(seed);
|
|
870
|
+
const out: EpochOutcome[] = [];
|
|
871
|
+
for (let i = 0; i < n; i++) {
|
|
872
|
+
const tieRoll = rand();
|
|
873
|
+
const armRoll = rand();
|
|
874
|
+
const winner: Arm | 'tie' = tieRoll < tieRate ? 'tie' : armRoll < pWarm ? 'warm' : 'cold';
|
|
875
|
+
out.push({ id: `mock-${i}`, class: options.class ?? null, winner });
|
|
876
|
+
}
|
|
877
|
+
return out;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ── Rendering ──────────────────────────────────────────────────────────────────────────────────
|
|
881
|
+
|
|
882
|
+
function fmtCi(ci: WilsonInterval | null): string {
|
|
883
|
+
return ci === null ? 'n/a' : `[${ci.lower.toFixed(3)}, ${ci.upper.toFixed(3)}]`;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export function renderEpochReplayResult(r: EpochReplayResult): string {
|
|
887
|
+
const out: string[] = [];
|
|
888
|
+
out.push('dz epoch-replay — cold (epoch 0) vs warm (epoch 1), Wilson-CI three-valued verdict');
|
|
889
|
+
out.push('');
|
|
890
|
+
if (r.refusal !== null) {
|
|
891
|
+
out.push(' REFUSED — the input is corrupt, so NO measurement was made:');
|
|
892
|
+
out.push(` ${r.refusal}`);
|
|
893
|
+
return out.join('\n');
|
|
894
|
+
}
|
|
895
|
+
out.push(
|
|
896
|
+
` SLICE: ${r.slice} · ${r.n} scored instance(s) · ${r.decisive} DECISIVE pair(s)` +
|
|
897
|
+
`${r.ties > 0 ? ` · ${r.ties} tie(s) excluded from the test` : ''}`,
|
|
898
|
+
);
|
|
899
|
+
const ciLabel = r.z === WILSON_Z ? 'CI95' : `CI(z=${r.z})`;
|
|
900
|
+
out.push(` COLD (epoch 0, no injected lessons): ${r.cold.wins}/${r.cold.n} decisive ${ciLabel} ${fmtCi(r.cold.ci)}`);
|
|
901
|
+
out.push(` WARM (epoch 1, apply-leg lessons): ${r.warm.wins}/${r.warm.n} decisive ${ciLabel} ${fmtCi(r.warm.ci)}`);
|
|
902
|
+
out.push(
|
|
903
|
+
` 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)}]`}`,
|
|
904
|
+
);
|
|
905
|
+
out.push('');
|
|
906
|
+
out.push(` VERDICT: ${r.verdict}`);
|
|
907
|
+
out.push(` ${r.reason}`);
|
|
908
|
+
out.push('');
|
|
909
|
+
out.push(` rule: ONE binomial over DECISIVE pairs (ties carry no direction and are excluded).`);
|
|
910
|
+
out.push(` SUPPORTED only when the lift interval lies ENTIRELY above zero.`);
|
|
911
|
+
out.push(
|
|
912
|
+
` 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}.`,
|
|
913
|
+
);
|
|
914
|
+
out.push(
|
|
915
|
+
` Everything else is INCONCLUSIVE (min ${r.minN} decisive pairs). A tie is UNDER-POWERED, never "refuted".`,
|
|
916
|
+
);
|
|
917
|
+
return out.join('\n');
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export function renderWorkOrderSummary(order: WorkOrder, outPath: string): string {
|
|
921
|
+
const withClass = order.items.filter((i) => typeof i.class === 'string' && i.class !== '').length;
|
|
922
|
+
const out: string[] = [];
|
|
923
|
+
out.push(`dz epoch-replay --emit → ${outPath}`);
|
|
924
|
+
out.push('');
|
|
925
|
+
out.push(` ${order.items.length} instance(s) · seed ${order.seed} · blind A/B assignment PRE-REGISTERED`);
|
|
926
|
+
out.push(` integrity digest: ${order.digest.slice(0, 16)}… (covers version + seed + every id:warmIsA)`);
|
|
927
|
+
out.push(` slice labels assigned: ${withClass}/${order.items.length}`);
|
|
928
|
+
out.push(` plan length target: ${order.wordMin}-${order.wordMax} words per arm`);
|
|
929
|
+
out.push('');
|
|
930
|
+
out.push(' NEXT (this runner never calls a model):');
|
|
931
|
+
out.push(' 1. Fill coldPlan/warmPlan for every item with an agent, arms generated symmetrically.');
|
|
932
|
+
out.push(` 2. dz epoch-replay --judge ${outPath} → blind judge prompts`);
|
|
933
|
+
out.push(' 3. Have an EXTERNAL (cross-model) judge answer each prompt; collect {id, winner} rows.');
|
|
934
|
+
out.push(` 4. dz epoch-replay --score <judgments.json> --work-order ${outPath}`);
|
|
935
|
+
out.push('');
|
|
936
|
+
out.push(' PRIVACY: the work order embeds raw local prompt texts — keep it out of version control.');
|
|
937
|
+
return out.join('\n');
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export function renderJudgePromptsSummary(result: JudgePromptsResult, outPath: string): string {
|
|
941
|
+
const out: string[] = [];
|
|
942
|
+
out.push(`dz epoch-replay --judge → ${outPath}`);
|
|
943
|
+
out.push('');
|
|
944
|
+
out.push(` ${result.prompts.length} blind judge prompt(s) rendered — the file carries {id, prompt} and NOTHING else`);
|
|
945
|
+
if (result.skipped.length > 0) {
|
|
946
|
+
out.push(` ${result.skipped.length} item(s) SKIPPED (half a pair is not a comparison):`);
|
|
947
|
+
for (const s of result.skipped) out.push(` · ${s.id}: ${s.reason}`);
|
|
948
|
+
out.push(' (this list stays HERE — its reasons name arms, so it is never written to the judge file)');
|
|
949
|
+
}
|
|
950
|
+
out.push('');
|
|
951
|
+
out.push(' Give each prompt to an EXTERNAL judge model (cross-model: not the generator).');
|
|
952
|
+
out.push(' Collect the answers as [{ "id": "<id>", "winner": "A|B|TIE" }, ...] — no arm labels:');
|
|
953
|
+
out.push(' un-blinding uses the work order\'s pre-registered assignment, not anything the judge wrote.');
|
|
954
|
+
return out.join('\n');
|
|
955
|
+
}
|