@mutagent/evaluator 0.1.0-alpha.5 → 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 (55) hide show
  1. package/.claude/skills/mutagent-evaluator/SKILL.md +28 -30
  2. package/.claude/skills/mutagent-evaluator/assets/agents/dataset-builder.md +6 -5
  3. package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +93 -30
  4. package/.claude/skills/mutagent-evaluator/assets/code-quality-criteria.yaml +75 -0
  5. package/.claude/skills/mutagent-evaluator/assets/templates/discover-report.template.html +397 -0
  6. package/.claude/skills/mutagent-evaluator/assets/templates/eval-report.template.html +594 -0
  7. package/.claude/skills/mutagent-evaluator/assets/templates/review-report.template.html +560 -0
  8. package/.claude/skills/mutagent-evaluator/golden/judge-trajectory.prose.md +69 -0
  9. package/.claude/skills/mutagent-evaluator/lenses/methodology-critic-lens.md +1 -1
  10. package/.claude/skills/mutagent-evaluator/references/data-registry.md +43 -0
  11. package/.claude/skills/mutagent-evaluator/references/edd-loop.md +6 -6
  12. package/.claude/skills/mutagent-evaluator/references/eval-layers.md +52 -0
  13. package/.claude/skills/mutagent-evaluator/references/eval-stage.md +37 -4
  14. package/.claude/skills/mutagent-evaluator/references/memory-format.md +1 -1
  15. package/.claude/skills/mutagent-evaluator/schemas/methodology-review.schema.yaml +1 -1
  16. package/.claude/skills/mutagent-evaluator/scripts/agent-dispatch.ts +0 -0
  17. package/.claude/skills/mutagent-evaluator/scripts/aggregate-discover.ts +254 -9
  18. package/.claude/skills/mutagent-evaluator/scripts/audience.ts +84 -0
  19. package/.claude/skills/mutagent-evaluator/scripts/build-review-ui.ts +320 -6
  20. package/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +32 -2
  21. package/.claude/skills/mutagent-evaluator/scripts/cli/profile-subject.ts +1 -1
  22. package/.claude/skills/mutagent-evaluator/scripts/code-eval-library.ts +201 -0
  23. package/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +60 -68
  24. package/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +3 -3
  25. package/.claude/skills/mutagent-evaluator/scripts/contracts/agentspec-evals.ts +101 -39
  26. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +326 -3
  27. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-types.ts +30 -4
  28. package/.claude/skills/mutagent-evaluator/scripts/determine-outcome.ts +8 -0
  29. package/.claude/skills/mutagent-evaluator/scripts/edd/change-request.ts +1 -1
  30. package/.claude/skills/mutagent-evaluator/scripts/edd/edd-types.ts +2 -2
  31. package/.claude/skills/mutagent-evaluator/scripts/judge-prompt-template.ts +144 -15
  32. package/.claude/skills/mutagent-evaluator/scripts/materialize-dataset.ts +130 -68
  33. package/.claude/skills/mutagent-evaluator/scripts/matrix-judge.ts +219 -1
  34. package/.claude/skills/mutagent-evaluator/scripts/memory/append.ts +1 -1
  35. package/.claude/skills/mutagent-evaluator/scripts/memory/ratify.ts +165 -0
  36. package/.claude/skills/mutagent-evaluator/scripts/merge-criteria.ts +849 -0
  37. package/.claude/skills/mutagent-evaluator/scripts/prep-tasks.ts +15 -4
  38. package/.claude/skills/mutagent-evaluator/scripts/read-manifest.ts +120 -0
  39. package/.claude/skills/mutagent-evaluator/scripts/read-unitf-traces.ts +34 -2
  40. package/.claude/skills/mutagent-evaluator/scripts/render-build-cards.ts +37 -0
  41. package/.claude/skills/mutagent-evaluator/scripts/render-discover-report-v3.ts +1155 -0
  42. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report-v3.ts +610 -0
  43. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +11 -92
  44. package/.claude/skills/mutagent-evaluator/scripts/render-review-report-v3.ts +691 -0
  45. package/.claude/skills/mutagent-evaluator/scripts/report-fragments.ts +173 -0
  46. package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +12 -9
  47. package/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +46 -1
  48. package/.claude/skills/mutagent-evaluator/scripts/run-pipeline.ts +39 -4
  49. package/.claude/skills/mutagent-evaluator/scripts/run-review.ts +266 -0
  50. package/.claude/skills/mutagent-evaluator/scripts/subject-profile.ts +82 -0
  51. package/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +2 -2
  52. package/.claude/skills/mutagent-evaluator/scripts/unitf-to-evaltrace.ts +64 -21
  53. package/.claude/skills/mutagent-evaluator/scripts/verify-render.ts +728 -0
  54. package/bin/mutagent-cli.mjs +9 -5
  55. package/package.json +2 -2
