@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
@@ -1,24 +1,27 @@
1
1
  /**
2
- * scripts/materialize-dataset.ts — F8: MATERIALIZE real dataset items from the
3
- * agentspec (Type A — PURE). Seed → ACTUAL items, not just definitions.
2
+ * scripts/materialize-dataset.ts — F8: PROJECT the agentspec's real dataset items
3
+ * into runnable evaluator DatasetCases (Type A — PURE).
4
4
  * ---------------------------------------------------------------------------
5
- * The ADL `*build` stage emits an agentspec whose `definition.evals` carries the
6
- * dataset DEFINITION (dataset_categories[] × edge_cases[]) + scenarios[]. The old
7
- * `*build-dataset` only had the dimensions/definitions; F8 requires it to
8
- * MATERIALIZE real, runnable DatasetCases ≥1 per category, plus one per declared
9
- * edge_case BEFORE the dataset-builder agent expands them synthetically.
5
+ * AgentSpec 0.3.0 (D18/D19) moved the dataset from a DEFINITION (0.2's
6
+ * `dataset_categories[] × edge_cases[]`, which the evaluator had to synthesize
7
+ * base+edge queries from) to `spec.evaluation.datasets[].items[]` REAL,
8
+ * already-materialized rows, each carrying `category` / `scenarioRef` / a `case`
9
+ * assignment over the dataset's `caseDimensions`, plus kind-specific `input` /
10
+ * `expected` payloads.
10
11
  *
11
- * The materialized cases are the SEED layer (source: "seed"):
12
- * - one BASE item per category from a category-tagged scenario when present,
13
- * else synthesized from the category description ("seed, don't duplicate").
14
- * - one EDGE item per declared edge_case query = the edge_case phrase in the
15
- * category's context; flagged `edge_case: "true"` in its tuple.
12
+ * So materialization is now a faithful PROJECTION, not a synthesis:
13
+ * - one DatasetCase per spec item (the seed layer, source: "seed").
14
+ * - the case TUPLE = the item's `category` + its `case` assignment (the axes the
15
+ * dataset varies oversee `dimensionsFromAgentspec`).
16
+ * - the case QUERY = the item's opaque `input` payload rendered deterministically
17
+ * (raw string when `input` is a string; a stable key-sorted serialization
18
+ * otherwise), so a re-projection is byte-identical.
16
19
  *
17
- * These are REAL queries (non-empty NL), not category definitions. The
18
- * dataset-builder agent then expands them; build-dataset.ts's deterministic
19
- * id/dedup/merge dedups any overlap. Subject-agnostic. DETERMINISTIC — stable
20
- * content-derived ids, scenarios/categories/edge_cases consumed in given order;
21
- * no clock / random / network → re-materializing is byte-identical (C-PIN).
20
+ * The dataset-builder agent then EXPANDS these seeds synthetically; build-dataset.ts's
21
+ * deterministic id/dedup/merge dedups any overlap. Subject-agnostic (EV-002): axes
22
+ * and values come from the agentspec, never hard-coded. DETERMINISTIC — items and
23
+ * axes consumed in given order; no clock / random / network → re-projecting is
24
+ * byte-identical (C-PIN).
22
25
  */
