@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
@@ -91,6 +91,15 @@ export const MatrixCriterionSchema = Type.Object(
91
91
  ]),
92
92
  /** the 3-dimension MECE coverage tag (optional context for the judge). */
93
93
  dimension: Type.Optional(Type.String()),
94
+ /**
95
+ * WHICH evidence layer this criterion binds to (the v3 layered walk's own
96
+ * taxonomy): a judge-walk layer id (`"L0".."L4"`), a discover-proposed
97
+ * MetricLevel (`"output" | "context" | "cross-stage"`), or the criteria
98
+ * super-layer (`"criteria"`) for a cross-cutting check. ADDITIVE/OPTIONAL —
99
+ * absent ⇒ renderers default to `"criteria"` (byte-stable pre-v3 rows).
100
+ * Tolerant free string for forward-compat; consumers map to LAYER_NAMES.
101
+ */
102
+ layer: Type.Optional(Type.String()),
94
103
  /** what the judge should read for this row (write-judge-prompt "choose what to pass"). */
95
104
  judgeInputs: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
96
105
  // ── T1 (B-U3) tier-0 router (ADDITIVE, OPTIONAL — grandfather) ──────────────
@@ -138,6 +147,7 @@ export function minedToMatrixCriterion(c: MinedCriterion): MatrixCriterion {
138
147
  passCondition: c.statement,
139
148
  severity: c.metadata.severity,
140
149
  dimension: c.metadata.dimension,
150
+ layer: c.metadata.level,
141
151
  checkMethod: c.metadata.check_method,
142
152
  ...(judgeInputs.length > 0 ? { judgeInputs } : {}),
143
153
  ...(c.codeEval !== undefined ? { codeEval: c.codeEval } : {}),
@@ -281,6 +291,14 @@ export const MatrixPacketSchema = Type.Object(
281
291
  { model: Type.String({ minLength: 1 }), temperature: Type.Literal(0) },
282
292
  { additionalProperties: false },
283
293
  ),
294
+ /**
295
+ * v3 LAYERED WALK (D1=B+) — layer-scope filter for the pipeable
296
+ * `*evaluate-<layer>` commands: absent/empty = the FULL walk (L0→L4 +
297
+ * criteria); a subset = the judge runs ONLY those layer phases (criteria
298
+ * still verdicted over whatever those phases gather — never left
299
+ * unverdicted). ADDITIVE/OPTIONAL: legacy packets still validate.
300
+ */
301
+ layerScope: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
284
302
  },
285
303
  { additionalProperties: false },
286
304
  );
