@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.
- package/.claude/skills/mutagent-evaluator/SKILL.md +23 -25
- package/.claude/skills/mutagent-evaluator/assets/agents/dataset-builder.md +6 -5
- package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +77 -14
- package/.claude/skills/mutagent-evaluator/assets/templates/discover-report.template.html +397 -0
- package/.claude/skills/mutagent-evaluator/assets/templates/eval-report.template.html +594 -0
- package/.claude/skills/mutagent-evaluator/assets/templates/review-report.template.html +560 -0
- package/.claude/skills/mutagent-evaluator/golden/judge-trajectory.prose.md +69 -0
- package/.claude/skills/mutagent-evaluator/references/data-registry.md +43 -0
- package/.claude/skills/mutagent-evaluator/references/eval-layers.md +52 -0
- package/.claude/skills/mutagent-evaluator/references/eval-stage.md +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/aggregate-discover.ts +254 -9
- package/.claude/skills/mutagent-evaluator/scripts/audience.ts +84 -0
- package/.claude/skills/mutagent-evaluator/scripts/code-eval-library.ts +201 -0
- package/.claude/skills/mutagent-evaluator/scripts/contracts/agentspec-evals.ts +101 -39
- package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +287 -3
- package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-types.ts +30 -4
- package/.claude/skills/mutagent-evaluator/scripts/materialize-dataset.ts +130 -68
- package/.claude/skills/mutagent-evaluator/scripts/matrix-judge.ts +219 -1
- package/.claude/skills/mutagent-evaluator/scripts/merge-criteria.ts +849 -0
- package/.claude/skills/mutagent-evaluator/scripts/render-discover-report-v3.ts +1155 -0
- package/.claude/skills/mutagent-evaluator/scripts/render-eval-report-v3.ts +610 -0
- package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +2 -1
- package/.claude/skills/mutagent-evaluator/scripts/render-review-report-v3.ts +691 -0
- package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +10 -7
- package/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +46 -1
- package/.claude/skills/mutagent-evaluator/scripts/run-review.ts +266 -0
- package/.claude/skills/mutagent-evaluator/scripts/verify-render.ts +728 -0
- 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
|
+
}
|