@mutagent/evaluator 0.1.0-alpha.6 → 0.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/.claude/skills/mutagent-evaluator/SKILL.md +23 -25
  2. package/.claude/skills/mutagent-evaluator/assets/agents/dataset-builder.md +6 -5
  3. package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +77 -14
  4. package/.claude/skills/mutagent-evaluator/assets/templates/discover-report.template.html +397 -0
  5. package/.claude/skills/mutagent-evaluator/assets/templates/eval-report.template.html +594 -0
  6. package/.claude/skills/mutagent-evaluator/assets/templates/review-report.template.html +560 -0
  7. package/.claude/skills/mutagent-evaluator/golden/judge-trajectory.prose.md +69 -0
  8. package/.claude/skills/mutagent-evaluator/references/data-registry.md +43 -0
  9. package/.claude/skills/mutagent-evaluator/references/eval-layers.md +52 -0
  10. package/.claude/skills/mutagent-evaluator/references/eval-stage.md +1 -1
  11. package/.claude/skills/mutagent-evaluator/scripts/aggregate-discover.ts +254 -9
  12. package/.claude/skills/mutagent-evaluator/scripts/audience.ts +84 -0
  13. package/.claude/skills/mutagent-evaluator/scripts/code-eval-library.ts +201 -0
  14. package/.claude/skills/mutagent-evaluator/scripts/contracts/agentspec-evals.ts +101 -39
  15. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +287 -3
  16. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-types.ts +30 -4
  17. package/.claude/skills/mutagent-evaluator/scripts/materialize-dataset.ts +130 -68
  18. package/.claude/skills/mutagent-evaluator/scripts/matrix-judge.ts +219 -1
  19. package/.claude/skills/mutagent-evaluator/scripts/merge-criteria.ts +849 -0
  20. package/.claude/skills/mutagent-evaluator/scripts/render-discover-report-v3.ts +1155 -0
  21. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report-v3.ts +610 -0
  22. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +2 -1
  23. package/.claude/skills/mutagent-evaluator/scripts/render-review-report-v3.ts +691 -0
  24. package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +10 -7
  25. package/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +46 -1
  26. package/.claude/skills/mutagent-evaluator/scripts/run-review.ts +266 -0
  27. package/.claude/skills/mutagent-evaluator/scripts/verify-render.ts +728 -0
  28. package/package.json +2 -2
