@dzhechkov/harness-core 0.8.35 → 0.8.37

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 (135) hide show
  1. package/.dz-manifest.json +224 -104
  2. package/README.md +335 -10
  3. package/dist/agentdb-index.d.ts +87 -7
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +416 -57
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +57 -1
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +450 -52
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/codex-hooks-assets.d.ts.map +1 -1
  12. package/dist/codex-hooks-assets.js +67 -5
  13. package/dist/codex-hooks-assets.js.map +1 -1
  14. package/dist/codex-hooks.d.ts +13 -1
  15. package/dist/codex-hooks.d.ts.map +1 -1
  16. package/dist/codex-hooks.js +13 -1
  17. package/dist/codex-hooks.js.map +1 -1
  18. package/dist/codex-rollouts.d.ts +118 -0
  19. package/dist/codex-rollouts.d.ts.map +1 -0
  20. package/dist/codex-rollouts.js +297 -0
  21. package/dist/codex-rollouts.js.map +1 -0
  22. package/dist/cost-ledger.d.ts +56 -4
  23. package/dist/cost-ledger.d.ts.map +1 -1
  24. package/dist/cost-ledger.js +176 -20
  25. package/dist/cost-ledger.js.map +1 -1
  26. package/dist/cross-family-control.d.ts +345 -0
  27. package/dist/cross-family-control.d.ts.map +1 -0
  28. package/dist/cross-family-control.js +802 -0
  29. package/dist/cross-family-control.js.map +1 -0
  30. package/dist/debt-ratchet.d.ts +53 -0
  31. package/dist/debt-ratchet.d.ts.map +1 -0
  32. package/dist/debt-ratchet.js +107 -0
  33. package/dist/debt-ratchet.js.map +1 -0
  34. package/dist/embedding-config.d.ts +42 -0
  35. package/dist/embedding-config.d.ts.map +1 -1
  36. package/dist/embedding-config.js +106 -10
  37. package/dist/embedding-config.js.map +1 -1
  38. package/dist/feature-adr-checkpoints.d.ts +6 -0
  39. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  40. package/dist/feature-adr-checkpoints.js +29 -0
  41. package/dist/feature-adr-checkpoints.js.map +1 -1
  42. package/dist/feature-adr-decision-recall.d.ts +2 -2
  43. package/dist/feature-adr-decision-recall.d.ts.map +1 -1
  44. package/dist/feature-adr-decision-recall.js +5 -3
  45. package/dist/feature-adr-decision-recall.js.map +1 -1
  46. package/dist/feature-adr-envelope.d.ts +96 -0
  47. package/dist/feature-adr-envelope.d.ts.map +1 -0
  48. package/dist/feature-adr-envelope.js +183 -0
  49. package/dist/feature-adr-envelope.js.map +1 -0
  50. package/dist/feature-adr-routing.d.ts +64 -0
  51. package/dist/feature-adr-routing.d.ts.map +1 -1
  52. package/dist/feature-adr-routing.js +122 -2
  53. package/dist/feature-adr-routing.js.map +1 -1
  54. package/dist/feature-adr-stage-canon.d.ts +79 -0
  55. package/dist/feature-adr-stage-canon.d.ts.map +1 -0
  56. package/dist/feature-adr-stage-canon.js +117 -0
  57. package/dist/feature-adr-stage-canon.js.map +1 -0
  58. package/dist/index.d.ts +23 -12
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +15 -7
  61. package/dist/index.js.map +1 -1
  62. package/dist/loop-blobs.generated.js +4 -4
  63. package/dist/loop-blobs.generated.js.map +1 -1
  64. package/dist/mutation-gate.d.ts +51 -0
  65. package/dist/mutation-gate.d.ts.map +1 -1
  66. package/dist/mutation-gate.js +295 -0
  67. package/dist/mutation-gate.js.map +1 -1
  68. package/dist/operations.d.ts +1 -0
  69. package/dist/operations.d.ts.map +1 -1
  70. package/dist/operations.js +18 -2
  71. package/dist/operations.js.map +1 -1
  72. package/dist/publish.d.ts +59 -7
  73. package/dist/publish.d.ts.map +1 -1
  74. package/dist/publish.js +205 -32
  75. package/dist/publish.js.map +1 -1
  76. package/dist/qe-bridge.d.ts.map +1 -1
  77. package/dist/qe-bridge.js +4 -2
  78. package/dist/qe-bridge.js.map +1 -1
  79. package/dist/qe-findings.d.ts +107 -0
  80. package/dist/qe-findings.d.ts.map +1 -0
  81. package/dist/qe-findings.js +417 -0
  82. package/dist/qe-findings.js.map +1 -0
  83. package/dist/recap.d.ts +1 -1
  84. package/dist/recap.d.ts.map +1 -1
  85. package/dist/recap.js +4 -2
  86. package/dist/recap.js.map +1 -1
  87. package/dist/release-line.d.ts +16 -0
  88. package/dist/release-line.d.ts.map +1 -1
  89. package/dist/release-line.js +31 -0
  90. package/dist/release-line.js.map +1 -1
  91. package/dist/round.d.ts +74 -1
  92. package/dist/round.d.ts.map +1 -1
  93. package/dist/round.js +112 -4
  94. package/dist/round.js.map +1 -1
  95. package/dist/run-records.d.ts +60 -0
  96. package/dist/run-records.d.ts.map +1 -1
  97. package/dist/run-records.js +244 -2
  98. package/dist/run-records.js.map +1 -1
  99. package/dist/score.d.ts +44 -1
  100. package/dist/score.d.ts.map +1 -1
  101. package/dist/score.js +78 -5
  102. package/dist/score.js.map +1 -1
  103. package/dist/vector-tier.d.ts +34 -3
  104. package/dist/vector-tier.d.ts.map +1 -1
  105. package/dist/vector-tier.js +105 -14
  106. package/dist/vector-tier.js.map +1 -1
  107. package/package.json +2 -2
  108. package/sbom.json +403 -103
  109. package/src/agentdb-index.ts +423 -60
  110. package/src/apply-leg.ts +469 -50
  111. package/src/codex-hooks-assets.ts +67 -5
  112. package/src/codex-hooks.ts +13 -1
  113. package/src/codex-rollouts.ts +374 -0
  114. package/src/cost-ledger.ts +232 -24
  115. package/src/cross-family-control.ts +960 -0
  116. package/src/debt-ratchet.ts +143 -0
  117. package/src/embedding-config.ts +131 -10
  118. package/src/feature-adr-checkpoints.ts +29 -0
  119. package/src/feature-adr-decision-recall.ts +6 -4
  120. package/src/feature-adr-envelope.ts +242 -0
  121. package/src/feature-adr-routing.ts +139 -2
  122. package/src/feature-adr-stage-canon.ts +141 -0
  123. package/src/index.ts +66 -7
  124. package/src/loop-blobs.generated.ts +4 -4
  125. package/src/mutation-gate.ts +316 -0
  126. package/src/operations.ts +18 -3
  127. package/src/publish.ts +247 -30
  128. package/src/qe-bridge.ts +4 -2
  129. package/src/qe-findings.ts +463 -0
  130. package/src/recap.ts +10 -3
  131. package/src/release-line.ts +32 -0
  132. package/src/round.ts +165 -6
  133. package/src/run-records.ts +282 -2
  134. package/src/score.ts +115 -6
  135. package/src/vector-tier.ts +127 -14