23
26
  import {
24
27
  type Dataset,
@@ -26,88 +29,147 @@ import {
26
29
  type DatasetTuple,
27
30
  type Dimension,
28
31
  } from "./contracts/dataset.ts";
29
- import type { AgentspecEvals, AgentspecScenario } from "./contracts/agentspec-evals.ts";
32
+ import type {
33
+ SpecEvaluation,
34
+ SpecEvalDatasetItem,
35
+ } from "./contracts/agentspec-evals.ts";
30
36
  import { buildCase, appendToDataset } from "./build-dataset.ts";
31
37
  import { CaseSource } from "./contracts/dataset.ts";
32
38
 
33
- /** The literal tuple values for the boolean edge_case dimension (DatasetTuple is string-valued). */
34
- const EDGE_TRUE = "true";
35
- const EDGE_FALSE = "false";
39
+ /** The tuple key under which an item's dataset-local category is recorded. */
40
+ const CATEGORY_DIM = "category";
36
41
 
37
42
  /**
38
- * The dimensions the materialized dataset varies over: `category` (values = the
39
- * category ids) + `edge_case` (boolean). Subject-agnostic derived from the
40
- * agentspec, never hard-coded. PURE.
43
+ * Deterministic, key-sorted serialization a stable rendering of an opaque item
44
+ * payload so two projections of the same payload are byte-identical regardless of
45
+ * key insertion order. PURE.
41
46
  */
42
- export function dimensionsFromAgentspec(evals: AgentspecEvals): Dimension[] {
43
- const categories = evals.dataset_categories.map((c) => c.id);
47
+ function stableStringify(value: unknown): string {
48
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
49
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
50
+ const record = value as Record<string, unknown>;
51
+ const body = Object.keys(record)
52
+ .sort()
53
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`)
54
+ .join(",");
55
+ return `{${body}}`;
56
+ }
57
+
58
+ /**
59
+ * The dimensions the projected dataset varies over: `category` (values = the
60
+ * dataset-local category ids, first-seen order) + one dimension per distinct
61
+ * `caseDimensions` axis (values merged across datasets, first-seen order). Both are
62
+ * derived from the agentspec, never hard-coded. Subject-agnostic. PURE.
63
+ */
64
+ export function dimensionsFromAgentspec(evaluation: SpecEvaluation): Dimension[] {
44
65
  const dims: Dimension[] = [];
45
- if (categories.length > 0) {
46
- dims.push({ name: "category", description: "the dataset-category slice", values: categories });
66
+
67
+ // category dimension the union of dataset-local category ids (first-seen, deduped).
68
+ const categoryIds: string[] = [];
69
+ const seenCategory = new Set<string>();
70
+ for (const dataset of evaluation.datasets) {
71
+ for (const category of dataset.categories ?? []) {
72
+ if (seenCategory.has(category.id)) continue;
73
+ seenCategory.add(category.id);
74
+ categoryIds.push(category.id);
75
+ }
76
+ }
77
+ if (categoryIds.length > 0) {
78
+ dims.push({ name: CATEGORY_DIM, description: "the dataset-local category slice", values: categoryIds });
47
79
  }
48
- dims.push({ name: "edge_case", description: "is this an edge/adversarial case", values: [EDGE_FALSE, EDGE_TRUE] });
49
- return dims;
50
- }
51
80
 
52
- /** The first scenario tagged into `categoryId` (given order), if any. PURE. */
53
- function baseScenarioFor(evals: AgentspecEvals, categoryId: string): AgentspecScenario | undefined {
54
- return evals.scenarios.find((s) => s.category === categoryId && s.edge_case !== true);
81
+ // caseDimensions each named axis, values merged across datasets (first-seen, deduped).
82
+ const axisOrder: string[] = [];
83
+ const axes = new Map<string, { description?: string; values: string[]; seen: Set<string> }>();
84
+ for (const dataset of evaluation.datasets) {
85
+ for (const [name, axis] of Object.entries(dataset.caseDimensions ?? {})) {
86
+ let entry = axes.get(name);
87
+ if (entry === undefined) {
88
+ entry = { values: [], seen: new Set<string>() };
89
+ if (axis.description !== undefined) entry.description = axis.description;
90
+ axes.set(name, entry);
91
+ axisOrder.push(name);
92
+ }
93
+ for (const value of axis.values) {
94
+ if (entry.seen.has(value)) continue;
95
+ entry.seen.add(value);
96
+ entry.values.push(value);
97
+ }
98
+ }
99
+ }
100
+ for (const name of axisOrder) {
101
+ const entry = axes.get(name);
102
+ if (entry === undefined) continue;
103
+ const dim: Dimension = { name, values: entry.values };
104
+ if (entry.description !== undefined) dim.description = entry.description;
105
+ dims.push(dim);
106
+ }
107
+
108
+ return dims;
55
109
  }
56
110
 
57
- /** The base query for a category — the scenario text when tagged, else the description. PURE. */
58
- function baseQueryFor(evals: AgentspecEvals, categoryId: string, description: string): string {
59
- const scenario = baseScenarioFor(evals, categoryId);
60
- return scenario !== undefined ? scenario.description : description;
111
+ /**
112
+ * The variation tuple an item realizes — its dataset-local `category` (when present)
113
+ * plus its `case` axis assignments. Falls back to the item id when an item declares
114
+ * neither (a DatasetTuple must have ≥1 assignment). PURE.
115
+ */
116
+ function tupleFromItem(item: SpecEvalDatasetItem): DatasetTuple {
117
+ const tuple: DatasetTuple = {};
118
+ if (item.category !== undefined && item.category.length > 0) tuple[CATEGORY_DIM] = item.category;
119
+ for (const [axis, value] of Object.entries(item.case ?? {})) tuple[axis] = value;
120
+ if (Object.keys(tuple).length === 0) tuple.item = item.id;
121
+ return tuple;
61
122
  }
62
123
 
63
- /** The edge query for an edge_case phrase in a category context. PURE. */
64
- function edgeQueryFor(categoryId: string, edgePhrase: string): string {
65
- // A real NL query expressing the edge condition in the category's context.
66
- return `[${categoryId}] handle the edge case: ${edgePhrase}`;
124
+ /**
125
+ * The natural-language query an item realizes — its opaque `input` payload rendered
126
+ * deterministically. A string input is used verbatim; any other payload is
127
+ * stable-serialized; a missing payload falls back to a deterministic descriptor so
128
+ * the query is always non-empty (DatasetCase requires it). PURE.
129
+ */
130
+ function queryFromItem(item: SpecEvalDatasetItem): string {
131
+ const input = item.input;
132
+ if (typeof input === "string" && input.length > 0) return input;
133
+ if (input !== undefined) {
134
+ const rendered = stableStringify(input);
135
+ if (rendered.length > 0) return rendered;
136
+ }
137
+ const context = item.category ?? item.scenarioRef ?? item.id;
138
+ return `[${context}] ${item.id}`;
67
139
  }
68
140
 
69
141
  /**
70
- * MATERIALIZE real DatasetCases from the agentspec.evals seed (F8). For EACH
71
- * category: one base item (≥1 per category the success gate) + one item per
72
- * declared edge_case (flagged edge_case). DETERMINISTIC, deduped by content id.
73
- * Returns [] for an empty evals block (no categories → no items). PURE.
142
+ * PROJECT the agentspec's real dataset items (0.3.0 `spec.evaluation.datasets[].items[]`)
143
+ * into runnable DatasetCases (F8), one seed case per item. DETERMINISTIC, deduped by
144
+ * content id (a re-projection collides → drop). Returns [] when no dataset carries
145
+ * items. PURE.
74
146
  */
75
- export function materializeFromAgentspec(evals: AgentspecEvals): DatasetCase[] {
147
+ export function materializeFromAgentspec(evaluation: SpecEvaluation): DatasetCase[] {
76
148
  const out: DatasetCase[] = [];
77
149
  const seen = new Set<string>();
78
- const push = (tuple: DatasetTuple, query: string): void => {
79
- const c = buildCase(tuple, query, CaseSource.Seed);
80
- if (seen.has(c.id)) return; // content-id dedup (re-materialize collides → drop)
81
- seen.add(c.id);
82
- out.push(c);
83
- };
84
- for (const cat of evals.dataset_categories) {
85
- // base item — one real query per category.
86
- push(
87
- { category: cat.id, edge_case: EDGE_FALSE },
88
- baseQueryFor(evals, cat.id, cat.description),
89
- );
90
- // edge items — one real query per declared edge_case.
91
- for (const edge of cat.edge_cases) {
92
- if (edge.length === 0) continue;
93
- push({ category: cat.id, edge_case: EDGE_TRUE }, edgeQueryFor(cat.id, edge));
150
+ for (const dataset of evaluation.datasets) {
151
+ for (const item of dataset.items ?? []) {
152
+ const c = buildCase(tupleFromItem(item), queryFromItem(item), CaseSource.Seed);
153
+ if (seen.has(c.id)) continue; // content-id dedup (re-project collides → drop)
154
+ seen.add(c.id);
155
+ out.push(c);
94
156
  }
95
157
  }
96
158
  return out;
97
159
  }
98
160
 
99
161
  /**
100
- * Assemble (or extend) a Dataset seeded with the materialized real items. When
101
- * `existing` is supplied, the materialized cases are merged MONOTONICALLY (no
162
+ * Assemble (or extend) a Dataset seeded with the projected real items. When
163
+ * `existing` is supplied, the projected cases are merged MONOTONICALLY (no
102
164
  * duplicates, version bumps). Subject-agnostic. DETERMINISTIC. PURE.
103
165
  */
104
166
  export function materializeToDataset(
105
167
  subject: string,
106
- evals: AgentspecEvals,
168
+ evaluation: SpecEvaluation,
107
169
  existing?: Dataset,
108
170
  ): Dataset {
109
- const dimensions = dimensionsFromAgentspec(evals);
110
- const cases = materializeFromAgentspec(evals);
171
+ const dimensions = dimensionsFromAgentspec(evaluation);
172
+ const cases = materializeFromAgentspec(evaluation);
111
173
  const base: Dataset = existing ?? { subject, dimensions, cases: [], version: 0 };
112
174
  return appendToDataset(base, cases);
113
175
  }
@@ -85,6 +85,18 @@ function transcriptOf(trace: EvalTrace): TranscriptTurn[] {
85
85
  return turns;
86
86
  }
87
87
 
88
+ /**
89
+ * The VERIFY-SURFACE view of a situation: each trace augmented with the SAME
90
+ * deterministic `trajectory`/`transcript` projections its packet was built from
91
+ * (`trajectoryOf`/`transcriptOf`). A judge cites refs against the packet it was
92
+ * handed — those paths must re-resolve at verify time, or a syntax/shape gap
93
+ * (not evidence weakness) silently downgrades a grounded verdict. Trace-shaped
94
+ * paths (`observations.N.…`) keep resolving via the spread. PURE.
95
+ */
96
+ export function verifySituationViews(traces: EvalTrace[]): EvalTrace[] {
97
+ return traces.map((t) => ({ ...t, trajectory: trajectoryOf(t), transcript: transcriptOf(t) }) as EvalTrace);
98
+ }
99
+
88
100
  // ── T1 (B-U3) tier-0 deterministic pre-pass ──────────────────────────────────
89
101
  //
90
102
  // THE real cost lever (§9.4.1): run the code-method rows FIRST (deterministic,
@@ -338,6 +350,7 @@ export function buildMatrixPacket(
338
350
  criteria: MatrixCriterion[],
339
351
  pin: { model: string; temperature: 0 },
340
352
  subjectProfile?: SubjectProfile,
353
+ layerScope?: string[],
341
354
  ): MatrixPacket {
342
355
  const packet: MatrixPacket = {
343
356
  subject,
@@ -349,6 +362,9 @@ export function buildMatrixPacket(
349
362
  // byte-stable legacy packet (the judge reconstructs the profile at reason-time).
350
363
  ...(subjectProfile !== undefined ? { subjectProfile } : {}),
351
364
  pin,
365
+ // v3 D1=B+ — the pipeable `*evaluate-<layer>` filter. ABSENT/empty ⇒ the FULL
366
+ // walk (byte-stable legacy packet — the field is omitted entirely).
367
+ ...(layerScope !== undefined && layerScope.length > 0 ? { layerScope } : {}),
352
368
  };
353
369
  assertMatrixPacket(packet);
354
370
  return packet;
@@ -604,7 +620,14 @@ export function aggregateMatrixScorecard(input: MatrixAggregateInput): MatrixAgg
604
620
  // proof that a DIFFERENT judge ran, not just "eligible for".
605
621
  const independentVerify: IndependentVerifyRecord[] = [];
606
622
  if (input.independentVerify !== undefined) {
607
- const { situation, signals } = input.independentVerify;
623
+ const { situation: rawSituation, signals } = input.independentVerify;
624
+ // The judge cites refs against the PACKET it was handed (trajectory[]/transcript[]
625
+ // projections), but the raw EvalTrace carries neither field — so a deterministic
626
+ // re-resolve against bare traces reads every packet-shaped ref as DEAD and
627
+ // silently downgrades a grounded fail to uncertain (observed live: gate flipped
628
+ // fail → incomplete on a verifier-UPHELD defect). Re-resolve against the trace
629
+ // AUGMENTED with the same deterministic projections the packet was built from.
630
+ const situation = verifySituationViews(rawSituation);
608
631
  const gatingFails = gatingFailVerdicts(verdicts, severityById, input.gatingSeverities);
609
632
  const gatingFailIds = new Set(gatingFails.map((v) => v.criterionId));
610
633
  for (const v of verdicts) {
@@ -748,3 +771,198 @@ export function readMatrixVerdictFiles(verdictDir: string, trajectoryIds: string
748
771
  }
749
772
  return raws;
750
773
  }
774
+
775
+ // ═══════════════════════════════════════════════════════════════════════════════
776
+ // v3 LAYERED WALK — aggregate-side folds (E-NEW-1/3/9 · D2-A). ALL PURE, ALL
777
+ // ADDITIVE: consumed by run-evaluate AFTER the (untouched) criteria aggregate;
778
+ // the GATE function is NOT edited — layer verdicts are diagnostic columns and
779
+ // cross-layer CONFLICTS are surfaced for a human, never auto-resolved (OT-1).
780
+ // ═══════════════════════════════════════════════════════════════════════════════
781
+
782
+ import { LAYER_IDS, type LayerVerdict, type WalkEmissions } from "./contracts/eval-matrix.ts";
783
+
784
+ /** One trajectory's layer row for the report's layer matrix (skipped ≠ undecidable). */
785
+ export interface LayerFoldRow {
786
+ trajectoryId: string;
787
+ /** layer id → verdict; layers the walk never mentioned are `absent` (pre-v3 file →
788
+ * NAMED degrade); `fired` = the L0 evidence-state (patterns fired, no verdict). */
789
+ byLayer: Record<string, "pass" | "fail" | "undecidable" | "skipped" | "fired" | "no-fires" | "absent">;
790
+ earlyExit?: string;
791
+ /** COERCED to a count (a live judge may emit the engaged layer-id LIST). */
792
+ layersEngaged?: number;
793
+ }
794
+
795
+ /** Per-layer fold buckets. `other` is the NaN-proof catch-all for any tolerant
796
+ * verdict value the buckets don't know yet — surfaced, never silently dropped
797
+ * (the raw value stays visible in the row's `byLayer`). */
798
+ export interface LayerFoldTotals {
799
+ pass: number;
800
+ fail: number;
801
+ undecidable: number;
802
+ skipped: number;
803
+ fired: number;
804
+ /** L0 clean-state (library ran, no pattern fired) — honest clean, never a pass. */
805
+ "no-fires": number;
806
+ absent: number;
807
+ other: number;
808
+ }
809
+
810
+ /** Coerce a tolerant `layersEngaged` (count OR layer-id list) to a count. */
811
+ export function layersEngagedCount(v: number | string[] | undefined): number | undefined {
812
+ return Array.isArray(v) ? v.length : v;
813
+ }
814
+
815
+ /** E-NEW-1 — fold per-walk layerVerdicts into per-trajectory rows + per-layer totals. */
816
+ export function foldLayerVerdicts(files: MatrixVerdictFile[]): {
817
+ rows: LayerFoldRow[];
818
+ totals: Record<string, LayerFoldTotals>;
819
+ } {
820
+ const totals: Record<string, LayerFoldTotals> = {};
821
+ for (const layer of LAYER_IDS)
822
+ totals[layer] = { pass: 0, fail: 0, undecidable: 0, skipped: 0, fired: 0, "no-fires": 0, absent: 0, other: 0 };
823
+ const rows: LayerFoldRow[] = files.map((file) => {
824
+ const byLayer: LayerFoldRow["byLayer"] = {};
825
+ for (const layer of LAYER_IDS) {
826
+ const lv = (file.layerVerdicts ?? []).find((x: LayerVerdict) => x.layer === layer);
827
+ const v = (lv?.verdict ?? "absent") as LayerFoldRow["byLayer"][string];
828
+ byLayer[layer] = v;
829
+ const bucket = totals[layer]![v as keyof LayerFoldTotals] !== undefined ? (v as keyof LayerFoldTotals) : "other";
830
+ totals[layer]![bucket] += 1;
831
+ }
832
+ return {
833
+ trajectoryId: file.trajectoryId,
834
+ byLayer,
835
+ earlyExit: file.earlyExit,
836
+ layersEngaged: layersEngagedCount(file.layersEngaged),
837
+ };
838
+ });
839
+ return { rows, totals };
840
+ }
841
+
842
+ /** E-NEW-3 · D2-A — one detected cross-layer disagreement. NEVER auto-resolved. */
843
+ export interface LayerConflict {
844
+ trajectoryId: string;
845
+ /** the disagreeing layers, e.g. ["L1","L2"]. */
846
+ layers: string[];
847
+ verdicts: Record<string, string>;
848
+ /** the judge's own note if it flagged the tension (honest emission). */
849
+ note?: string;
850
+ }
851
+
852
+ /**
853
+ * Detect outcome-vs-deep-layer disagreements (the OT-1 class): L1 pass with any
854
+ * of L2/L3/L4 fail, or L1 fail with ALL deep layers pass. Detection ONLY — the
855
+ * conflict routes to the human calibration queue; the GATE never reads it.
856
+ */
857
+ export function detectLayerConflicts(files: MatrixVerdictFile[]): LayerConflict[] {
858
+ const out: LayerConflict[] = [];
859
+ for (const file of files) {
860
+ const lvs = file.layerVerdicts ?? [];
861
+ const get = (layer: string): LayerVerdict | undefined => lvs.find((x: LayerVerdict) => x.layer === layer);
862
+ const l1 = get("L1");
863
+ if (!l1) continue;
864
+ const deep = ["L2", "L3", "L4"].map((l) => get(l)).filter((x): x is LayerVerdict => x !== undefined);
865
+ const deepFails = deep.filter((x) => x.verdict === "fail");
866
+ const deepDecided = deep.filter((x) => x.verdict === "pass" || x.verdict === "fail");
867
+ if (l1.verdict === "pass" && deepFails.length > 0) {
868
+ const layers = ["L1", ...deepFails.map((x) => x.layer)];
869
+ out.push({
870
+ trajectoryId: file.trajectoryId,
871
+ layers,
872
+ verdicts: Object.fromEntries([["L1", "pass"], ...deepFails.map((x) => [x.layer, x.verdict])]),
873
+ note: deepFails.map((x) => x.note).filter(Boolean).join(" · ") || l1.note,
874
+ });
875
+ } else if (l1.verdict === "fail" && deepDecided.length > 0 && deepFails.length === 0) {
876
+ out.push({
877
+ trajectoryId: file.trajectoryId,
878
+ layers: ["L1", ...deepDecided.map((x) => x.layer)],
879
+ verdicts: Object.fromEntries([["L1", "fail"], ...deepDecided.map((x) => [x.layer, x.verdict])]),
880
+ note: l1.note,
881
+ });
882
+ }
883
+ }
884
+ return out;
885
+ }
886
+
887
+ /**
888
+ * COMPLETENESS LAW (S1.2) — every (criterion × complete-fidelity trajectory) cell
889
+ * must be accounted for: decided, abstained (uncertain), or explicitly `na` in the
890
+ * denseMap. A silently-missing cell THROWS — a criterion may never go unverdicted
891
+ * because nothing bound. INCOMPLETE (fidelity) trajectories are exempt by design.
892
+ */
893
+ export function assertNoUnverdictedCriterion(criteria: MatrixCriterion[], files: MatrixVerdictFile[]): void {
894
+ const missing: string[] = [];
895
+ for (const file of files) {
896
+ if (file.fidelity && file.fidelity.complete === false) continue; // INCOMPLETE exempt
897
+ const judged = new Set(file.verdicts.map((v) => v.criterionId));
898
+ const dense = file.denseMap ?? {};
899
+ for (const c of criteria) {
900
+ if (judged.has(c.criterionId)) continue;
901
+ if (dense[c.criterionId] !== undefined) continue; // na / dense-mapped = accounted
902
+ missing.push(`${file.trajectoryId} × ${c.criterionId}`);
903
+ }
904
+ }
905
+ if (missing.length > 0) {
906
+ throw new Error(
907
+ `COMPLETENESS LAW: ${missing.length} (criterion × trajectory) cell(s) unaccounted — a criterion ` +
908
+ `may NEVER be silently unverdicted (decide, abstain-with-reason, or mark na in denseMap). ` +
909
+ `First offenders: ${missing.slice(0, 5).join(" · ")}`,
910
+ );
911
+ }
912
+ }
913
+
914
+ /** MR-5 (E-NEW-9b) — fold the walks' emissions self-manifests into the run
915
+ * data-completeness scorecard: expected-key coverage + every NAMED missing reason. */
916
+ export interface EmissionsScorecard {
917
+ walksWithManifest: number;
918
+ walksTotal: number;
919
+ /** expected emission keys of the v3 contract. */
920
+ expectedKeys: string[];
921
+ /** key → number of complete-fidelity walks that emitted it. */
922
+ emittedCounts: Record<string, number>;
923
+ /** every named degrade: {trajectoryId, key, reason}. */
924
+ namedMissing: { trajectoryId: string; key: string; reason: string }[];
925
+ /** walks that emitted NO manifest at all (pre-v3 / defect — a named degrade itself). */
926
+ manifestAbsent: string[];
927
+ }
928
+
929
+ export const V3_EXPECTED_EMISSIONS: readonly string[] = [
930
+ "layerVerdicts",
931
+ "understanding",
932
+ "expectedTrajectory",
933
+ "agentSteps",
934
+ "judgeSteps",
935
+ "emissions",
936
+ ];
937
+
938
+ export function foldEmissionsScorecard(files: MatrixVerdictFile[]): EmissionsScorecard {
939
+ const complete = files.filter((f) => !(f.fidelity && f.fidelity.complete === false));
940
+ const emittedCounts: Record<string, number> = {};
941
+ for (const k of V3_EXPECTED_EMISSIONS) emittedCounts[k] = 0;
942
+ const namedMissing: EmissionsScorecard["namedMissing"] = [];
943
+ const manifestAbsent: string[] = [];
944
+ for (const f of complete) {
945
+ // ground truth from the file itself (never trust-only the self-report):
946
+ const present = (k: string): boolean =>
947
+ (f as unknown as Record<string, unknown>)[k] !== undefined &&
948
+ (!Array.isArray((f as unknown as Record<string, unknown>)[k]) ||
949
+ ((f as unknown as Record<string, unknown>)[k] as unknown[]).length > 0);
950
+ for (const k of V3_EXPECTED_EMISSIONS) if (present(k)) emittedCounts[k]! += 1;
951
+ const manifest: WalkEmissions | undefined = f.emissions;
952
+ if (!manifest) {
953
+ manifestAbsent.push(f.trajectoryId);
954
+ continue;
955
+ }
956
+ for (const m of manifest.missing ?? []) {
957
+ namedMissing.push({ trajectoryId: f.trajectoryId, key: m.key, reason: m.reason });
958
+ }
959
+ }
960
+ return {
961
+ walksWithManifest: complete.length - manifestAbsent.length,
962
+ walksTotal: complete.length,
963
+ expectedKeys: [...V3_EXPECTED_EMISSIONS],
964
+ emittedCounts,
965
+ namedMissing,
966
+ manifestAbsent,
967
+ };
968
+ }