@@ -0,0 +1,849 @@
1
+ /**
2
+ * scripts/merge-criteria.ts — the `*discover` near-duplicate MERGE (no data loss).
3
+ * ---------------------------------------------------------------------------
4
+ * Two mined categories can mean the SAME thing under different ids
5
+ * (`send-integrity` vs `payload-well-formed-on-send`) and today both enter the
6
+ * living suite, so the operator reads two recommendations for one behaviour.
7
+ * This module collapses the provably-same ones and SURFACES the rest.
8
+ *
9
+ * It merges at the ANNOTATION level, not the criterion level. That is the whole
10
+ * trick: rewriting an absorbed category's annotations onto the survivor lets
11
+ * `deriveMinedCriteria` recompute support, prevalence, refs, grounding and
12
+ * severity over the UNION set from real per-trace evidence — instead of
13
+ * hand-recombining already-derived strings ("2/5 sampled" + "1/4 sampled" is not
14
+ * a number you can add). Nothing is discarded anywhere in the path.
15
+ *
16
+ * ── WHY A TUNED THRESHOLD IS NOT ENOUGH (measured, 32 synthetic pairs) ───────
17
+ * The inherited `dedupNearQueries` threshold of 0.8 does NOT transfer from
18
+ * dataset queries to criterion statements: statements are longer, share a
19
+ * `Pass = ` prefix and a large functional vocabulary, so genuine restatements
20
+ * land at j≈0.33–0.75 and 0.8 catches only word-order permutations (2/16).
21
+ *
22
+ * Worse, the two distributions OVERLAP COMPLETELY — these all score at or above
23
+ * a genuine restatement while being genuinely DIFFERENT criteria:
24
+ * consent before STORING ‖ before SHARING j=0.714
25
+ * cites AT LEAST one doc ‖ AT MOST one doc j=0.667
26
+ * payload INCLUDES acct ‖ EXCLUDES acct j=0.600
27
+ * escalation for BILLING ‖ for TECHNICAL j=0.600
28
+ * tool called BEFORE ‖ AFTER the summary j=0.600
29
+ * A bag of words cannot see a polarity, scope or noun inversion: the one word
30
+ * that flips the meaning is a single token among many, and frequently a
31
+ * stopword. At j≥0.5 plain Jaccard false-merges 3/16 of them — and a false merge
32
+ * DESTROYS a real criterion, the exact data loss this work forbids.
33
+ *
34
+ * So similarity alone never decides. Two deterministic GUARDS do:
35
+ * 1. contrastive-marker parity — NEG · BEFORE · AFTER · MIN · MAX · EXACT ·
36
+ * ALL · SOME. Differing marker sets ⇒ BLOCKED.
37
+ * 2. no 1-for-1 substitution — each side holding exactly one content token the
38
+ * other lacks is a swapped noun/verb, not a rephrasing ⇒ BLOCKED.
39
+ * With both guards: ZERO false merges at every threshold from 0.4 to 0.8 across
40
+ * all 16 distinct pairs (incl. the 8 adversarial near-vocabulary ones).
41
+ *
42
+ * A blocked pair is NOT dropped and NOT merged — it is reported as a
43
+ * `NearDuplicateFinding` so the reader sees the suspected redundancy and rules
44
+ * on it. Per the operator: when merging would misrepresent the evidence, that is
45
+ * a FINDING, never a silent merge.
46
+ *
47
+ * NO LLM anywhere in this path. PURE + deterministic — no clock, no random, no
48
+ * network; the same annotations always yield the same plan (C-PIN).
49
+ */
50
+ import { fnv1aHex, jaccard } from "./build-dataset.ts";
51
+ import type { TraceAnnotation } from "./discover-criteria.ts";
52
+ import {
53
+ OutcomeVerdict,
54
+ Severity,
55
+ type DiscoveryRef,
56
+ type MinedCriterion,
57
+ type SeverityValue,
58
+ } from "./contracts/eval-types.ts";
59
+ import { assertMonotonicGrowth, type LivingSuite } from "./living-suite.ts";
60
+ import type { CodeEvalSpec } from "./code-eval.ts";
61
+
62
+ /**
63
+ * The DEFAULT similarity floor for a merge candidate. Chosen at 0.4 — the
64
+ * measured floor at which the two guards still hold false merges at ZERO while
65
+ * recall of the safely-collapsible class is highest. The threshold is NOT what
66
+ * makes the merge safe (the guards are); it only bounds how far down the
67
+ * candidate band is worth reading. Below ~0.4 the findings list fills with
68
+ * unrelated pairs without yielding further safe merges.
69
+ */
70
+ export const DEFAULT_MERGE_THRESHOLD = 0.4;
71
+
72
+ /**
73
+ * Stopwords for statement similarity. Deliberately EXCLUDES the contrastive
74
+ * words (not/never/no/before/after/only/all/any…): those are load-bearing for
75
+ * meaning and are handled by the marker guard, not thrown away here.
76
+ */
77
+ const STATEMENT_STOPWORDS = new Set([
78
+ "pass", "fail", "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
79
+ "and", "or", "but", "if", "then", "while", "of", "to", "in", "on", "at", "by", "for",
80
+ "with", "from", "as", "that", "this", "these", "those", "it", "its", "has", "have",
81
+ "had", "does", "do", "did", "so", "such", "than", "there", "their", "they", "he",
82
+ "she", "we", "you", "i", "must", "should", "shall", "will", "can", "may", "when",
83
+ ]);
84
+
85
+ /**
86
+ * Canonical CONTRASTIVE MARKERS. Each bucket collects the surface forms that
87
+ * mean the same thing (`before`/`prior`/`until`), so a true restatement that
88
+ * swaps one for another still matches, while a genuine inversion does not.
89
+ */
90
+ const CONTRASTIVE_MARKERS: readonly (readonly [RegExp, string])[] = [
91
+ [/\b(not|never|no|none|without|exclude|excludes|excluding|forbidden)\b/, "NEG"],
92
+ [/\b(before|prior|preceding|until)\b/, "BEFORE"],
93
+ [/\b(after|once|following|subsequent|subsequently)\b/, "AFTER"],
94
+ [/\bat least\b|\bno fewer than\b/, "MIN"],
95
+ [/\bat most\b|\bno more than\b|\bup to\b/, "MAX"],
96
+ [/\b(only|exactly|solely)\b/, "EXACT"],
97
+ [/\b(always|all|every)\b/, "ALL"],
98
+ [/\b(any|some)\b/, "SOME"],
99
+ ];
100
+
101
+ /** Strip the binary-statement preamble (`Pass = …`) + lowercase. PURE. */
102
+ function normalizeStatement(statement: string): string {
103
+ return statement.toLowerCase().replace(/^\s*pass\s*[=:]\s*/, "");
104
+ }
105
+
106
+ /**
107
+ * The content-token set of a criterion statement: normalized, stopword-filtered,
108
+ * single characters dropped. Stripping the `Pass = ` prefix is MANDATORY — left
109
+ * in, it inflates every pair by a shared constant and the restatement/distinct
110
+ * separation measures NEGATIVE. PURE.
111
+ */
112
+ export function statementTokens(statement: string): Set<string> {
113
+ return new Set(
114
+ normalizeStatement(statement)
115
+ .split(/[^a-z0-9]+/)
116
+ .filter((t) => t.length > 1 && !STATEMENT_STOPWORDS.has(t)),
117
+ );
118
+ }
119
+
120
+ /** The contrastive markers a statement carries (canonical buckets). PURE. */
121
+ export function contrastiveMarkers(statement: string): Set<string> {
122
+ const padded = ` ${normalizeStatement(statement)} `;
123
+ const out = new Set<string>();
124
+ for (const [re, tag] of CONTRASTIVE_MARKERS) if (re.test(padded)) out.add(tag);
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * Token-Jaccard similarity of two criterion statements, in [0,1]. Reuses the
130
+ * PROVEN `jaccard` primitive from the dataset near-dup filter — same maths, a
131
+ * statement-tuned tokenizer. PURE.
132
+ *
133
+ * ╔══════════════════════════════════════════════════════════════════════════╗
134
+ * ║ READ THIS BEFORE CHANGING THE THRESHOLD OR DELETING A GUARD. ║
135
+ * ╚══════════════════════════════════════════════════════════════════════════╝
136
+ * The obvious change here is "just raise/lower the number". That is the exact
137
+ * move that was measured and REJECTED. This score is NOT a decision function.
138
+ *
139
+ * MEASUREMENT — 32 hand-built synthetic pairs (16 genuine restatements of one
140
+ * behaviour, 16 genuinely-distinct criteria, 8 of those adversarial look-alikes),
141
+ * normalization = strip `Pass = ` + stopwords:
142
+ *
143
+ * threshold 0.8 (inherited from dedupNearQueries) → 2/16 restatements caught.
144
+ * It does not transfer: dataset QUERIES are short and noun-heavy; criterion
145
+ * STATEMENTS are long, share a `Pass = ` preamble and a big functional
146
+ * vocabulary, so real restatements land at j≈0.33–0.75, not ≥0.8.
147
+ *
148
+ * AND — the load-bearing result — the two distributions OVERLAP COMPLETELY.
149
+ * These are GENUINELY DIFFERENT criteria, scoring at or above real restatements:
150
+ *
151
+ * "Pass = the payload INCLUDES the account number"
152
+ * "Pass = the payload EXCLUDES the account number" j = 0.600
153
+ * ↑ two opposite checks — and 0.600 is ABOVE this real restatement:
154
+ * "Pass = a transient send failure schedules a retry"
155
+ * "Pass = a transient failure on send is retried" j = 0.500
156
+ *
157
+ * "…asks for consent before STORING data" ‖ "before SHARING" j = 0.714
158
+ * "…cites AT LEAST one document" ‖ "AT MOST one" j = 0.667
159
+ * "…the tool is called BEFORE the summary"‖ "AFTER" j = 0.600
160
+ *
161
+ * There is therefore NO threshold at which similarity alone is safe. At j≥0.5
162
+ * plain Jaccard false-merges 3/16 of the distinct pairs — and a false merge
163
+ * DESTROYS a real criterion while the report asserts redundancy was handled.
164
+ * A bag of words cannot see a polarity/scope/noun inversion: the one word that
165
+ * flips the meaning is a single token among many, and often a stopword.
166
+ *
167
+ * WHAT ACTUALLY HOLDS THE LINE — the two guards below, not this number:
168
+ * GUARD 1 contrastive-marker parity (NEG·BEFORE·AFTER·MIN·MAX·EXACT·ALL·SOME)
169
+ * GUARD 2 no 1-for-1 content substitution (billing↔technical, retried↔escalated)
170
+ * With BOTH: ZERO false merges at EVERY threshold from 0.4 to 0.8, across all 16
171
+ * distinct pairs including the 8 adversarial ones. The threshold only bounds how
172
+ * far down the candidate band is worth reading; the guards decide.
173
+ *
174
+ * So: if you raise this number you lose safe merges and gain nothing. If you
175
+ * lower it you gain findings-list noise. If you DELETE A GUARD you reintroduce
176
+ * silent criterion destruction. Re-run the corpus in
177
+ * `tests/merge-criteria.test.ts` (describe "NEGATIVE — genuinely different
178
+ * criteria must NOT merge") before touching any of it.
179
+ */
180
+ export function statementSimilarity(a: string, b: string): number {
181
+ return jaccard(statementTokens(a), statementTokens(b));
182
+ }
183
+
184
+ /** Set equality over the marker buckets. PURE. */
185
+ function sameMarkers(a: string, b: string): boolean {
186
+ const ma = contrastiveMarkers(a);
187
+ const mb = contrastiveMarkers(b);
188
+ if (ma.size !== mb.size) return false;
189
+ for (const t of ma) if (!mb.has(t)) return false;
190
+ return true;
191
+ }
192
+
193
+ /** The tokens each statement holds that the other does not. PURE. */
194
+ function tokenDelta(a: string, b: string): { onlyA: string[]; onlyB: string[] } {
195
+ const ta = statementTokens(a);
196
+ const tb = statementTokens(b);
197
+ return {
198
+ onlyA: [...ta].filter((t) => !tb.has(t)),
199
+ onlyB: [...tb].filter((t) => !ta.has(t)),
200
+ };
201
+ }
202
+
203
+ /** Why a near-duplicate pair was NOT merged. Each kind is a real, surfaced finding. */
204
+ export const NearDuplicateKind = {
205
+ /** the statements disagree on a polarity / ordering / quantifier marker. */
206
+ ContrastiveMarkers: "contrastive-markers",
207
+ /** exactly one content word was swapped on each side — a different subject. */
208
+ OneForOneSubstitution: "one-for-one-substitution",
209
+ /** the SAME trace is labelled differently by the two categories. */
210
+ LabelConflict: "label-conflict",
211
+ /** both carry an executable code-check and the two specs differ. */
212
+ CodeEvalConflict: "code-eval-conflict",
213
+ } as const;
214
+ export type NearDuplicateKindValue = (typeof NearDuplicateKind)[keyof typeof NearDuplicateKind];
215
+
216
+ /**
217
+ * One side of a near-duplicate pair, carried VERBATIM so a human can rule on the
218
+ * pair without re-opening the run that produced it.
219
+ */
220
+ export interface NearDuplicateSide {
221
+ id: string;
222
+ /** the binary statement, verbatim — never truncated, never re-worded. */
223
+ statement: string;
224
+ /** honest `k/n` over the traces this side annotated. */
225
+ prevalence: string;
226
+ /** the traces this side was seen on. */
227
+ traceIds: string[];
228
+ /** this side's structured grounding refs. */
229
+ refs: DiscoveryRef[];
230
+ }
231
+
232
+ /** A pair that LOOKS redundant but was deliberately kept apart — reported, never dropped. */
233
+ export interface NearDuplicateFinding {
234
+ /** the first-seen criterion id of the pair. */
235
+ a: string;
236
+ /** the later criterion id of the pair. */
237
+ b: string;
238
+ similarity: number;
239
+ kind: NearDuplicateKindValue;
240
+ /**
241
+ * The crisp "which guard, on what" line — e.g. `marker parity: [BEFORE] vs
242
+ * [AFTER]`. This is the part that tells a reader at a glance that the pair is
243
+ * a REAL distinction rather than a wording accident.
244
+ */
245
+ guardSummary: string;
246
+ /** the concrete evidence for the block (which markers, which tokens, which trace). */
247
+ detail: string;
248
+ /** BOTH sides verbatim + their evidence — what a human needs to rule. */
249
+ sides: [NearDuplicateSide, NearDuplicateSide];
250
+ }
251
+
252
+ /** One applied merge: the surviving id + everything folded into it. */
253
+ export interface CriterionMerge {
254
+ /** the FIRST-SEEN id — it survives (operator ruling). */
255
+ survivor: string;
256
+ /** absorbed ids, in first-seen order. Kept as aliases; never dropped. */
257
+ absorbed: string[];
258
+ /** per-absorbed similarity to the survivor, index-aligned with `absorbed`. */
259
+ similarity: number[];
260
+ /** the statement carried forward (the most specific of the group). */
261
+ statement: string;
262
+ /** the severity carried forward (the strongest present, else left to derivation). */
263
+ severity?: SeverityValue;
264
+ }
265
+
266
+ export interface MergePlan {
267
+ merges: CriterionMerge[];
268
+ findings: NearDuplicateFinding[];
269
+ threshold: number;
270
+ }
271
+
272
+ /** A category as seen across its annotations — the unit the plan reasons over. */
273
+ interface CategoryView {
274
+ id: string;
275
+ statement: string;
276
+ severity?: SeverityValue;
277
+ codeEval?: CodeEvalSpec;
278
+ /** traceId → label, for the label-conflict guard. */
279
+ labels: Map<string, string>;
280
+ /** this category's structured refs, in first-seen order (deduped). */
281
+ refs: DiscoveryRef[];
282
+ }
283
+
284
+ /** Project a category view into the verbatim side a pending decision carries. */
285
+ function sideOf(v: CategoryView): NearDuplicateSide {
286
+ const fails = [...v.labels.values()].filter((l) => l === OutcomeVerdict.Fail).length;
287
+ return {
288
+ id: v.id,
289
+ statement: v.statement,
290
+ prevalence: `${fails}/${v.labels.size} sampled`,
291
+ traceIds: [...v.labels.keys()],
292
+ refs: v.refs,
293
+ };
294
+ }
295
+
296
+ const SEVERITY_RANK: Record<string, number> = {
297
+ [Severity.Crit]: 4,
298
+ [Severity.High]: 3,
299
+ [Severity.Med]: 2,
300
+ [Severity.Low]: 1,
301
+ };
302
+
303
+ /** The stronger of two severities (absent loses to present). PURE. */
304
+ function strongerSeverity(a?: SeverityValue, b?: SeverityValue): SeverityValue | undefined {
305
+ if (a === undefined) return b;
306
+ if (b === undefined) return a;
307
+ return (SEVERITY_RANK[a] ?? 0) >= (SEVERITY_RANK[b] ?? 0) ? a : b;
308
+ }
309
+
310
+ /**
311
+ * The MORE SPECIFIC of two statements: more content tokens wins; ties break on
312
+ * the longer string, then on the incumbent (the survivor's). Deterministic —
313
+ * never a coin flip, never an LLM judgement. PURE.
314
+ */
315
+ function moreSpecific(incumbent: string, challenger: string): string {
316
+ const ni = statementTokens(incumbent).size;
317
+ const nc = statementTokens(challenger).size;
318
+ if (nc > ni) return challenger;
319
+ if (nc < ni) return incumbent;
320
+ return challenger.length > incumbent.length ? challenger : incumbent;
321
+ }
322
+
323
+ /** Collect one view per category, in FIRST-APPEARANCE order (the survivor order). */
324
+ function categoryViews(annotations: TraceAnnotation[]): CategoryView[] {
325
+ const views = new Map<string, CategoryView>();
326
+ for (const a of annotations) {
327
+ if (a.category === undefined || a.category.length === 0) continue;
328
+ let v = views.get(a.category);
329
+ if (v === undefined) {
330
+ v = { id: a.category, statement: a.statement ?? "", labels: new Map(), refs: [] };
331
+ views.set(a.category, v);
332
+ }
333
+ if (v.statement.length === 0 && a.statement !== undefined) v.statement = a.statement;
334
+ if (v.severity === undefined && a.severity !== undefined) v.severity = a.severity;
335
+ if (v.codeEval === undefined && a.codeEval !== undefined) v.codeEval = a.codeEval;
336
+ if (!v.labels.has(a.traceId)) v.labels.set(a.traceId, a.label);
337
+ for (const r of a.refs ?? []) {
338
+ if (!v.refs.some((x) => x.obs === r.obs && x.path === r.path && x.value === r.value)) v.refs.push(r);
339
+ }
340
+ }
341
+ return [...views.values()];
342
+ }
343
+
344
+ /** The first trace the two categories label DIFFERENTLY, if any. PURE. */
345
+ function conflictingLabel(a: CategoryView, b: CategoryView): { traceId: string; a: string; b: string } | null {
346
+ for (const [traceId, label] of a.labels) {
347
+ const other = b.labels.get(traceId);
348
+ if (other !== undefined && other !== label) return { traceId, a: label, b: other };
349
+ }
350
+ return null;
351
+ }
352
+
353
+ /** Stable structural equality for two code-eval specs (key order-insensitive). PURE. */
354
+ function sameCodeEval(a: CodeEvalSpec, b: CodeEvalSpec): boolean {
355
+ const canon = (s: CodeEvalSpec): string =>
356
+ JSON.stringify(
357
+ Object.fromEntries(Object.entries(s as Record<string, unknown>).sort(([x], [y]) => x.localeCompare(y))),
358
+ );
359
+ return canon(a) === canon(b);
360
+ }
361
+
362
+ /**
363
+ * PLAN the near-duplicate merges over a batch of annotations. Compares every
364
+ * not-yet-absorbed category against each surviving earlier one, in first-seen
365
+ * order, so the FIRST-SEEN id always survives and the plan is order-stable.
366
+ *
367
+ * A pair at or above `threshold` is either MERGED (both guards clear, evidence
368
+ * compatible) or recorded as a `NearDuplicateFinding` — never silently dropped
369
+ * and never silently merged. Pairs below the threshold are simply unrelated.
370
+ *
371
+ * Comparison always uses the ORIGINAL survivor statement, not the merged-forward
372
+ * one, so the plan does not depend on the order merges are applied. PURE.
373
+ */
374
+ export function planCriterionMerges(
375
+ annotations: TraceAnnotation[],
376
+ opts: { threshold?: number } = {},
377
+ ): MergePlan {
378
+ const threshold = opts.threshold ?? DEFAULT_MERGE_THRESHOLD;
379
+ const views = categoryViews(annotations);
380
+ const absorbed = new Set<string>();
381
+ const merges = new Map<string, CriterionMerge>();
382
+ const findings: NearDuplicateFinding[] = [];
383
+
384
+ for (let i = 0; i < views.length; i++) {
385
+ const survivor = views[i]!;
386
+ if (absorbed.has(survivor.id)) continue; // already folded into an earlier survivor
387
+ for (let j = i + 1; j < views.length; j++) {
388
+ const other = views[j]!;
389
+ if (absorbed.has(other.id)) continue;
390
+ // an empty statement carries no signal — never merge on nothing.
391
+ if (survivor.statement.length === 0 || other.statement.length === 0) continue;
392
+ const similarity = statementSimilarity(survivor.statement, other.statement);
393
+ if (similarity < threshold) continue;
394
+
395
+ // ── GUARD 1 — contrastive-marker parity (polarity / ordering / quantifier).
396
+ if (!sameMarkers(survivor.statement, other.statement)) {
397
+ const ma = [...contrastiveMarkers(survivor.statement)].sort();
398
+ const mb = [...contrastiveMarkers(other.statement)].sort();
399
+ findings.push({
400
+ a: survivor.id,
401
+ b: other.id,
402
+ similarity,
403
+ kind: NearDuplicateKind.ContrastiveMarkers,
404
+ guardSummary: `marker parity: [${ma.join(", ") || "none"}] vs [${mb.join(", ") || "none"}]`,
405
+ detail:
406
+ `wording is ${Math.round(similarity * 100)}% alike but the statements disagree on a contrastive marker ` +
407
+ `(${survivor.id}: [${ma.join(", ") || "none"}] vs ${other.id}: [${mb.join(", ") || "none"}]) — ` +
408
+ "a negation / ordering / quantifier flip changes what is being checked, so these are kept apart.",
409
+ sides: [sideOf(survivor), sideOf(other)],
410
+ });
411
+ continue;
412
+ }
413
+
414
+ // ── GUARD 2 — a 1-for-1 content-word swap is a different subject, not a rephrasing.
415
+ const delta = tokenDelta(survivor.statement, other.statement);
416
+ if (delta.onlyA.length === 1 && delta.onlyB.length === 1) {
417
+ findings.push({
418
+ a: survivor.id,
419
+ b: other.id,
420
+ similarity,
421
+ kind: NearDuplicateKind.OneForOneSubstitution,
422
+ guardSummary: `one word swapped: "${delta.onlyA[0]}" vs "${delta.onlyB[0]}"`,
423
+ detail:
424
+ `wording is ${Math.round(similarity * 100)}% alike but exactly one word is swapped ` +
425
+ `("${delta.onlyA[0]}" vs "${delta.onlyB[0]}") — a single substituted term usually names a ` +
426
+ "different subject, so these are kept apart for review rather than merged.",
427
+ sides: [sideOf(survivor), sideOf(other)],
428
+ });
429
+ continue;
430
+ }
431
+
432
+ // ── GUARD 3 — the evidence itself disagrees: same trace, different label.
433
+ const clash = conflictingLabel(survivor, other);
434
+ if (clash !== null) {
435
+ findings.push({
436
+ a: survivor.id,
437
+ b: other.id,
438
+ similarity,
439
+ kind: NearDuplicateKind.LabelConflict,
440
+ guardSummary: `evidence disagrees on trace '${clash.traceId}': ${clash.a} vs ${clash.b}`,
441
+ detail:
442
+ `wording is ${Math.round(similarity * 100)}% alike but trace '${clash.traceId}' is labelled ` +
443
+ `'${clash.a}' by ${survivor.id} and '${clash.b}' by ${other.id} — merging would claim one trace ` +
444
+ "both passed and failed the same check, so the disagreement is surfaced instead.",
445
+ sides: [sideOf(survivor), sideOf(other)],
446
+ });
447
+ continue;
448
+ }
449
+
450
+ // ── GUARD 4 — two DIFFERENT executable checks are not one check.
451
+ if (
452
+ survivor.codeEval !== undefined &&
453
+ other.codeEval !== undefined &&
454
+ !sameCodeEval(survivor.codeEval, other.codeEval)
455
+ ) {
456
+ findings.push({
457
+ a: survivor.id,
458
+ b: other.id,
459
+ similarity,
460
+ kind: NearDuplicateKind.CodeEvalConflict,
461
+ guardSummary: `different code-checks: ${survivor.codeEval.primitive} vs ${other.codeEval.primitive}`,
462
+ detail:
463
+ `wording is ${Math.round(similarity * 100)}% alike but the two carry DIFFERENT executable ` +
464
+ `code-checks (${survivor.codeEval.primitive} vs ${other.codeEval.primitive}) — merging would have to ` +
465
+ "discard one runnable spec, so both are kept.",
466
+ sides: [sideOf(survivor), sideOf(other)],
467
+ });
468
+ continue;
469
+ }
470
+
471
+ // ── MERGE: first-seen id survives; the pair is provably the same check.
472
+ const existing = merges.get(survivor.id);
473
+ const merge: CriterionMerge = existing ?? {
474
+ survivor: survivor.id,
475
+ absorbed: [],
476
+ similarity: [],
477
+ statement: survivor.statement,
478
+ ...(survivor.severity !== undefined ? { severity: survivor.severity } : {}),
479
+ };
480
+ merge.absorbed.push(other.id);
481
+ merge.similarity.push(similarity);
482
+ merge.statement = moreSpecific(merge.statement, other.statement);
483
+ const sev = strongerSeverity(merge.severity, other.severity);
484
+ if (sev !== undefined) merge.severity = sev;
485
+ merges.set(survivor.id, merge);
486
+ absorbed.add(other.id);
487
+ }
488
+ }
489
+ return { merges: [...merges.values()], findings, threshold };
490
+ }
491
+
492
+ /**
493
+ * APPLY a merge plan to the annotations: absorbed categories are RELABELLED onto
494
+ * their survivor, and the group's carried-forward statement / severity /
495
+ * judgeKind are stamped on every member so `deriveMinedCriteria`'s
496
+ * first-defined pick is the DECIDED value rather than an accident of order.
497
+ *
498
+ * Everything else is left to derivation, which is the point: support, prevalence
499
+ * (k/n), refs, grounding and confidence are all recomputed from the real union
500
+ * of per-trace evidence, so no string is ever hand-recombined.
501
+ *
502
+ * NO ANNOTATION IS DROPPED. Where the two merged categories both annotated the
503
+ * SAME trace, the pair is folded into one annotation whose `refs` is the UNION
504
+ * of both (deduped by exact `obs|path|value`), because counting one trace twice
505
+ * would inflate the prevalence denominator and misreport the evidence. That fold
506
+ * applies ONLY inside a merged group — with no merges the output is
507
+ * byte-identical to the input (zero behaviour change).
508
+ *
509
+ * A judge-class survivor that absorbs a criterion carrying a `codeEval` is
510
+ * promoted to `hybrid` (code pre-filter + judge) rather than losing the runnable
511
+ * spec — that is exactly what the two leaves jointly observed, and it keeps
512
+ * `lint-uniformity` satisfied (a judge row may not carry a codeEval). PURE.
513
+ */
514
+ export function applyCriterionMerges(
515
+ annotations: TraceAnnotation[],
516
+ plan: MergePlan,
517
+ ): TraceAnnotation[] {
518
+ if (plan.merges.length === 0) return annotations;
519
+
520
+ const survivorOf = new Map<string, CriterionMerge>();
521
+ for (const m of plan.merges) {
522
+ survivorOf.set(m.survivor, m);
523
+ for (const id of m.absorbed) survivorOf.set(id, m);
524
+ }
525
+
526
+ // the judgeKind each merged group carries forward (survivor's, promoted when a
527
+ // runnable code-check arrives from an absorbed judge-class sibling).
528
+ const judgeKindOf = new Map<string, string>();
529
+ const groupHasCodeEval = new Map<string, boolean>();
530
+ for (const a of annotations) {
531
+ const m = a.category !== undefined ? survivorOf.get(a.category) : undefined;
532
+ if (m === undefined) continue;
533
+ if (a.category === m.survivor && a.judgeKind !== undefined && !judgeKindOf.has(m.survivor)) {
534
+ judgeKindOf.set(m.survivor, a.judgeKind);
535
+ }
536
+ if (a.codeEval !== undefined) groupHasCodeEval.set(m.survivor, true);
537
+ }
538
+ for (const [survivor, kind] of judgeKindOf) {
539
+ if (kind === "llm-judge" && groupHasCodeEval.get(survivor) === true) {
540
+ judgeKindOf.set(survivor, "hybrid");
541
+ }
542
+ }
543
+
544
+ const out: TraceAnnotation[] = [];
545
+ /** `${survivorId}${traceId}` → index into `out`, for the same-trace fold. */
546
+ const foldIndex = new Map<string, number>();
547
+
548
+ for (const a of annotations) {
549
+ const m = a.category !== undefined ? survivorOf.get(a.category) : undefined;
550
+ if (m === undefined) {
551
+ out.push(a); // untouched category — byte-identical passthrough
552
+ continue;
553
+ }
554
+ const kind = judgeKindOf.get(m.survivor);
555
+ const rewritten: TraceAnnotation = {
556
+ ...a,
557
+ category: m.survivor,
558
+ statement: m.statement,
559
+ ...(m.severity !== undefined ? { severity: m.severity } : {}),
560
+ ...(kind !== undefined ? { judgeKind: kind as TraceAnnotation["judgeKind"] } : {}),
561
+ };
562
+ const key = `${m.survivor}${a.traceId}`;
563
+ const at = foldIndex.get(key);
564
+ if (at === undefined) {
565
+ foldIndex.set(key, out.length);
566
+ out.push(rewritten);
567
+ continue;
568
+ }
569
+ // SAME (survivor, trace) seen twice — fold, unioning the evidence refs so the
570
+ // absorbed category's grounding survives while the trace is counted once.
571
+ const kept = out[at]!;
572
+ const refs = [...(kept.refs ?? [])];
573
+ const seen = new Set(refs.map((r) => `${r.obs}|${r.path}|${r.value}`));
574
+ for (const r of rewritten.refs ?? []) {
575
+ const k = `${r.obs}|${r.path}|${r.value}`;
576
+ if (!seen.has(k)) {
577
+ seen.add(k);
578
+ refs.push(r);
579
+ }
580
+ }
581
+ out[at] = {
582
+ ...kept,
583
+ ...(refs.length > 0 ? { refs } : {}),
584
+ // keep the incumbent's note/pointer; adopt the absorbed one only to fill a gap.
585
+ ...(kept.note === undefined && rewritten.note !== undefined ? { note: rewritten.note } : {}),
586
+ ...(kept.evidencePointer === undefined && rewritten.evidencePointer !== undefined
587
+ ? { evidencePointer: rewritten.evidencePointer }
588
+ : {}),
589
+ ...(kept.codeEval === undefined && rewritten.codeEval !== undefined
590
+ ? { codeEval: rewritten.codeEval }
591
+ : {}),
592
+ };
593
+ }
594
+ return out;
595
+ }
596
+
597
+ /**
598
+ * Stamp the merge PROVENANCE onto the derived criteria: `mergedFrom` (what was
599
+ * absorbed) + `aliases` (the absorbed ids, kept resolvable so an older report or
600
+ * dataset recipe naming the pre-merge id still finds its criterion). Criteria
601
+ * that absorbed nothing are returned untouched. PURE.
602
+ */
603
+ export function stampMergeProvenance(
604
+ criteria: MinedCriterion[],
605
+ plan: MergePlan,
606
+ ): MinedCriterion[] {
607
+ if (plan.merges.length === 0) return criteria;
608
+ const bySurvivor = new Map(plan.merges.map((m) => [m.survivor, m]));
609
+ return criteria.map((c) => {
610
+ const m = bySurvivor.get(c.id);
611
+ if (m === undefined) return c;
612
+ const aliases = [...new Set([...(c.aliases ?? []), ...m.absorbed])];
613
+ return { ...c, mergedFrom: [...m.absorbed], aliases };
614
+ });
615
+ }
616
+
617
+ // ════════════════════════════════════════════════════════════════════════════
618
+ // PENDING NEAR-DUPLICATE DECISIONS — "nothing destroyed · nothing forgotten"
619
+ // ════════════════════════════════════════════════════════════════════════════
620
+ //
621
+ // The guards above refuse to merge a look-alike pair whenever merging could
622
+ // misrepresent it. Drawing that refusal only in the report would mean the
623
+ // finding dies when the reader closes the tab, and the NEXT run would re-derive
624
+ // the same pair as if it had never been seen. So a blocked pair becomes a
625
+ // DURABLE PENDING DECISION carried in the suite data:
626
+ //
627
+ // - STABLE IDENTITY — the same pair keeps the same `id` across runs (derived
628
+ // from the two criterion ids, order-independent), so it is recognisably the
629
+ // same open question rather than a fresh discovery each time.
630
+ // - IT STAYS UNTIL RULED — a pending decision is never dropped, not even on a
631
+ // run where the pair does not resurface. Monotonic, like the suite itself.
632
+ // - A RULING IS NEVER OVERWRITTEN — once a human rules, later runs refresh the
633
+ // evidence but leave the verdict alone.
634
+ //
635
+ // ⚠️ NOT-YET-WIRED SURFACE (deliberate, named): NOTHING in this skill consumes a
636
+ // ruling yet. Recording a decision is implemented here; APPLYING one (acting on
637
+ // `merge-approved`, propagating it into the suite, retiring the pair) belongs to
638
+ // the W4 review→validate→CALIBRATE loop, which is not built. The data shape is
639
+ // defined now so the calibration loop has something to read; a half-built apply
640
+ // path here would be worse than none. The report says so in plain words rather
641
+ // than implying a loop exists.
642
+
643
+ /** Where a near-duplicate pair stands. Only `pending` is produced by this module. */
644
+ export const NearDuplicateStatus = {
645
+ /** surfaced, awaiting a human ruling. The ONLY status `*discover` writes. */
646
+ Pending: "pending",
647
+ /** RULED: the pair really is one check — the calibration loop may merge them. */
648
+ MergeApproved: "merge-approved",
649
+ /** RULED: genuinely different criteria; stop surfacing this pair. */
650
+ KeptDistinct: "kept-distinct",
651
+ } as const;
652
+ export type NearDuplicateStatusValue =
653
+ (typeof NearDuplicateStatus)[keyof typeof NearDuplicateStatus];
654
+
655
+ /** One durable, human-rulable near-duplicate decision. */
656
+ export interface NearDuplicateDecision {
657
+ /** STABLE across runs — derived from the pair, order-independent. */
658
+ id: string;
659
+ /** the pair, sorted, so the identity does not depend on mining order. */
660
+ pair: [string, string];
661
+ status: NearDuplicateStatusValue;
662
+ /** which guard blocked the merge. */
663
+ kind: NearDuplicateKindValue;
664
+ /** the crisp "marker parity: [BEFORE] vs [AFTER]" line. */
665
+ guardSummary: string;
666
+ detail: string;
667
+ /** the most recent similarity measured for this pair. */
668
+ similarity: number;
669
+ /** BOTH statements verbatim + both evidence sets — enough to rule offline. */
670
+ sides: [NearDuplicateSide, NearDuplicateSide];
671
+ /** how many runs have surfaced this pair. A clock-free recurrence signal. */
672
+ timesSurfaced: number;
673
+ /** set ONLY by the (unbuilt) calibration loop — never by `*discover`. */
674
+ ruling?: { by: string; note: string };
675
+ }
676
+
677
+ /** The durable ledger of open (and ruled) near-duplicate questions. */
678
+ export interface NearDuplicateLedger {
679
+ decisions: NearDuplicateDecision[];
680
+ /** a pure counter, bumped per recording pass (NO clock — byte-identity). */
681
+ version: number;
682
+ }
683
+
684
+ /** A fresh empty ledger. PURE. */
685
+ export function emptyNearDuplicateLedger(): NearDuplicateLedger {
686
+ return { decisions: [], version: 0 };
687
+ }
688
+
689
+ /**
690
+ * The STABLE id of a near-duplicate pair: order-independent (sorting the two ids
691
+ * first, so `a~b` and `b~a` are one question) and content-derived via the same
692
+ * FNV-1a the dataset ids use — no clock, no random, so the same pair yields the
693
+ * same id in every run forever. PURE.
694
+ */
695
+ export function nearDuplicateId(a: string, b: string): string {
696
+ const [x, y] = [a, b].sort();
697
+ return `nd-${fnv1aHex(`${x}::${y}`)}`;
698
+ }
699
+
700
+ /**
701
+ * Fold this run's blocked look-alikes into the durable ledger.
702
+ *
703
+ * MONOTONIC — every prior decision is retained in its original order, whether or
704
+ * not it resurfaced this run ("nothing forgotten"). A pair already present is
705
+ * REFRESHED in place (latest similarity + evidence, `timesSurfaced` bumped) and
706
+ * keeps its id, so it reads as the same open question rather than a new
707
+ * discovery. A pair already RULED keeps its ruling and status untouched — this
708
+ * function never re-opens or overwrites a human decision. PURE.
709
+ */
710
+ export function recordNearDuplicateFindings(
711
+ ledger: NearDuplicateLedger,
712
+ findings: readonly NearDuplicateFinding[],
713
+ ): NearDuplicateLedger {
714
+ const byId = new Map(ledger.decisions.map((d) => [d.id, d]));
715
+ const out: NearDuplicateDecision[] = ledger.decisions.map((d) => ({ ...d }));
716
+ const indexOf = new Map(out.map((d, i) => [d.id, i]));
717
+
718
+ for (const f of findings) {
719
+ const id = nearDuplicateId(f.a, f.b);
720
+ const existing = byId.get(id);
721
+ if (existing === undefined) {
722
+ out.push({
723
+ id,
724
+ pair: [f.a, f.b].sort() as [string, string],
725
+ status: NearDuplicateStatus.Pending,
726
+ kind: f.kind,
727
+ guardSummary: f.guardSummary,
728
+ detail: f.detail,
729
+ similarity: f.similarity,
730
+ sides: f.sides,
731
+ timesSurfaced: 1,
732
+ });
733
+ indexOf.set(id, out.length - 1);
734
+ byId.set(id, out[out.length - 1]!);
735
+ continue;
736
+ }
737
+ // seen before — refresh the EVIDENCE, never the verdict.
738
+ const at = indexOf.get(id)!;
739
+ out[at] = {
740
+ ...existing,
741
+ kind: f.kind,
742
+ guardSummary: f.guardSummary,
743
+ detail: f.detail,
744
+ similarity: f.similarity,
745
+ sides: f.sides,
746
+ timesSurfaced: existing.timesSurfaced + 1,
747
+ // status + ruling deliberately NOT touched: a ruled pair stays ruled.
748
+ };
749
+ }
750
+
751
+ assertMonotonicGrowth(ledger.decisions, out, (d) => d.id);
752
+ return { decisions: out, version: ledger.version + 1 };
753
+ }
754
+
755
+ /** The decisions still awaiting a human ruling. PURE. */
756
+ export function pendingDecisions(ledger: NearDuplicateLedger): NearDuplicateDecision[] {
757
+ return ledger.decisions.filter((d) => d.status === NearDuplicateStatus.Pending);
758
+ }
759
+
760
+ /**
761
+ * Resolve a criterion id that may be a PRE-MERGE alias to the id that survives.
762
+ * Exact ids win over aliases (an id is never shadowed by someone else's alias).
763
+ * Returns `undefined` when nothing claims it. PURE.
764
+ */
765
+ export function resolveCriterionId(
766
+ criteria: readonly MinedCriterion[],
767
+ id: string,
768
+ ): string | undefined {
769
+ for (const c of criteria) if (c.id === id) return c.id;
770
+ for (const c of criteria) if ((c.aliases ?? []).includes(id)) return c.id;
771
+ return undefined;
772
+ }
773
+
774
+ // ── Suite-awareness: show the leaf what already exists (prevention, not cleanup) ──
775
+
776
+ /** One already-known criterion, as handed to the `#mode-discover` leaf. */
777
+ export interface ExistingCriterionBrief {
778
+ id: string;
779
+ statement: string;
780
+ severity: SeverityValue;
781
+ level: string;
782
+ checkedBy: string;
783
+ }
784
+
785
+ /**
786
+ * An OPEN near-duplicate question, as shown to the mining leaf. Carries the pair
787
+ * and WHY they were kept apart — deliberately NOT the evidence (see the brief's
788
+ * doc): both sides' statements are already in `criteria`, so this needs only to
789
+ * point at them.
790
+ */
791
+ export interface OpenQuestionBrief {
792
+ pair: [string, string];
793
+ /** e.g. `marker parity: [BEFORE] vs [AFTER]` — why these are still two checks. */
794
+ guardSummary: string;
795
+ }
796
+
797
+ /** The brief the mining leaf pre-reads so it can REINFORCE instead of restate. */
798
+ export interface ExistingSuiteBrief {
799
+ version: number;
800
+ total: number;
801
+ criteria: ExistingCriterionBrief[];
802
+ /**
803
+ * Pairs already awaiting a human ruling. Shown so the leaf does not mint a
804
+ * THIRD name for a behaviour that is already an open question — it should
805
+ * reuse whichever side its cluster actually matches. Present (possibly empty)
806
+ * whenever a ledger is supplied; absent when the caller passed none.
807
+ */
808
+ openQuestions?: OpenQuestionBrief[];
809
+ }
810
+
811
+ /**
812
+ * Project the living suite into the compact brief the `#mode-discover` leaf
813
+ * reads before clustering. Carries ONLY what the leaf needs to recognise an
814
+ * already-known behaviour (id + binary statement + severity + level + who checks
815
+ * it) — not the evidence, which would bias the leaf toward re-finding the same
816
+ * failures rather than reading the traces on their own terms.
817
+ *
818
+ * INFORMS, never blocks: seeing the same behaviour again is legitimate
819
+ * REINFORCING evidence for the existing criterion, so the leaf attaches the
820
+ * trace to that id instead of minting a restatement. The merge above stays the
821
+ * safety net for when it mints one anyway. PURE.
822
+ */
823
+ export function buildExistingSuiteBrief(
824
+ suite: LivingSuite<MinedCriterion>,
825
+ ledger?: NearDuplicateLedger,
826
+ ): ExistingSuiteBrief {
827
+ return {
828
+ version: suite.provenance.version,
829
+ total: suite.entries.length,
830
+ criteria: suite.entries.map((c) => ({
831
+ id: c.id,
832
+ statement: c.statement,
833
+ severity: c.metadata.severity,
834
+ level: c.metadata.level,
835
+ checkedBy: c.metadata.check_method,
836
+ })),
837
+ // OPEN QUESTIONS — the pairs already awaiting a ruling. Both sides are live
838
+ // criteria (a blocked pair is never collapsed), so their statements are
839
+ // already in `criteria` above and this only has to point at them, which is
840
+ // what keeps the brief evidence-free by construction.
841
+ ...(ledger !== undefined
842
+ ? {
843
+ openQuestions: ledger.decisions
844
+ .filter((d) => d.status === NearDuplicateStatus.Pending)
845
+ .map((d) => ({ pair: d.pair, guardSummary: d.guardSummary })),
846
+ }
847
+ : {}),
848
+ };
849
+ }