@@ -392,7 +410,9 @@ export type ExpectedStep = Static<typeof ExpectedStepSchema>;
392
410
  export const AgentStepSchema = Type.Object(
393
411
  {
394
412
  n: Type.Number(),
395
- tool: Type.Optional(Type.String()),
413
+ /** TOLERANT: explicit `null` = an honest "no tool call this step" (live-judge shape
414
+ * on a zero-tool trajectory) — distinct from an omitted field. */
415
+ tool: Type.Optional(Type.Union([Type.String(), Type.Null()])),
396
416
  /** ok | error | warn | false-success */
397
417
  status: Type.Optional(Type.String()),
398
418
  detail: Type.Optional(Type.String()),
@@ -448,6 +468,139 @@ export const CandidateItemSchema = Type.Object(
448
468
  );
449
469
  export type CandidateItem = Static<typeof CandidateItemSchema>;
450
470
 
471
+ // ── v3 LAYERED WALK (E-NEW-1..6,9 — ADDITIVE, OPTIONAL) ──────────────────────
472
+ // Evaluator-v3 contract (operator-frozen 2026-07-24): ONE judge per trajectory
473
+ // performs the whole evidence-layer walk internally — L0 code checks → L1
474
+ // outcome → (descend per stopping rules) L2 trajectory → L3 tool outputs → L4
475
+ // context — and EMITS one LayerVerdict per engaged layer alongside the
476
+ // (unchanged) per-criterion verdicts. Cross-layer disagreement is SIGNAL:
477
+ // aggregate DETECTS conflicts, never resolves them (D2-A / OT-1). All fields
478
+ // OPTIONAL (grandfather): pre-v3 verdict files still validate; renderers show
479
+ // NAMED degrade badges, never silent blanks.
480
+
481
+ /** The five evidence layers of the walk. */
482
+ export const LayerId = {
483
+ Code: "L0",
484
+ Outcome: "L1",
485
+ Trajectory: "L2",
486
+ ToolOutputs: "L3",
487
+ Context: "L4",
488
+ } as const;
489
+ export type LayerIdValue = (typeof LayerId)[keyof typeof LayerId];
490
+ export const LAYER_IDS: readonly string[] = Object.values(LayerId);
491
+
492
+ /** Human names — the vocabulary reports print beside the L-codes. */
493
+ export const LAYER_NAMES: Record<string, string> = {
494
+ L0: "code checks",
495
+ L1: "outcome",
496
+ L2: "trajectory",
497
+ L3: "tool outputs",
498
+ L4: "context",
499
+ };
500
+
501
+ /** One per-layer verdict from the walk (E-NEW-1). `skipped` marks layers the
502
+ * stopping rules never engaged (early-exit) — skipped ≠ undecidable. */
503
+ export const LayerVerdictSchema = Type.Object(
504
+ {
505
+ /** L0 | L1 | L2 | L3 | L4 (tolerant free string for forward-compat). */
506
+ layer: Type.String({ minLength: 1 }),
507
+ verdict: Type.Union([
508
+ Type.Literal("pass"),
509
+ Type.Literal("fail"),
510
+ Type.Literal("undecidable"),
511
+ Type.Literal("skipped"),
512
+ /** L0 EVIDENCE-STATE (live-judge shape): patterns fired, evidence recorded —
513
+ * honoring the meta-judge fence a pass/fail on L0 would violate (a fired
514
+ * pattern is a candidate, not a verdict). L0-specific. */
515
+ Type.Literal("fired"),
516
+ /** L0 CLEAN-STATE (live-judge shape): the library RAN and NO pattern fired.
517
+ * Distinct from `pass` (a pass would assert L0 proved the layer correct) and
518
+ * from `skipped` (the library did run). "no code-detectable candidate" — an
519
+ * honest clean, never a verdict on the deeper layers. */
520
+ Type.Literal("no-fires"),
521
+ ]),
522
+ /** annotation-only — may NEVER flip the verdict. */
523
+ confidence: Type.Optional(Type.Number()),
524
+ /** grounding citations (same tolerant shape as judge-step refs). */
525
+ refs: Type.Optional(Type.Array(Type.Union([Type.String(), GaRefSchema]))),
526
+ /** one-breath reasoning for THIS layer's verdict. */
527
+ note: Type.Optional(Type.String()),
528
+ /** L1: the classified scenario (STRUCTURED, not prose — E-NEW-2). */
529
+ scenario: Type.Optional(Type.String()),
530
+ /** L1: the expected terminal actions for that scenario (E-NEW-2). */
531
+ expectedExit: Type.Optional(Type.Array(Type.String())),
532
+ /** L3: the first problematic observation — everything downstream suspect. */
533
+ /** TOLERANT: explicit `null` = an honest "no problem origin exists" (live-judge
534
+ * shape — naming one would invent it), distinct from an omitted field. */
535
+ firstProblemOrigin: Type.Optional(Type.Union([Type.String(), Type.Null()])),
536
+ /**
537
+ * L4: retrieval (never fetched) vs attention (present but ignored).
538
+ *
539
+ * TOLERANT-BUT-STILL-CLASSIFIED. A live judge emitted the required distinction
540
+ * and then QUALIFIED it — "attention (transient, non-terminal — self-corrected
541
+ * before send); no retrieval gap" — which the bare 2-literal union rejected.
542
+ * Both forms are now accepted, but deliberately NOT by loosening to a free
543
+ * string: the qualified form must still BEGIN with one of the two kinds, so
544
+ * the classification stays machine-extractable (read the leading token) and a
545
+ * value that classifies nothing (`"unclear"`) is still refused. Loosening to
546
+ * `Type.String()` would have kept this file parsing while silently forfeiting
547
+ * the fail-loud property the field exists for.
548
+ */
549
+ gapKind: Type.Optional(
550
+ Type.Union([
551
+ Type.Literal("retrieval"),
552
+ Type.Literal("attention"),
553
+ Type.String({ pattern: "^(retrieval|attention)\\b" }),
554
+ ]),
555
+ ),
556
+ /**
557
+ * L2: the ONE-LINE statement of where the actual path left the expected path
558
+ * — the headline of a trajectory-layer failure, distilled out of the much
559
+ * longer `note`. A live judge emitted it alongside a `note` that walks the
560
+ * full chronological path, i.e. it is the summary a reader needs, not a
561
+ * duplicate: "mandatory reviewWithManager step omitted between draft
562
+ * generation and the terminal sendMessage".
563
+ */
564
+ divergence: Type.Optional(Type.String()),
565
+ },
566
+ { additionalProperties: false },
567
+ );
568
+ export type LayerVerdict = Static<typeof LayerVerdictSchema>;
569
+
570
+ /** E-NEW-6 — one L0 code-eval library hit (the judge runs the library itself). */
571
+ export const CodeEvalHitSchema = Type.Object(
572
+ {
573
+ /** the library pattern id (e.g. CE-P1 fault-passthrough). */
574
+ pattern: Type.String({ minLength: 1 }),
575
+ /** where it fired (obs id / step anchor). */
576
+ anchor: Type.Optional(Type.Union([Type.Number(), Type.String()])),
577
+ detail: Type.Optional(Type.String()),
578
+ },
579
+ { additionalProperties: false },
580
+ );
581
+ export type CodeEvalHit = Static<typeof CodeEvalHitSchema>;
582
+
583
+ /** E-NEW-9b — the walk's SELF-MANIFEST of emitted vs missing emissions; AGGREGATE
584
+ * folds these into the MR-5 data-completeness scorecard so a skipped expected
585
+ * emission is caught the same run, not at render time. */
586
+ export const WalkEmissionsSchema = Type.Object(
587
+ {
588
+ /** emission keys the judge DID produce. */
589
+ emitted: Type.Array(Type.String()),
590
+ /** expected-but-missing keys, each with the judge's own reason. */
591
+ missing: Type.Optional(
592
+ Type.Array(
593
+ Type.Object(
594
+ { key: Type.String({ minLength: 1 }), reason: Type.String({ minLength: 1 }) },
595
+ { additionalProperties: false },
596
+ ),
597
+ ),
598
+ ),
599
+ },
600
+ { additionalProperties: false },
601
+ );
602
+ export type WalkEmissions = Static<typeof WalkEmissionsSchema>;
603
+
451
604
  // ── MatrixVerdictFile — the Judge Agent OUTPUT (one per trajectory) ──────────
452
605
  export const MatrixVerdictFileSchema = Type.Object(
453
606
  {
@@ -508,10 +661,91 @@ export const MatrixVerdictFileSchema = Type.Object(
508
661
  Type.String(),
509
662
  Type.Object(
510
663
  {
511
- root: Type.String({ minLength: 1 }),
664
+ /** The primary root (root-not-symptom). REQUIRED on a structured localize —
665
+ * a rootless walk emits `null` (an honest "no failure to localize"), and a
666
+ * judge that has only a `summary` has its root FILLED from it at parse time
667
+ * (see the coercion below) so the invariant holds without rejecting honest
668
+ * output. */
669
+ root: Type.Union([Type.String({ minLength: 1 }), Type.Null()]),
670
+ /**
671
+ * A short SUMMARY a judge emits when there is NO failure to localize to a
672
+ * single root (a clean/hold trajectory with independent non-criterion
673
+ * deviations). Live-judge shape: instead of `root`, it summarises the whole
674
+ * trajectory and routes the deviations to `candidates`. Consumers coerce
675
+ * either to a readable band (`root` preferred, `summary` fallback).
676
+ */
677
+ summary: Type.Optional(Type.String()),
512
678
  symptom: Type.Optional(Type.String()),
679
+ /** grounding prose for the root claim (first emitted by a live D1 judge). */
680
+ evidence: Type.Optional(Type.String()),
681
+ /** the criterionIds this root OWNS (judge-emitted consolidate-by-locus hint). */
682
+ criteria: Type.Optional(Type.Array(Type.String())),
683
+ note: Type.Optional(Type.String()),
513
684
  ref: Type.Optional(Type.Union([Type.String(), GaRefSchema])),
514
685
  routeTo: Type.Optional(Type.String()),
686
+ /**
687
+ * FURTHER roots that are INDEPENDENT of `root` — not steps in one causal
688
+ * chain, but separate failures that happen to appear in the same
689
+ * trajectory. Emitted by a live judge following `root_not_symptom`'s
690
+ * "multiple INDEPENDENT roots ⇒ multiple criteria": it kept the primary
691
+ * root in `root` and listed the rest here rather than silently collapsing
692
+ * them into one locus. Consolidate-by-locus clusters on `root`; these are
693
+ * additional loci, so a consumer that reads only `root` UNDER-REPORTS.
694
+ */
695
+ independentRoots: Type.Optional(Type.Array(Type.String())),
696
+ /**
697
+ * The L2 actual-vs-expected divergence. Accepted HERE as well as on the
698
+ * layer verdict because the OPERATOR-FROZEN judge prose explicitly says
699
+ * "(L2 trajectory) … emit layerVerdicts L2 (+ divergence in localize)" —
700
+ * so a judge obeying the contract literally puts it in this object, and
701
+ * the closed schema rejected exactly that. (The live D1 judge happened to
702
+ * put it on `layerVerdicts[L2]` instead, which is why this never fired.)
703
+ * Same class as the `rootCause`/`firstThingWrong` drift: contract prose
704
+ * naming a field the schema refuses. The prose is C-PIN golden, so the
705
+ * SCHEMA accepts both placements rather than churning a frozen prompt.
706
+ */
707
+ divergence: Type.Optional(Type.String()),
708
+ /**
709
+ * An OT-1 conflict the judge surfaced UNRECONCILED (e.g. the outcome layer
710
+ * passes while the trajectory layer fails). Doctrine is to expose the
711
+ * disagreement for a human rather than have the judge resolve it, so this
712
+ * is prose stating both sides and explicitly declining to reconcile —
713
+ * frequently the single most decision-relevant field on the file.
714
+ */
715
+ /**
716
+ * An OT-1 conflict the judge surfaced UNRECONCILED (e.g. the outcome layer
717
+ * passes while the trajectory layer fails). Doctrine is to expose the
718
+ * disagreement for a human rather than have the judge resolve it. TOLERANT:
719
+ * free prose, OR a structured {sideA, sideB, note?} pair (a live judge
720
+ * emitted the structured form; both carry the same meaning — two sides,
721
+ * explicitly not reconciled). Frequently the single most decision-relevant
722
+ * field on the file.
723
+ */
724
+ conflict: Type.Optional(
725
+ Type.Union([
726
+ Type.String(),
727
+ Type.Object(
728
+ {
729
+ sideA: Type.String(),
730
+ sideB: Type.String(),
731
+ note: Type.Optional(Type.String()),
732
+ },
733
+ { additionalProperties: false },
734
+ ),
735
+ /** `null` = an honest "no conflict — the layers agree" (live-judge shape). */
736
+ Type.Null(),
737
+ ]),
738
+ ),
739
+ /**
740
+ * EV-051 routing prose. NOTE: this is the SAME CONCEPT as `routeTo` above,
741
+ * emitted under a second name by a real judge. Both are accepted because
742
+ * rejecting the file over a synonym is the worse failure — but do NOT add
743
+ * a third spelling: a closed schema carrying synonyms is how the
744
+ * `rootCause`/`firstThingWrong` drift started. `routeTo` currently has ZERO
745
+ * consumers, so consolidating onto ONE of these is a free cleanup whenever
746
+ * someone touches the routing path.
747
+ */
748
+ routing: Type.Optional(Type.String()),
515
749
  },
516
750
  { additionalProperties: false },
517
751
  ),
@@ -534,6 +768,13 @@ export const MatrixVerdictFileSchema = Type.Object(
534
768
  ]),
535
769
  ),
536
770
  ),
771
+ /**
772
+ * E-NEW additive (first emitted by a live D1 judge): per-criterion REASON a
773
+ * denseMap cell is `na` (`cid → why the scoping predicate is positively
774
+ * falsified`). `na` is the third accounting path of the completeness law —
775
+ * this carries its rationale so the report can display WHY, not just that.
776
+ */
777
+ naRationale: Type.Optional(Type.Record(Type.String(), Type.String())),
537
778
  /**
538
779
  * §9.4.2 node 1 — the EXAMINE fidelity gate. `complete:false` means the trace
539
780
  * was truncated / unreadable → the trajectory EXITS early as INCOMPLETE (no
@@ -547,6 +788,19 @@ export const MatrixVerdictFileSchema = Type.Object(
547
788
  ),
548
789
  /** §9.4.2 node 2.5 — candidate eval/dataset items for UNMATCHED detections. */
549
790
  candidates: Type.Optional(Type.Array(CandidateItemSchema)),
791
+ // ── v3 LAYERED WALK emissions (E-NEW — OPTIONAL/additive, pre-v3 files validate) ──
792
+ /** E-NEW-1 — one verdict per engaged evidence layer (skipped ≠ undecidable). */
793
+ layerVerdicts: Type.Optional(Type.Array(LayerVerdictSchema)),
794
+ /** E-NEW-1 — which layer (if any) the stopping rules exited at (e.g. "L1"). */
795
+ earlyExit: Type.Optional(Type.String()),
796
+ /** E-NEW-1/9 — how many layers the walk actually engaged (economy stat).
797
+ * TOLERANT: a count OR the engaged layer-id list (live-judge shape,
798
+ * e.g. ["L0","L1","L2"] — richer; consumers coerce via length). */
799
+ layersEngaged: Type.Optional(Type.Union([Type.Number(), Type.Array(Type.String())])),
800
+ /** E-NEW-6 — L0 code-eval library hits the judge recorded (it runs the library itself). */
801
+ codeEvalHits: Type.Optional(Type.Array(CodeEvalHitSchema)),
802
+ /** E-NEW-9b — the walk's self-manifest of emitted vs missing emissions (→ MR-5). */
803
+ emissions: Type.Optional(WalkEmissionsSchema),
550
804
  /**
551
805
  * the per-trajectory judge SELF-REPORTED health micro-summary. NOTE: this is
552
806
  * IGNORED for the report's health roll-up — T4 DERIVES health from the walk
@@ -578,7 +832,9 @@ export type MatrixVerdictFile = Static<typeof MatrixVerdictFileSchema>;
578
832
  export function localizeText(localize: MatrixVerdictFile["localize"]): string {
579
833
  if (localize === undefined) return "";
580
834
  if (typeof localize === "string") return localize;
581
- return localize.symptom !== undefined ? `${localize.root} (symptom: ${localize.symptom})` : localize.root;
835
+ // `null` root = an honest "no failure to localize" the summary carries the band.
836
+ const root = localize.root ?? localize.summary ?? "";
837
+ return localize.symptom !== undefined ? `${root} (symptom: ${localize.symptom})` : root;
582
838
  }
583
839
 
584
840
  /**
@@ -594,6 +850,34 @@ export function parseMatrixVerdictFile(raw: string): MatrixVerdictFile {
594
850
  } catch {
595
851
  throw new Error(`parseMatrixVerdictFile: not valid JSON: ${raw.slice(0, 120)}`);
596
852
  }
853
+ // COERCE the live-judge rootless-localize: a judge with NO single root to localize
854
+ // emits a structured localize WITHOUT `root` — carrying a `summary` (clean/hold
855
+ // trajectory) or a `conflict` (a real disagreement whose disagreement IS the thing to
856
+ // localize). Rather than weaken the contract's required-root invariant (a guard test
857
+ // asserts a structured localize with no root is refused), FILL the root from the
858
+ // summary, or from the conflict's own text, so honest rootless output is accepted AND
859
+ // the invariant holds. NOTHING is discarded (root takes precedence when present). A
860
+ // structured localize with NONE of root/summary/conflict still fails — the guard shape.
861
+ if (
862
+ parsed !== null &&
863
+ typeof parsed === "object" &&
864
+ !Array.isArray(parsed) &&
865
+ (parsed as Record<string, unknown>)["localize"] !== null &&
866
+ typeof (parsed as Record<string, unknown>)["localize"] === "object"
867
+ ) {
868
+ const loc = (parsed as Record<string, unknown>)["localize"] as Record<string, unknown>;
869
+ if (loc["root"] === undefined) {
870
+ if (typeof loc["summary"] === "string") {
871
+ loc["root"] = loc["summary"];
872
+ } else if (typeof loc["conflict"] === "string") {
873
+ loc["root"] = loc["conflict"];
874
+ } else if (loc["conflict"] !== null && typeof loc["conflict"] === "object") {
875
+ const c = loc["conflict"] as Record<string, unknown>;
876
+ const parts = [c["sideA"], c["sideB"], c["note"]].filter((x): x is string => typeof x === "string");
877
+ if (parts.length > 0) loc["root"] = parts.join(" · ");
878
+ }
879
+ }
880
+ }
597
881
  if (!Value.Check(MatrixVerdictFileSchema, parsed)) {
598
882
  const first = [...Value.Errors(MatrixVerdictFileSchema, parsed)][0];
599
883
  throw new Error(
@@ -642,6 +642,22 @@ export interface DiscoveryRationale {
642
642
  export interface MinedCriterion extends DiscoveredCriterion {
643
643
  metadata: MetricMetadata;
644
644
  discovery: DiscoveryRationale;
645
+ /**
646
+ * The criterion ids ABSORBED into this one by the near-duplicate MERGE
647
+ * (`scripts/merge-criteria.ts`). Provenance, not a pointer: the absorbed
648
+ * criterion's evidence lives on THIS criterion (refs unioned, prevalence
649
+ * recomputed over the union), so the merge is lossless and auditable. The
650
+ * FIRST-SEEN id survives; every absorbed id is listed here. Absent ⇒ nothing
651
+ * was merged into this criterion.
652
+ */
653
+ mergedFrom?: string[];
654
+ /**
655
+ * Alternate ids that RESOLVE to this criterion — the absorbed ids kept live so
656
+ * an older report, a dataset recipe or a handoff that names the pre-merge id
657
+ * still finds its criterion (`resolveCriterionId`). A merge must never
658
+ * invalidate an outstanding reference.
659
+ */
660
+ aliases?: string[];
645
661
  /**
646
662
  * The EXECUTABLE code-check reference (the uniform-standard companion of the
647
663
  * human-readable `statement`). REQUIRED when `metadata.check_method` is
@@ -848,6 +864,10 @@ export const MinedCriterionSchema = Type.Object(
848
864
  supportCount: Type.Integer({ minimum: 0 }),
849
865
  metadata: MetricMetadataSchema,
850
866
  discovery: DiscoveryRationaleSchema,
867
+ /** near-duplicate MERGE provenance — the absorbed ids (first-seen id survives). */
868
+ mergedFrom: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
869
+ /** alternate ids that still RESOLVE here (an absorbed id never goes dead). */
870
+ aliases: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
851
871
  /** the executable code-check reference (uniform standard); OPTIONAL at the
852
872
  * schema layer — `lint-uniformity.ts` enforces presence by check_method. */
853
873
  codeEval: Type.Optional(CodeEvalSpecSchema),
@@ -987,12 +1007,18 @@ function locateByObs(obs: string, traces: EvalTrace[]): EvalTrace | undefined {
987
1007
 
988
1008
  /**
989
1009
  * Read the value at a dotted `path` (e.g. `output.response`, `observations.0.output`)
990
- * out of a trace. Numeric segments index arrays. Returns `undefined` when ANY
991
- * segment is absent (no value at that path) the caller treats that as
992
- * UNRESOLVED. An empty path reads the whole trace object. PURE.
1010
+ * out of a trace. Numeric segments index arrays. Bracket indices are TOLERATED
1011
+ * (`observations[3].output` `observations.3.output`judges naturally emit the
1012
+ * bracket form; a syntax preference must never manufacture a dead ref). Returns
1013
+ * `undefined` when ANY segment is absent (no value at that path) — the caller
1014
+ * treats that as UNRESOLVED. An empty path reads the whole trace object. PURE.
993
1015
  */
994
1016
  function readPath(root: unknown, path: string): unknown {
995
- const segments = path.split(".").map((s) => s.trim()).filter((s) => s.length > 0);
1017
+ const segments = path
1018
+ .replace(/\[(\d+)\]/g, ".$1")
1019
+ .split(".")
1020
+ .map((s) => s.trim())
1021
+ .filter((s) => s.length > 0);
996
1022
  let cur: unknown = root;
997
1023
  for (const seg of segments) {
998
1024
  if (cur === null || cur === undefined) return undefined;