@@ -17,15 +17,22 @@
17
17
  * categories[] : { name, definition, class, fixOrEval, exampleTraceIds, … }
18
18
  *
19
19
  * AGGREGATE JOINS them into `TraceAnnotation[]` (NO hand-rolled annotations in
20
- * the path), then `deriveMinedCriteria` (§5b metadata + §5c DR-2) `growLivingSuite`
20
+ * the path), MERGES near-duplicate categories (`merge-criteria.ts` deterministic,
21
+ * no LLM), then `deriveMinedCriteria` (§5b metadata + §5c DR-2) → `growLivingSuite`
21
22
  * (append-only, monotonic). FAIL-LOUD: a missing verdict file (mirrors
22
23
  * `missingVerdictKeys`) or a category referencing an undispatched trace THROWS —
23
24
  * a dispatch gap is never a silently-fabricated annotation.
24
25
  *
26
+ * The merge runs BEFORE derivation on purpose: relabelling an absorbed category's
27
+ * annotations onto its survivor lets support / prevalence / refs / grounding be
28
+ * recomputed from the real union of per-trace evidence, rather than adding up
29
+ * already-derived strings. Nothing is dropped — a near-duplicate pair the guards
30
+ * refuse to merge is REPORTED (`mergePlan.findings`), not discarded.
31
+ *
25
32
  * PURE except for fs reads (the verdict files). No clock / random / network — the
26
33
  * mined criteria + suite are byte-identical for the same inputs (C-PIN).
27
34
  */
28
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
35
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
29
36
  import { dirname, join } from "node:path";
30
37
  import { type Static, Type } from "@sinclair/typebox";
31
38
  import { Value } from "@sinclair/typebox/value";
@@ -44,8 +51,12 @@ import type { DatasetCase } from "./contracts/dataset.ts";
44
51
  import type { CandidateItem, MatrixVerdictFile } from "./contracts/eval-matrix.ts";
45
52
  import {
46
53
  CodeEvalSpecSchema,
54
+ Generality,
47
55
  JudgeKind,
56
+ MetricDimension,
57
+ MetricLevel,
48
58
  OutcomeVerdict,
59
+ Severity,
49
60
  assertGroundingHonest,
50
61
  type DiscoveryRef,
51
62
  type EvalTrace,
@@ -55,6 +66,16 @@ import {
55
66
  } from "./contracts/eval-types.ts";
56
67
  import type { CodeEvalSpec } from "./code-eval.ts";
57
68
  import { applyDiffDiscrimination, type CriterionFireSignals } from "./diff-discriminate.ts";
69
+ import {
70
+ applyCriterionMerges,
71
+ buildExistingSuiteBrief,
72
+ emptyNearDuplicateLedger,
73
+ planCriterionMerges,
74
+ recordNearDuplicateFindings,
75
+ stampMergeProvenance,
76
+ type MergePlan,
77
+ type NearDuplicateLedger,
78
+ } from "./merge-criteria.ts";
58
79
 
59
80
  // ── The leaf's per-batch mining report (real #mode-discover output shape) ────
60
81
 
@@ -66,6 +87,29 @@ export const DiscoverLabelSchema = Type.Object(
66
87
  Type.Literal(OutcomeVerdict.Fail),
67
88
  Type.Literal(OutcomeVerdict.Uncertain),
68
89
  ]),