@@ -0,0 +1,960 @@
1
+ /**
2
+ * cross-family-control-branch (ADR-001, tier M): the pure core of `dz control-review` and
3
+ * `dz score --by-family` — normalization, matching, diffing and ledger aggregation for the
4
+ * measurement of foreign-unique findings between two INDEPENDENT reviews of the same tree.
5
+ *
6
+ * PURE, deliberately: no node:fs / node:child_process import here (NFR-1, guarded by
7
+ * test/core-boundary.test.ts). Every file read, subprocess spawn or hash computation belongs to
8
+ * the CLI (`dz control-review`), same as every other core module (qe-findings.ts, qe-bridge.ts).
9
+ *
10
+ * Context (ADR-001): every review in the run-cost ledger and every qe-bridge signoff is ONE
11
+ * direction over one tree — there was no observation of whether the OTHER family finds what the
12
+ * coder's own family misses. This module answers that by diffing two closed-vocabulary finding
13
+ * lists (Codex's `## Findings ledger` table, Claude's qe-bridge signoff) over the SAME scope.
14
+ *
15
+ * ── Fix round 1 (codex-r1-verdict.txt, Grade C, 17 findings) — the vocabulary shift ──────────────
16
+ * Round 1 called an automatic title/location match "matched" — an OVERLAP claim. Codex r1 finding 1
17
+ * proved that claim false with two real counter-examples (a 4/6-token false pair; two unrelated
18
+ * findings in the same file three lines apart). The fix is not a smarter matcher — a smarter matcher
19
+ * still guesses — it is an honest vocabulary: automatic pairs are **candidates**, never overlap.
20
+ * Only a human adjudication produces a **confirmed** pair. Every output (`ControlDiff`, the ledger
21
+ * row, `dz score --by-family`) now keeps FOUR buckets apart: `confirmed`, `candidate`, `onlyCodex`,
22
+ * `onlyClaude` — and the word "overlap"/"matched" is reserved for `confirmed` alone.
23
+ */
24
+
25
+ import { QE_SEVERITIES, type QeSeverity, type QeStatus } from './qe-findings.js';
26
+
27
+ /* ── D1: severity normalization (A4) ─────────────────────────────────────────────────────────── */
28
+
29
+ /**
30
+ * The Codex control brief and the Claude qe-bridge signoff both speak the informal
31
+ * critical/major/minor vocabulary (`buildBridgePrompt`'s own brief text: "a severity
32
+ * (critical/major/minor)"). This maps that vocabulary onto the CLOSED `QeSeverity` dictionary
33
+ * qe-findings.ts already defines — `critical`→`CRITICAL`, `major`→`HIGH`, `minor`→`LOW` — and
34
+ * refuses (returns `null`) anything else, case/whitespace-insensitive. The CALLER decides what a
35
+ * refusal means (A4: the finding is written into a `refused` bucket with a named reason, never
36
+ * coerced to the nearest known value — coercion here would make the resulting severity tally
37
+ * unprovable, the same argument qe-findings.ts already makes for its own closed dictionaries).
38
+ */
39
+ export function normalizeBridgeSeverity(s: string): QeSeverity | null {
40
+ const t = String(s ?? '').trim().toLowerCase();
41
+ if (t === 'critical') return 'CRITICAL';
42
+ if (t === 'major') return 'HIGH';
43
+ if (t === 'minor') return 'LOW';
44
+ return null;
45
+ }
46
+
47
+ /* ── D2: title normalization + matching (A5, A7) ─────────────────────────────────────────────── */
48
+
49
+ /**
50
+ * Lowercase, strip punctuation/backticks (anything that is not a Unicode letter or digit becomes
51
+ * a separator), split on whitespace, keep tokens of length >= 3, deduplicate, sort. The resulting
52
+ * token SET is what `matchFindings`/`dedupeWithinFamily` compare with Jaccard similarity — a
53
+ * bag-of-words match, not a substring one, so word order never matters.
54
+ */
55
+ export function normalizeFindingTitle(t: string): string[] {
56
+ const cleaned = String(t ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ');
57
+ const tokens = cleaned.split(/\s+/).filter((w) => w.length >= 3);
58
+ return [...new Set(tokens)].sort();
59
+ }
60
+
61
+ function jaccard(a: readonly string[], b: readonly string[]): number {
62
+ if (a.length === 0 || b.length === 0) return 0;
63
+ const sa = new Set(a);
64
+ const sb = new Set(b);
65
+ let inter = 0;
66
+ for (const w of sa) if (sb.has(w)) inter++;
67
+ const union = new Set([...sa, ...sb]).size;
68
+ return union === 0 ? 0 : inter / union;
69
+ }
70
+
71
+ /** Two findings are a compatible location for matching purposes when at least one names no file
72
+ * (nothing to contradict), or both name the SAME file. Two findings that each name a DIFFERENT
73
+ * file are never compatible — r1-1/r1-3's shared premise: a file is corroborating evidence only
74
+ * when it agrees; disagreeing file names are disqualifying, not merely uninformative. */
75
+ function filesCompatible(fa: string | undefined, fb: string | undefined): boolean {
76
+ return fa === undefined || fb === undefined || fa === fb;
77
+ }
78
+
79
+ export interface ControlFinding {
80
+ readonly family: 'codex' | 'claude';
81
+ readonly id: string;
82
+ readonly severity: QeSeverity;
83
+ readonly title: string;
84
+ readonly file?: string;
85
+ readonly line?: number;
86
+ readonly status?: QeStatus;
87
+ }
88
+
89
+ export interface MatchPair {
90
+ readonly a: string;
91
+ readonly b: string;
92
+ readonly rule: 'title-jaccard' | 'file-line';
93
+ readonly score: number;
94
+ }
95
+
96
+ /**
97
+ * Solves the small assignment problem (Kuhn–Munkres / Hungarian algorithm, O(rows²·cols)) that
98
+ * finds the MINIMUM total cost perfect assignment of every row to a distinct column, `rows <=
99
+ * cols`. Every row/column is real — including a "column" a row is assigned to on the cost-matrix
100
+ * even where there is no genuine candidate — so the CALLER decides which assignments are real
101
+ * matches (this function has no notion of "no match", only of cost).
102
+ */
103
+ function hungarianMinCost(cost: readonly (readonly number[])[]): number[] {
104
+ const rows = cost.length;
105
+ const cols = rows === 0 ? 0 : (cost[0] as readonly number[]).length;
106
+ if (rows === 0 || cols === 0) return [];
107
+ const INF = Number.POSITIVE_INFINITY;
108
+ const u = new Array<number>(rows + 1).fill(0);
109
+ const v = new Array<number>(cols + 1).fill(0);
110
+ const p = new Array<number>(cols + 1).fill(0); // p[j] = 1-based row assigned to column j (0 = none)
111
+ const way = new Array<number>(cols + 1).fill(0);
112
+ for (let i = 1; i <= rows; i++) {
113
+ p[0] = i;
114
+ let j0 = 0;
115
+ const minv = new Array<number>(cols + 1).fill(INF);
116
+ const used = new Array<boolean>(cols + 1).fill(false);
117
+ do {
118
+ used[j0] = true;
119
+ const i0 = p[j0] as number;
120
+ let delta = INF;
121
+ let j1 = -1;
122
+ for (let j = 1; j <= cols; j++) {
123
+ if (used[j]) continue;
124
+ const cur = (cost[i0 - 1] as readonly number[])[j - 1]! - (u[i0] as number) - (v[j] as number);
125
+ if (cur < (minv[j] as number)) { minv[j] = cur; way[j] = j0; }
126
+ if ((minv[j] as number) < delta) { delta = minv[j] as number; j1 = j; }
127
+ }
128
+ for (let j = 0; j <= cols; j++) {
129
+ if (used[j]) { u[p[j] as number] = (u[p[j] as number] as number) + delta; v[j] = (v[j] as number) - delta; }
130
+ else minv[j] = (minv[j] as number) - delta;
131
+ }
132
+ j0 = j1;
133
+ } while ((p[j0] as number) !== 0);
134
+ do {
135
+ const j1 = way[j0] as number;
136
+ p[j0] = p[j1] as number;
137
+ j0 = j1;
138
+ } while (j0 !== 0);
139
+ }
140
+ const result = new Array<number>(rows).fill(-1);
141
+ for (let j = 1; j <= cols; j++) {
142
+ const r = p[j] as number;
143
+ if (r > 0) result[r - 1] = j - 1;
144
+ }
145
+ return result;
146
+ }
147
+
148
+ /**
149
+ * Deterministic MAXIMUM-CARDINALITY matching between two finding lists, sum-of-score as the
150
+ * secondary objective (ADR-001, amended after Codex r1 finding 2 — greedy-by-score needlessly
151
+ * drops valid pairs: titles `A1/B1="alpha beta gamma delta"`, `A2="gamma delta"`,
152
+ * `B2="alpha beta"` greedily keep only A1–B1 and lose two genuine pairs A1–B2/A2–B1).
153
+ *
154
+ * Solved as a weighted bipartite ASSIGNMENT (Hungarian): a real candidate edge costs
155
+ * `-(BONUS + score)` with `BONUS = 1 + min(rows, cols)`; a non-candidate edge costs `0`. Lead delta
156
+ * after Codex r2 (new HIGH #1): a bonus of `1` did NOT make cardinality dominant — two score-1 edges
157
+ * (weight 4) beat three score-0.25 edges (weight 3.75). Since every score is <= 1, the total score of
158
+ * ANY matching is < min(rows, cols) + 1 = BONUS, so one extra edge always outweighs any score
159
+ * difference: cardinality first, total score second, in one assignment.
160
+ *
161
+ * Two rules propose CANDIDATES (never confirmed overlap — Codex r1 finding 1: an automatic pair,
162
+ * however matched, is a candidate for lead adjudication, printed as such everywhere it travels):
163
+ * - title Jaccard >= `opts.jaccard` (default 0.5) on tokens of length >= 3, AND a compatible file
164
+ * (r1-1: a file that DISAGREES between the two findings disqualifies an otherwise-good title
165
+ * match — two findings about the "same" defect in two different files are two defects);
166
+ * - same file with `|line delta| <= opts.lineSlack` (default 3) AND title Jaccard >=
167
+ * `opts.fileLineJaccard` (default 0.2) — r1-1's second counter-example: same file, adjacent
168
+ * lines, ZERO shared vocabulary used to pair for free; a location match now needs SOME
169
+ * corroborating text, not just proximity.
170
+ * A pair meeting neither rule's threshold is never proposed — "below-threshold titles stay
171
+ * unique" remains the load-bearing property this module protects.
172
+ */
173
+ export function matchFindings(
174
+ a: readonly ControlFinding[],
175
+ b: readonly ControlFinding[],
176
+ opts?: { readonly jaccard?: number; readonly lineSlack?: number; readonly fileLineJaccard?: number },
177
+ ): { readonly pairs: readonly MatchPair[] } {
178
+ if (a.length === 0 || b.length === 0) return { pairs: [] };
179
+ const jaccardThreshold = opts?.jaccard ?? 0.5;
180
+ const lineSlack = opts?.lineSlack ?? 3;
181
+ const fileLineJaccardThreshold = opts?.fileLineJaccard ?? 0.2;
182
+
183
+ // Canonical id order: any residual tie in the assignment algorithm then resolves the same way
184
+ // every run (determinism), favoring the earliest-scanned column for a tied minimum delta.
185
+ const sa = [...a].sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
186
+ const sb = [...b].sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
187
+ const titleA = sa.map((f) => normalizeFindingTitle(f.title));
188
+ const titleB = sb.map((f) => normalizeFindingTitle(f.title));
189
+
190
+ type Candidate = { readonly rule: 'title-jaccard' | 'file-line'; readonly score: number };
191
+ const best: Array<Array<Candidate | null>> = [];
192
+ for (let i = 0; i < sa.length; i++) {
193
+ const row: Array<Candidate | null> = [];
194
+ const fa = sa[i] as ControlFinding;
195
+ for (let j = 0; j < sb.length; j++) {
196
+ const fb = sb[j] as ControlFinding;
197
+ const jscore = jaccard(titleA[i] as string[], titleB[j] as string[]);
198
+ let candidate: Candidate | null = null;
199
+ if (jscore >= jaccardThreshold && filesCompatible(fa.file, fb.file)) {
200
+ candidate = { rule: 'title-jaccard', score: jscore };
201
+ }
202
+ if (fa.file !== undefined && fb.file !== undefined && fa.file === fb.file && fa.line !== undefined && fb.line !== undefined) {
203
+ const dl = Math.abs(fa.line - fb.line);
204
+ if (dl <= lineSlack && jscore >= fileLineJaccardThreshold) {
205
+ const flScore = 1 - dl / (lineSlack + 1);
206
+ if (candidate === null || flScore > candidate.score) candidate = { rule: 'file-line', score: flScore };
207
+ }
208
+ }
209
+ row.push(candidate);
210
+ }
211
+ best.push(row);
212
+ }
213
+
214
+ const n = sa.length;
215
+ const m = sb.length;
216
+ const rowsAreA = n <= m;
217
+ const rows = rowsAreA ? n : m;
218
+ const cols = rowsAreA ? m : n;
219
+ const cardinalityBonus = 1 + Math.min(rows, cols);
220
+ const cost: number[][] = [];
221
+ for (let r = 0; r < rows; r++) {
222
+ const costRow: number[] = [];
223
+ for (let c = 0; c < cols; c++) {
224
+ const cand = rowsAreA ? (best[r] as Array<Candidate | null>)[c] : (best[c] as Array<Candidate | null>)[r];
225
+ costRow.push(cand ? -(cardinalityBonus + cand.score) : 0);
226
+ }
227
+ cost.push(costRow);
228
+ }
229
+ const assignment = hungarianMinCost(cost);
230
+
231
+ const pairs: MatchPair[] = [];
232
+ for (let r = 0; r < rows; r++) {
233
+ const c = assignment[r] as number;
234
+ if (c < 0) continue;
235
+ const cand = rowsAreA ? (best[r] as Array<Candidate | null>)[c] : (best[c] as Array<Candidate | null>)[r];
236
+ if (cand === null || cand === undefined) continue; // assigned to a zero-cost filler — no real match
237
+ const ai = rowsAreA ? r : c;
238
+ const bi = rowsAreA ? c : r;
239
+ pairs.push({ a: (sa[ai] as ControlFinding).id, b: (sb[bi] as ControlFinding).id, rule: cand.rule, score: cand.score });
240
+ }
241
+ pairs.sort((x, y) => (x.a < y.a ? -1 : x.a > y.a ? 1 : x.b < y.b ? -1 : x.b > y.b ? 1 : 0));
242
+ return { pairs };
243
+ }
244
+
245
+ /**
246
+ * A5: collapse near-duplicate findings WITHIN one reviewer's own list (Jaccard >= 0.8 on titles,
247
+ * a tighter threshold than cross-family matching since these are the SAME reviewer restating
248
+ * itself, e.g. across multiple table rows). The first occurrence in list order is kept; every
249
+ * later near-duplicate is recorded in `collapsed` rather than silently dropped.
250
+ *
251
+ * r1-3 (Codex r1 finding 3): collapsing on title tokens ALONE let two DISTINCT defects with the
252
+ * same generic title ("missing null check in parser") in two different files collapse into one —
253
+ * a real defect silently lost. A location disagreement now blocks the collapse: two findings only
254
+ * collapse when their files are COMPATIBLE (both absent, or identical) — same rule `matchFindings`
255
+ * applies across families, applied here within one.
256
+ */
257
+ export function dedupeWithinFamily(
258
+ list: readonly ControlFinding[],
259
+ jaccardThreshold = 0.8,
260
+ ): { readonly kept: ControlFinding[]; readonly collapsed: ReadonlyArray<{ readonly kept: string; readonly dropped: string }> } {
261
+ const kept: ControlFinding[] = [];
262
+ const collapsed: Array<{ kept: string; dropped: string }> = [];
263
+ for (const f of list) {
264
+ const ft = normalizeFindingTitle(f.title);
265
+ let dupOf: ControlFinding | null = null;
266
+ for (const k of kept) {
267
+ if (jaccard(ft, normalizeFindingTitle(k.title)) >= jaccardThreshold && filesCompatible(f.file, k.file)) {
268
+ dupOf = k;
269
+ break;
270
+ }
271
+ }
272
+ if (dupOf !== null) collapsed.push({ kept: dupOf.id, dropped: f.id });
273
+ else kept.push(f);
274
+ }
275
+ return { kept, collapsed };
276
+ }
277
+
278
+ /* ── D3: the diff (A6, A7) ────────────────────────────────────────────────────────────────────── */
279
+
280
+ /**
281
+ * Explicit lead adjudication, read from `--adjudicate <file>`: named `pairs` OVERRIDE the
282
+ * automatic match for the ids they name (in either direction — a pair may correct a wrong auto
283
+ * match or supply one the automatic rule missed) and become CONFIRMED overlap. `none` marks
284
+ * findings the lead has confirmed have NO counterpart in the other family (excluded from the
285
+ * automatic pool, so they land in `onlyCodex`/`onlyClaude` on purpose rather than by omission).
286
+ *
287
+ * r1-5 (Codex r1 finding 5): `none` entries are now FAMILY-QUALIFIED (`"codex:<id>"` /
288
+ * `"claude:<id>"`) — a bare id like `"1"` used to apply to BOTH families whenever they happened to
289
+ * share an id, silently excluding the wrong finding from the automatic pool. A bare legacy entry
290
+ * is refused outright, not guessed.
291
+ */
292
+ export interface Adjudication {
293
+ readonly pairs: ReadonlyArray<{ readonly codex: string | number; readonly claude: string | number }>;
294
+ readonly none: readonly string[];
295
+ }
296
+
297
+ export interface ControlDiffBySeverity {
298
+ readonly confirmed: Readonly<Record<string, number>>;
299
+ readonly candidate: Readonly<Record<string, number>>;
300
+ readonly onlyCodex: Readonly<Record<string, number>>;
301
+ readonly onlyClaude: Readonly<Record<string, number>>;
302
+ }
303
+
304
+ export interface ControlDiff {
305
+ /** Adjudicated pairs ONLY — the sole bucket allowed to be called "overlap"/"matched" anywhere
306
+ * this diff travels (r1-1). */
307
+ readonly confirmed: ReadonlyArray<{ readonly codex: string; readonly claude: string }>;
308
+ /** Automatic pairs — a LOWER-BOUND CANDIDATE, never confirmed overlap, until a lead adjudicates. */
309
+ readonly candidate: ReadonlyArray<{ readonly codex: string; readonly claude: string; readonly rule: MatchPair['rule']; readonly score: number }>;
310
+ readonly onlyCodex: readonly string[];
311
+ readonly onlyClaude: readonly string[];
312
+ readonly bySeverity: ControlDiffBySeverity;
313
+ readonly matchRule: string;
314
+ readonly adjudicated: boolean;
315
+ readonly collapsed: { readonly codex: number; readonly claude: number };
316
+ }
317
+
318
+ /** A7: an adjudication naming an id neither list carries is a RESULT, never a thrown exception —
319
+ * the CLI must be able to print the reason and exit 1, not crash. */
320
+ export type ControlDiffResult = ({ readonly ok: true } & ControlDiff) | { readonly ok: false; readonly reason: string };
321
+
322
+ const MATCH_RULE_LABEL =
323
+ 'CANDIDATE only (confirmed requires adjudication): title-jaccard>=0.5 with compatible file | ' +
324
+ 'same file+line±3 AND jaccard>=0.2';
325
+
326
+ function bySeverityCounts(list: readonly ControlFinding[]): Record<string, number> {
327
+ const out: Record<string, number> = {};
328
+ for (const f of list) out[f.severity] = (out[f.severity] ?? 0) + 1;
329
+ return out;
330
+ }
331
+
332
+ /** Duplicate ids WITHIN one family's raw list are an identity error, not a matching problem
333
+ * (Codex r1 finding 4: `new Map(...)` used to silently keep only the LATER of two same-id
334
+ * findings, discarding the first without a trace). Checked before dedupe, on the raw list. */
335
+ function findDuplicateId(list: readonly ControlFinding[]): string | null {
336
+ const seen = new Set<string>();
337
+ for (const f of list) {
338
+ if (seen.has(f.id)) return f.id;
339
+ seen.add(f.id);
340
+ }
341
+ return null;
342
+ }
343
+
344
+ const NONE_ENTRY_RE = /^(codex|claude):(.+)$/;
345
+
346
+ export function diffFamilyFindings(
347
+ codex: readonly ControlFinding[],
348
+ claude: readonly ControlFinding[],
349
+ adjudication?: Adjudication,
350
+ ): ControlDiffResult {
351
+ const dupCodex = findDuplicateId(codex);
352
+ if (dupCodex !== null) return { ok: false, reason: `duplicate finding id codex:${dupCodex}` };
353
+ const dupClaude = findDuplicateId(claude);
354
+ if (dupClaude !== null) return { ok: false, reason: `duplicate finding id claude:${dupClaude}` };
355
+
356
+ const dedupCodex = dedupeWithinFamily(codex);
357
+ const dedupClaude = dedupeWithinFamily(claude);
358
+ const codexById = new Map(dedupCodex.kept.map((f) => [f.id, f] as const));
359
+ const claudeById = new Map(dedupClaude.kept.map((f) => [f.id, f] as const));
360
+
361
+ const confirmed: Array<{ codex: string; claude: string }> = [];
362
+ const excludedCodex = new Set<string>();
363
+ const excludedClaude = new Set<string>();
364
+ let adjudicated = false;
365
+
366
+ if (adjudication !== undefined) {
367
+ adjudicated = true;
368
+ // r1-5: every codex/claude endpoint may be named in `pairs` AT MOST ONCE — a repeated endpoint
369
+ // produced one-to-many matches (Codex r1 finding 5's `{codex:"c1",claude:"l1"}` +
370
+ // `{codex:"c1",claude:"l2"}`).
371
+ const usedCodexInPairs = new Set<string>();
372
+ const usedClaudeInPairs = new Set<string>();
373
+ for (const p of adjudication.pairs) {
374
+ const cId = String(p.codex);
375
+ const clId = String(p.claude);
376
+ if (usedCodexInPairs.has(cId)) return { ok: false, reason: `adjudication reuses codex finding id ${cId} in more than one pair` };
377
+ if (usedClaudeInPairs.has(clId)) return { ok: false, reason: `adjudication reuses claude finding id ${clId} in more than one pair` };
378
+ usedCodexInPairs.add(cId);
379
+ usedClaudeInPairs.add(clId);
380
+ if (!codexById.has(cId)) return { ok: false, reason: `unknown finding id codex:${cId}` };
381
+ if (!claudeById.has(clId)) return { ok: false, reason: `unknown finding id ${clId}` };
382
+ confirmed.push({ codex: cId, claude: clId });
383
+ excludedCodex.add(cId);
384
+ excludedClaude.add(clId);
385
+ }
386
+ for (const raw of adjudication.none) {
387
+ const m = NONE_ENTRY_RE.exec(raw);
388
+ if (m === null) {
389
+ return { ok: false, reason: `adjudication "none" entry ${JSON.stringify(raw)} must be family-qualified as codex:<id> or claude:<id>` };
390
+ }
391
+ const fam = m[1] as 'codex' | 'claude';
392
+ const id = m[2] as string;
393
+ if (fam === 'codex') {
394
+ if (usedCodexInPairs.has(id)) return { ok: false, reason: `adjudication pair/none conflict for codex:${id}` };
395
+ if (!codexById.has(id)) return { ok: false, reason: `unknown finding id codex:${id}` };
396
+ excludedCodex.add(id);
397
+ } else {
398
+ if (usedClaudeInPairs.has(id)) return { ok: false, reason: `adjudication pair/none conflict for claude:${id}` };
399
+ if (!claudeById.has(id)) return { ok: false, reason: `unknown finding id claude:${id}` };
400
+ excludedClaude.add(id);
401
+ }
402
+ }
403
+ }
404
+
405
+ // The automatic rule runs ONLY over what adjudication left unclaimed — a named pair or a named
406
+ // "none" always wins over the heuristic (A5/A7's "adjudication overrides the automatic match").
407
+ const remainingCodex = [...codexById.values()].filter((f) => !excludedCodex.has(f.id));
408
+ const remainingClaude = [...claudeById.values()].filter((f) => !excludedClaude.has(f.id));
409
+ const auto = matchFindings(remainingCodex, remainingClaude);
410
+ const candidate = auto.pairs.map((p) => ({ codex: p.a, claude: p.b, rule: p.rule, score: p.score }));
411
+
412
+ const confirmedCodexIds = new Set(confirmed.map((p) => p.codex));
413
+ const confirmedClaudeIds = new Set(confirmed.map((p) => p.claude));
414
+ const candidateCodexIds = new Set(candidate.map((p) => p.codex));
415
+ const candidateClaudeIds = new Set(candidate.map((p) => p.claude));
416
+ const onlyCodex = [...codexById.values()].filter((f) => !confirmedCodexIds.has(f.id) && !candidateCodexIds.has(f.id)).map((f) => f.id);
417
+ const onlyClaude = [...claudeById.values()].filter((f) => !confirmedClaudeIds.has(f.id) && !candidateClaudeIds.has(f.id)).map((f) => f.id);
418
+
419
+ const bySeverity: ControlDiffBySeverity = {
420
+ confirmed: bySeverityCounts(confirmed.map((p) => codexById.get(p.codex) as ControlFinding)),
421
+ candidate: bySeverityCounts(candidate.map((p) => codexById.get(p.codex) as ControlFinding)),
422
+ onlyCodex: bySeverityCounts(onlyCodex.map((id) => codexById.get(id) as ControlFinding)),
423
+ onlyClaude: bySeverityCounts(onlyClaude.map((id) => claudeById.get(id) as ControlFinding)),
424
+ };
425
+
426
+ return {
427
+ ok: true,
428
+ confirmed,
429
+ candidate,
430
+ onlyCodex,
431
+ onlyClaude,
432
+ bySeverity,
433
+ matchRule: MATCH_RULE_LABEL,
434
+ adjudicated,
435
+ collapsed: { codex: dedupCodex.collapsed.length, claude: dedupClaude.collapsed.length },
436
+ };
437
+ }
438
+
439
+ /* ── D4: the ledger row (FR-6; A1, A2) ────────────────────────────────────────────────────────── */
440
+
441
+ export interface ControlLedgerRow {
442
+ readonly slug: string;
443
+ readonly stage: 'control';
444
+ readonly runId: string;
445
+ /** Who wrote the code under review — the same family this control run's `--coder-family` (and
446
+ * qe-bridge's own recorded debt) named. Decides which half's `onlyX` count is FOREIGN. */
447
+ readonly coderFamily: 'codex' | 'claude';
448
+ readonly scope: readonly string[];
449
+ readonly treeShaBefore: string;
450
+ readonly treeShaAfterClaude: string;
451
+ readonly treeShaAfterCodex: string;
452
+ readonly codexGrade: string | null;
453
+ readonly claudeGrade: string | null;
454
+ readonly confirmed: number;
455
+ readonly candidate: number;
456
+ readonly onlyCodex: number;
457
+ readonly onlyClaude: number;
458
+ readonly bySeverity: ControlDiffBySeverity;
459
+ readonly matchRule: string;
460
+ readonly adjudicated: boolean;
461
+ readonly collapsed: { readonly codex: number; readonly claude: number };
462
+ /** r1-8: findings refused for cause (an unnormalizable severity, an out-of-scope file) are
463
+ * recorded DURABLY here, in the ledger row itself — never only in a best-effort convenience
464
+ * file written after the ledger, which a witnessed-reread gate never protects. */
465
+ readonly refused: { readonly claude: number; readonly codex: number; readonly reasons: readonly string[] };
466
+ /** false whenever ANY finding was refused for cause (`refused.claude + refused.codex > 0`) —
467
+ * the row still measures something real, but it is an INCOMPLETE measurement, printed as such. */
468
+ readonly complete: boolean;
469
+ readonly tokens: number | null;
470
+ readonly minutes: number | null;
471
+ }
472
+
473
+ export interface BuildControlRowInput {
474
+ readonly slug: string;
475
+ readonly runId: string;
476
+ readonly coderFamily: 'codex' | 'claude';
477
+ readonly scope: readonly string[];
478
+ readonly tree: { readonly before: string; readonly afterClaude: string; readonly afterCodex: string };
479
+ readonly codex: { readonly accepted: boolean; readonly grade: string | null };
480
+ readonly claude: { readonly accepted: boolean; readonly grade: string | null };
481
+ readonly diff: ControlDiff;
482
+ readonly refused?: { readonly claude?: number; readonly codex?: number; readonly reasons?: readonly string[] };
483
+ readonly tokens?: number | null;
484
+ readonly minutes?: number | null;
485
+ }
486
+
487
+ export type BuildControlRowResult = { readonly ok: true; readonly row: ControlLedgerRow } | { readonly ok: false; readonly reason: string };
488
+
489
+ /** A named field present but EMPTY (`{}`, an object with no keys) is not the same as absent — the
490
+ * lesson this guards: a presence-only check on a required object field lets an empty stand-in
491
+ * through. Every required nested object below is checked for at least one key, not merely typeof. */
492
+ function isNonEmptyObject(v: unknown): v is Record<string, unknown> {
493
+ return v !== null && typeof v === 'object' && !Array.isArray(v) && Object.keys(v as object).length > 0;
494
+ }
495
+
496
+ /**
497
+ * Refuses (never throws) on:
498
+ * - an empty scope (nothing was reviewed);
499
+ * - either required half missing, malformed, or carrying an EMPTY object where a real result was
500
+ * required (the empty-required-object lesson);
501
+ * - either half lacking an accepted findings table (A1) — an accepted-HOLLOW half (a genuine
502
+ * zero-findings verdict) is fine; a half whose only table was rejected, or that has none, is not;
503
+ * - a tree-hash drift across EITHER half (A2) — the claude half compares `before` to
504
+ * `afterClaude`, the codex half compares `afterClaude` to `afterCodex`; a control whose halves
505
+ * did not see the identical tree writes NO ledger row.
506
+ * Unnormalizable-severity / out-of-scope findings do NOT refuse the row outright (they are a
507
+ * partial-measurement fact, not a total failure) — they land in `refused`/`complete:false` instead.
508
+ */
509
+ export function buildControlRow(input: BuildControlRowInput): BuildControlRowResult {
510
+ if (typeof input.slug !== 'string' || input.slug.trim() === '') return { ok: false, reason: 'slug is required' };
511
+ if (typeof input.runId !== 'string' || input.runId.trim() === '') return { ok: false, reason: 'runId is required' };
512
+ if (input.coderFamily !== 'codex' && input.coderFamily !== 'claude') {
513
+ return { ok: false, reason: 'coderFamily must be codex or claude' };
514
+ }
515
+ if (!Array.isArray(input.scope) || input.scope.length === 0 || input.scope.some((s) => typeof s !== 'string' || s.trim() === '')) {
516
+ return { ok: false, reason: 'scope must be a non-empty list of files — nothing was reviewed' };
517
+ }
518
+ if (
519
+ !isNonEmptyObject(input.tree) ||
520
+ typeof input.tree.before !== 'string' || input.tree.before.trim() === '' ||
521
+ typeof input.tree.afterClaude !== 'string' || input.tree.afterClaude.trim() === '' ||
522
+ typeof input.tree.afterCodex !== 'string' || input.tree.afterCodex.trim() === ''
523
+ ) {
524
+ return { ok: false, reason: 'tree snapshot hashes (before/afterClaude/afterCodex) are required' };
525
+ }
526
+ if (input.tree.before !== input.tree.afterClaude) {
527
+ return { ok: false, reason: `treeSha drift after claude half: ${input.tree.before} -> ${input.tree.afterClaude}` };
528
+ }
529
+ if (input.tree.afterClaude !== input.tree.afterCodex) {
530
+ return { ok: false, reason: `treeSha drift after codex half: ${input.tree.afterClaude} -> ${input.tree.afterCodex}` };
531
+ }
532
+ if (!isNonEmptyObject(input.claude) || typeof input.claude.accepted !== 'boolean') {
533
+ return { ok: false, reason: 'claude half result is required' };
534
+ }
535
+ if (!input.claude.accepted) return { ok: false, reason: 'claude half has no accepted findings table' };
536
+ if (!isNonEmptyObject(input.codex) || typeof input.codex.accepted !== 'boolean') {
537
+ return { ok: false, reason: 'codex half result is required' };
538
+ }
539
+ if (!input.codex.accepted) return { ok: false, reason: 'codex half has no accepted findings table' };
540
+ if (!isNonEmptyObject(input.diff)) return { ok: false, reason: 'diff is required' };
541
+ if (!isNonEmptyObject(input.diff.bySeverity)) return { ok: false, reason: 'diff.bySeverity is required' };
542
+ if (
543
+ !Array.isArray(input.diff.confirmed) || !Array.isArray(input.diff.candidate) ||
544
+ !Array.isArray(input.diff.onlyCodex) || !Array.isArray(input.diff.onlyClaude)
545
+ ) {
546
+ return { ok: false, reason: 'diff.confirmed/candidate/onlyCodex/onlyClaude must be arrays' };
547
+ }
548
+
549
+ const refusedClaude = input.refused?.claude ?? 0;
550
+ const refusedCodex = input.refused?.codex ?? 0;
551
+ const refusedReasons = input.refused?.reasons ?? [];
552
+
553
+ return {
554
+ ok: true,
555
+ row: {
556
+ slug: input.slug,
557
+ stage: 'control',
558
+ runId: input.runId,
559
+ coderFamily: input.coderFamily,
560
+ scope: input.scope,
561
+ treeShaBefore: input.tree.before,
562
+ treeShaAfterClaude: input.tree.afterClaude,
563
+ treeShaAfterCodex: input.tree.afterCodex,
564
+ codexGrade: input.codex.grade,
565
+ claudeGrade: input.claude.grade,
566
+ confirmed: input.diff.confirmed.length,
567
+ candidate: input.diff.candidate.length,
568
+ onlyCodex: input.diff.onlyCodex.length,
569
+ onlyClaude: input.diff.onlyClaude.length,
570
+ bySeverity: input.diff.bySeverity,
571
+ matchRule: input.diff.matchRule,
572
+ adjudicated: input.diff.adjudicated,
573
+ collapsed: input.diff.collapsed,
574
+ refused: { claude: refusedClaude, codex: refusedCodex, reasons: refusedReasons },
575
+ complete: refusedClaude === 0 && refusedCodex === 0,
576
+ tokens: input.tokens ?? null,
577
+ minutes: input.minutes ?? null,
578
+ },
579
+ };
580
+ }
581
+
582
+ /* ── D5: reading the ledger back (FR-7; A8) ──────────────────────────────────────────────────── */
583
+
584
+ export interface ParsedControlRows {
585
+ readonly rows: readonly ControlLedgerRow[];
586
+ readonly roundRows: readonly Record<string, unknown>[];
587
+ readonly fullRows: readonly Record<string, unknown>[];
588
+ /** A8: a line that is not parseable JSON, or parses to something that is not a plain object, or
589
+ * is a `stage:'control'` row that fails FULL schema validation (r1-12), is counted here — never
590
+ * silently skipped and never crashing the aggregate. A well-formed line whose `stage` this
591
+ * module does not read (plan/impl/fix/loop-run/round-exec/the header comment row) is NOT
592
+ * unreadable: it was read fine, this module simply has nothing to do with it. */
593
+ readonly unreadable: number;
594
+ }
595
+
596
+ const CONTROL_BY_SEVERITY_KEYS = ['confirmed', 'candidate', 'onlyCodex', 'onlyClaude'] as const;
597
+ const QE_SEVERITY_SET: ReadonlySet<string> = new Set(QE_SEVERITIES);
598
+
599
+ function isNonNegInt(v: unknown): v is number {
600
+ return typeof v === 'number' && Number.isFinite(v) && Number.isInteger(v) && v >= 0;
601
+ }
602
+
603
+ /** A severity-count map: any object whose keys are all in the closed `QE_SEVERITIES` dictionary
604
+ * and whose values are all non-negative integers. An EMPTY map (`{}`) is valid — a genuine
605
+ * zero-findings bucket is not the same defect as the presence-only-check lesson guards against
606
+ * (that lesson is about a REQUIRED object being empty, not a legitimately-empty COUNT map). */
607
+ function isValidSeverityCounts(v: unknown): boolean {
608
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
609
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
610
+ if (!QE_SEVERITY_SET.has(k)) return false;
611
+ if (!isNonNegInt(val)) return false;
612
+ }
613
+ return true;
614
+ }
615
+
616
+ /** r1-12 (Codex r1 finding 12): `bySeverity:{bogus:1}` used to pass a presence-only check and then
617
+ * crash `aggregateByFamily`'s `Object.entries(undefined)` on the missing `onlyCodex` key. Every
618
+ * one of the four named buckets is now required to be PRESENT and individually valid. */
619
+ function isValidControlBySeverity(v: unknown): v is ControlDiffBySeverity {
620
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
621
+ const obj = v as Record<string, unknown>;
622
+ for (const k of Object.keys(obj)) if (!(CONTROL_BY_SEVERITY_KEYS as readonly string[]).includes(k)) return false;
623
+ for (const bucket of CONTROL_BY_SEVERITY_KEYS) {
624
+ if (!(bucket in obj)) return false;
625
+ if (!isValidSeverityCounts(obj[bucket])) return false;
626
+ }
627
+ return true;
628
+ }
629
+
630
+ /** Full structural validation of one `stage:'control'` ledger row (r1-12) — every field named in
631
+ * the fix-round brief, checked for TYPE and SHAPE, never merely presence. A row that fails any of
632
+ * these is `unreadable` (folded into `parsed.unreadable`, INCOMPLETE), never a thrown exception. */
633
+ function isValidControlRow(obj: Record<string, unknown>): boolean {
634
+ if (typeof obj['slug'] !== 'string' || obj['slug'].trim() === '') return false;
635
+ if (typeof obj['runId'] !== 'string' || obj['runId'].trim() === '') return false;
636
+ if (obj['coderFamily'] !== 'codex' && obj['coderFamily'] !== 'claude') return false;
637
+ const scope = obj['scope'];
638
+ if (!Array.isArray(scope) || scope.length === 0 || scope.some((s) => typeof s !== 'string' || s.trim() === '')) return false;
639
+ for (const key of ['treeShaBefore', 'treeShaAfterClaude', 'treeShaAfterCodex'] as const) {
640
+ const v = obj[key];
641
+ if (typeof v !== 'string' || v.trim() === '') return false;
642
+ }
643
+ if (!(obj['codexGrade'] === null || typeof obj['codexGrade'] === 'string')) return false;
644
+ if (!(obj['claudeGrade'] === null || typeof obj['claudeGrade'] === 'string')) return false;
645
+ for (const key of ['confirmed', 'candidate', 'onlyCodex', 'onlyClaude'] as const) {
646
+ if (!isNonNegInt(obj[key])) return false;
647
+ }
648
+ if (!isValidControlBySeverity(obj['bySeverity'])) return false;
649
+ if (typeof obj['matchRule'] !== 'string' || obj['matchRule'].trim() === '') return false;
650
+ if (typeof obj['adjudicated'] !== 'boolean') return false;
651
+ const collapsed = obj['collapsed'];
652
+ if (!isNonEmptyObject(collapsed) || !isNonNegInt(collapsed['codex']) || !isNonNegInt(collapsed['claude'])) return false;
653
+ if (typeof obj['complete'] !== 'boolean') return false;
654
+ const refused = obj['refused'];
655
+ if (!isNonEmptyObject(refused) || !isNonNegInt(refused['claude']) || !isNonNegInt(refused['codex'])) return false;
656
+ if (!Array.isArray(refused['reasons']) || refused['reasons'].some((r) => typeof r !== 'string')) return false;
657
+ if (!(obj['tokens'] === null || typeof obj['tokens'] === 'number')) return false;
658
+ if (!(obj['minutes'] === null || typeof obj['minutes'] === 'number')) return false;
659
+ // Lead delta after Codex r2 (new MEDIUM #2): RELATIONAL invariants, not only field shapes — a row
660
+ // claiming `complete:true` with refused entries, a top-level count that disagrees with its own
661
+ // severity map, or unequal tree hashes is a self-contradicting row and is unreadable.
662
+ const refusedTotal = (refused['claude'] as number) + (refused['codex'] as number);
663
+ if (obj['complete'] !== (refusedTotal === 0)) return false;
664
+ const bySev = obj['bySeverity'] as unknown as Record<string, Record<string, number>>;
665
+ for (const key of ['confirmed', 'candidate', 'onlyCodex', 'onlyClaude'] as const) {
666
+ const sum = Object.values(bySev[key] as Record<string, number>).reduce((a, b) => a + b, 0);
667
+ if (sum !== obj[key]) return false;
668
+ }
669
+ if (obj['treeShaBefore'] !== obj['treeShaAfterClaude'] || obj['treeShaAfterClaude'] !== obj['treeShaAfterCodex']) return false;
670
+ return true;
671
+ }
672
+
673
+ /**
674
+ * Reads every line of a run-cost-ledger.jsonl body, classifying `stage:'control'` rows (this
675
+ * feature's own, fully schema-validated — r1-12), `stage:'round'` and `stage:'full'` rows (the two
676
+ * existing per-review stages `aggregateByFamily` reads for its per-pair table), and counting
677
+ * everything unreadable (A8).
678
+ */
679
+ export function parseControlRows(lines: readonly string[]): ParsedControlRows {
680
+ const rows: ControlLedgerRow[] = [];
681
+ const roundRows: Record<string, unknown>[] = [];
682
+ const fullRows: Record<string, unknown>[] = [];
683
+ let unreadable = 0;
684
+ for (const raw of lines) {
685
+ const line = raw.trim();
686
+ if (line === '') continue;
687
+ let parsed: unknown;
688
+ try {
689
+ parsed = JSON.parse(line);
690
+ } catch {
691
+ unreadable++;
692
+ continue;
693
+ }
694
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
695
+ unreadable++;
696
+ continue;
697
+ }
698
+ const obj = parsed as Record<string, unknown>;
699
+ const stage = obj['stage'];
700
+ if (stage === 'control') {
701
+ if (!isValidControlRow(obj)) {
702
+ unreadable++;
703
+ continue;
704
+ }
705
+ rows.push(obj as unknown as ControlLedgerRow);
706
+ } else if (stage === 'round') {
707
+ roundRows.push(obj);
708
+ } else if (stage === 'full') {
709
+ fullRows.push(obj);
710
+ }
711
+ // Every other stage (plan/impl/fix/loop-run/round-exec/…) and the header/comment row (no
712
+ // string `stage`) are readable, just not addressed by this module.
713
+ }
714
+ return { rows, roundRows, fullRows, unreadable };
715
+ }
716
+
717
+ /* ── D6: the per-family-pair aggregate (FR-7; A6, A8) ────────────────────────────────────────── */
718
+
719
+ type SpecFamily = 'codex' | 'claude' | 'other';
720
+
721
+ /**
722
+ * r1-15 (Codex r1 finding 15): a provider-qualified spec like `anthropic/claude-sonnet-4` or
723
+ * `codex:gpt-5.6-sol:high` used to normalize to `other` because the WHOLE string never started
724
+ * with a bare family keyword. The spec is now split on BOTH `/` and `:` into segments, and any
725
+ * segment matching the closed vocabulary decides the family — order-independent, so a leading
726
+ * provider qualifier (`anthropic/…`) or a trailing modifier (`…:high`) no longer hides the model.
727
+ */
728
+ function normalizeSpecFamily(spec: unknown): SpecFamily {
729
+ if (typeof spec !== 'string') return 'other';
730
+ const s = spec.trim().toLowerCase();
731
+ if (s === '') return 'other';
732
+ const segments = s.split(/[/:]+/).filter((seg) => seg !== '');
733
+ const CLAUDE_SEGMENTS = new Set(['claude', 'sonnet', 'opus', 'fable', 'haiku', 'anthropic']);
734
+ const CODEX_SEGMENTS = new Set(['codex', 'openai']);
735
+ for (const seg of segments) if (CLAUDE_SEGMENTS.has(seg) || seg.startsWith('claude')) return 'claude';
736
+ for (const seg of segments) if (CODEX_SEGMENTS.has(seg) || seg.startsWith('gpt')) return 'codex';
737
+ return 'other';
738
+ }
739
+
740
+ export interface FamilyPairAggregate {
741
+ readonly n: number;
742
+ readonly grades: Readonly<Record<string, number>>;
743
+ readonly shippedShare: number | 'unknown';
744
+ /** r1-14: round/full rows for this pair with an EXPLICIT non-shipped outcome (refuted/blocked/
745
+ * abandoned) — named separately so a reader never mistakes "excluded from draftToShipped" for
746
+ * "no data". */
747
+ readonly notShipped: number;
748
+ readonly fixRounds: { readonly n: number; readonly mean: number | 'unknown' };
749
+ readonly foreignUnique: {
750
+ /** r1-13: the count of `stage:'control'` rows folded into THIS pair — `0` means every other
751
+ * field below is `'unknown'`, never a fabricated non-observation. */
752
+ readonly n: number;
753
+ /** Lead delta after Codex r2 (question d / new HIGH #2): control rows with `complete:false`
754
+ * (refused entries) are COUNTED here and EXCLUDED from every measured figure below — an
755
+ * incomplete measurement never contaminates the totals, and its presence marks the whole
756
+ * aggregate `incomplete`. */
757
+ readonly incompleteRuns: number;
758
+ readonly bySeverity: Readonly<Record<string, number>> | 'unknown';
759
+ /** Foreign-unique FINDINGS (summed) from complete control rows that were NOT adjudicated — a
760
+ * candidate-only figure. `'unknown'` only when `n===0`. (Codex r2 new HIGH #3: these used to
761
+ * count ROWS under a findings label; the row counts now live in `autoRuns`/`adjudicatedRuns`.) */
762
+ readonly auto: number | 'unknown';
763
+ /** Foreign-unique FINDINGS (summed) from complete control rows a lead DID adjudicate — the
764
+ * trustworthy figure. `'unknown'` only when `n===0`. */
765
+ readonly adjudicated: number | 'unknown';
766
+ readonly autoRuns: number | 'unknown';
767
+ readonly adjudicatedRuns: number | 'unknown';
768
+ };
769
+ readonly refutedShare: { readonly n: number; readonly value: number | 'unknown' };
770
+ readonly costPerConfirmed: { readonly n: number; readonly value: number | 'unknown' };
771
+ readonly draftToShipped: ReadonlyArray<{ readonly slug: string; readonly first: string; readonly final: string; readonly finals: number }>;
772
+ }
773
+
774
+ export interface FamilyAggregate {
775
+ /** Keyed `<coderFamily>:<reviewerFamily>`, e.g. `codex:claude`. */
776
+ readonly pairs: Readonly<Record<string, FamilyPairAggregate>>;
777
+ /** A8: true when `parsed.unreadable > 0` — the aggregate is honest about what it could not read. */
778
+ readonly incomplete: boolean;
779
+ /** control rows with `complete:false` — excluded from the measured figures (r2 delta) */
780
+ readonly incompleteControlRows: number;
781
+ /** A6: the raw count of `stage:'control'` rows folded in — `0` means every `foreignUnique`
782
+ * figure below is a true, honestly-printed absence, not a fabricated non-observation. */
783
+ readonly controlRows: number;
784
+ }
785
+
786
+ interface MutablePair {
787
+ n: number;
788
+ grades: Record<string, number>;
789
+ shipped: number;
790
+ shippedTotal: number;
791
+ fixRoundsList: number[];
792
+ foreignN: number;
793
+ foreignBySeverity: Record<string, number>;
794
+ foreignAuto: number;
795
+ foreignAdjudicated: number;
796
+ foreignAutoRuns: number;
797
+ foreignAdjudicatedRuns: number;
798
+ foreignIncomplete: number;
799
+ refutedN: number;
800
+ refutedSum: number;
801
+ costN: number;
802
+ costSum: number;
803
+ drafts: Array<{ slug: string; first: string; final: string; finals: number }>;
804
+ }
805
+
806
+ function newPair(): MutablePair {
807
+ return {
808
+ n: 0, grades: {}, shipped: 0, shippedTotal: 0, fixRoundsList: [],
809
+ foreignN: 0, foreignBySeverity: {}, foreignAuto: 0, foreignAdjudicated: 0, foreignAutoRuns: 0, foreignAdjudicatedRuns: 0, foreignIncomplete: 0,
810
+ refutedN: 0, refutedSum: 0, costN: 0, costSum: 0, drafts: [],
811
+ };
812
+ }
813
+
814
+ /**
815
+ * `dz score --by-family`'s aggregate: per (coder family, reviewer family) pair, how many rounds
816
+ * ran, their grade distribution, `shippedShare`/`notShipped`, mean `fixRounds`, the FOREIGN-unique
817
+ * findings measured by `control` rows for that pair's cross-family direction (split `auto` vs
818
+ * `adjudicated`, r1-1/r1-13), `refutedShare` and `costPerConfirmed` from `full` rows' findings
819
+ * tables, and `draftToShipped` — the earliest known verdict for a slug (a qe-bridge signoff, or
820
+ * this run's own claude-half grade) next to its final SHIPPED `round`/`full` grade for that exact
821
+ * (slug, family-pair) key (r1-14). Every ratio is `'unknown'`, never a fabricated `0`, when its
822
+ * denominator is zero (NFR-4: absent data is reported as absent).
823
+ */
824
+ export function aggregateByFamily(
825
+ parsed: ParsedControlRows,
826
+ signoffs?: ReadonlyArray<{ readonly slug: string; readonly emittedAt: string; readonly grade: string; readonly coderFamily: string }>,
827
+ ): FamilyAggregate {
828
+ const pairs = new Map<string, MutablePair>();
829
+ const bucket = (coder: unknown, reviewer: unknown): MutablePair => {
830
+ const key = `${normalizeSpecFamily(coder)}:${normalizeSpecFamily(reviewer)}`;
831
+ let b = pairs.get(key);
832
+ if (b === undefined) {
833
+ b = newPair();
834
+ pairs.set(key, b);
835
+ }
836
+ return b;
837
+ };
838
+
839
+ for (const row of parsed.roundRows) {
840
+ const b = bucket(row['coder'], row['reviewer']);
841
+ b.n++;
842
+ const grade = typeof row['grade'] === 'string' ? row['grade'] : null;
843
+ if (grade !== null) b.grades[grade] = (b.grades[grade] ?? 0) + 1;
844
+ const outcome = row['outcome'];
845
+ if (outcome === 'shipped') {
846
+ b.shipped++;
847
+ b.shippedTotal++;
848
+ } else if (outcome === 'refuted' || outcome === 'blocked' || outcome === 'abandoned') {
849
+ b.shippedTotal++;
850
+ }
851
+ }
852
+
853
+ for (const row of parsed.fullRows) {
854
+ const b = bucket(row['coder'], row['reviewer'] ?? null);
855
+ b.n++;
856
+ const grade = typeof row['grade'] === 'string' ? row['grade'] : null;
857
+ if (grade !== null) b.grades[grade] = (b.grades[grade] ?? 0) + 1;
858
+ if (typeof row['fixRounds'] === 'number' && Number.isFinite(row['fixRounds'])) b.fixRoundsList.push(row['fixRounds']);
859
+ const findings = row['findings'] as { status?: unknown; summary?: { byStatus?: Record<string, unknown> } } | undefined;
860
+ if (findings !== undefined && findings.status === 'present' && isNonEmptyObject(findings.summary?.byStatus)) {
861
+ const byStatus = findings.summary!.byStatus as Record<string, unknown>;
862
+ const asNum = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
863
+ const total = Object.values(byStatus).reduce((s: number, v) => s + asNum(v), 0);
864
+ if (total > 0) {
865
+ b.refutedN++;
866
+ b.refutedSum += asNum(byStatus['refuted']) / total;
867
+ }
868
+ const denom = asNum(byStatus['fixed']) + asNum(byStatus['confirmed']);
869
+ const tokens = typeof row['tokens'] === 'number' && Number.isFinite(row['tokens']) ? row['tokens'] : null;
870
+ if (tokens !== null && tokens > 0 && denom > 0) {
871
+ b.costN++;
872
+ b.costSum += tokens / denom;
873
+ }
874
+ }
875
+ }
876
+
877
+ for (const cr of parsed.rows) {
878
+ const reviewerOfInterest = cr.coderFamily === 'codex' ? 'claude' : 'codex';
879
+ const b = bucket(cr.coderFamily, reviewerOfInterest);
880
+ if (!cr.complete) { b.foreignIncomplete++; continue; } // excluded from every measured figure
881
+ b.foreignN++;
882
+ const foreign = cr.coderFamily === 'codex' ? cr.bySeverity.onlyClaude : cr.bySeverity.onlyCodex;
883
+ let foreignTotal = 0;
884
+ for (const [sev, n] of Object.entries(foreign)) { b.foreignBySeverity[sev] = (b.foreignBySeverity[sev] ?? 0) + n; foreignTotal += n; }
885
+ if (cr.adjudicated) { b.foreignAdjudicatedRuns++; b.foreignAdjudicated += foreignTotal; }
886
+ else { b.foreignAutoRuns++; b.foreignAuto += foreignTotal; }
887
+ }
888
+
889
+ // draftToShipped: earliest known verdict per slug (a qe-bridge signoff, or this control row's
890
+ // own claude-half grade when no signoff was given) against the slug's final grade — keyed by
891
+ // slug PLUS the normalized (coder,reviewer) family pair (r1-14: two final rows for the same slug
892
+ // but opposite family pairs must never overwrite each other), counting only round/full rows
893
+ // whose `outcome` is EXPLICITLY `'shipped'` (a refuted/blocked/abandoned row is never a "final"
894
+ // — its count is folded into `notShipped` above via `shippedTotal - shipped`). Several shipped
895
+ // candidates for the same key resolve to the LATEST by `ts`, and the discarded count survives as
896
+ // `finals` on the winning entry.
897
+ // Lead delta after Codex r2 (new HIGH #4): the FIRST grade is keyed by slug PLUS the family pair,
898
+ // exactly like the final side — a qe-bridge signoff is always a Claude review of `coderFamily`
899
+ // code, so its key is `<slug>::<coderFamily>:claude`; a control row's Claude half likewise.
900
+ const bySlugFirst = new Map<string, string>();
901
+ if (signoffs !== undefined) {
902
+ const sorted = [...signoffs].sort((x, y) => (x.emittedAt < y.emittedAt ? -1 : x.emittedAt > y.emittedAt ? 1 : 0));
903
+ for (const s of sorted) {
904
+ const key = `${s.slug}::${normalizeSpecFamily(s.coderFamily)}:claude`;
905
+ if (!bySlugFirst.has(key)) bySlugFirst.set(key, s.grade);
906
+ }
907
+ }
908
+ for (const cr of parsed.rows) {
909
+ const key = `${cr.slug}::${cr.coderFamily}:claude`;
910
+ if (!bySlugFirst.has(key) && cr.claudeGrade !== null) bySlugFirst.set(key, cr.claudeGrade);
911
+ }
912
+ interface FinalCandidate { readonly slug: string; readonly grade: string; readonly ts: string; readonly coder: unknown; readonly reviewer: unknown; readonly key: string }
913
+ const finalCandidatesByKey = new Map<string, FinalCandidate[]>();
914
+ for (const row of [...parsed.roundRows, ...parsed.fullRows]) {
915
+ const slug = typeof row['slug'] === 'string' ? row['slug'] : null;
916
+ const grade = typeof row['grade'] === 'string' ? row['grade'] : null;
917
+ if (slug === null || grade === null) continue;
918
+ if (row['outcome'] !== 'shipped') continue;
919
+ const key = `${slug}::${normalizeSpecFamily(row['coder'])}:${normalizeSpecFamily(row['reviewer'] ?? null)}`;
920
+ const ts = typeof row['ts'] === 'string' ? row['ts'] : '';
921
+ const list = finalCandidatesByKey.get(key) ?? [];
922
+ list.push({ slug, grade, ts, coder: row['coder'], reviewer: row['reviewer'], key });
923
+ finalCandidatesByKey.set(key, list);
924
+ }
925
+ for (const candidates of finalCandidatesByKey.values()) {
926
+ const sorted = [...candidates].sort((x, y) => (x.ts < y.ts ? -1 : x.ts > y.ts ? 1 : 0));
927
+ const final = sorted[sorted.length - 1] as FinalCandidate;
928
+ const b = bucket(final.coder, final.reviewer);
929
+ b.drafts.push({ slug: final.slug, first: bySlugFirst.get(final.key) ?? 'unknown', final: final.grade, finals: candidates.length });
930
+ }
931
+
932
+ const out: Record<string, FamilyPairAggregate> = {};
933
+ for (const [key, b] of pairs) {
934
+ out[key] = {
935
+ n: b.n,
936
+ grades: b.grades,
937
+ shippedShare: b.shippedTotal > 0 ? b.shipped / b.shippedTotal : 'unknown',
938
+ notShipped: b.shippedTotal - b.shipped,
939
+ fixRounds: {
940
+ n: b.fixRoundsList.length,
941
+ mean: b.fixRoundsList.length > 0 ? b.fixRoundsList.reduce((s, n) => s + n, 0) / b.fixRoundsList.length : 'unknown',
942
+ },
943
+ foreignUnique: {
944
+ n: b.foreignN,
945
+ incompleteRuns: b.foreignIncomplete,
946
+ bySeverity: b.foreignN > 0 ? b.foreignBySeverity : 'unknown',
947
+ auto: b.foreignN > 0 ? b.foreignAuto : 'unknown',
948
+ adjudicated: b.foreignN > 0 ? b.foreignAdjudicated : 'unknown',
949
+ autoRuns: b.foreignN > 0 ? b.foreignAutoRuns : 'unknown',
950
+ adjudicatedRuns: b.foreignN > 0 ? b.foreignAdjudicatedRuns : 'unknown',
951
+ },
952
+ refutedShare: { n: b.refutedN, value: b.refutedN > 0 ? b.refutedSum / b.refutedN : 'unknown' },
953
+ costPerConfirmed: { n: b.costN, value: b.costN > 0 ? b.costSum / b.costN : 'unknown' },
954
+ draftToShipped: b.drafts,
955
+ };
956
+ }
957
+
958
+ const incompleteControlRows = parsed.rows.filter((r) => !r.complete).length;
959
+ return { pairs: out, incomplete: parsed.unreadable > 0 || incompleteControlRows > 0, incompleteControlRows, controlRows: parsed.rows.length };
960
+ }