90
+ /**
91
+ * The ROOT cause of the failure — NOT the first visible symptom.
92
+ *
93
+ * The name is historical and the SEMANTICS OVERRIDE IT: per the
94
+ * `root_not_symptom` invariant the leaf traces each failure to its root with
95
+ * judgement, because the first thing that looks wrong is usually downstream
96
+ * of the real cause. Read this field as "root cause"; keep one criterion per
97
+ * ROOT, and let independent roots produce separate criteria.
98
+ *
99
+ * ⚠️ DO NOT RENAME to `rootCause` casually. That rename was decided once,
100
+ * never executed, and left the agent contract asserting a field this closed
101
+ * schema rejects — a leaf that followed the contract literally hard-threw the
102
+ * whole AGGREGATE (`Expected required property firstThingWrong`, plus
103
+ * `additionalProperties:false` refusing the extra key). The contract was
104
+ * deliberately aligned to this shipped name RATHER THAN performing the rename,
105
+ * because the rename is a BREAKING change to the on-disk leaf-artifact
106
+ * contract — every stored mining report would need migrating — while the
107
+ * intent it was meant to express already lives in the `root_not_symptom`
108
+ * invariant, in the workflow step "Trace each Fail to its ROOT", and in this
109
+ * comment. The field NAME was never what carried the semantics. If the rename
110
+ * is genuinely wanted, schedule it as its own breaking change WITH an artifact
111
+ * migration; do not slip it into unrelated work.
112
+ */
69
113
  firstThingWrong: Type.String(),
70
114
  evidencePointer: Type.String(),
71
115
  /** GA-1 — optional STRUCTURED grounding refs `{obs,path,value}` the leaf
@@ -92,10 +136,40 @@ export const DiscoverCategorySchema = Type.Object(
92
136
  fixOrEval: Type.Union([Type.Literal("eval-worthy"), Type.Literal("fixable")]),
93
137
  exampleTraceIds: Type.Array(Type.String({ minLength: 1 })),
94
138
  // OPTIONAL §5b metadata proposals (additive agent-contract extension).
95
- dimension: Type.Optional(Type.String({ minLength: 1 })),
96
- level: Type.Optional(Type.String({ minLength: 1 })),
97
- generality: Type.Optional(Type.String({ minLength: 1 })),
98
- severity: Type.Optional(Type.String({ minLength: 1 })),
139
+ //
140
+ // CLOSED UNIONS, not free strings. These are the leaf's PROPOSALS and
141
+ // `parseDiscoverAnnotations` carries them through to the mined criterion, so
142
+ // a typo (`level: "outputs"`) must fail LOUD at parse time rather than be
143
+ // silently ignored — silently ignoring is precisely the bug that pinned every
144
+ // mined criterion to `level: output` and made layer-coverage unreadable.
145
+ dimension: Type.Optional(
146
+ Type.Union([
147
+ Type.Literal(MetricDimension.OperationCorrectness),
148
+ Type.Literal(MetricDimension.DataCorrectness),
149
+ Type.Literal(MetricDimension.OperationalDeviation),
150
+ ]),
151
+ ),
152
+ level: Type.Optional(
153
+ Type.Union([
154
+ Type.Literal(MetricLevel.Context),
155
+ Type.Literal(MetricLevel.Output),
156
+ Type.Literal(MetricLevel.CrossStage),
157
+ ]),
158
+ ),
159
+ generality: Type.Optional(
160
+ Type.Union([
161
+ Type.Literal(Generality.GeneralStructural),
162
+ Type.Literal(Generality.SpecificSemantic),
163
+ ]),
164
+ ),
165
+ severity: Type.Optional(
166
+ Type.Union([
167
+ Type.Literal(Severity.Crit),
168
+ Type.Literal(Severity.High),
169
+ Type.Literal(Severity.Med),
170
+ Type.Literal(Severity.Low),
171
+ ]),
172
+ ),
99
173
  judgeInputs: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
100
174
  /** UNIFORM STANDARD — the executable code-check the leaf emits for a
101
175
  * deterministically-checkable category (validated against the canonical
@@ -218,6 +292,17 @@ export function parseDiscoverAnnotations(input: AggregateDiscoverInput): TraceAn
218
292
  if (meta !== undefined && meta.evidencePointer.length > 0) ann.evidencePointer = meta.evidencePointer;
219
293
  if (meta !== undefined && meta.refs !== undefined && meta.refs.length > 0) ann.refs = meta.refs;
220
294
  if (cat.judgeInputs !== undefined) ann.judgeInputs = cat.judgeInputs;
295
+ // §5b PROPOSALS — carry the leaf's OWN metadata through to the annotation.
296
+ // `deriveMinedCriteria` treats these as the value and its heuristics as the
297
+ // FALLBACK (`firstDefined(list, key) ?? derive(...)`), so present ⇒ honored,
298
+ // absent ⇒ derived. Before this, the schema accepted them and the JOIN
299
+ // dropped them on the floor: every mined criterion came out `level: output`
300
+ // with a fail-count-derived severity no matter what the leaf proposed, and
301
+ // the report's layer-coverage could never read more than one layer.
302
+ if (cat.level !== undefined) ann.level = cat.level;
303
+ if (cat.severity !== undefined) ann.severity = cat.severity;
304
+ if (cat.dimension !== undefined) ann.dimension = cat.dimension;
305
+ if (cat.generality !== undefined) ann.generality = cat.generality;
221
306
  // UNIFORM STANDARD — carry the leaf's executable code-check through to the
222
307
  // annotation so deriveMinedCriteria lands it on MinedCriterion.codeEval.
223
308
  if (cat.codeEval !== undefined) ann.codeEval = cat.codeEval as CodeEvalSpec;
@@ -229,12 +314,38 @@ export function parseDiscoverAnnotations(input: AggregateDiscoverInput): TraceAn
229
314
 
230
315
  // ── Full AGGREGATE: annotations → mined criteria → living-suite ──────────────
231
316
 
317
+ /**
318
+ * How a mined criterion relates to the suite that existed BEFORE this run.
319
+ * `merged` is NOT a member: a criterion can be new-and-merged or
320
+ * reinforced-and-merged, so the merge is carried separately on
321
+ * `MinedCriterion.mergedFrom` and the report shows both facts.
322
+ */
323
+ export const DiscoverCriterionState = {
324
+ /** minted by this run — no criterion with this id existed before. */
325
+ New: "new",
326
+ /** already in the suite; this run attached FURTHER evidence to it. */
327
+ Reinforced: "reinforced",
328
+ } as const;
329
+ export type DiscoverCriterionStateValue =
330
+ (typeof DiscoverCriterionState)[keyof typeof DiscoverCriterionState];
331
+
232
332
  export interface AggregateDiscoverResult {
233
333
  annotations: TraceAnnotation[];
234
334
  criteria: MinedCriterion[];
235
335
  suite: LivingSuite<MinedCriterion>;
236
336
  /** T6 — failure/uncertain DATASET CANDIDATES (present iff `traces` supplied). */
237
337
  datasetCandidates: DatasetCase[];
338
+ /** the near-duplicate MERGE plan actually applied (merges + surfaced findings). */
339
+ mergePlan: MergePlan;
340
+ /** criterion id → new | reinforced, judged against the PRE-run suite. */
341
+ criterionStates: Record<string, DiscoverCriterionStateValue>;
342
+ /**
343
+ * The DURABLE near-duplicate decision ledger, grown by this run. Pairs the
344
+ * guards refused to merge live here across runs with a stable identity until a
345
+ * human rules — "nothing destroyed, nothing forgotten". Persist it with
346
+ * `writeNearDuplicateLedger` and feed it back in on the next run.
347
+ */
348
+ nearDuplicateLedger: NearDuplicateLedger;
238
349
  }
239
350
 
240
351
  /**
@@ -258,9 +369,27 @@ export function aggregateDiscover(
258
369
  suite: LivingSuite<MinedCriterion>;
259
370
  traces?: EvalTrace[];
260
371
  fireSignals?: CriterionFireSignals[];
372
+ /** near-duplicate merge floor; omit for the measured default (0.4). */
373
+ mergeThreshold?: number;
374
+ /**
375
+ * The PRIOR durable near-duplicate ledger (from the last run). Omit on a
376
+ * first run. Supplying it is what stops an already-surfaced pair from being
377
+ * re-proposed as a fresh discovery every run.
378
+ */
379
+ nearDuplicateLedger?: NearDuplicateLedger;
261
380
  },
262
381
  ): AggregateDiscoverResult {
263
- const annotations = parseDiscoverAnnotations(input);
382
+ const parsed = parseDiscoverAnnotations(input);
383
+
384
+ // Near-duplicate MERGE — BEFORE derivation, so support / prevalence / refs /
385
+ // grounding are all recomputed over the real union of per-trace evidence
386
+ // instead of hand-recombining derived strings. Blocked pairs are surfaced on
387
+ // `mergePlan.findings`, never dropped and never silently merged. With no
388
+ // merges, `applyCriterionMerges` returns the input untouched (zero change).
389
+ const mergePlan = planCriterionMerges(parsed, {
390
+ ...(input.mergeThreshold !== undefined ? { threshold: input.mergeThreshold } : {}),
391
+ });
392
+ const annotations = applyCriterionMerges(parsed, mergePlan);
264
393
  let criteria = deriveMinedCriteria(annotations);
265
394
 
266
395
  // GA-11 — observed-eligibility via broken∧¬healthy diff (deterministic).
@@ -273,6 +402,20 @@ export function aggregateDiscover(
273
402
  for (const c of criteria) assertGroundingHonest(c, input.traces);
274
403
  }
275
404
 
405
+ // MERGE provenance — `mergedFrom` (what was absorbed) + `aliases` (the absorbed
406
+ // ids kept resolvable, so an older report / dataset recipe / handoff naming a
407
+ // pre-merge id still finds its criterion).
408
+ criteria = stampMergeProvenance(criteria, mergePlan);
409
+
410
+ // NEW vs REINFORCED, judged against the suite as it stood BEFORE this run.
411
+ const knownBefore = new Set(input.suite.entries.map((c) => c.id));
412
+ const criterionStates: Record<string, DiscoverCriterionStateValue> = {};
413
+ for (const c of criteria) {
414
+ criterionStates[c.id] = knownBefore.has(c.id)
415
+ ? DiscoverCriterionState.Reinforced
416
+ : DiscoverCriterionState.New;
417
+ }
418
+
276
419
  const suite = growLivingSuite(input.suite, criteria, (c) => c.id);
277
420
 
278
421
  // T6 — surface failure/uncertain DATASET CANDIDATES (reusing the EV-047 selectors)
@@ -282,7 +425,23 @@ export function aggregateDiscover(
282
425
  ? collectDatasetCandidates({ determinerSpecs: input.determinerSpecs, verdictDir: input.verdictDir, traces: input.traces })
283
426
  : [];
284
427
 
285
- return { annotations, criteria, suite, datasetCandidates };
428
+ // The undecidable band becomes DURABLE state, not a drawing: a pair a guard
429
+ // refused to merge is recorded (stable id, monotonic, rulings preserved) so it
430
+ // survives the reader closing the report and is not re-derived as new next run.
431
+ const nearDuplicateLedger = recordNearDuplicateFindings(
432
+ input.nearDuplicateLedger ?? emptyNearDuplicateLedger(),
433
+ mergePlan.findings,
434
+ );
435
+
436
+ return {
437
+ annotations,
438
+ criteria,
439
+ suite,
440
+ datasetCandidates,
441
+ mergePlan,
442
+ criterionStates,
443
+ nearDuplicateLedger,
444
+ };
286
445
  }
287
446
 
288
447
  // ── Report wiring: AGGREGATE result → report.html (mirrors writeRunReport) ───
@@ -343,6 +502,84 @@ export function writeDiscoverReport(input: WriteDiscoverReportInput): string {
343
502
  return outPath;
344
503
  }
345
504
 
505
+ // ── PREP-side: hand the EXISTING suite to the mining leaf (prevention) ───────
506
+
507
+ /** The file the `#mode-discover` leaf pre-reads to see what already exists. */
508
+ export const EXISTING_CRITERIA_FILE = "existing-criteria.json";
509
+
510
+ /**
511
+ * Write the existing living suite into the run dir as `existing-criteria.json`
512
+ * so the dispatched `#mode-discover` leaf can see WHAT ALREADY EXISTS before it
513
+ * clusters. Prevention, where the merge above is only cleanup: a leaf that
514
+ * recognises a behaviour it already has a criterion for attaches the trace to
515
+ * that criterion's id as REINFORCING evidence instead of minting a restatement
516
+ * under a new name.
517
+ *
518
+ * INFORMS, never blocks (the agent contract is explicit): observing the same
519
+ * behaviour again is legitimate evidence, and the leaf still mints a genuinely
520
+ * new criterion whenever the traces show one. An EMPTY suite still writes the
521
+ * file — "nothing exists yet" is information, and a missing file would be
522
+ * indistinguishable from a wiring gap. Returns the written path.
523
+ */
524
+ export function writeExistingSuiteBrief(
525
+ dir: string,
526
+ suite: LivingSuite<MinedCriterion>,
527
+ ledger?: NearDuplicateLedger,
528
+ ): string {
529
+ mkdirSync(dir, { recursive: true });
530
+ const outPath = join(dir, EXISTING_CRITERIA_FILE);
531
+ writeFileSync(outPath, `${JSON.stringify(buildExistingSuiteBrief(suite, ledger), null, 2)}\n`);
532
+ return outPath;
533
+ }
534
+
535
+ // ── The durable near-duplicate decision ledger (cross-run, like the suite) ───
536
+
537
+ /** The ledger filename. CROSS-RUN — lives beside the living suite, not under a run id. */
538
+ export const NEAR_DUPLICATE_LEDGER_FILE = "near-duplicate-decisions.json";
539
+
540
+ /**
541
+ * Read the durable near-duplicate ledger from `dir`, or an EMPTY ledger when it
542
+ * does not exist yet (a first run legitimately has none). A malformed ledger
543
+ * THROWS rather than being silently reset — quietly starting over would drop
544
+ * every open question and every ruling, which is precisely the
545
+ * "nothing forgotten" guarantee this file exists to keep.
546
+ */
547
+ export function readNearDuplicateLedger(dir: string): NearDuplicateLedger {
548
+ const path = join(dir, NEAR_DUPLICATE_LEDGER_FILE);
549
+ if (!existsSync(path)) return emptyNearDuplicateLedger();
550
+ const raw = readFileSync(path, "utf8");
551
+ let parsed: unknown;
552
+ try {
553
+ parsed = JSON.parse(raw);
554
+ } catch (err) {
555
+ throw new Error(
556
+ `readNearDuplicateLedger: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). ` +
557
+ "Refusing to silently start a fresh ledger — that would forget every open near-duplicate " +
558
+ "question AND every human ruling. Fix or explicitly remove the file.",
559
+ );
560
+ }
561
+ const l = parsed as Partial<NearDuplicateLedger>;
562
+ if (!Array.isArray(l.decisions) || typeof l.version !== "number") {
563
+ throw new Error(
564
+ `readNearDuplicateLedger: ${path} is not a NearDuplicateLedger ({decisions[], version}). ` +
565
+ "Refusing to silently start over (see above).",
566
+ );
567
+ }
568
+ return l as NearDuplicateLedger;
569
+ }
570
+
571
+ /**
572
+ * Persist the durable near-duplicate ledger. CROSS-RUN state (mirrors the living
573
+ * suite's location, NOT the run dir) — the whole point is that it outlives the
574
+ * run that surfaced it. Returns the written path.
575
+ */
576
+ export function writeNearDuplicateLedger(dir: string, ledger: NearDuplicateLedger): string {
577
+ mkdirSync(dir, { recursive: true });
578
+ const outPath = join(dir, NEAR_DUPLICATE_LEDGER_FILE);
579
+ writeFileSync(outPath, `${JSON.stringify(ledger, null, 2)}\n`);
580
+ return outPath;
581
+ }
582
+
346
583
  // ── T6 (B-U5) discover → DATASET CANDIDATES + unmatched-detection handoff ────
347
584
  //
348
585
  // `*discover-evals` mined CRITERIA only; T6 ALSO surfaces a failure/uncertain DATASET
@@ -449,6 +686,10 @@ export interface AggregateDiscoverCliResult {
449
686
  suiteTotal: number;
450
687
  lastAppended: number;
451
688
  criterionIds: string[];
689
+ /** near-duplicate merges applied: `survivor <- absorbed, …` (empty when none). */
690
+ merged: string[];
691
+ /** near-duplicate pairs deliberately NOT merged, surfaced for review. */
692
+ nearDuplicateFindings: string[];
452
693
  /** present iff a `report.html` out path was supplied (5th arg). */
453
694
  reportPath?: string;
454
695
  /** whether the GA-1 ground-gate ran in this CLI invocation (traces supplied). */
@@ -483,7 +724,7 @@ export function runAggregateDiscoverCli(
483
724
  ? (JSON.parse(io.readFile(tracesPath)) as EvalTrace[])
484
725
  : undefined;
485
726
 
486
- const { criteria, suite } = aggregateDiscover({
727
+ const { criteria, suite, mergePlan } = aggregateDiscover({
487
728
  determinerSpecs,
488
729
  verdictDir,
489
730
  miningReport,
@@ -512,6 +753,10 @@ export function runAggregateDiscoverCli(
512
753
  suiteTotal: suite.provenance.total,
513
754
  lastAppended: suite.provenance.lastAppended,
514
755
  criterionIds: criteria.map((c) => c.id),
756
+ merged: mergePlan.merges.map((m) => `${m.survivor} <- ${m.absorbed.join(", ")}`),
757
+ nearDuplicateFindings: mergePlan.findings.map(
758
+ (f) => `${f.a} ~ ${f.b} (${f.similarity.toFixed(2)}) NOT merged: ${f.kind}`,
759
+ ),
515
760
  ...(writtenReport !== undefined ? { reportPath: writtenReport } : {}),
516
761
  groundGateRan: traces !== undefined && traces.length > 0,
517
762
  };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * audience — the V11 report AUDIENCE CONTRACT, shared by every v3 report renderer.
3
+ *
4
+ * WHY THIS EXISTS. `render-eval-report-v3` declared `audience: "internal" | "external"`
5
+ * and NEVER READ IT, so an external render was byte-identical to an internal one; the
6
+ * discovery renderer had no audience concept at all. Both therefore shipped their
7
+ * INTERNAL methodology surface to clients. This module holds the ONE implementation so
8
+ * the rule cannot drift between surfaces — and so the review surface, when it lands,
9
+ * inherits it instead of rediscovering it.
10
+ *
11
+ * ── WHAT AN `external` RENDER STRIPS ────────────────────────────────────────────
12
+ * 1. the INTERNAL methodology surface — the eval report's ⑤ Self-Eval (MR-1..5
13
+ * self-grading) and the discovery report's ⑥ Methodology (process-as-executed,
14
+ * provenance, proof-of-work, honest gaps) — INCLUDING:
15
+ * · its TAB-BAR entry (never leave a tab that opens onto nothing), and
16
+ * · its HTML COMMENT marker. A comment ships to the client exactly like any
17
+ * other byte, so removing the visible tab and leaving `<!-- ⑤ SELF-EVAL -->`
18
+ * behind is still a leak. This is not hypothetical: it is the case the
19
+ * verifier's comment-blind matching caught.
20
+ * 2. run-internal OPERATIONAL detail — the pinned judge-model string, internal run
21
+ * ids, and the gitignored artifact path printed in the footer.
22
+ *
23
+ * ── WHAT IT KEEPS ───────────────────────────────────────────────────────────────
24
+ * verdicts · quoted evidence · findings · criteria · the gate. The client gets the
25
+ * judgment and the proof, not how the judge graded itself. The determinism CLAIM
26
+ * survives (rendered as "pinned · temp 0") because reproducibility is
27
+ * client-relevant; the model identifier is not.
28
+ *
29
+ * ── ORDERING (a real trap) ──────────────────────────────────────────────────────
30
+ * Strip AFTER slot-fill. The internal panel contains a data slot, so cutting it
31
+ * first trips the renderer's fail-loud "TEMPLATE SLOT MISSING" guard. Fill every
32
+ * slot, then remove the surface.
33
+ *
34
+ * Enforced browser-free by `verify-render.ts`, in BOTH directions — an external
35
+ * render must contain none of the internal markers, an internal render must still
36
+ * contain them. A one-directional check passes vacuously the moment a strip
37
+ * over-reaches or the renderer emits nothing.
38
+ *
39
+ * PURE — no clock, no randomness, no network, no filesystem.
40
+ */
41
+
42
+ export type Audience = "internal" | "external";
43
+
44
+ /** True when this render is for a CLIENT audience and must carry no internal surface. */
45
+ export function isExternal(input: { audience?: Audience }): boolean {
46
+ return input.audience === "external";
47
+ }
48
+
49
+ /**
50
+ * Cut `startAnchor` … `endAnchor` out of `html`. FAIL-LOUD when either anchor is
51
+ * missing — a silent no-op here would ship the very surface being removed, which is
52
+ * the worst possible failure mode for a leak strip.
53
+ *
54
+ * `consumeEnd: false` stops immediately BEFORE the end anchor — needed when the anchor
55
+ * is markup that must survive (e.g. the container's closing tag after the last panel).
56
+ *
57
+ * NOTE on choosing an end anchor: do not reach for the panel's own closing tags. The
58
+ * cards inside these panels contain the same `</div></div>` sequence, so a first-match
59
+ * end anchor cuts one card and leaves the rest. Anchor on something UNIQUE that follows
60
+ * the panel (the footer marker) and stop before it.
61
+ */
62
+ export function cutBlock(html: string, startAnchor: string, endAnchor: string, what: string, consumeEnd = true): string {
63
+ const start = html.indexOf(startAnchor);
64
+ const end = start >= 0 ? html.indexOf(endAnchor, start) : -1;
65
+ if (start < 0 || end < 0) throw new Error(`AUDIENCE STRIP anchor missing: ${what}`);
66
+ return html.slice(0, start) + html.slice(consumeEnd ? end + endAnchor.length : end);
67
+ }
68
+
69
+ /** The unique marker that follows the LAST panel in both v3 templates. */
70
+ export const FOOT_ANCHOR = ' </div>\n\n <div class="foot">';
71
+
72
+ /**
73
+ * Strip an internal methodology surface: its comment-marked panel plus its tab-bar
74
+ * entry. `tabIndex` is the `onclick="tab(N)"` index of the entry to remove — the
75
+ * internal surface is the LAST tab on both surfaces, so the remaining indices stay
76
+ * contiguous and the template's index-based `tab()` switcher keeps working.
77
+ */
78
+ export function stripInternalSurface(
79
+ html: string,
80
+ opts: { commentMarker: string; tabIndex: number; label: string },
81
+ ): string {
82
+ const withoutPanel = cutBlock(html, opts.commentMarker, FOOT_ANCHOR, `${opts.label} panel`, false);
83
+ return cutBlock(withoutPanel, `<s onclick="tab(${opts.tabIndex})">`, "</s>", `${opts.label} tab-bar entry`);
84
+ }