@mutagent/evaluator 0.1.0-alpha.2

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 (126) hide show
  1. package/.claude/skills/mutagent-evaluator/SKILL.md +538 -0
  2. package/.claude/skills/mutagent-evaluator/assets/agents/audit-executor.md +169 -0
  3. package/.claude/skills/mutagent-evaluator/assets/agents/dataset-builder.md +160 -0
  4. package/.claude/skills/mutagent-evaluator/assets/agents/discovery.md +425 -0
  5. package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +1044 -0
  6. package/.claude/skills/mutagent-evaluator/assets/brand/theme.css +213 -0
  7. package/.claude/skills/mutagent-evaluator/assets/brand/wordmark.html +9 -0
  8. package/.claude/skills/mutagent-evaluator/lenses/context-flow-lens.md +85 -0
  9. package/.claude/skills/mutagent-evaluator/lenses/data-lens.md +47 -0
  10. package/.claude/skills/mutagent-evaluator/lenses/decision-lens.md +48 -0
  11. package/.claude/skills/mutagent-evaluator/lenses/methodology-critic-lens.md +53 -0
  12. package/.claude/skills/mutagent-evaluator/lenses/trajectory-lens.md +44 -0
  13. package/.claude/skills/mutagent-evaluator/references/build-review-interface.md +83 -0
  14. package/.claude/skills/mutagent-evaluator/references/edd-loop.md +134 -0
  15. package/.claude/skills/mutagent-evaluator/references/error-analysis.md +113 -0
  16. package/.claude/skills/mutagent-evaluator/references/eval-audit.md +154 -0
  17. package/.claude/skills/mutagent-evaluator/references/eval-stage.md +168 -0
  18. package/.claude/skills/mutagent-evaluator/references/generate-synthetic-data.md +81 -0
  19. package/.claude/skills/mutagent-evaluator/references/grounded-adjudication.md +221 -0
  20. package/.claude/skills/mutagent-evaluator/references/memory-format.md +65 -0
  21. package/.claude/skills/mutagent-evaluator/references/methodology.md +201 -0
  22. package/.claude/skills/mutagent-evaluator/references/operation-inventory.md +196 -0
  23. package/.claude/skills/mutagent-evaluator/references/validate-evaluator.md +125 -0
  24. package/.claude/skills/mutagent-evaluator/references/workflows/orchestrator-protocol.md +287 -0
  25. package/.claude/skills/mutagent-evaluator/references/write-judge-prompt.md +123 -0
  26. package/.claude/skills/mutagent-evaluator/schemas/behavior-tree.schema.yaml +73 -0
  27. package/.claude/skills/mutagent-evaluator/schemas/dataset.schema.yaml +66 -0
  28. package/.claude/skills/mutagent-evaluator/schemas/edd-change-request.schema.yaml +114 -0
  29. package/.claude/skills/mutagent-evaluator/schemas/eval-matrix.schema.yaml +74 -0
  30. package/.claude/skills/mutagent-evaluator/schemas/flow-graph.schema.yaml +69 -0
  31. package/.claude/skills/mutagent-evaluator/schemas/flow-profile.schema.yaml +49 -0
  32. package/.claude/skills/mutagent-evaluator/schemas/methodology-review.schema.yaml +40 -0
  33. package/.claude/skills/mutagent-evaluator/schemas/scorecard.schema.yaml +85 -0
  34. package/.claude/skills/mutagent-evaluator/scripts/agent-dispatch.ts +0 -0
  35. package/.claude/skills/mutagent-evaluator/scripts/aggregate-discover.ts +543 -0
  36. package/.claude/skills/mutagent-evaluator/scripts/artifact-paths.ts +99 -0
  37. package/.claude/skills/mutagent-evaluator/scripts/assemble-scorecard.ts +172 -0
  38. package/.claude/skills/mutagent-evaluator/scripts/build-dataset.ts +186 -0
  39. package/.claude/skills/mutagent-evaluator/scripts/build-evals.ts +93 -0
  40. package/.claude/skills/mutagent-evaluator/scripts/build-review-ui.ts +393 -0
  41. package/.claude/skills/mutagent-evaluator/scripts/check-method-router.ts +170 -0
  42. package/.claude/skills/mutagent-evaluator/scripts/cli/aggregate.ts +112 -0
  43. package/.claude/skills/mutagent-evaluator/scripts/cli/audit-run.ts +175 -0
  44. package/.claude/skills/mutagent-evaluator/scripts/cli/doctor.ts +211 -0
  45. package/.claude/skills/mutagent-evaluator/scripts/cli/dogfood.ts +133 -0
  46. package/.claude/skills/mutagent-evaluator/scripts/cli/init.ts +601 -0
  47. package/.claude/skills/mutagent-evaluator/scripts/cli/methodology-review.ts +122 -0
  48. package/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +279 -0
  49. package/.claude/skills/mutagent-evaluator/scripts/cli/profile-subject.ts +165 -0
  50. package/.claude/skills/mutagent-evaluator/scripts/cli/run.sh +56 -0
  51. package/.claude/skills/mutagent-evaluator/scripts/cli/variance-check.ts +105 -0
  52. package/.claude/skills/mutagent-evaluator/scripts/code-eval.ts +248 -0
  53. package/.claude/skills/mutagent-evaluator/scripts/codegen-evals.ts +173 -0
  54. package/.claude/skills/mutagent-evaluator/scripts/cold-start-project.ts +151 -0
  55. package/.claude/skills/mutagent-evaluator/scripts/cold-start-sampler.ts +267 -0
  56. package/.claude/skills/mutagent-evaluator/scripts/config/load.ts +307 -0
  57. package/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +325 -0
  58. package/.claude/skills/mutagent-evaluator/scripts/contracts/agentspec-evals.ts +85 -0
  59. package/.claude/skills/mutagent-evaluator/scripts/contracts/dataset.ts +149 -0
  60. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-engine.ts +118 -0
  61. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +577 -0
  62. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-types.ts +1074 -0
  63. package/.claude/skills/mutagent-evaluator/scripts/contracts/flow-graph.ts +194 -0
  64. package/.claude/skills/mutagent-evaluator/scripts/contracts/types.ts +303 -0
  65. package/.claude/skills/mutagent-evaluator/scripts/contracts/validation.ts +193 -0
  66. package/.claude/skills/mutagent-evaluator/scripts/derive-dataset.ts +152 -0
  67. package/.claude/skills/mutagent-evaluator/scripts/determine-outcome.ts +219 -0
  68. package/.claude/skills/mutagent-evaluator/scripts/diff-discriminate.ts +160 -0
  69. package/.claude/skills/mutagent-evaluator/scripts/discover-criteria.ts +348 -0
  70. package/.claude/skills/mutagent-evaluator/scripts/edd/change-request.ts +232 -0
  71. package/.claude/skills/mutagent-evaluator/scripts/edd/edd-types.ts +210 -0
  72. package/.claude/skills/mutagent-evaluator/scripts/edd/variance-gate.ts +186 -0
  73. package/.claude/skills/mutagent-evaluator/scripts/emit-completeness.ts +111 -0
  74. package/.claude/skills/mutagent-evaluator/scripts/eval-engine.ts +160 -0
  75. package/.claude/skills/mutagent-evaluator/scripts/evaluate.ts +333 -0
  76. package/.claude/skills/mutagent-evaluator/scripts/flow-graph.ts +230 -0
  77. package/.claude/skills/mutagent-evaluator/scripts/judge-prompt-template.ts +253 -0
  78. package/.claude/skills/mutagent-evaluator/scripts/judge-provider.ts +135 -0
  79. package/.claude/skills/mutagent-evaluator/scripts/lint-grounding.ts +229 -0
  80. package/.claude/skills/mutagent-evaluator/scripts/lint-uniformity.ts +168 -0
  81. package/.claude/skills/mutagent-evaluator/scripts/living-suite.ts +109 -0
  82. package/.claude/skills/mutagent-evaluator/scripts/load-bundle.ts +203 -0
  83. package/.claude/skills/mutagent-evaluator/scripts/load-profile-vocab.ts +64 -0
  84. package/.claude/skills/mutagent-evaluator/scripts/load-profile.ts +106 -0
  85. package/.claude/skills/mutagent-evaluator/scripts/mask.ts +138 -0
  86. package/.claude/skills/mutagent-evaluator/scripts/materialize-dataset.ts +113 -0
  87. package/.claude/skills/mutagent-evaluator/scripts/matrix-judge.ts +750 -0
  88. package/.claude/skills/mutagent-evaluator/scripts/memory/append.ts +215 -0
  89. package/.claude/skills/mutagent-evaluator/scripts/memory/read.ts +168 -0
  90. package/.claude/skills/mutagent-evaluator/scripts/prep-tasks.ts +125 -0
  91. package/.claude/skills/mutagent-evaluator/scripts/profile-subject.ts +310 -0
  92. package/.claude/skills/mutagent-evaluator/scripts/publish-report.ts +131 -0
  93. package/.claude/skills/mutagent-evaluator/scripts/read-unitf-traces.ts +81 -0
  94. package/.claude/skills/mutagent-evaluator/scripts/render-build-cards.ts +195 -0
  95. package/.claude/skills/mutagent-evaluator/scripts/render-discover-report.ts +1640 -0
  96. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +3823 -0
  97. package/.claude/skills/mutagent-evaluator/scripts/render-report.ts +212 -0
  98. package/.claude/skills/mutagent-evaluator/scripts/resolve-credential.ts +110 -0
  99. package/.claude/skills/mutagent-evaluator/scripts/resolve-ref.ts +98 -0
  100. package/.claude/skills/mutagent-evaluator/scripts/result-verify.ts +129 -0
  101. package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +320 -0
  102. package/.claude/skills/mutagent-evaluator/scripts/run-deterministic.ts +271 -0
  103. package/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +715 -0
  104. package/.claude/skills/mutagent-evaluator/scripts/run-judge.ts +155 -0
  105. package/.claude/skills/mutagent-evaluator/scripts/run-pipeline.ts +175 -0
  106. package/.claude/skills/mutagent-evaluator/scripts/sample-traces.ts +210 -0
  107. package/.claude/skills/mutagent-evaluator/scripts/self-audit.ts +387 -0
  108. package/.claude/skills/mutagent-evaluator/scripts/source-map.ts +106 -0
  109. package/.claude/skills/mutagent-evaluator/scripts/subject-profile.ts +134 -0
  110. package/.claude/skills/mutagent-evaluator/scripts/substrate.ts +162 -0
  111. package/.claude/skills/mutagent-evaluator/scripts/ui-slots.ts +119 -0
  112. package/.claude/skills/mutagent-evaluator/scripts/unitf-to-evaltrace.ts +284 -0
  113. package/.claude/skills/mutagent-evaluator/scripts/validate-judge.ts +358 -0
  114. package/.claude/skills/mutagent-evaluator/scripts/variance-compare.ts +177 -0
  115. package/.claude/skills/mutagent-evaluator/subjects/mutagent-diagnostics/behavior-tree.yaml +140 -0
  116. package/.claude/skills/mutagent-evaluator/subjects/mutagent-diagnostics/eval-matrix.yaml +1270 -0
  117. package/.claude/skills/mutagent-evaluator/subjects/mutagent-diagnostics/methodology-review.yaml +105 -0
  118. package/.claude/skills/mutagent-evaluator/workflows/audit.workflow.js +82 -0
  119. package/.claude/skills/mutagent-evaluator/workflows/data-leak.workflow.js +236 -0
  120. package/.claude/skills/mutagent-evaluator/workflows/variance.workflow.js +163 -0
  121. package/LICENSE +201 -0
  122. package/NOTICE +23 -0
  123. package/README.md +90 -0
  124. package/bin/mutagent-cli.mjs +9263 -0
  125. package/bin/mutagent-evaluator.mjs +96 -0
  126. package/package.json +52 -0
@@ -0,0 +1,3823 @@
1
+ /**
2
+ * scripts/render-eval-report.ts — P4 (D-1/D-2/D-3): the v2 `*evaluate` reporting.
3
+ * ---------------------------------------------------------------------------
4
+ * The `*evaluate` path (P5) produces a Scorecard (GATE + variance + per-criterion
5
+ * verdicts); this module is the v2 HTML reporting. It is a faithful TS PORT of the
6
+ * operator-APPROVED 5-tab component spec (the sample-eval-1 reference renderer —
7
+ * `.mutagent/evaluator/sample-eval-1/scripts/render-report.py`), NOT a flat table:
8
+ *
9
+ * - D-1 renderEvalCards : terminal per-criterion cards from the Scorecard.
10
+ * - D-2/D-3 renderEvalReport: the 5-tab HTML eval-report —
11
+ * §1 Overview — KPIs + coverage-contract note + gating-criteria
12
+ * table (pass-rate over applicable) + top-findings teaser.
13
+ * §2 Trajectory·Judge — per-trace ledger (filter/search) + click-row drill:
14
+ * Target-Agent‖Judge SIDE-BY-SIDE (gather-context band,
15
+ * two lanes, judge micro-steps anchored to agent steps,
16
+ * localize band, judge-health) when judge_steps exist,
17
+ * else the per-trajectory scorecard.
18
+ * §3 Eval Scorecard — cohort heatmap (criteria × route cohort) + nested
19
+ * subcards (grounding / verdict / why-abstained) +
20
+ * inline calibration buttons.
21
+ * §4 Findings — verbatim evidence bullets + judge reasoning chain +
22
+ * agree/revise/refute alignment review.
23
+ * §5 Self-Eval [INTERNAL] — judge-health + methodology + EV-051 routed
24
+ * decisions; carries a `strip-for-client` marker.
25
+ * - autoOpenCommand : cross-platform open helper (built; the orchestrator
26
+ * fires it post-render — never fired here).
27
+ *
28
+ * GRACEFUL DEGRADE: the rich tabs CONSUME the §9.4 judge contract (a dense
29
+ * na-explicit per-criterion map + `judge_steps[]` anchored to agent steps) WHEN
30
+ * the judge emits it — and fall back to the scorecard-derived rendering when it is
31
+ * absent. The judge-AGENT emission of dense-map + judge_steps is a SEPARATE change
32
+ * (see TODO[§9.4-judge-emit] in buildEvalReportInput + the additive optional
33
+ * fields the renderer reads off `MatrixVerdictFile`).
34
+ *
35
+ * Brand: rendered from the evaluator's OWN bundled `assets/brand/theme.css` (the
36
+ * unified design-system tokens, aligned to @mutagent/templates/tokens.css) +
37
+ * report-component CSS referencing those same tokens — no diagnostics-package
38
+ * runtime ref (sealed-sibling). SHARP corners · SUBTLE non-black cards · TONED
39
+ * status · RESTRAINED glow. Tabs are <button> (NOT <s>) — no strikethrough.
40
+ *
41
+ * DETERMINISTIC + null-guarded: the ONLY non-deterministic input is the injected
42
+ * `generatedAt`, so two renders of the same report input are BYTE-IDENTICAL after
43
+ * the generatedAt mask (`mask.ts`). v1 render-report.ts / evaluate.ts / mask.ts are
44
+ * UNTOUCHED — this is a separate v2 renderer.
45
+ */
46
+ import { readFileSync } from "node:fs";
47
+ import { dirname, join } from "node:path";
48
+ import { fileURLToPath } from "node:url";
49
+ import {
50
+ AssumptionKind,
51
+ CriterionFlag,
52
+ Grounding,
53
+ OutcomeVerdict,
54
+ type CriterionVerdict,
55
+ type DiscoveryRationale,
56
+ type EvalTrace,
57
+ type MinedCriterion,
58
+ } from "./contracts/eval-types.ts";
59
+ import { localizeText, PROFILE_UNKNOWN } from "./contracts/eval-matrix.ts";
60
+ import type {
61
+ ExpectedStep,
62
+ MatrixCriterion,
63
+ MatrixVerdictFile,
64
+ SubjectProfile,
65
+ Understanding,
66
+ } from "./contracts/eval-matrix.ts";
67
+ import { deriveWalkHealth } from "./matrix-judge.ts";
68
+ import { assessEmitCompleteness, type EmitCompleteness } from "./emit-completeness.ts";
69
+ import type { Scorecard } from "./evaluate.ts";
70
+ import type { SourceMap } from "./source-map.ts";
71
+ import { routeFailures, type FailureRef, type HandoverBundle } from "./route-failures.ts";
72
+
73
+ const HERE = dirname(fileURLToPath(import.meta.url));
74
+ const BRAND_DIR = join(HERE, "..", "assets", "brand");
75
+
76
+ /** HTML-escape (null-guarded — no throw on undefined). Mirrors render-report.ts. */
77
+ export function esc(s: unknown): string {
78
+ return String(s ?? "")
79
+ .replace(/&/g, "&amp;")
80
+ .replace(/</g, "&lt;")
81
+ .replace(/>/g, "&gt;")
82
+ .replace(/"/g, "&quot;");
83
+ }
84
+
85
+ // ── Render input (rich; every rich field OPTIONAL → graceful degrade) ─────────
86
+
87
+ /** §9.4.4 R4 — where a criterion CAME FROM (defined-in-suite vs source-mined). */
88
+ export interface CriterionProvenance {
89
+ /** `defined` = an authored matrix row · `source` = mined from the trace batch. */
90
+ kind: "defined" | "source";
91
+ /** a human-readable origin label (e.g. "eval-matrix.yaml" / "mined: ✓/✗ split"). */
92
+ label: string;
93
+ /** the source detail (e.g. how many traces it was seen in), when known. */
94
+ detail?: string;
95
+ }
96
+
97
+ /** One criterion as the report needs it (mined OR matrix-derived). */
98
+ export interface ReportCriterion {
99
+ id: string;
100
+ statement: string;
101
+ severity: string; // CRIT | HIGH | MED | LOW
102
+ gating?: boolean;
103
+ dimension?: string;
104
+ level?: string; // context | output | cross-stage
105
+ appliesTo?: string[];
106
+ /** §9.4.4 R4 — the concrete binary pass condition (the full definition for the hover). */
107
+ passCondition?: string;
108
+ /** UI-7 — what the judge READS to evaluate this criterion (the "how it's judged"
109
+ * inputs, eval-matrix `judgeInputs`). Render-layer surfacing of an existing field. */
110
+ judgeInputs?: string[];
111
+ /** §9.4.4 R4 — where the criterion came from (defined vs source). */
112
+ provenance?: CriterionProvenance;
113
+ /** UI-10 — the code-vs-judge ROUTER for this criterion (eval-matrix `checkMethod`
114
+ * / mined `metadata.check_method`): `deterministic` (CODE — a code-eval, no judge
115
+ * tokens) · `hybrid` (code pre-filter + judge) · `llm-judge` (an LLM-judge
116
+ * criterion). Defaults to `llm-judge` when the source row omits it (the legacy
117
+ * all-judge behaviour). Render-layer surfacing — drives the CODE vs JUDGE chip;
118
+ * NO judge-protocol change. */
119
+ checkMethod?: string;
120
+ /** the §5c DR-2 discovery-rationale block (present iff mined). */
121
+ discovery?: DiscoveryRationale;
122
+ }
123
+
124
+ /** One ordered judge micro-step (§9.4 `judge_steps[]`), anchored to an agent step. */
125
+ export interface JudgeStep {
126
+ /** context | examine | detect | bind | ground | critique | decide | verify */
127
+ kind: string;
128
+ text?: string;
129
+ /** a grounding citation — a string OR a structured {obs,path,value} ref (coerced in the renderer). */
130
+ ref?: string | { obs: string; path: string; value: string };
131
+ /** the agent-step index this judge step anchors to (0 / "context" = gather band).
132
+ * TOLERANT: real judges may emit a stringified index / label / range (coerced below). */
133
+ anchor?: number | string;
134
+ }
135
+
136
+ /** One target-agent step (the left lane of the §2 side-by-side). */
137
+ export interface AgentStep {
138
+ n: number;
139
+ tool?: string;
140
+ /** ok | error | warn | false-success */
141
+ status?: string;
142
+ detail?: string;
143
+ /** The trace OBSERVATION id this step corresponds to. Carried best-effort by
144
+ * `enrichAgentStepsObs` (verdict files don't emit it natively): recovered from
145
+ * the per-criterion verdict `refs` (`path:"name"` → `{obs, value:<tool>}`) and
146
+ * the `tool@<obs>` tokens the judge embeds in its critiques. Lets the §2 judge
147
+ * lane map a step to the criterion verdicts that EXAMINED it via `ref.obs ===
148
+ * step.obs` (the precise key) on top of the tool-name fallback. Undefined when
149
+ * no obs id could be recovered for the step (then tool-name match still applies). */
150
+ obs?: string;
151
+ }
152
+
153
+ /** WS-1 — one per-criterion JUDGE verdict for a SINGLE trajectory, read verbatim
154
+ * from the verdict file's `verdicts[]` (critique-before-verdict + grounding refs).
155
+ * Distinct from the FOLDED `CriterionVerdict` (one-per-criterion across the batch):
156
+ * this is the per-trace judgement bound to the §2 drill so EVERY evaluated trace
157
+ * surfaces its judge reasoning (result · critique · refs), whether or not a
158
+ * `judge_steps[]` walk was emitted for it. */
159
+ export interface CriterionTrajectoryVerdict {
160
+ criterionId: string;
161
+ /** pass | fail | uncertain | na */
162
+ result: string;
163
+ confidence?: number;
164
+ confidenceBand?: string;
165
+ critique?: string;
166
+ /** grounding citations — a string OR a structured {obs,path,value} (coerced in the UI). */
167
+ refs?: Array<string | { obs?: string; path?: string; value?: string }>;
168
+ }
169
+
170
+ /** Per-trajectory judge-health micro-summary (the §2 footer). */
171
+ export interface JudgeHealthRow {
172
+ contextGathered?: boolean;
173
+ grounded?: number;
174
+ assumed?: number;
175
+ stoppedAtSymptom?: boolean;
176
+ }
177
+
178
+ /** WS-5 — the resolution class for an INDETERMINATE (uncertain) per-criterion verdict.
179
+ * `na` = the criterion's TRIGGER/precondition was ABSENT in this trace (not applicable —
180
+ * DROPPED from the applicable denominator). `needs-evidence` = the precondition WAS
181
+ * present but the judge could not decide (stays in the denominator, carries a concrete
182
+ * next-action). NEVER a bare "indeterminate". */
183
+ export type IndeterminateResolutionClass = "na" | "needs-evidence";
184
+ /** WS-5 — the chain next-action for a `needs-evidence` item (the deterministic decision
185
+ * chain: re-check the code, get a 2nd judge, revise the criterion, or HITL-spot-check). */
186
+ export type IndeterminateNextAction = "code-recheck" | "2nd-judge" | "revise-criterion" | "hitl-spot-check";
187
+ /** WS-5 — one resolved indeterminate per-criterion verdict (§3 resolution chain). */
188
+ export interface IndeterminateResolution {
189
+ criterionId: string;
190
+ resolutionClass: IndeterminateResolutionClass;
191
+ /** present iff `needs-evidence` — the concrete chain next-action. */
192
+ nextAction?: IndeterminateNextAction;
193
+ /** one-line WHY: the missing precondition (na) or the missing signal (needs-evidence). */
194
+ reason: string;
195
+ }
196
+
197
+ /** One per-trace ledger row (§2). The side-by-side fields are present iff the
198
+ * judge emitted `judge_steps[]` for this trajectory. */
199
+ export interface LedgerRow {
200
+ trajectoryId: string;
201
+ route?: string;
202
+ /** PASS | FAIL | INDETERMINATE | INCOMPLETE */
203
+ verdict: string;
204
+ /** dense, na-explicit per-criterion map: cid -> pass|fail|uncertain|na. */
205
+ perCriterion: Record<string, string>;
206
+ failingCriteria: string[];
207
+ root?: string;
208
+ grounding?: string;
209
+ /** WS-1 — the per-criterion JUDGE verdicts for THIS trajectory (result · critique-
210
+ * before-verdict · grounding refs), read verbatim from the verdict file's
211
+ * `verdicts[]`. Bound to the §2 drill ("How the Judge Reasoned") so EVERY trace —
212
+ * PASS or FAIL, walk or no walk — surfaces its judge reasoning. Present whenever
213
+ * the verdict file carried ≥1 per-criterion verdict (i.e. all judged trajectories). */
214
+ criterionVerdicts?: CriterionTrajectoryVerdict[];
215
+ // ── §2 side-by-side (present iff judge_steps[] emitted) ──
216
+ context?: { harness?: string; scenario?: string; exitStates?: string };
217
+ /** Gap A — the RAW triggering INPUT for this trajectory (the thing that fired
218
+ * the subject agent). Sourced from the TRACE (`EvalTrace.input.prompt`, fallback
219
+ * packet `transcript[0]`) keyed by traceId in `buildEvalReportInput` — NOT a
220
+ * judge-emitted field (the input is ground-truth in the trace, no judge echo
221
+ * needed). ABSENT when the trace carried no readable input ⇒ the §2 drill cell
222
+ * renders "—" (never faked). The judge's `context.scenario` LABEL still shows
223
+ * beneath it — the input is the WHAT, the scenario is the route summary. */
224
+ input?: string;
225
+ agentSteps?: AgentStep[];
226
+ judgeSteps?: JudgeStep[];
227
+ localize?: string;
228
+ health?: JudgeHealthRow;
229
+ // ── §9.4.4 v2.2 (present iff the judge emitted them) ──
230
+ /** M2 node-0 train-of-thought (rephrase + given-vs-inferred). */
231
+ understanding?: Understanding;
232
+ /** M3 node-0.5 expected-trajectory (how the target SHOULD have acted). */
233
+ expectedTrajectory?: ExpectedStep[];
234
+ /** M1 the subject profile the judge reasoned under (echoed / reconstructed). */
235
+ subjectProfile?: SubjectProfile;
236
+ /** §9.4.5 E3 — the trace's wall-clock timestamp (ISO), when the source carried it.
237
+ * Powers the eval-HEALTH temporal heatmap (correctness over time). ABSENT ⇒ that
238
+ * trajectory falls into the data-pending bucket (structure renders, never faked). */
239
+ timestamp?: string;
240
+ /** WS-5 — per-criterion INDETERMINATE resolution for THIS trace (keyed by criterionId):
241
+ * the deterministic precondition gate's verdict (`na` precondition-absent vs
242
+ * `needs-evidence` + next-action). Present only for criteria whose raw verdict was
243
+ * uncertain on this trace. */
244
+ indeterminateResolution?: Record<string, IndeterminateResolution>;
245
+ /** WS-2 — how this trajectory's judgement RESOLVED, made honest + visible (a trace
246
+ * with no §2 walk is NOT "unjudged"). One of:
247
+ * • `judge-walk` — the judge emitted the §9.4 walk (agentSteps/judgeSteps)
248
+ * • `judged-walk-not-captured` — the trace WAS judged (per-criterion verdicts present)
249
+ * but the emit-contract dropped the structured walk
250
+ * • `truncated` — INCOMPLETE fidelity (node-1 gate); never walked by design
251
+ * Drives the §2 ledger resolution badge + the per-trace drill routing line. */
252
+ resolution?: TrajectoryResolution;
253
+ }
254
+
255
+ /** WS-2 — the resolution class of one ledger row (see `LedgerRow.resolution`). */
256
+ export type TrajectoryResolution = "judge-walk" | "judged-walk-not-captured" | "truncated";
257
+
258
+ /** WS-2 — derive the resolution class for one verdict file. PURE.
259
+ * truncated > judge-walk (walk emitted) > judged-walk-not-captured (judged, walk dropped). */
260
+ export function deriveTrajectoryResolution(f: {
261
+ fidelity?: { complete?: boolean };
262
+ agentSteps?: unknown[];
263
+ judgeSteps?: unknown[];
264
+ verdicts?: unknown[];
265
+ }): TrajectoryResolution {
266
+ if (f.fidelity?.complete === false) return "truncated";
267
+ const hasWalk = (f.judgeSteps?.length ?? 0) > 0 || (f.agentSteps?.length ?? 0) > 0;
268
+ if (hasWalk) return "judge-walk";
269
+ return "judged-walk-not-captured";
270
+ }
271
+
272
+ /** WS-2 — the human badge label + one-line routing explanation per resolution class. */
273
+ export function resolutionMeta(r: TrajectoryResolution): { badge: string; cls: string; routing: string } {
274
+ switch (r) {
275
+ case "judge-walk":
276
+ return {
277
+ badge: "JUDGE·WALK",
278
+ cls: "res-walk",
279
+ routing:
280
+ "Routed through the full §9.4 judge walk — the target step lane + the anchored judge reasoning were captured, so the side-by-side below is complete.",
281
+ };
282
+ case "judged-walk-not-captured":
283
+ return {
284
+ badge: "judged · walk not captured",
285
+ cls: "res-nowalk",
286
+ routing:
287
+ "This trace WAS judged (per-criterion verdicts + critiques are present below) — the judge simply did not persist the structured §9.4 walk (agentSteps/judgeSteps/M2/M3). The verdict stands; only the step-by-step lane is missing. The emit-completeness gate (§5) tracks this gap; a fresh run with the hardened emit-contract captures the walk natively.",
288
+ };
289
+ case "truncated":
290
+ return {
291
+ badge: "TRUNCATED",
292
+ cls: "res-trunc",
293
+ routing:
294
+ "The node-1 fidelity gate fired — the trace was truncated/unreadable, so it was NEVER walked per-criterion (no fabricated verdicts from a partial trace). Resolved INCOMPLETE by design, not a judge omission.",
295
+ };
296
+ }
297
+ }
298
+
299
+ /** §9.4.4 R3/M5 — one DETECTED-but-unmatched flag (a node-2.5 candidate). NOT a
300
+ * verdict: a real behaviour the judge flagged with NO matching criterion. Surfaced
301
+ * in §4 CLEARLY SEPARATED from fails-on-existing-criteria, and routed to discover. */
302
+ export interface DetectedFlag {
303
+ /** `eval` = a candidate criterion to mine · `dataset` = a candidate test case. */
304
+ kind: string;
305
+ detection: string;
306
+ trajectoryId: string;
307
+ anchor?: number | string;
308
+ ref?: string | { obs: string; path: string; value: string };
309
+ }
310
+
311
+ /** One §4 finding (one failing/at-risk criterion, aggregated over the ledger). */
312
+ export interface TopFinding {
313
+ criterion: string;
314
+ severity?: string;
315
+ applicable: number;
316
+ failCount: number;
317
+ /** % over the APPLICABLE denominator (GA-D2b — na never counted). */
318
+ prevalencePctOverApplicable: number;
319
+ root?: string;
320
+ /** the trace whose judge-walk powers the verbatim evidence. */
321
+ exampleTraceId?: string;
322
+ }
323
+
324
+ /** One gating-criterion roll (§1 table + §3 subcards). */
325
+ export interface GatingRoll {
326
+ criterion: string;
327
+ severity?: string;
328
+ applicable: number;
329
+ fail: number;
330
+ /** WS-5 — GENUINE under-evidence (precondition present, undecided). NOT N/A. */
331
+ indeterminate?: number;
332
+ /** WS-5 — N/A (precondition/trigger absent) — DROPPED from the applicable denominator. */
333
+ na?: number;
334
+ passRateOverApplicable: number;
335
+ denominatorNote?: string;
336
+ root?: string;
337
+ }
338
+
339
+ /** WS-5 — one per-criterion INDETERMINATE resolution roll (§3 resolution chain). Covers
340
+ * ALL criteria (not just gating). The applicable denominator EXCLUDES `na`. */
341
+ export interface CriterionResolution {
342
+ criterion: string;
343
+ severity?: string;
344
+ /** applicable = pass + fail + needsEvidence (N/A excluded — denominator-honest). */
345
+ applicable: number;
346
+ pass: number;
347
+ fail: number;
348
+ /** N/A (precondition absent) — shown SEPARATELY, never in the applicable denominator. */
349
+ na: number;
350
+ /** genuine under-evidence (precondition present, undecided) — carries next-actions. */
351
+ needsEvidence: number;
352
+ passRateOverApplicable: number;
353
+ /** the dominant N/A reason (the missing precondition), when na > 0. */
354
+ naReason?: string;
355
+ /** aggregated next-actions for the needsEvidence items (action → count + a reason). */
356
+ nextActions: Array<{ action: IndeterminateNextAction; count: number; reason: string }>;
357
+ }
358
+
359
+ export interface EvalReportInput {
360
+ subject: { name: string; org?: string; source?: string; models?: string[] };
361
+ /** the v2 *evaluate Scorecard (GATE + variance). */
362
+ scorecard: Scorecard;
363
+ /** the folded per-criterion verdicts. */
364
+ verdicts: CriterionVerdict[];
365
+ /** the criteria (mined → DR-2 cards; matrix → statement/severity only). */
366
+ criteria: ReportCriterion[];
367
+ // ── rich (all OPTIONAL — derived by `buildEvalReportInput` from real pipeline
368
+ // data; the renderer degrades gracefully when absent) ──
369
+ /** per-trace ledger (§2). */
370
+ ledger?: LedgerRow[];
371
+ /** coverage contract (§1). */
372
+ coverage?: { judged?: number; triaged?: number; byVerdict?: Record<string, number>; gapNote?: string };
373
+ /** route cohorts (§3 heatmap): counts per cohort + cid×cohort cells. */
374
+ cohorts?: {
375
+ counts: Record<string, number>;
376
+ matrix: Record<string, Record<string, { pass: number; fail: number; indeterminate: number }>>;
377
+ };
378
+ /** the gating-criteria rolls (§1 table + §3 subcards). */
379
+ gatingCriteria?: GatingRoll[];
380
+ /** WS-5 — per-criterion INDETERMINATE resolution chain (ALL criteria): the
381
+ * applicable·pass·fail·N/A·needs-evidence split that resolves the single
382
+ * indeterminate bucket into precondition-absent (N/A, denominator-excluded) vs
383
+ * genuine under-evidence (carries a next-action). §3 Scorecard. */
384
+ criterionResolutions?: CriterionResolution[];
385
+ /** the §4 top findings (verbatim-evidence cards). */
386
+ topFindings?: TopFinding[];
387
+ /** §9.4.4 R3/M5 — the DETECTED-but-unmatched flags (node-2.5 candidates), shown
388
+ * in §4 SEPARATED from criterion fails. A detection routed to discover, never minted. */
389
+ detectedFlags?: DetectedFlag[];
390
+ /** §9.4.4 M1/R2 — the subject profile (identity·purpose·tools·skill·scope) for the
391
+ * INTERNAL calibration tab + the §2 "who is the agent" band. */
392
+ subjectProfile?: SubjectProfile;
393
+ /** §9.4.5 E2 — per-tool OBSERVED call-count (from the trace observations / trajectory
394
+ * steps). Drives the entity-hero tool CHIPS' "obs N" badge — honest in the trace-only
395
+ * (no-code-access) case where the only tool evidence is what the traces show. */
396
+ toolObservations?: Record<string, number>;
397
+ /** judge-health (§5 self-eval). */
398
+ judgeHealth?: { groundedPct?: number; abstentionRatePct?: number; stoppedAtSymptomPct?: number };
399
+ /** P2 source-map topology (coverage note). */
400
+ sourceMap?: SourceMap;
401
+ /** the EV-051 diagnostics handover (§5 decisions); null when nothing failed. */
402
+ handover?: HandoverBundle | null;
403
+ /** WS-3 — the §5 Self-Eval AGGREGATE judge-calibration derivation (behavior map +
404
+ * the 9 components). Deterministic over the verdict files; ABSENT ⇒ §5 degrades to
405
+ * the legacy calibration view. */
406
+ selfEval?: SelfEvalAggregate;
407
+ /** Overview provenance meta-strip — the RUN config (run-id · date · source · JUDGE
408
+ * substrate · pinned judge model · temp-0 · C-PIN). Distinct from the TARGET model
409
+ * (`subject.models`). All OPTIONAL: a genuinely-unavailable field is MARKED, never
410
+ * faked; substrate/temp/C-PIN carry sensible engine defaults. */
411
+ runConfig?: RunConfig;
412
+ /** the ONLY non-deterministic input (masked for byte-identity). */
413
+ generatedAt: string;
414
+ }
415
+
416
+ /** §Overview-redesign — the run-configuration the provenance meta-strip renders. The
417
+ * JUDGE substrate + pinned judge model are DISTINCT from the TARGET model under eval. */
418
+ export interface RunConfig {
419
+ /** the *evaluate run id (the artifact dot-root key). */
420
+ runId?: string;
421
+ /** the run date/timestamp (defaults to `generatedAt` when absent). */
422
+ date?: string;
423
+ /** the trace SOURCE platform (langfuse · otel · local-ndjson · …). */
424
+ source?: string;
425
+ /** the JUDGE substrate: agent-dispatch (host runtime) · ai-sdk · code. */
426
+ judgeSubstrate?: string;
427
+ /** the PINNED judge model (C-PIN) — NOT the target model. */
428
+ judgeModel?: string;
429
+ /** the pinned judge temperature (C-PIN ⇒ 0). */
430
+ temperature?: number;
431
+ /** C-PIN engaged (pinned model + temp ⇒ byte-identical reruns). */
432
+ cPin?: boolean;
433
+ }
434
+
435
+ // ── helpers ─────────────────────────────────────────────────────────────────
436
+
437
+ function sevCls(sev: string | undefined): string {
438
+ const s = String(sev ?? "med").toLowerCase();
439
+ if (s === "crit") return "crit";
440
+ if (s === "high") return "high";
441
+ if (s === "med" || s === "medium") return "med";
442
+ return "low";
443
+ }
444
+ /** Heatmap / pass-rate colour by % (fail<80 · warn<95 · pass). Deterministic. */
445
+ function rateCol(r: number): string {
446
+ return r < 80 ? "var(--fail)" : r < 95 ? "var(--warn)" : "var(--pass)";
447
+ }
448
+ /** Map a per-criterion result string to its display token (uncertain→indeterminate). */
449
+ function resultToken(r: string): string {
450
+ return r === OutcomeVerdict.Uncertain ? "indeterminate" : r;
451
+ }
452
+ function verdictCls(v: string): string {
453
+ const u = v.toUpperCase();
454
+ return u === "PASS" ? "pass" : u === "FAIL" ? "fail" : "inc";
455
+ }
456
+ function criterionById(input: EvalReportInput, id: string): ReportCriterion | undefined {
457
+ return input.criteria.find((c) => c.id === id);
458
+ }
459
+ /** Render structured grounding refs `{obs,path,value}` as compact citations. */
460
+ function refsText(refs: { obs: string; path: string; value: string }[]): string {
461
+ return refs.map((r) => `${r.obs}${r.path ? "/" + r.path : ""}: "${r.value}"`).join(" · ");
462
+ }
463
+
464
+ /** UI-6 — pass-RATE → an accent class (driven by SUCCESS rate, NOT severity): green-ish
465
+ * high · amber mid · red-ish low. Mirrors the heatmap thresholds (≥95 ok · ≥80 mid ·
466
+ * <80 low) so a criterion card's colour reflects how it actually scored. Deterministic. */
467
+ export function rateClass(r: number): "rate-ok" | "rate-mid" | "rate-low" {
468
+ return r >= 95 ? "rate-ok" : r >= 80 ? "rate-mid" : "rate-low";
469
+ }
470
+
471
+ // ── UI-2 — verdict-state legend (Overview + Scorecard) ───────────────────────
472
+ /**
473
+ * UI-2 — the verdict-state LEGEND: plain-words for every run/trajectory verdict state
474
+ * so a reader never has to guess what INCOMPLETE means. Rendered in the Overview AND the
475
+ * Scorecard. INCOMPLETE is called out as DISTINCT from FAIL (a CRIT/HIGH criterion is
476
+ * uncertain → the run can't be certified, but it is not a fail). PURE.
477
+ */
478
+ function verdictLegend(): string {
479
+ const items: [string, string, string][] = [
480
+ ["pass", "PASS", "every applicable criterion was met — the run is certified as pass."],
481
+ ["fail", "FAIL", "at least one gating (CRIT/HIGH) criterion failed."],
482
+ [
483
+ "inc",
484
+ "INCOMPLETE",
485
+ "a CRIT/HIGH criterion is UNCERTAIN (or the trace was too truncated to judge) — the run can't be certified as pass, but it is NOT a fail.",
486
+ ],
487
+ ["indet", "INDETERMINATE", "judged, but no confident pass/fail on this criterion (not certain either way)."],
488
+ ["na", "na", "not applicable — out of scope for this trajectory; never counted as a fail."],
489
+ ];
490
+ const cells = items
491
+ .map(
492
+ ([cls, k, v]) =>
493
+ `<div class="vl-item"><span class="vl-k ${cls}">${esc(k)}</span><span class="vl-v">${esc(v)}</span></div>`,
494
+ )
495
+ .join("");
496
+ return `<div class="vlegend"><div class="vl-h">▾ what the verdicts mean</div>${cells}</div>`;
497
+ }
498
+
499
+ // ── UI-7 — the criterion DEFINITION block (explainability) ────────────────────
500
+ /**
501
+ * UI-7 — the criterion DEFINITION (what it is + HOW it's judged), shown BESIDE the
502
+ * verdict so a card reads as explainable, not robotic. Sourced purely from the
503
+ * criterion's EXISTING fields (statement · passCondition · dimension/severity/level ·
504
+ * judgeInputs · provenance · mined discovery). Every field OPTIONAL → graceful. PURE.
505
+ */
506
+ function criterionDefn(c: ReportCriterion | undefined, fallbackId: string): string {
507
+ if (c === undefined) return `<div class="row">${esc(fallbackId)}</div>`;
508
+ const meta = [
509
+ c.dimension ? `dimension · ${c.dimension}` : "",
510
+ c.severity ? `severity · ${String(c.severity).toUpperCase()}` : "",
511
+ c.level ? `level · ${c.level}` : "",
512
+ c.gating ? "gating" : "",
513
+ ]
514
+ .filter(Boolean)
515
+ .join(" · ");
516
+ const judged =
517
+ c.judgeInputs && c.judgeInputs.length > 0
518
+ ? `<div class="row"><span class="ref">judged from:</span> ${esc(c.judgeInputs.join(", "))}</div>`
519
+ : "";
520
+ const disc =
521
+ c.discovery !== undefined
522
+ ? `<div class="row"><span class="ref">mined:</span> ${esc(c.discovery.targets)} — ${esc(c.discovery.why_problem)}</div>`
523
+ : "";
524
+ return (
525
+ `<div class="row">${esc(c.statement)}</div>` +
526
+ (c.passCondition && c.passCondition !== c.statement
527
+ ? `<div class="row"><span class="ref">pass when:</span> ${esc(c.passCondition)}</div>`
528
+ : "") +
529
+ (meta ? `<div class="row defn-meta">${esc(meta)}</div>` : "") +
530
+ judged +
531
+ `<div class="row"><span class="ref">provenance:</span> ${esc(c.provenance?.label ?? "—")}${c.provenance?.detail ? " · " + esc(c.provenance.detail) : ""}</div>` +
532
+ disc
533
+ );
534
+ }
535
+
536
+ /**
537
+ * WS-3 — a ONE-LINE plain-language gloss for a criterion: de-jargons WHAT it measures
538
+ * and WHY it matters, so a non-author can read the scorecard/findings without parsing
539
+ * the "Pass = …; Fail = …" statement scaffolding or the dimension/refs.
540
+ *
541
+ * SOURCED honestly, in priority order — NEVER fabricates beyond what the criterion
542
+ * already states:
543
+ * 1) the mined discovery rationale — `discovery.targets` (the behaviour it guards)
544
+ * + `discovery.why_problem` (the user/correctness consequence). These are the
545
+ * purpose-built readable fields, used verbatim.
546
+ * 2) DERIVED deterministically from the `statement` by stripping the
547
+ * `Pass = …` / `Fail = …` scaffolding into "Measures: <pass clause>. Why it
548
+ * matters: failing means <fail clause>." (a pure restructure — no new content).
549
+ * 3) the bare statement, when it carries no Pass/Fail scaffolding.
550
+ * Always returns a non-empty string for any criterion with a statement. PURE.
551
+ */
552
+ export function plainExplainer(c: ReportCriterion | undefined): string {
553
+ if (c === undefined) return "";
554
+ const clean = (s: string): string => s.replace(/\s+/g, " ").trim();
555
+ // (1) mined criteria carry the most readable, purpose-built fields.
556
+ if (c.discovery !== undefined) {
557
+ const what = clean(c.discovery.targets);
558
+ const why = clean(c.discovery.why_problem);
559
+ if (what !== "") return `Measures: ${what}${why !== "" ? `. Why it matters: ${why}` : ""}.`;
560
+ }
561
+ // (2) derive from the statement — strip the Pass/Fail scaffolding (tolerant of
562
+ // `=`/`:` and casing; the pass clause runs up to a `;` or the `Fail` marker).
563
+ const stmt = clean(c.statement);
564
+ if (stmt === "") return "";
565
+ const passM = stmt.match(/pass\s*[:=]\s*([^;]+?)(?:;\s*|\s+fail\s*[:=]|$)/i);
566
+ const failM = stmt.match(/fail\s*[:=]\s*(.+)$/i);
567
+ if (passM) {
568
+ const what = clean(passM[1]);
569
+ const why = failM ? clean(failM[1]).replace(/\.$/, "") : "";
570
+ return `Measures: ${what}${why !== "" ? `. Why it matters: failing means ${why}` : ""}.`;
571
+ }
572
+ // (3) no scaffolding — present the statement as the measure.
573
+ return `Measures: ${stmt}`;
574
+ }
575
+
576
+ /** WS-3 — the muted plain-language banner element (above the technical block). Empty
577
+ * source ⇒ empty string (no stray banner). Shared by the §3 subcards + §4 findings. */
578
+ function plainBanner(c: ReportCriterion | undefined): string {
579
+ const txt = plainExplainer(c);
580
+ return txt === "" ? "" : `<div class="plain">${esc(txt)}</div>`;
581
+ }
582
+
583
+ /**
584
+ * WS-5 — the deterministic INDETERMINATE-resolution gate. Resolves ONE uncertain
585
+ * per-criterion verdict into the decision chain, NEVER leaving a bare "indeterminate":
586
+ *
587
+ * ① PRECONDITION GATE (deterministic) — is the criterion's TRIGGER/precondition even
588
+ * present in this trace? Signals, in priority:
589
+ * (a) the judge's DENSE na-map marks this criterion `na` on this trace — its
590
+ * explicit, per-trace applicability call (the strongest deterministic signal);
591
+ * (b) the abstain is `blockedBy.kind === "scope"` — the criterion's referent is
592
+ * out of scope for this route;
593
+ * (c) a derivable precondition TRIGGER (tool/event) did NOT fire in the observed
594
+ * agent steps (used when the criterion carries one + the trace has steps).
595
+ * Any ⇒ `na` (precondition absent) — DROPPED from the applicable denominator.
596
+ * ② Otherwise the precondition WAS present but the judge could not decide ⇒
597
+ * `needs-evidence`, with a concrete NEXT-ACTION: code-recheck (a deterministic/hybrid
598
+ * criterion) · 2nd-judge (re-ground a `factual-intent` abstain) · revise-criterion
599
+ * (a `normative` value-call — operator-owned) · hitl-spot-check (default).
600
+ * PURE. `reason` is the judge's own `blockedBy.text` when present (the unbound term),
601
+ * never invented.
602
+ */
603
+ export function classifyIndeterminate(
604
+ criterion: ReportCriterion | undefined,
605
+ verdict: { result?: string; blockedBy?: { kind?: string; text?: string } } | undefined,
606
+ denseValue: string | undefined,
607
+ agentSteps?: AgentStep[],
608
+ ): IndeterminateResolution {
609
+ const cid = criterion?.id ?? "";
610
+ const bb = verdict?.blockedBy;
611
+ const trigger = preconditionTrigger(criterion);
612
+ const norm = (s: string): string => s.trim().toLowerCase();
613
+ const triggerAbsent =
614
+ trigger !== undefined && agentSteps !== undefined && agentSteps.length > 0
615
+ ? !agentSteps.some((s) => norm(s.tool ?? "") === norm(trigger))
616
+ : false;
617
+ // ① precondition-absent ⇒ N/A (excluded from the applicable denominator)
618
+ if (denseValue === "na" || bb?.kind === AssumptionKind.Scope || triggerAbsent) {
619
+ const reason =
620
+ bb?.text && bb.text.trim().length > 0
621
+ ? bb.text.trim()
622
+ : trigger
623
+ ? `trigger '${trigger}' not invoked in this trace`
624
+ : "criterion not applicable to this trace — precondition/trigger absent";
625
+ return { criterionId: cid, resolutionClass: "na", reason };
626
+ }
627
+ // ② precondition present but undecided ⇒ needs-evidence + a chain next-action
628
+ const cm = criterion?.checkMethod;
629
+ const nextAction: IndeterminateNextAction =
630
+ cm === "deterministic" || cm === "hybrid"
631
+ ? "code-recheck"
632
+ : bb?.kind === AssumptionKind.FactualIntent
633
+ ? "2nd-judge"
634
+ : bb?.kind === AssumptionKind.Normative
635
+ ? "revise-criterion"
636
+ : "hitl-spot-check";
637
+ const reason =
638
+ bb?.text && bb.text.trim().length > 0
639
+ ? bb.text.trim()
640
+ : "judge abstained — evidence present but inconclusive; needs a confirming pass";
641
+ return { criterionId: cid, resolutionClass: "needs-evidence", nextAction, reason };
642
+ }
643
+
644
+ /** WS-5 — the precondition TRIGGER (tool/event) a criterion requires before it applies,
645
+ * when derivable. RESERVED hook: `ReportCriterion` carries no codeEval today, so this
646
+ * returns undefined and the deterministic gate runs off the judge's dense na-map + the
647
+ * typed `blockedBy`. A codeEval-bearing criterion (discover-side) can override later. */
648
+ function preconditionTrigger(_criterion: ReportCriterion | undefined): string | undefined {
649
+ return undefined;
650
+ }
651
+
652
+ // ── §9.4.4 R3 — per-tab "what this tab shows" description ─────────────────────
653
+ /** A standardized tab-description banner (R3): every tab states what it shows. */
654
+ function tabDesc(text: string): string {
655
+ return `<div class="tabdesc"><span class="td-i">ⓘ</span> ${esc(text)}</div>`;
656
+ }
657
+
658
+ // ── §9.4.4 R4 — criterion provenance + human-readable statement + hover ───────
659
+ /**
660
+ * The provenance chip for a criterion (R4): `defined` (authored matrix row) vs
661
+ * `source` (mined from the trace ✓/✗ split), with a hover title carrying the origin.
662
+ */
663
+ function provChip(c: ReportCriterion | undefined): string {
664
+ const p = c?.provenance;
665
+ if (p === undefined) return "";
666
+ const cls = p.kind === "defined" ? "defined" : "source";
667
+ const title = `${p.label}${p.detail ? " — " + p.detail : ""}`;
668
+ return `<span class="prov ${cls}" title="${esc(title)}">${esc(p.kind)}</span>`;
669
+ }
670
+
671
+ /**
672
+ * UI-10 — the CODE vs JUDGE chip for a criterion, from its `checkMethod` router:
673
+ * `deterministic` → CODE (a deterministic code-eval — zero judge tokens) · `hybrid`
674
+ * → CODE+JUDGE (code pre-filter, then judge on the residual) · else JUDGE (scored by
675
+ * the critique-before-verdict LLM judge). Lets a reader tell at a glance HOW each
676
+ * criterion is evaluated — code-checked vs judged. Absent checkMethod ⇒ no chip.
677
+ */
678
+ function methodKind(c: ReportCriterion | undefined): "code" | "hybrid" | "judge" | undefined {
679
+ const m = c?.checkMethod;
680
+ if (m === undefined) return undefined;
681
+ return m === "deterministic" ? "code" : m === "hybrid" ? "hybrid" : "judge";
682
+ }
683
+ function methodChip(c: ReportCriterion | undefined): string {
684
+ const kind = methodKind(c);
685
+ if (kind === undefined) return "";
686
+ const label = kind === "code" ? "CODE" : kind === "hybrid" ? "CODE+JUDGE" : "JUDGE";
687
+ const title =
688
+ kind === "code"
689
+ ? "code-eval — deterministic check, no LLM judge (zero judge tokens)"
690
+ : kind === "hybrid"
691
+ ? "hybrid — deterministic code pre-filter, then LLM judge on the residual"
692
+ : "llm-judge — scored by the critique-before-verdict LLM judge";
693
+ return `<span class="method ${kind}" title="${esc(title)}">${label}</span>`;
694
+ }
695
+
696
+ /**
697
+ * An info-hover badge carrying the criterion's FULL definition (R4): the human-
698
+ * readable statement + pass condition + provenance. Rendered as a `title=`-tooltip
699
+ * on a small ⓘ glyph so every criterion id is self-describing on hover.
700
+ */
701
+ function critHover(c: ReportCriterion | undefined): string {
702
+ if (c === undefined) return "";
703
+ const kind = methodKind(c);
704
+ const parts = [
705
+ `STATEMENT: ${c.statement}`,
706
+ c.passCondition ? `PASS WHEN: ${c.passCondition}` : "",
707
+ // UI-10 — the code-vs-judge method, spelled out in the hover.
708
+ kind !== undefined ? `EVALUATED BY: ${kind === "code" ? "CODE (deterministic check)" : kind === "hybrid" ? "HYBRID (code pre-filter + judge)" : "LLM JUDGE"}` : "",
709
+ c.provenance ? `PROVENANCE: ${c.provenance.label}${c.provenance.detail ? " (" + c.provenance.detail + ")" : ""}` : "",
710
+ ].filter(Boolean);
711
+ return `<span class="ihover" title="${esc(parts.join("\n"))}">ⓘ</span>`;
712
+ }
713
+
714
+ /**
715
+ * Gap A — the RAW triggering INPUT for a trace: the thing that fired the subject
716
+ * agent. PRIMARY source is `EvalTrace.input.prompt` (the canonical contract field,
717
+ * contracts/eval-types.ts). FALLBACK is the packet-style `transcript[0]` raw user
718
+ * turn carried on some exports (`input.transcript[0].content|text|message`). An
719
+ * absent / non-string / empty value ⇒ `undefined` (the row gets no input; the
720
+ * drill cell renders "—" — never a fabricated input). PURE, read-only.
721
+ */
722
+ function traceInputText(t: EvalTrace): string | undefined {
723
+ const input = t.input;
724
+ if (input === undefined || input === null) return undefined;
725
+ // PRIMARY: the canonical `input.prompt`.
726
+ const prompt = (input as { prompt?: unknown }).prompt;
727
+ if (typeof prompt === "string" && prompt.trim().length > 0) return prompt;
728
+ // FALLBACK: a packet/transcript-style first user turn (`transcript[0]`).
729
+ const transcript = (input as { transcript?: unknown }).transcript;
730
+ if (Array.isArray(transcript) && transcript.length > 0) {
731
+ const first = transcript[0] as Record<string, unknown> | string | undefined;
732
+ if (typeof first === "string" && first.trim().length > 0) return first;
733
+ if (first !== null && typeof first === "object") {
734
+ for (const key of ["content", "text", "message", "prompt"] as const) {
735
+ const v = first[key];
736
+ if (typeof v === "string" && v.trim().length > 0) return v;
737
+ }
738
+ }
739
+ }
740
+ return undefined;
741
+ }
742
+
743
+ /**
744
+ * WS-6 — reconstruct the subject's SYSTEM PROMPT from a raw trace. The eval
745
+ * `subjectProfile` carries no `systemPrompt` (0/N verdicts), but the agent's static
746
+ * system message rides on every LLM-call observation as the first `role:"system"`
747
+ * message — so it is RECOVERABLE from the trace batch, not confabulated.
748
+ *
749
+ * Scans, in priority order: (1) each observation's `input` — a bare messages ARRAY
750
+ * (e.g. Langfuse `ai.generateText.doGenerate.input = [{role:"system",content:…}, …]`)
751
+ * OR a `{messages:[…]}` wrapper (the `ai.generateText` SPAN shape); (2) the trace-level
752
+ * `input` in those same two shapes. Returns the FIRST `role:"system"` message's text
753
+ * (string content, or the joined `text` parts of an array content). Returns `undefined`
754
+ * when no system message exists — the caller then leaves the prompt UNAVAILABLE
755
+ * (NEVER fabricated). PURE, read-only over the trace. REUSABLE: the discover-side
756
+ * entity hero can import this to mirror the same reconstruction.
757
+ */
758
+ export function reconstructSystemPrompt(t: EvalTrace): string | undefined {
759
+ // content → text: a message content is a string OR an array of {type,text} parts.
760
+ const contentText = (content: unknown): string => {
761
+ if (typeof content === "string") return content;
762
+ if (Array.isArray(content)) {
763
+ return content
764
+ .map((part) =>
765
+ part !== null && typeof part === "object"
766
+ ? String((part as { text?: unknown }).text ?? "")
767
+ : typeof part === "string"
768
+ ? part
769
+ : "",
770
+ )
771
+ .join("");
772
+ }
773
+ return "";
774
+ };
775
+ // scan a message ARRAY for the first role:"system" → its (non-empty) content text.
776
+ const fromMessages = (msgs: unknown): string | undefined => {
777
+ if (!Array.isArray(msgs)) return undefined;
778
+ for (const m of msgs) {
779
+ if (m !== null && typeof m === "object" && (m as { role?: unknown }).role === "system") {
780
+ const txt = contentText((m as { content?: unknown }).content).trim();
781
+ if (txt.length > 0) return txt;
782
+ }
783
+ }
784
+ return undefined;
785
+ };
786
+ // a container is either a bare messages array OR a {messages:[…]} wrapper.
787
+ const fromContainer = (c: unknown): string | undefined => {
788
+ const direct = fromMessages(c);
789
+ if (direct !== undefined) return direct;
790
+ if (c !== null && typeof c === "object" && !Array.isArray(c)) {
791
+ return fromMessages((c as { messages?: unknown }).messages);
792
+ }
793
+ return undefined;
794
+ };
795
+ for (const o of t.observations ?? []) {
796
+ const hit = fromContainer(o.input);
797
+ if (hit !== undefined) return hit;
798
+ }
799
+ return fromContainer(t.input);
800
+ }
801
+
802
+ /**
803
+ * UI-9 (§9.4.5 E3) — the trace WALL-CLOCK timestamp, sourced from the RAW input
804
+ * prompt's `<current_time>…ISO…</current_time>` tag. Real agent harnesses stamp the
805
+ * wall-clock into the prompt they fire the subject with (e.g. a sample subject:
806
+ * `<current_time>2026-05-05T18:39:19.934Z</current_time>`), so the time the run
807
+ * happened is GROUND-TRUTH in the trace — NOT a judge-emit field. The eval-health
808
+ * heatmap buckets pass-rate by this timestamp. TOLERANT: returns the inner ISO when
809
+ * it parses as a real date (never a fabricated stamp); absent/garbage ⇒ undefined
810
+ * (the row lands in the heatmap data-pending bucket). PURE, read-only over traces.
811
+ */
812
+ export function traceTimestamp(t: EvalTrace): string | undefined {
813
+ const raw = traceInputText(t);
814
+ if (raw === undefined) return undefined;
815
+ const m = raw.match(/<current_time>\s*([^<]+?)\s*<\/current_time>/i);
816
+ if (m === null) return undefined;
817
+ const iso = m[1].trim();
818
+ // validate: a real, date-parseable stamp of ISO-ish length — never fake.
819
+ if (iso.length < 10 || Number.isNaN(Date.parse(iso))) return undefined;
820
+ return iso;
821
+ }
822
+
823
+ /** A criterion's human-readable one-line statement (R4), truncated for table rows. */
824
+ function critStatement(c: ReportCriterion | undefined, max = 88): string {
825
+ const s = c?.statement ?? "";
826
+ return s.length > max ? s.slice(0, max - 1) + "…" : s;
827
+ }
828
+
829
+ /** Coerce a tolerant context band (string | string[]) to readable strings — real
830
+ * judges emit exit-states / scenarios as a LIST; join them for the §2 band. */
831
+ function coerceContext(
832
+ ctx: { harness?: unknown; scenario?: unknown; exitStates?: unknown },
833
+ ): { harness?: string; scenario?: string; exitStates?: string } {
834
+ // TOLERANT — real judges emit exitStates/scenario as a string, a string[], OR a
835
+ // structured object (e.g. `{steps: 7}`). Coerce all three to a readable string so
836
+ // the §2 drill never renders a raw "[object Object]" (WS-1: the rich verdict files
837
+ // that carry an object exitStates are exactly the ones with the judge_steps walk).
838
+ const one = (v: unknown): string | undefined =>
839
+ v === undefined || v === null
840
+ ? undefined
841
+ : Array.isArray(v)
842
+ ? v.join(" · ")
843
+ : typeof v === "object"
844
+ ? Object.entries(v as Record<string, unknown>)
845
+ .map(([k, val]) => `${k}: ${val}`)
846
+ .join(", ")
847
+ : String(v);
848
+ return {
849
+ ...(ctx.harness !== undefined ? { harness: one(ctx.harness) } : {}),
850
+ ...(ctx.scenario !== undefined ? { scenario: one(ctx.scenario) } : {}),
851
+ ...(ctx.exitStates !== undefined ? { exitStates: one(ctx.exitStates) } : {}),
852
+ };
853
+ }
854
+
855
+ // ── criterion adapters (mined ⇄ matrix → ReportCriterion) ────────────────────
856
+
857
+ const GATING_SEVERITIES = new Set(["CRIT", "HIGH"]);
858
+
859
+ /** A mined criterion carries the full §5b metadata + §5c DR-2 rationale. Its
860
+ * provenance is `source` (R4): mined from the ✓/✗ trace split. */
861
+ export function fromMinedCriteria(criteria: MinedCriterion[]): ReportCriterion[] {
862
+ return criteria.map((c) => ({
863
+ id: c.id,
864
+ statement: c.statement,
865
+ severity: c.metadata.severity,
866
+ gating: GATING_SEVERITIES.has(c.metadata.severity),
867
+ dimension: c.metadata.dimension,
868
+ level: c.metadata.level,
869
+ appliesTo: c.metadata.applies_to,
870
+ // UI-10 — carry the code-vs-judge router (default llm-judge when the metadata omits it).
871
+ checkMethod: c.metadata.check_method ?? "llm-judge",
872
+ ...(c.metadata.judge_inputs !== undefined ? { judgeInputs: c.metadata.judge_inputs } : {}),
873
+ provenance: {
874
+ kind: "source" as const,
875
+ label: "mined from trace ✓/✗ split",
876
+ ...(c.discovery !== undefined
877
+ ? { detail: `grounding ${c.discovery.evidence.grounding} · seen in ${c.discovery.evidence.seen_in_traces} trace(s)` }
878
+ : {}),
879
+ },
880
+ discovery: c.discovery,
881
+ }));
882
+ }
883
+
884
+ /** A matrix criterion carries statement + pass condition + severity. Its provenance
885
+ * is `defined` (R4): an authored eval-matrix row. */
886
+ export function fromMatrixCriteria(criteria: MatrixCriterion[]): ReportCriterion[] {
887
+ return criteria.map((c) => ({
888
+ id: c.criterionId,
889
+ statement: c.statement,
890
+ severity: c.severity,
891
+ gating: GATING_SEVERITIES.has(c.severity),
892
+ passCondition: c.passCondition,
893
+ // UI-10 — carry the code-vs-judge router (default llm-judge when the matrix row omits it).
894
+ checkMethod: c.checkMethod ?? "llm-judge",
895
+ provenance: { kind: "defined" as const, label: "defined eval-matrix criterion" },
896
+ ...(c.dimension !== undefined ? { dimension: c.dimension } : {}),
897
+ ...(c.judgeInputs !== undefined ? { judgeInputs: c.judgeInputs } : {}),
898
+ }));
899
+ }
900
+
901
+ // ── D-1 terminal eval-cards ─────────────────────────────────────────────────
902
+
903
+ /**
904
+ * Render per-criterion terminal cards from the Scorecard: criterion · verdict ·
905
+ * severity · critique snippet · the §5c DR-2 grounding tag (when mined). Plain
906
+ * text, deterministic (criteria in `verdicts` order). The D-1 dogfood fix.
907
+ */
908
+ export function renderEvalCards(input: EvalReportInput): string {
909
+ const lines: string[] = [];
910
+ const g = input.scorecard.gate;
911
+ lines.push(`╔═ EVAL CARDS — ${input.subject.name} ═══`);
912
+ lines.push(`║ GATE: ${g.passed ? "PASS" : (g.runVerdict ?? "fail").toUpperCase()} (${g.passCount}/${g.total} pass, ${g.gatedBy.length} gating)`);
913
+ lines.push("╠══════════════════════════════════════");
914
+ for (const v of input.verdicts) {
915
+ const crit = criterionById(input, v.criterionId);
916
+ const sev = crit?.severity ?? "MED";
917
+ // UI-10 — the code-vs-judge tag, so the terminal cards distinguish code-checked
918
+ // criteria from judged ones (CODE · HYBRID · JUDGE). Default JUDGE when absent.
919
+ const mkind = methodKind(crit);
920
+ const method = mkind === "code" ? "CODE" : mkind === "hybrid" ? "HYBRID" : "JUDGE";
921
+ const grounding = crit?.discovery?.evidence.grounding ?? Grounding.Inferred;
922
+ const prevalence = crit?.discovery?.evidence.prevalence ?? "n/a";
923
+ const snippet = v.critique.length > 80 ? v.critique.slice(0, 77) + "…" : v.critique;
924
+ lines.push(`║ ▸ ${v.criterionId} [${sev}·${method}] ${resultToken(v.result).toUpperCase()} (conf ${v.confidence})`);
925
+ lines.push(`║ grounding: ${grounding} (${prevalence})`);
926
+ lines.push(`║ ${snippet}`);
927
+ }
928
+ lines.push("╚══════════════════════════════════════");
929
+ return lines.join("\n");
930
+ }
931
+
932
+ // ── wiring: build the rich report input from real pipeline data ──────────────
933
+
934
+ function ledgerVerdict(perCriterion: Record<string, string>): string {
935
+ const vals = Object.values(perCriterion).filter((v) => v !== "na");
936
+ if (vals.includes(OutcomeVerdict.Fail)) return "FAIL";
937
+ if (vals.includes(OutcomeVerdict.Uncertain)) return "INDETERMINATE";
938
+ if (vals.length === 0) return "INCOMPLETE";
939
+ return "PASS";
940
+ }
941
+
942
+ /** Optional §9.4 trajectory-level fields the judge MAY emit (additive). */
943
+ interface MatrixVerdictFileRich extends MatrixVerdictFile {
944
+ route?: string;
945
+ context?: { harness?: string | string[]; scenario?: string | string[]; exitStates?: string | string[] };
946
+ agentSteps?: AgentStep[];
947
+ judgeSteps?: JudgeStep[];
948
+ localize?: string;
949
+ health?: JudgeHealthRow;
950
+ // §9.4.4 v2.2
951
+ understanding?: Understanding;
952
+ expectedTrajectory?: ExpectedStep[];
953
+ subjectProfile?: SubjectProfile;
954
+ /** §9.4.5 E3 — the trace wall-clock timestamp (ISO), when the source carried it.
955
+ * Common spellings tolerated below (`timestamp` · `traceTimestamp` · `startTime`). */
956
+ timestamp?: string;
957
+ traceTimestamp?: string;
958
+ startTime?: string;
959
+ }
960
+
961
+ /**
962
+ * Best-effort carry of an observation id onto each `AgentStep` so the §2 judge
963
+ * lane can map a step to the criterion verdicts that EXAMINED it by the precise
964
+ * `ref.obs === step.obs` key (on top of the tool-name fallback). The verdict
965
+ * files DON'T emit a per-step obs id — but the per-criterion verdict `refs` and
966
+ * critiques DO carry obs ids bound to a tool:
967
+ * (a) `refs[]` with `path:"name"` → `{obs:<id>, value:<toolName>}`.
968
+ * (b) `tool@<obsid>` tokens the judge embeds in `critique` text
969
+ * (e.g. "sendMessage@04ff44e20dbacb37") — the ONLY source for tools that
970
+ * were cited only by an `output.*` ref (which carries no tool name).
971
+ * We gather tool → ordered-unique obs ids from both, then assign positionally per
972
+ * step (nth call of a tool → nth obs id). A tool with no recovered obs id leaves
973
+ * the step's obs UNDEFINED (honest — the tool-name match still covers it). PURE.
974
+ */
975
+ export function enrichAgentStepsObs(f: {
976
+ agentSteps?: AgentStep[];
977
+ verdicts?: Array<{ critique?: string; refs?: Array<{ obs?: string; path?: string; value?: string }> }>;
978
+ }): AgentStep[] | undefined {
979
+ const steps = f.agentSteps;
980
+ if (steps === undefined) return undefined;
981
+ const toolObs = new Map<string, string[]>();
982
+ const push = (tool: string, obs: string): void => {
983
+ if (!tool || !obs) return;
984
+ const list = toolObs.get(tool) ?? [];
985
+ if (!list.includes(obs)) {
986
+ list.push(obs);
987
+ toolObs.set(tool, list);
988
+ }
989
+ };
990
+ for (const v of f.verdicts ?? []) {
991
+ for (const rf of v.refs ?? []) {
992
+ if (rf && typeof rf === "object" && rf.path === "name" && rf.value && rf.obs) {
993
+ push(String(rf.value), String(rf.obs));
994
+ }
995
+ }
996
+ // `tool@<obs>` tokens in the critique (obs ids are 6+ hex chars).
997
+ const crit = typeof v.critique === "string" ? v.critique : "";
998
+ const re = /([A-Za-z_]\w*)@([0-9a-f]{6,})/g;
999
+ let m: RegExpExecArray | null;
1000
+ while ((m = re.exec(crit)) !== null) push(m[1], m[2]);
1001
+ }
1002
+ if (toolObs.size === 0) return steps;
1003
+ const counter: Record<string, number> = {};
1004
+ return steps.map((s) => {
1005
+ const tool = s.tool ?? "";
1006
+ const list = toolObs.get(tool);
1007
+ if (list === undefined || list.length === 0) return s;
1008
+ const i = counter[tool] ?? 0;
1009
+ counter[tool] = i + 1;
1010
+ const obs = list[i];
1011
+ return obs !== undefined ? { ...s, obs } : s;
1012
+ });
1013
+ }
1014
+
1015
+ /**
1016
+ * Derive the rich `EvalReportInput` fields (ledger · coverage · cohorts · gating
1017
+ * rolls · top findings · judge-health) from the REAL pipeline outputs — the
1018
+ * per-trajectory Judge-Agent verdict files + the folded scorecard + the criteria.
1019
+ * Invents NO data: every cell is read from a verdict file or the scorecard.
1020
+ *
1021
+ * §9.4-judge-emit CLOSED (T2): the JUDGE AGENT now EMITS the side-by-side fields
1022
+ * (`judgeSteps`, `agentSteps`, `context`, `localize`, `health`, `route`), a dense
1023
+ * na-explicit `denseMap`, a `fidelity` gate, and per-verdict `confidenceBand` —
1024
+ * mandated in `evaluator.md #mode-judge-trajectory`. The renderer uses an emitted
1025
+ * `denseMap` verbatim when present and otherwise synthesizes one from `verdicts[]`
1026
+ * (na for any unjudged row), so a judge that omits the walk still renders. PURE.
1027
+ */
1028
+ export function buildEvalReportInput(params: {
1029
+ subject: EvalReportInput["subject"];
1030
+ scorecard: Scorecard;
1031
+ verdicts: CriterionVerdict[];
1032
+ criteria: ReportCriterion[];
1033
+ matrixVerdictFiles?: MatrixVerdictFile[];
1034
+ sourceMap?: SourceMap;
1035
+ handover?: HandoverBundle | null;
1036
+ generatedAt: string;
1037
+ triaged?: number;
1038
+ /** §9.4.4 M1/R2 — the subject profile (given or reconstructed) for the report. */
1039
+ subjectProfile?: SubjectProfile;
1040
+ /** Overview provenance meta-strip — the run config (judge substrate · pinned model · …). */
1041
+ runConfig?: RunConfig;
1042
+ /**
1043
+ * Gap A — the INGESTED trace batch (the EvalTraces under evaluation). Used ONLY
1044
+ * to source each ledger row's RAW triggering INPUT (`EvalTrace.input.prompt`,
1045
+ * keyed by trace id == `MatrixVerdictFile.trajectoryId`). The input is
1046
+ * ground-truth in the trace — NOT a judge-emit field, so this stays non-protocol
1047
+ * (the judge schema is untouched). ABSENT ⇒ rows carry no `input` and the §2
1048
+ * drill cell renders "—" (graceful, never faked). PURE — read-only over traces.
1049
+ */
1050
+ traces?: EvalTrace[];
1051
+ }): EvalReportInput {
1052
+ const files = (params.matrixVerdictFiles ?? []) as MatrixVerdictFileRich[];
1053
+ const critIds = params.criteria.map((c) => c.id);
1054
+ // Gap A — index the raw triggering INPUT by trace id (== trajectoryId). Read
1055
+ // `EvalTrace.input.prompt`; an absent/empty input leaves the row without `input`
1056
+ // (the drill renders "—"). NO judge echo — the input is read straight from the
1057
+ // trace the verdict was produced over.
1058
+ const inputByTrace = new Map<string, string>();
1059
+ // UI-9 (§9.4.5 E3) — index the wall-clock timestamp by trace id (== trajectoryId),
1060
+ // extracted from the trace's `<current_time>` prompt stamp. This is the REAL data
1061
+ // path the eval-health heatmap needs; verdict files carry no timestamp, but the
1062
+ // ingested traces do. Mirror inputByTrace; NO contract change.
1063
+ const timestampByTrace = new Map<string, string>();
1064
+ for (const t of params.traces ?? []) {
1065
+ const raw = traceInputText(t);
1066
+ if (raw !== undefined) inputByTrace.set(t.id, raw);
1067
+ const ts = traceTimestamp(t);
1068
+ if (ts !== undefined) timestampByTrace.set(t.id, ts);
1069
+ }
1070
+ const sevById = new Map(params.criteria.map((c) => [c.id, c.severity]));
1071
+ const critById = new Map(params.criteria.map((c) => [c.id, c]));
1072
+ const gatingIds = new Set(params.criteria.filter((c) => c.gating).map((c) => c.id));
1073
+
1074
+ // ── ledger: one dense row per trajectory ──
1075
+ const ledger: LedgerRow[] = files.map((f) => {
1076
+ const perCriterion: Record<string, string> = {};
1077
+ for (const cid of critIds) perCriterion[cid] = "na"; // dense, na-explicit
1078
+ // §9.4.2 node 9: prefer the judge's EMITTED dense map (na-explicit) verbatim;
1079
+ // else synthesize from verdicts[] (any unjudged row stays na).
1080
+ const emittedDense = (f as { denseMap?: Record<string, string> }).denseMap;
1081
+ if (emittedDense !== undefined) {
1082
+ for (const cid of critIds) if (emittedDense[cid] !== undefined) perCriterion[cid] = emittedDense[cid];
1083
+ }
1084
+ for (const v of f.verdicts) perCriterion[v.criterionId] = v.result;
1085
+ // WS-5 — the INDETERMINATE resolution chain: deterministically resolve every
1086
+ // uncertain per-criterion verdict into `na` (precondition absent — DROPPED from the
1087
+ // applicable denominator) or `needs-evidence` (+ a next-action). A precondition-
1088
+ // absent uncertain is RECLASSIFIED to `na` in `perCriterion` so EVERY downstream
1089
+ // count (rolls · cohorts · heatmap · trace verdict) becomes denominator-honest —
1090
+ // no bare "indeterminate" that conflates not-applicable with under-evidenced.
1091
+ const verdictByCid = new Map(
1092
+ f.verdicts.map((v) => [v.criterionId, v as { result?: string; blockedBy?: { kind?: string; text?: string } }]),
1093
+ );
1094
+ const indeterminateResolution: Record<string, IndeterminateResolution> = {};
1095
+ for (const cid of critIds) {
1096
+ if (perCriterion[cid] === OutcomeVerdict.Uncertain) {
1097
+ const res = classifyIndeterminate(critById.get(cid), verdictByCid.get(cid), emittedDense?.[cid], f.agentSteps);
1098
+ indeterminateResolution[cid] = res;
1099
+ if (res.resolutionClass === "na") perCriterion[cid] = "na";
1100
+ }
1101
+ }
1102
+ const failingCriteria = f.verdicts.filter((v) => v.result === OutcomeVerdict.Fail).map((v) => v.criterionId);
1103
+ const grounding = f.verdicts
1104
+ .flatMap((v) => v.refs ?? [])
1105
+ .map((r) => `${r.obs}/${r.path}`)
1106
+ .slice(0, 3)
1107
+ .join(" · ");
1108
+ // #3 EXAMINE fidelity gate: a trajectory whose deterministic fidelity gate
1109
+ // fired (truncated / unterminated) is INCOMPLETE regardless of its dense map —
1110
+ // it was never walked per-criterion.
1111
+ const isIncomplete = (f as { fidelity?: { complete?: boolean } }).fidelity?.complete === false;
1112
+ const row: LedgerRow = {
1113
+ trajectoryId: f.trajectoryId,
1114
+ verdict: isIncomplete ? "INCOMPLETE" : ledgerVerdict(perCriterion),
1115
+ perCriterion,
1116
+ failingCriteria,
1117
+ ...(f.route !== undefined ? { route: f.route } : {}),
1118
+ ...(grounding ? { grounding } : {}),
1119
+ // WS-1 — bind the per-criterion JUDGE verdicts (result · critique · refs) for
1120
+ // THIS trajectory, read verbatim off the verdict file's `verdicts[]`. This is
1121
+ // the rich "How the Judge Reasoned" payload that EVERY judged trace carries
1122
+ // (PASS or FAIL), independent of whether a judge_steps[] walk was emitted —
1123
+ // so the §2 drill never renders an empty reasoning panel when the verdict file
1124
+ // has data. NO invention: a row with no verdicts simply omits the field.
1125
+ ...(f.verdicts.length > 0
1126
+ ? { criterionVerdicts: f.verdicts as unknown as CriterionTrajectoryVerdict[] }
1127
+ : {}),
1128
+ // Gap A — the raw triggering INPUT for this trajectory, sourced from the
1129
+ // trace by id (== trajectoryId). ABSENT ⇒ the drill cell renders "—".
1130
+ ...(inputByTrace.has(f.trajectoryId) ? { input: inputByTrace.get(f.trajectoryId) } : {}),
1131
+ ...(f.context !== undefined ? { context: coerceContext(f.context) } : {}),
1132
+ // Carry recovered obs ids onto each agent step so the §2 judge lane can map
1133
+ // a step → the criterion verdicts that examined it by `ref.obs === step.obs`.
1134
+ ...(f.agentSteps !== undefined ? { agentSteps: enrichAgentStepsObs(f) } : {}),
1135
+ ...(f.judgeSteps !== undefined ? { judgeSteps: f.judgeSteps } : {}),
1136
+ ...(f.localize !== undefined ? { localize: localizeText(f.localize) } : {}),
1137
+ // §9.4.4 v2.2 — node-0 understanding · node-0.5 expected-trajectory · M1 profile.
1138
+ ...(f.understanding !== undefined ? { understanding: f.understanding } : {}),
1139
+ ...(f.expectedTrajectory !== undefined ? { expectedTrajectory: f.expectedTrajectory } : {}),
1140
+ ...(f.subjectProfile !== undefined ? { subjectProfile: f.subjectProfile } : {}),
1141
+ // T4: DERIVE health from the walk when judge_steps were emitted (don't trust
1142
+ // the self-reported `health`); fall back to the self-reported field otherwise.
1143
+ ...((f.judgeSteps?.length ?? 0) > 0
1144
+ ? { health: deriveWalkHealth(f) }
1145
+ : f.health !== undefined
1146
+ ? { health: f.health }
1147
+ : {}),
1148
+ // §9.4.5 E3 / UI-9 — carry the trace timestamp for the eval-HEALTH temporal
1149
+ // heatmap. PRIMARY = the trace's `<current_time>` wall-clock (real ground-truth,
1150
+ // sourced from params.traces); FALLBACK = a verdict-file timestamp (tolerant
1151
+ // spellings) if a judge ever emits one. ABSENT ⇒ this row lands in the
1152
+ // data-pending bucket (never faked).
1153
+ ...(((timestampByTrace.get(f.trajectoryId) ?? f.timestamp ?? f.traceTimestamp ?? f.startTime) !== undefined)
1154
+ ? { timestamp: timestampByTrace.get(f.trajectoryId) ?? f.timestamp ?? f.traceTimestamp ?? f.startTime }
1155
+ : {}),
1156
+ // WS-5 — the per-criterion indeterminate resolution for this trace (na vs needs-evidence).
1157
+ ...(Object.keys(indeterminateResolution).length > 0 ? { indeterminateResolution } : {}),
1158
+ // WS-2 — how this trajectory resolved (judge-walk · judged-walk-not-captured ·
1159
+ // truncated). Makes the "no walk" traces HONEST: judged, not unjudged.
1160
+ resolution: deriveTrajectoryResolution(f),
1161
+ };
1162
+ return row;
1163
+ });
1164
+
1165
+ // ── §9.4.5 E2 — per-tool OBSERVED call-count (from the trajectory agent-steps). In
1166
+ // the trace-only (no-code-access) case this is the ONLY honest tool evidence: how
1167
+ // often each tool was actually seen called across the judged trajectories. PURE. ──
1168
+ const toolObservations: Record<string, number> = {};
1169
+ for (const f of files) {
1170
+ for (const step of f.agentSteps ?? []) {
1171
+ const name = step.tool;
1172
+ if (typeof name === "string" && name.length > 0) {
1173
+ toolObservations[name] = (toolObservations[name] ?? 0) + 1;
1174
+ }
1175
+ }
1176
+ }
1177
+
1178
+ // ── §9.4.4 R3/M5 — collect the DETECTED-but-unmatched flags (node-2.5 candidates),
1179
+ // surfaced in §4 SEPARATED from criterion fails (a detection, never a verdict). ──
1180
+ const detectedFlags: DetectedFlag[] = [];
1181
+ for (const f of files) {
1182
+ for (const cand of f.candidates ?? []) {
1183
+ detectedFlags.push({
1184
+ kind: cand.kind,
1185
+ detection: cand.detection,
1186
+ trajectoryId: f.trajectoryId,
1187
+ ...(cand.anchor !== undefined ? { anchor: cand.anchor } : {}),
1188
+ ...(cand.ref !== undefined ? { ref: cand.ref } : {}),
1189
+ });
1190
+ }
1191
+ }
1192
+ // §9.4.4 M1/R2 — the subject profile: prefer the run-supplied one, else the first
1193
+ // one a judge echoed/reconstructed on its verdict file.
1194
+ const subjectProfileBase = params.subjectProfile ?? files.find((f) => f.subjectProfile !== undefined)?.subjectProfile;
1195
+ // WS-6 — reconstruct the SYSTEM PROMPT from the trace batch when the profile lacks
1196
+ // one. The eval subjectProfile never carries `systemPrompt`, but the agent's static
1197
+ // system message rides on each LLM-call observation (role:"system"). Extract it ONCE
1198
+ // from the first trace that yields one, attach it, and TAG it reconstructed (added to
1199
+ // `inferredFields` ⇒ the entity hero shows the "system prompt · reconstructed"
1200
+ // collapsible). NEVER confabulate: if no trace carries a system message, leave it
1201
+ // UNAVAILABLE. If no profile exists at all but a system prompt is recoverable, mint a
1202
+ // MINIMAL reconstructed profile so the recovered prompt still surfaces.
1203
+ let reconstructedSystemPrompt: string | undefined;
1204
+ if (subjectProfileBase?.systemPrompt === undefined || subjectProfileBase.systemPrompt.trim() === "") {
1205
+ for (const t of params.traces ?? []) {
1206
+ const sys = reconstructSystemPrompt(t);
1207
+ if (sys !== undefined) {
1208
+ reconstructedSystemPrompt = sys;
1209
+ break;
1210
+ }
1211
+ }
1212
+ }
1213
+ const subjectProfile: SubjectProfile | undefined =
1214
+ reconstructedSystemPrompt !== undefined
1215
+ ? ({
1216
+ ...(subjectProfileBase ?? { provenance: "reconstructed" }),
1217
+ systemPrompt: reconstructedSystemPrompt,
1218
+ inferredFields: Array.from(
1219
+ new Set([...(subjectProfileBase?.inferredFields ?? []), "systemPrompt"]),
1220
+ ),
1221
+ } as SubjectProfile)
1222
+ : subjectProfileBase;
1223
+
1224
+ // ── coverage: byVerdict tally over the ledger ──
1225
+ const byVerdict: Record<string, number> = { PASS: 0, FAIL: 0, INDETERMINATE: 0, INCOMPLETE: 0 };
1226
+ for (const r of ledger) byVerdict[r.verdict] = (byVerdict[r.verdict] ?? 0) + 1;
1227
+ const coverage = {
1228
+ judged: ledger.length,
1229
+ triaged: params.triaged ?? ledger.length,
1230
+ byVerdict,
1231
+ gapNote:
1232
+ ledger.length > 0
1233
+ ? `${ledger.length} trajectory verdict(s) folded; every in-scope trajectory carries a per-criterion judgement (0 silently dropped).`
1234
+ : "no per-trajectory verdict files supplied for this render — scorecard-only.",
1235
+ };
1236
+
1237
+ // ── cohorts: criteria × route cohort (route defaults to "all") ──
1238
+ const counts: Record<string, number> = {};
1239
+ const matrix: Record<string, Record<string, { pass: number; fail: number; indeterminate: number }>> = {};
1240
+ for (const r of ledger) {
1241
+ const cohort = r.route ?? "all";
1242
+ counts[cohort] = (counts[cohort] ?? 0) + 1;
1243
+ for (const [cid, res] of Object.entries(r.perCriterion)) {
1244
+ if (res === "na") continue;
1245
+ matrix[cid] ??= {};
1246
+ matrix[cid][cohort] ??= { pass: 0, fail: 0, indeterminate: 0 };
1247
+ if (res === OutcomeVerdict.Pass) matrix[cid][cohort].pass += 1;
1248
+ else if (res === OutcomeVerdict.Fail) matrix[cid][cohort].fail += 1;
1249
+ else matrix[cid][cohort].indeterminate += 1;
1250
+ }
1251
+ }
1252
+
1253
+ // ── per-criterion roll (over the ledger). WS-5 — the applicable denominator EXCLUDES
1254
+ // `na` (precondition absent); the remaining `uncertain` are GENUINE needs-evidence
1255
+ // (the precondition-absent uncertains were already reclassified to `na` above). ──
1256
+ function rollOf(cid: string): {
1257
+ applicable: number;
1258
+ pass: number;
1259
+ fail: number;
1260
+ needsEvidence: number;
1261
+ na: number;
1262
+ example?: string;
1263
+ } {
1264
+ let applicable = 0,
1265
+ pass = 0,
1266
+ fail = 0,
1267
+ needsEvidence = 0,
1268
+ na = 0;
1269
+ let example: string | undefined;
1270
+ for (const r of ledger) {
1271
+ const res = r.perCriterion[cid];
1272
+ if (res === undefined) continue;
1273
+ if (res === "na") {
1274
+ na += 1;
1275
+ continue;
1276
+ }
1277
+ applicable += 1;
1278
+ if (res === OutcomeVerdict.Fail) {
1279
+ fail += 1;
1280
+ example ??= r.trajectoryId;
1281
+ } else if (res === OutcomeVerdict.Uncertain) {
1282
+ needsEvidence += 1;
1283
+ example ??= r.trajectoryId;
1284
+ } else pass += 1;
1285
+ }
1286
+ return { applicable, pass, fail, needsEvidence, na, ...(example !== undefined ? { example } : {}) };
1287
+ }
1288
+
1289
+ // #6 — the AUDITABLE second-judge note: surfaces that an INDEPENDENT verifier ran
1290
+ // over a gating fail and what it concluded (upheld vs refuted→downgraded), so the
1291
+ // report shows the refutation result (not just "eligible for"). PURE.
1292
+ function ivNote(folded?: CriterionVerdict): string {
1293
+ const iv = folded?.independentVerify;
1294
+ if (iv === undefined) return "";
1295
+ const who = iv.reviewerId !== undefined ? ` (${iv.reviewerId})` : "";
1296
+ return ` · [INDEPENDENT VERIFY — 2nd judge${who}: ${iv.upheld ? "UPHELD" : "REFUTED → downgraded"}; ${iv.reason}]`;
1297
+ }
1298
+
1299
+ // ── gating-criteria rolls (the gating subset; GA-D2b applicable denominator) ──
1300
+ const gatingCriteria: GatingRoll[] = [...gatingIds]
1301
+ .map((cid) => {
1302
+ const roll = rollOf(cid);
1303
+ const passRate = roll.applicable > 0 ? Math.round((100 * roll.pass) / roll.applicable) : 100;
1304
+ const folded = params.verdicts.find((v) => v.criterionId === cid);
1305
+ return {
1306
+ criterion: cid,
1307
+ severity: sevById.get(cid) ?? "MED",
1308
+ applicable: roll.applicable,
1309
+ fail: roll.fail,
1310
+ indeterminate: roll.needsEvidence,
1311
+ na: roll.na,
1312
+ passRateOverApplicable: passRate,
1313
+ denominatorNote: `N/A excluded — denominator = ${roll.applicable} applicable (na ${roll.na} dropped, GA-D2b)`,
1314
+ ...(folded?.critique ? { root: folded.critique + ivNote(folded) } : {}),
1315
+ };
1316
+ })
1317
+ .sort((a, b) => a.passRateOverApplicable - b.passRateOverApplicable);
1318
+
1319
+ // ── WS-5 — per-criterion INDETERMINATE resolution chain (ALL criteria, not just
1320
+ // gating). Splits each criterion's indeterminate into N/A (precondition absent,
1321
+ // denominator-excluded) vs needs-evidence (carries aggregated chain next-actions).
1322
+ // The reasons/next-actions are aggregated from each trace's deterministic
1323
+ // `indeterminateResolution`. ──
1324
+ const criterionResolutions: CriterionResolution[] = params.criteria.map((c) => {
1325
+ const roll = rollOf(c.id);
1326
+ const naReasons: Record<string, number> = {};
1327
+ const actionAgg: Record<string, { count: number; reason: string }> = {};
1328
+ for (const r of ledger) {
1329
+ const res = r.indeterminateResolution?.[c.id];
1330
+ if (res === undefined) continue;
1331
+ if (res.resolutionClass === "na") naReasons[res.reason] = (naReasons[res.reason] ?? 0) + 1;
1332
+ else if (res.nextAction !== undefined) {
1333
+ const a = (actionAgg[res.nextAction] ??= { count: 0, reason: res.reason });
1334
+ a.count += 1;
1335
+ }
1336
+ }
1337
+ const naReason = Object.entries(naReasons).sort((a, b) => b[1] - a[1])[0]?.[0];
1338
+ return {
1339
+ criterion: c.id,
1340
+ severity: c.severity,
1341
+ applicable: roll.applicable,
1342
+ pass: roll.pass,
1343
+ fail: roll.fail,
1344
+ na: roll.na,
1345
+ needsEvidence: roll.needsEvidence,
1346
+ passRateOverApplicable: roll.applicable > 0 ? Math.round((100 * roll.pass) / roll.applicable) : 100,
1347
+ ...(naReason !== undefined ? { naReason } : {}),
1348
+ nextActions: Object.entries(actionAgg)
1349
+ .map(([action, v]) => ({ action: action as IndeterminateNextAction, count: v.count, reason: v.reason }))
1350
+ .sort((a, b) => b.count - a.count),
1351
+ };
1352
+ });
1353
+
1354
+ // ── top findings: every criterion with ≥1 applicable fail, worst-first ──
1355
+ const topFindings: TopFinding[] = params.criteria
1356
+ .map((c) => {
1357
+ const roll = rollOf(c.id);
1358
+ const prev = roll.applicable > 0 ? Math.round((100 * roll.fail) / roll.applicable) : 0;
1359
+ const folded = params.verdicts.find((v) => v.criterionId === c.id);
1360
+ return {
1361
+ criterion: c.id,
1362
+ severity: c.severity,
1363
+ applicable: roll.applicable,
1364
+ failCount: roll.fail,
1365
+ prevalencePctOverApplicable: prev,
1366
+ ...(folded?.critique ? { root: folded.critique + ivNote(folded) } : {}),
1367
+ ...(roll.example !== undefined ? { exampleTraceId: roll.example } : {}),
1368
+ };
1369
+ })
1370
+ .filter((f) => f.failCount > 0)
1371
+ .sort((a, b) => b.prevalencePctOverApplicable - a.prevalencePctOverApplicable || b.failCount - a.failCount);
1372
+
1373
+ // ── judge-health: grounded% (verdicts citing refs) · abstention% · symptom% ──
1374
+ const allV = files.flatMap((f) => f.verdicts);
1375
+ // UI-12-B — groundedPct is computed over DECIDED verdicts ONLY. An `uncertain`
1376
+ // abstain carries empty refs BY DESIGN (it's blockedBy / na for grounding, NOT
1377
+ // "ungrounded"), so counting it in the denominator dilutes the honest grounded
1378
+ // rate (a clean 100%-of-decided run reads e.g. ~92% just because some rows abstained).
1379
+ // Abstains are surfaced separately as the abstention rate. When there are NO decided
1380
+ // verdicts to ground (all abstained / none judged), groundedPct is undefined →
1381
+ // the cell renders "capture-unavailable" (never a fabricated 100%/0%).
1382
+ const decidedV = allV.filter((v) => v.result !== OutcomeVerdict.Uncertain);
1383
+ const grounded = decidedV.filter((v) => (v.refs?.length ?? 0) > 0).length;
1384
+ const abstained = allV.filter((v) => v.result === OutcomeVerdict.Uncertain).length;
1385
+ const symptom = ledger.filter((r) => r.health?.stoppedAtSymptom).length;
1386
+ const judgeHealth = {
1387
+ ...(decidedV.length > 0 ? { groundedPct: Math.round((100 * grounded) / decidedV.length) } : {}),
1388
+ abstentionRatePct: allV.length > 0 ? Math.round((100 * abstained) / allV.length) : 0,
1389
+ stoppedAtSymptomPct: ledger.length > 0 ? Math.round((100 * symptom) / ledger.length) : 0,
1390
+ };
1391
+
1392
+ // WS-3 — derive the §5 Self-Eval AGGREGATE (behavior map + 9 components) from the
1393
+ // SAME parsed files + ledger. Deterministic; the emit-completeness meter (5.7) reads
1394
+ // the WS-1 gate. PURE — no extra dispatch (lean ~0 latency).
1395
+ const selfEval = deriveSelfEval({ ledger, criteria: params.criteria, emit: assessEmitCompleteness(files) });
1396
+
1397
+ return {
1398
+ subject: params.subject,
1399
+ scorecard: params.scorecard,
1400
+ verdicts: params.verdicts,
1401
+ criteria: params.criteria,
1402
+ ledger,
1403
+ selfEval,
1404
+ coverage,
1405
+ cohorts: { counts, matrix },
1406
+ gatingCriteria,
1407
+ criterionResolutions,
1408
+ topFindings,
1409
+ detectedFlags,
1410
+ judgeHealth,
1411
+ ...(Object.keys(toolObservations).length > 0 ? { toolObservations } : {}),
1412
+ ...(subjectProfile !== undefined ? { subjectProfile } : {}),
1413
+ ...(params.sourceMap !== undefined ? { sourceMap: params.sourceMap } : {}),
1414
+ ...(params.runConfig !== undefined ? { runConfig: params.runConfig } : {}),
1415
+ handover: params.handover ?? null,
1416
+ generatedAt: params.generatedAt,
1417
+ };
1418
+ }
1419
+
1420
+ // ── WS-3 §5 Self-Eval — the AGGREGATE judge-calibration derivation ────────────
1421
+
1422
+ /** WS-3 — one behavior cluster in the §5.0 Judge Behavior Map. */
1423
+ export interface BehaviorCluster {
1424
+ label: string;
1425
+ count: number;
1426
+ /** good | warn | bad | neutral — drives the chip tint. */
1427
+ tone: string;
1428
+ note: string;
1429
+ }
1430
+
1431
+ /** WS-3 — the §5 Self-Eval AGGREGATE: a deterministic judge-calibration roll-up over
1432
+ * the WHOLE population of verdicts (NOT per-trace). Each field maps to a wireframe
1433
+ * component; panels that need ground-truth the run does not have are MARKED proxy. */
1434
+ export interface SelfEvalAggregate {
1435
+ /** the headline trust band (0..100 + label + the basis sentence). */
1436
+ trust: { score: number; label: string; basis: string };
1437
+ /** 5.0 Judge Behavior Map — how the judge handled the population. */
1438
+ behaviorMap: {
1439
+ total: number;
1440
+ verdict: { pass: number; fail: number; indeterminate: number; incomplete: number };
1441
+ resolution: { walk: number; noWalk: number; truncated: number };
1442
+ clusters: BehaviorCluster[];
1443
+ /** where fails CONCENTRATE (top criteria by fail count). */
1444
+ concentration: { id: string; statement: string; fails: number; pct: number }[];
1445
+ };
1446
+ /** 5.1 Reference Integrity — grounded-ref presence over decided verdicts. */
1447
+ refIntegrity: { decided: number; grounded: number; ungrounded: number; groundedPct: number; note: string };
1448
+ /** 5.2 Confidence Calibration — verdicts bucketed by confidence band. */
1449
+ confidence: { band: string; count: number; pct: number }[];
1450
+ /** 5.3 Per-Criterion Reliability. */
1451
+ perCriterion: {
1452
+ id: string;
1453
+ statement: string;
1454
+ severity: string;
1455
+ pass: number;
1456
+ fail: number;
1457
+ unc: number;
1458
+ na: number;
1459
+ groundedPct: number;
1460
+ flag: string;
1461
+ }[];
1462
+ /** 5.4 Criterion Value / Applicability Audit. */
1463
+ criterionValue: { id: string; statement: string; severity: string; applicablePct: number; fails: number; verdict: string }[];
1464
+ /** 5.5 Diligence / Trace Coverage. */
1465
+ diligence: { examined: number; total: number; avgGroundingRefs: number; note: string };
1466
+ /** 5.6 Reasoning Transparency (blocked-aware). */
1467
+ transparency: { m2Pct: number; m3Pct: number; walkPct: number; blocked: boolean; note: string };
1468
+ /** 5.7 Emit-Completeness — the WS-1 self-honesty meter. */
1469
+ emit: EmitCompleteness;
1470
+ /** 5.8 Spot-Check / Disagreement queue — the brittle verdicts worth an operator look. */
1471
+ spotCheck: { trace: string; criterion: string; verdict: string; conf: string; reason: string }[];
1472
+ }
1473
+
1474
+ /** WS-3 — derive the §5 Self-Eval aggregate. PURE + deterministic (sorted outputs). */
1475
+ export function deriveSelfEval(args: {
1476
+ ledger: LedgerRow[];
1477
+ criteria: ReportCriterion[];
1478
+ emit: EmitCompleteness;
1479
+ }): SelfEvalAggregate {
1480
+ const { ledger, criteria, emit } = args;
1481
+ const total = ledger.length;
1482
+ const critById = new Map(criteria.map((c) => [c.id, c]));
1483
+
1484
+ // ── verdict + resolution tallies ──
1485
+ const verdict = { pass: 0, fail: 0, indeterminate: 0, incomplete: 0 };
1486
+ const resolution = { walk: 0, noWalk: 0, truncated: 0 };
1487
+ for (const r of ledger) {
1488
+ const v = (r.verdict || "").toUpperCase();
1489
+ if (v === "PASS") verdict.pass++;
1490
+ else if (v === "FAIL") verdict.fail++;
1491
+ else if (v === "INCOMPLETE") verdict.incomplete++;
1492
+ else verdict.indeterminate++;
1493
+ if (r.resolution === "judge-walk") resolution.walk++;
1494
+ else if (r.resolution === "truncated") resolution.truncated++;
1495
+ else resolution.noWalk++;
1496
+ }
1497
+
1498
+ // ── per-criterion roll-up (pass/fail/unc/na + grounding) ──
1499
+ type Agg = { pass: number; fail: number; unc: number; na: number; refd: number; decided: number };
1500
+ const agg = new Map<string, Agg>();
1501
+ for (const c of criteria) agg.set(c.id, { pass: 0, fail: 0, unc: 0, na: 0, refd: 0, decided: 0 });
1502
+ for (const r of ledger) {
1503
+ for (const [cid, res] of Object.entries(r.perCriterion)) {
1504
+ const a = agg.get(cid);
1505
+ if (!a) continue;
1506
+ if (res === "pass") a.pass++;
1507
+ else if (res === "fail") a.fail++;
1508
+ else if (res === "uncertain" || res === "indeterminate") a.unc++;
1509
+ else a.na++;
1510
+ }
1511
+ for (const cv of r.criterionVerdicts ?? []) {
1512
+ const a = agg.get(cv.criterionId);
1513
+ if (!a) continue;
1514
+ const decided = cv.result === "pass" || cv.result === "fail";
1515
+ if (decided) {
1516
+ a.decided++;
1517
+ if ((cv.refs?.length ?? 0) > 0) a.refd++;
1518
+ }
1519
+ }
1520
+ }
1521
+
1522
+ const perCriterion = criteria.map((c) => {
1523
+ const a = agg.get(c.id)!;
1524
+ const groundedPct = a.decided > 0 ? Math.round((100 * a.refd) / a.decided) : 100;
1525
+ const flag = a.fail > 0 ? "fails-present" : a.pass + a.fail + a.unc === 0 ? "never-applies" : "solid";
1526
+ return { id: c.id, statement: c.statement, severity: c.severity, pass: a.pass, fail: a.fail, unc: a.unc, na: a.na, groundedPct, flag };
1527
+ });
1528
+
1529
+ // 5.4 criterion value: applicable% + ever-fails → keep/low-value verdict.
1530
+ const criterionValue = criteria.map((c) => {
1531
+ const a = agg.get(c.id)!;
1532
+ const applicable = a.pass + a.fail + a.unc;
1533
+ const denom = applicable + a.na;
1534
+ const applicablePct = denom > 0 ? Math.round((100 * applicable) / denom) : 0;
1535
+ const everFails = a.fail > 0;
1536
+ const verdictTag = applicable === 0 ? "never-applies → review" : everFails ? "keep — discriminates" : "keep — guards";
1537
+ return { id: c.id, statement: c.statement, severity: c.severity, applicablePct, fails: a.fail, verdict: verdictTag };
1538
+ });
1539
+
1540
+ // 5.0 concentration — where fails concentrate (top criteria by fail count).
1541
+ const concentration = perCriterion
1542
+ .filter((p) => p.fail > 0)
1543
+ .sort((a, b) => b.fail - a.fail)
1544
+ .slice(0, 6)
1545
+ .map((p) => ({ id: p.id, statement: p.statement, fails: p.fail, pct: total > 0 ? Math.round((100 * p.fail) / total) : 0 }));
1546
+
1547
+ // 5.1 ref integrity — grounded-ref presence over decided verdicts (population).
1548
+ let decided = 0;
1549
+ let groundedRefs = 0;
1550
+ let totalRefs = 0;
1551
+ for (const r of ledger) {
1552
+ for (const cv of r.criterionVerdicts ?? []) {
1553
+ if (cv.result === "pass" || cv.result === "fail") {
1554
+ decided++;
1555
+ const n = cv.refs?.length ?? 0;
1556
+ totalRefs += n;
1557
+ if (n > 0) groundedRefs++;
1558
+ }
1559
+ }
1560
+ }
1561
+ const refIntegrity = {
1562
+ decided,
1563
+ grounded: groundedRefs,
1564
+ ungrounded: decided - groundedRefs,
1565
+ groundedPct: decided > 0 ? Math.round((100 * groundedRefs) / decided) : 100,
1566
+ note:
1567
+ `${totalRefs} structured {obs,path,value} refs cited across ${decided} decided verdicts. ` +
1568
+ "Presence is checked here; full resolveRef-against-trace (GA-1) is the engine readiness gate.",
1569
+ };
1570
+
1571
+ // 5.2 confidence calibration — bucket decided verdicts by confidence band.
1572
+ const bands: { band: string; lo: number; hi: number }[] = [
1573
+ { band: "0.9–1.0", lo: 0.9, hi: 1.01 },
1574
+ { band: "0.7–0.9", lo: 0.7, hi: 0.9 },
1575
+ { band: "0.5–0.7", lo: 0.5, hi: 0.7 },
1576
+ { band: "<0.5", lo: -1, hi: 0.5 },
1577
+ ];
1578
+ const bandCount = bands.map(() => 0);
1579
+ let confTotal = 0;
1580
+ for (const r of ledger) {
1581
+ for (const cv of r.criterionVerdicts ?? []) {
1582
+ const conf = typeof cv.confidence === "number" ? cv.confidence : undefined;
1583
+ if (conf === undefined) continue;
1584
+ confTotal++;
1585
+ const i = bands.findIndex((b) => conf >= b.lo && conf < b.hi);
1586
+ if (i >= 0) bandCount[i]++;
1587
+ }
1588
+ }
1589
+ const confidence = bands.map((b, i) => ({
1590
+ band: b.band,
1591
+ count: bandCount[i],
1592
+ pct: confTotal > 0 ? Math.round((100 * bandCount[i]) / confTotal) : 0,
1593
+ }));
1594
+
1595
+ // 5.5 diligence — traces examined + avg grounding refs per trace.
1596
+ const examined = ledger.filter((r) => (r.criterionVerdicts?.length ?? 0) > 0).length;
1597
+ const avgGroundingRefs = examined > 0 ? Math.round((10 * totalRefs) / examined) / 10 : 0;
1598
+ const diligence = {
1599
+ examined,
1600
+ total,
1601
+ avgGroundingRefs,
1602
+ note: `${examined}/${total} trajectories carry ≥1 per-criterion judge verdict; avg ${avgGroundingRefs} grounding refs cited per examined trace.`,
1603
+ };
1604
+
1605
+ // 5.6 transparency — read off the emit-completeness (blocked when M2 is 0%).
1606
+ const fieldPct = (f: string): number => emit.fields.find((x) => x.field === f)?.pct ?? 0;
1607
+ const transparency = {
1608
+ m2Pct: fieldPct("understanding"),
1609
+ m3Pct: fieldPct("expectedTrajectory"),
1610
+ walkPct: fieldPct("judgeSteps"),
1611
+ blocked: fieldPct("understanding") === 0,
1612
+ note:
1613
+ fieldPct("understanding") === 0
1614
+ ? "BLOCKED — the judge did not persist M2 understanding / M3 expected-trajectory (emit-contract gap, §5.7). A fresh run with the hardened contract captures them."
1615
+ : "the judge's train-of-thought (M2 understanding · M3 expected-trajectory) is captured and auditable.",
1616
+ };
1617
+
1618
+ // 5.8 spot-check queue — the brittle verdicts (fails + low-confidence) worth a look.
1619
+ const spotCheck: SelfEvalAggregate["spotCheck"] = [];
1620
+ for (const r of ledger) {
1621
+ for (const cv of r.criterionVerdicts ?? []) {
1622
+ const conf = typeof cv.confidence === "number" ? cv.confidence : undefined;
1623
+ const lowConf = conf !== undefined && conf < 0.6;
1624
+ const isFail = cv.result === "fail";
1625
+ if (!lowConf && !isFail) continue;
1626
+ spotCheck.push({
1627
+ trace: r.trajectoryId,
1628
+ criterion: critById.get(cv.criterionId)?.statement ?? cv.criterionId,
1629
+ verdict: String(cv.result || "").toUpperCase(),
1630
+ conf: conf !== undefined ? conf.toFixed(2) : "—",
1631
+ reason: isFail ? (lowConf ? "FAIL · low-confidence" : "FAIL") : "low-confidence call",
1632
+ });
1633
+ }
1634
+ }
1635
+ spotCheck.sort((a, b) => (a.conf === "—" ? 1 : b.conf === "—" ? -1 : Number(a.conf) - Number(b.conf)));
1636
+ const spotCheckTop = spotCheck.slice(0, 20);
1637
+
1638
+ // headline trust band — a conservative composite (grounding · emit-completeness · fail-concentration).
1639
+ const emitPct = emit.completePct;
1640
+ const trustScore = Math.round(0.5 * refIntegrity.groundedPct + 0.5 * emitPct);
1641
+ const trustLabel =
1642
+ transparency.blocked || emitPct < 50
1643
+ ? "PARTIAL — reasoning transparency blocked by the emit-contract gap"
1644
+ : trustScore >= 85
1645
+ ? "STRONG — grounded + transparent"
1646
+ : "MODERATE — grounded, transparency improving";
1647
+ const trust = {
1648
+ score: trustScore,
1649
+ label: trustLabel,
1650
+ basis: `grounding ${refIntegrity.groundedPct}% of decided verdicts · emit-completeness ${emitPct}% · ${verdict.fail} failing trajectories across ${total}.`,
1651
+ };
1652
+
1653
+ return {
1654
+ trust,
1655
+ behaviorMap: { total, verdict, resolution, clusters: behaviorClusters(verdict, resolution, total), concentration },
1656
+ refIntegrity,
1657
+ confidence,
1658
+ perCriterion,
1659
+ criterionValue,
1660
+ diligence,
1661
+ transparency,
1662
+ emit,
1663
+ spotCheck: spotCheckTop,
1664
+ };
1665
+ }
1666
+
1667
+ /** WS-3 — derive the §5.0 behavior clusters from the population tallies. PURE. */
1668
+ function behaviorClusters(
1669
+ verdict: { pass: number; fail: number; indeterminate: number; incomplete: number },
1670
+ resolution: { walk: number; noWalk: number; truncated: number },
1671
+ total: number,
1672
+ ): BehaviorCluster[] {
1673
+ const pct = (n: number): number => (total > 0 ? Math.round((100 * n) / total) : 0);
1674
+ const out: BehaviorCluster[] = [
1675
+ { label: "Passed (criteria satisfied)", count: verdict.pass, tone: "good", note: `${pct(verdict.pass)}% of the population judged PASS on all applicable criteria.` },
1676
+ { label: "Failed (≥1 criterion)", count: verdict.fail, tone: "bad", note: `${pct(verdict.fail)}% carry ≥1 failing criterion — the discriminating cases.` },
1677
+ { label: "Abstained (indeterminate)", count: verdict.indeterminate, tone: "warn", note: `${pct(verdict.indeterminate)}% abstained — inputs could not decide (blockedBy), routed to calibration not gate.` },
1678
+ { label: "Truncated (INCOMPLETE)", count: verdict.incomplete, tone: "neutral", note: `${pct(verdict.incomplete)}% gated INCOMPLETE by the node-1 fidelity short-circuit (never walked).` },
1679
+ { label: "Walk captured", count: resolution.walk, tone: "good", note: `${pct(resolution.walk)}% persisted the full §9.4 judge walk (target lane + anchored reasoning).` },
1680
+ { label: "Walk dropped (judged)", count: resolution.noWalk, tone: "warn", note: `${pct(resolution.noWalk)}% were judged but did NOT persist the structured walk — the emit-contract gap (§5.7).` },
1681
+ ];
1682
+ return out.filter((c) => c.count > 0).sort((a, b) => b.count - a.count);
1683
+ }
1684
+
1685
+ // ── §1 Overview ──────────────────────────────────────────────────────────────
1686
+
1687
+ /**
1688
+ * Overview-redesign — the full-width ENTITY HERO card: rich subject identity from the
1689
+ * §9.4.4 M1 subject profile (name · entityType · purpose · tools · skill · scope ·
1690
+ * version · code-access · system-prompt). HONEST: a field the profile cannot establish
1691
+ * renders the `unknown` sentinel (never confabulated); an INFERRED (reconstructed)
1692
+ * field carries an `inferred` honesty chip; the system prompt is COLLAPSED. PURE.
1693
+ */
1694
+ function entityHero(input: EvalReportInput): string {
1695
+ const p = input.subjectProfile;
1696
+ const inferred = new Set(p?.inferredFields ?? []);
1697
+ const name = p?.identity ?? input.subject.name;
1698
+ const codeAccess = p?.provenance ?? "reconstructed";
1699
+ // §9.4.5 E1 — TRACE-ONLY when there is no code access (reconstructed profile, or no
1700
+ // profile at all). Drives the honest "CODE-ACCESS: NONE · trace-only" header + the
1701
+ // UNAVAILABLE (never-confabulated) marking of harness + system-prompt.
1702
+ const isTraceOnly = codeAccess !== "given";
1703
+ const nTraces =
1704
+ input.sourceMap?.traceCount ?? input.coverage?.judged ?? input.ledger?.length ?? 0;
1705
+
1706
+ // a value cell: shows the value, or the `unknown` sentinel when absent; an `inferred`
1707
+ // chip flags a reconstructed (not-given) field so given-vs-inferred is honest.
1708
+ const cell = (value: string | undefined, key?: string): string => {
1709
+ const has = typeof value === "string" && value.trim().length > 0 && value !== PROFILE_UNKNOWN;
1710
+ const body = has ? esc(value as string) : `<span class="unk">${esc(PROFILE_UNKNOWN)}</span>`;
1711
+ const inf = key !== undefined && inferred.has(key) ? ` <span class="inf">inferred</span>` : "";
1712
+ return `${body}${inf}`;
1713
+ };
1714
+
1715
+ // §9.4.5 E2 — tools as diagnostics-style CHIPS with a per-tool OBSERVED call-count
1716
+ // ("obs N", from the trajectory agent-steps) instead of a flat comma list. In the
1717
+ // trace-only case this is the only honest tool evidence (what was actually called).
1718
+ const obs = input.toolObservations ?? {};
1719
+ const tools = p?.tools ?? [];
1720
+ const toolsInferred = inferred.has("tools");
1721
+ const toolChips =
1722
+ tools.length > 0
1723
+ ? tools
1724
+ .map((t) => {
1725
+ const n = obs[t] ?? 0;
1726
+ const count =
1727
+ n > 0
1728
+ ? ` <b class="obs" title="observed ${n} call(s) across the judged trajectories">obs ${n}</b>`
1729
+ : ` <b class="obs none" title="present in the inventory; 0 calls observed in the judged trajectories">obs 0</b>`;
1730
+ return `<span class="tchip">${esc(t)}${count}</span>`;
1731
+ })
1732
+ .join("")
1733
+ : `<span class="unk">${esc(PROFILE_UNKNOWN)}</span>`;
1734
+ const toolsCell =
1735
+ `<div class="tchips">${toolChips}${toolsInferred ? ` <span class="inf">inferred</span>` : ""}</div>`;
1736
+
1737
+ const targetModels = (input.subject.models ?? []).join(", ");
1738
+
1739
+ // §9.4.5 E1 — harness is UNAVAILABLE (never confabulated) in the trace-only case.
1740
+ const harnessHas = typeof p?.harness === "string" && p.harness.trim().length > 0 && p.harness !== PROFILE_UNKNOWN;
1741
+ const harnessCell = harnessHas
1742
+ ? cell(p?.harness, "harness")
1743
+ : `<span class="unk">${esc(PROFILE_UNKNOWN)}</span> <span class="recon-note">UNAVAILABLE — ${isTraceOnly ? "reconstructed, never confabulated" : "not supplied"}</span>`;
1744
+
1745
+ const rows: [string, string][] = [
1746
+ ["purpose", cell(p?.purpose, "purpose")],
1747
+ ["tools", toolsCell],
1748
+ ["skill", cell(p?.skill, "skill")],
1749
+ ["scope", cell(p?.scope, "scope")],
1750
+ ["harness", harnessCell],
1751
+ ["source", cell(input.subject.source)],
1752
+ ["org", cell(input.subject.org)],
1753
+ ["target model(s)", cell(targetModels)],
1754
+ ];
1755
+ const grid = rows.map(([k, v]) => `<div class="hk">${esc(k)}</div><div class="hv">${v}</div>`).join("");
1756
+
1757
+ // version + entityType + code-access provenance ride in the hero header.
1758
+ const verChip = p?.version ? `<span class="hchip">v ${esc(p.version)}</span>` : "";
1759
+ const typeChip = `<span class="hchip type">${cell(p?.entityType, "entityType")}</span>`;
1760
+ // §9.4.5 E1 — code-access chip: GIVEN reads green; trace-only reads the explicit
1761
+ // "NONE · trace-only" so a reader never mistakes a reconstructed profile for a known one.
1762
+ const accessChip = isTraceOnly
1763
+ ? `<span class="hchip access recon">code-access: NONE · trace-only</span>`
1764
+ : `<span class="hchip access given">code-access: given</span>`;
1765
+
1766
+ // system prompt — present when GIVEN (code access) OR RECONSTRUCTED from the traces
1767
+ // (UI-14). `inferred.has("systemPrompt")` distinguishes the two for the provenance tag.
1768
+ const sysHas = typeof p?.systemPrompt === "string" && p.systemPrompt.trim().length > 0;
1769
+ const sysReconstructed = inferred.has("systemPrompt");
1770
+
1771
+ // §9.4.5 E1 — the trace-reconstructed banner: only when trace-only. States the N traces
1772
+ // the identity/tools were inferred from. The system prompt is now RECONSTRUCTED from the
1773
+ // batch when an LLM call carried it (UI-14); only harness stays UNAVAILABLE in that case.
1774
+ const reconLine = isTraceOnly
1775
+ ? `<div class="hrecon">⟲ TRACE-RECONSTRUCTED · inferred from ${esc(nTraces)} trace(s) — identity + tools${sysHas ? " + system-prompt" : ""} observed from the batch; harness${sysHas ? "" : " + system-prompt"} UNAVAILABLE (never confabulated).</div>`
1776
+ : "";
1777
+
1778
+ // system prompt — COLLAPSED (it can be tens of KB); carries a provenance tag —
1779
+ // "RECONSTRUCTED · from traces" when derived from a GENERATION message list, or
1780
+ // "given" under code access. Marked UNAVAILABLE only when genuinely not found
1781
+ // (never confabulated).
1782
+ const sysProvTag = sysReconstructed
1783
+ ? `<span class="hsys-prov recon">RECONSTRUCTED · from traces</span>`
1784
+ : `<span class="hsys-prov given">given · code access</span>`;
1785
+ const sysPrompt = sysHas
1786
+ ? `<details class="hsys"><summary>system prompt ${sysProvTag}</summary><pre>${esc(p?.systemPrompt)}</pre></details>`
1787
+ : `<div class="hsys-na">system prompt — <span class="unk">${esc(PROFILE_UNKNOWN)}</span> · UNAVAILABLE (${isTraceOnly ? "reconstructed, no code access — never confabulated" : "not supplied"})</div>`;
1788
+
1789
+ // honesty footer: which fields are inferred (reconstructed), not given.
1790
+ const infList = [...inferred];
1791
+ const honesty =
1792
+ infList.length > 0
1793
+ ? `<div class="hhonesty"><span class="inf">inferred</span> ${esc(infList.join(" · "))} — reconstructed from the trace batch, not given. The rest is GIVEN (code/metadata access).</div>`
1794
+ : `<div class="hhonesty"><span class="inf given">all given</span> every field supplied with code/metadata access — nothing reconstructed.</div>`;
1795
+
1796
+ return (
1797
+ `<div class="hero">` +
1798
+ `<div class="hero-top"><div class="hname">${esc(name)}</div>${typeChip}${verChip}${accessChip}</div>` +
1799
+ reconLine +
1800
+ `<div class="hero-grid">${grid}</div>` +
1801
+ sysPrompt +
1802
+ honesty +
1803
+ `</div>`
1804
+ );
1805
+ }
1806
+
1807
+ /**
1808
+ * Overview-redesign — the thin PROVENANCE meta-strip under the header: the RUN config
1809
+ * (run-id · date · source · JUDGE substrate · pinned judge model · temp-0 · C-PIN).
1810
+ * The JUDGE substrate + model are DISTINCT from the TARGET model under eval. Defaults
1811
+ * that are ALWAYS true for this engine (agent-dispatch host runtime · temp 0 · C-PIN)
1812
+ * fill in when no run-config is threaded; a genuinely-unavailable field is MARKED. PURE.
1813
+ */
1814
+ function provenanceStrip(input: EvalReportInput): string {
1815
+ const rc = input.runConfig ?? {};
1816
+ const substrate = rc.judgeSubstrate ?? "agent-dispatch";
1817
+ const temp = rc.temperature ?? 0;
1818
+ const cPin = rc.cPin !== false; // C-PIN is the engine default
1819
+ const date = rc.date ?? input.generatedAt;
1820
+ const source = rc.source ?? input.subject.source;
1821
+ const unk = `<span class="unk">${esc(PROFILE_UNKNOWN)}</span>`;
1822
+ const v = (val: string | undefined): string =>
1823
+ typeof val === "string" && val.trim().length > 0 ? esc(val) : unk;
1824
+ const item = (label: string, body: string): string =>
1825
+ `<span class="ps-item"><span class="ps-k">${esc(label)}</span><span class="ps-v">${body}</span></span>`;
1826
+ return (
1827
+ `<div class="provstrip">` +
1828
+ item("run-id", v(rc.runId)) +
1829
+ item("date", v(date)) +
1830
+ item("source", v(source)) +
1831
+ item("judge substrate", esc(substrate)) +
1832
+ item("judge model", v(rc.judgeModel)) +
1833
+ item("temp", esc(`temp ${temp}`)) +
1834
+ `<span class="ps-item"><span class="ps-badge ${cPin ? "on" : "off"}">${cPin ? "C-PIN" : "no C-PIN"}</span></span>` +
1835
+ `</div>`
1836
+ );
1837
+ }
1838
+
1839
+ /**
1840
+ * Overview-redesign — the segmented COVERAGE FUNNEL: ingested → triaged → judged →
1841
+ * outcomes. INCOMPLETE is a FIRST-CLASS outcome (a distinct segment from indeterminate:
1842
+ * one is "trace too truncated to judge", the other is "judged but not certain"). Counts
1843
+ * read from `coverage.{triaged,judged,byVerdict}` + the source-map trace count. PURE.
1844
+ */
1845
+ function coverageFunnel(input: EvalReportInput): string {
1846
+ const cov = input.coverage ?? {};
1847
+ const bv = cov.byVerdict ?? {};
1848
+ const judged = cov.judged ?? input.ledger?.length ?? 0;
1849
+ const triaged = cov.triaged ?? judged;
1850
+ const ingested = input.sourceMap?.traceCount ?? triaged;
1851
+ const stage = (label: string, n: number, note: string): string =>
1852
+ `<div class="fstage"><div class="fn">${esc(n)}</div><div class="fl">${esc(label)}</div><div class="fnote">${esc(note)}</div></div>`;
1853
+ const arrow = `<div class="farrow">›</div>`;
1854
+ // the OUTCOME segment — four FIRST-CLASS buckets, INCOMPLETE separate from indeterminate.
1855
+ const outcomes: [string, number, string][] = [
1856
+ ["pass", bv.PASS ?? 0, "pass"],
1857
+ ["fail", bv.FAIL ?? 0, "fail"],
1858
+ ["indeterminate", bv.INDETERMINATE ?? 0, "indet"],
1859
+ ["incomplete", bv.INCOMPLETE ?? 0, "inc"],
1860
+ ];
1861
+ const outcomePills = outcomes
1862
+ .map(([l, n, cls]) => `<div class="fpill ${cls}"><b>${esc(n)}</b> ${esc(l)}</div>`)
1863
+ .join("");
1864
+ return (
1865
+ `<div class="funnel">` +
1866
+ stage("Ingested", ingested, "traces pulled from source") +
1867
+ arrow +
1868
+ stage("Triaged", triaged, "tier-0 + fidelity gate") +
1869
+ arrow +
1870
+ stage("Judged", judged, "per-criterion verdicts") +
1871
+ arrow +
1872
+ `<div class="fstage outc"><div class="fl">Outcomes</div><div class="foutc">${outcomePills}</div></div>` +
1873
+ `</div>`
1874
+ );
1875
+ }
1876
+
1877
+ /**
1878
+ * §9.4.5 E3 — the eval-HEALTH temporal heatmap: CORRECTNESS over time (NOT latency —
1879
+ * latency belongs to diagnostics). Each bucket is an hour window; its COLOUR is the
1880
+ * pass-rate over the trajectories judged in that window (fail-clustering pops as a red
1881
+ * window → a deploy/incident regression signal), and its NUMBER is the count of
1882
+ * trajectories judged in the bucket. Computed from the ledger verdicts + the per-trace
1883
+ * timestamps (E3 data path). When NO trajectory carries a timestamp, the heatmap renders
1884
+ * its STRUCTURE with a single data-pending cell + an explicit note (never faked). PURE.
1885
+ */
1886
+ function evalHealthHeatmap(input: EvalReportInput): string {
1887
+ const ledger = input.ledger ?? [];
1888
+ const timed = ledger.filter((r) => typeof r.timestamp === "string" && (r.timestamp as string).length >= 13);
1889
+ const head = `<h3>eval-health over time — correctness, not latency</h3>`;
1890
+
1891
+ if (timed.length === 0) {
1892
+ // structure + data-pending: the wiring is live, the batch just lacks timestamps.
1893
+ return (
1894
+ head +
1895
+ `<div class="ehm" data-pending="1">` +
1896
+ `<div class="ehm-cell skip" title="awaiting timestamped traces">·</div>` +
1897
+ `</div>` +
1898
+ `<div class="ehm-pending">⏲ data-pending — no per-trace timestamps in this batch. Correctness-over-time wiring is active; it renders real pass-rate windows as soon as the source carries trace timestamps.</div>`
1899
+ );
1900
+ }
1901
+
1902
+ // bucket by hour window (ISO "YYYY-MM-DDTHH"), deterministic chronological order.
1903
+ const buckets = new Map<string, { judged: number; pass: number; fail: number }>();
1904
+ for (const r of timed) {
1905
+ const key = (r.timestamp as string).slice(0, 13); // YYYY-MM-DDTHH
1906
+ const b = buckets.get(key) ?? { judged: 0, pass: 0, fail: 0 };
1907
+ b.judged += 1;
1908
+ const v = r.verdict.toUpperCase();
1909
+ if (v === "PASS") b.pass += 1;
1910
+ else if (v === "FAIL") b.fail += 1; // INCOMPLETE/INDETERMINATE count as judged, not in the rate
1911
+ buckets.set(key, b);
1912
+ }
1913
+ const keys = [...buckets.keys()].sort();
1914
+ const cells = keys
1915
+ .map((k) => {
1916
+ const b = buckets.get(k)!;
1917
+ const denom = b.pass + b.fail;
1918
+ const rate = denom > 0 ? Math.round((100 * b.pass) / denom) : -1;
1919
+ const cls = rate < 0 ? "skip" : rate < 80 ? "fail" : rate < 95 ? "indet" : "pass";
1920
+ const hour = k.slice(11, 13);
1921
+ const day = k.slice(5, 10);
1922
+ const title = `${esc(k)}:00 — ${b.judged} judged · ${b.pass} pass / ${b.fail} fail${rate >= 0 ? ` · ${rate}% pass-rate` : " · rate n/a"}`;
1923
+ return (
1924
+ `<div class="ehm-col">` +
1925
+ `<div class="ehm-cell ${cls}" title="${title}">${esc(b.judged)}</div>` +
1926
+ `<div class="ehm-lab">${esc(day)}<br>${esc(hour)}h</div>` +
1927
+ `</div>`
1928
+ );
1929
+ })
1930
+ .join("");
1931
+ return (
1932
+ head +
1933
+ `<div class="ehm-legend">colour = pass-rate per window (<span class="sw fail"></span>&lt;80% &nbsp;<span class="sw indet"></span>&lt;95% &nbsp;<span class="sw pass"></span>≥95%) · number = trajectories judged</div>` +
1934
+ `<div class="ehm">${cells}</div>`
1935
+ );
1936
+ }
1937
+
1938
+ function overviewTab(input: EvalReportInput): string {
1939
+ const g = input.scorecard.gate;
1940
+ const sm = input.sourceMap;
1941
+ const cov = input.coverage ?? {};
1942
+ const runVerdict = g.runVerdict ?? (g.passed ? "pass" : "fail");
1943
+ const indetCount = g.indeterminateBy?.length ?? 0;
1944
+ const verdictClass = runVerdict === "pass" ? "pass" : runVerdict === "incomplete" ? "skip" : "fail";
1945
+ const verdictText =
1946
+ runVerdict === "pass"
1947
+ ? "GATE PASS — no CRIT/HIGH criterion failed or was indeterminate."
1948
+ : runVerdict === "incomplete"
1949
+ ? `GATE INCOMPLETE — ${indetCount} CRIT/HIGH criterion/criteria indeterminate (${(g.indeterminateBy ?? []).map((x) => esc(x.criterionId)).join(", ")}). No CRIT/HIGH fail, but the run cannot be certified — NOT a pass.`
1950
+ : `GATE FAIL — ${g.gatedBy.length} gating criterion/criteria failed (${g.gatedBy.map((x) => esc(x.criterionId)).join(", ")}).`;
1951
+
1952
+ // 6-tile big-stat row (theme.css .big-stat .s/.v/.l) — status-acuity: the OUTCOME
1953
+ // tiles (pass/fail/indeterminate) carry a status tint + left-accent so the eye
1954
+ // separates outcomes from the neutral COUNT tiles (criteria/trajectories/traces).
1955
+ const tileRows: [string, string, string][] = [
1956
+ ["Criteria", String(g.total), "count"],
1957
+ ["Pass", String(g.passCount), "pass"],
1958
+ ["Fail", String(g.gatedBy.length), "fail"],
1959
+ ["Indeterminate", String(indetCount), "indet"],
1960
+ ["Trajectories", String(input.ledger?.length ?? 0), "count"],
1961
+ ["Traces (coverage)", String(sm?.traceCount ?? cov.judged ?? 0), "count"],
1962
+ ];
1963
+ const tiles = tileRows
1964
+ .map(([l, v, kind]) => `<div class="s ${kind}"><div class="v">${esc(v)}</div><div class="l">${esc(l)}</div></div>`)
1965
+ .join("");
1966
+
1967
+ const coverageNote = cov.gapNote
1968
+ ? `<div class="note"><span class="tag">★ COVERAGE CONTRACT</span>&nbsp;${esc(cov.gapNote)}</div>`
1969
+ : sm
1970
+ ? `<div class="note"><span class="tag">★ COVERAGE CONTRACT</span>&nbsp;${esc(sm.traceCount)} trace(s) · ${esc(sm.nullOutputTraces)} with null trace-output (read from GENERATION, SV-1) · platform <code>${esc(sm.platform)}</code>.</div>`
1971
+ : "";
1972
+
1973
+ // gating-criteria table — pass-rate over applicable (R4: statement + provenance + hover)
1974
+ const gating = input.gatingCriteria ?? [];
1975
+ const gatingRows =
1976
+ gating.length > 0
1977
+ ? gating
1978
+ .map((d) => {
1979
+ const c = criterionById(input, d.criterion);
1980
+ return (
1981
+ `<tr><td class="cn"><b class="mono">${esc(d.criterion)}</b> ${critHover(c)} ${provChip(c)} ${methodChip(c)}` +
1982
+ `<div class="cstmt">${esc(critStatement(c))}</div></td>` +
1983
+ `<td>${esc(d.applicable)}</td><td>${esc(d.fail)}</td>` +
1984
+ `<td style="color:${rateCol(d.passRateOverApplicable)};font-weight:600">${esc(d.passRateOverApplicable)}%</td></tr>`
1985
+ );
1986
+ })
1987
+ .join("")
1988
+ : `<tr><td colspan="4" style="color:var(--dim)">no gating criteria</td></tr>`;
1989
+ const gatingTable =
1990
+ `<h3>gating criteria — pass-rate over applicable</h3>` +
1991
+ `<table><thead><tr><th>criterion (statement · provenance)</th><th>applicable</th><th>fail</th><th>pass-rate</th></tr></thead><tbody>${gatingRows}</tbody></table>`;
1992
+
1993
+ // top-findings teaser
1994
+ const teaser = (input.topFindings ?? [])
1995
+ .slice(0, 5)
1996
+ .map(
1997
+ (fd) =>
1998
+ `<div class="teaser-row"><span class="sev ${sevCls(fd.severity)}">${esc((fd.severity ?? "med").toUpperCase())}</span>` +
1999
+ `<b class="mono">${esc(fd.criterion)}</b>` +
2000
+ `<span class="teaser-num">${esc(fd.failCount)}/${esc(fd.applicable)} fail · ${esc(fd.prevalencePctOverApplicable)}%</span></div>`,
2001
+ )
2002
+ .join("");
2003
+ const teaserBlock = (input.topFindings ?? []).length > 0 ? `<h3>top findings</h3>${teaser}` : "";
2004
+
2005
+ return (
2006
+ `<h2>① Overview</h2><div class="sub">subject identity · provenance · coverage · gate · top findings</div>` +
2007
+ tabDesc("What this tab shows: WHO the subject is (entity hero), HOW the run was judged (provenance strip — judge substrate + pinned model, distinct from the target), the coverage funnel (ingested → triaged → judged → outcomes, INCOMPLETE first-class), the GATE verdict, the gating criteria with their pass-rate over the applicable denominator, and a teaser of the top findings.") +
2008
+ entityHero(input) +
2009
+ provenanceStrip(input) +
2010
+ coverageFunnel(input) +
2011
+ `<div class="verdict ${verdictClass}"><strong>${esc(verdictText)}</strong></div>` +
2012
+ verdictLegend() +
2013
+ `<div class="big-stat">${tiles}</div>` +
2014
+ coverageNote +
2015
+ evalHealthHeatmap(input) +
2016
+ gatingTable +
2017
+ teaserBlock
2018
+ );
2019
+ }
2020
+
2021
+ // ── §2 Trajectory · Judge Behaviour (ledger + drill) ─────────────────────────
2022
+
2023
+ function trajectoryTab(input: EvalReportInput): string {
2024
+ const ledger = input.ledger ?? [];
2025
+ if (ledger.length === 0) {
2026
+ return (
2027
+ `<h2>② Trajectory · Judge Behaviour</h2>` +
2028
+ `<div class="sub">per-trace judge ledger — no per-trajectory verdict files supplied for this render.</div>`
2029
+ );
2030
+ }
2031
+ const walks = ledger.filter((r) => r.judgeSteps && r.judgeSteps.length > 0).length;
2032
+ return (
2033
+ `<h2>② Trajectory · Judge Behaviour</h2>` +
2034
+ tabDesc("What this tab shows: the Target-Agent trajectory ‖ Judge trajectory SIDE-BY-SIDE. Click a row to drill in — the agent's steps render on the LEFT lane, the judge's reasoning walk (anchored to each step) on the RIGHT, with the judge's gather-context, expected-trajectory and root localization.") +
2035
+ `<div class="sub">all ${esc(ledger.length)} — click a row; ✦ = full agent‖judge walk (${esc(walks)} with judge_steps)</div>` +
2036
+ `<div class="lfilter">` +
2037
+ `<b class="on" data-flt="all">all</b><b data-flt="FAIL">fail</b><b data-flt="INDETERMINATE">indeterminate</b><b data-flt="PASS">pass</b>` +
2038
+ `<input id="q" placeholder="search route / trace id…"><span class="sp" id="cnt"></span></div>` +
2039
+ `<table class="ledger"><thead><tr><th>trace</th><th>route</th><th>verdict</th><th>resolution</th><th>pass</th><th>fail</th><th>indet</th><th>failing criteria</th></tr></thead><tbody id="lrows"></tbody></table>` +
2040
+ `<div id="virt" class="mono virt"></div>` +
2041
+ `<div id="drill"></div>`
2042
+ );
2043
+ }
2044
+
2045
+ // ── §3 Eval Scorecard (heatmap + subcards + calibration) ─────────────────────
2046
+
2047
+ function heatmapHtml(input: EvalReportInput): string {
2048
+ const cohorts = input.cohorts;
2049
+ if (!cohorts || Object.keys(cohorts.counts).length === 0) {
2050
+ return `<div class="sub">criteria × route cohort — no per-trajectory cohort data for this render.</div>`;
2051
+ }
2052
+ const cohortKeys = Object.entries(cohorts.counts)
2053
+ .sort((a, b) => b[1] - a[1])
2054
+ .slice(0, 7)
2055
+ .map(([c]) => c);
2056
+ const head =
2057
+ `<span class="lab"></span>` +
2058
+ cohortKeys.map((c) => `<span class="colh">${esc(c)}<br>${esc(cohorts.counts[c] ?? 0)}</span>`).join("");
2059
+ const cids = Object.keys(cohorts.matrix).slice(0, 14);
2060
+ const rows = cids.map((cid) => {
2061
+ let cells = `<span class="lab" title="${esc(cid)}">${esc(cid.slice(0, 26))}</span>`;
2062
+ for (const co of cohortKeys) {
2063
+ const cell = cohorts.matrix[cid]?.[co];
2064
+ if (!cell) {
2065
+ cells += `<span class="cell skip"></span>`;
2066
+ continue;
2067
+ }
2068
+ const tot = cell.pass + cell.fail + cell.indeterminate;
2069
+ if (tot === 0) {
2070
+ cells += `<span class="cell skip"></span>`;
2071
+ continue;
2072
+ }
2073
+ const r = Math.round((100 * cell.pass) / tot);
2074
+ const cls = r >= 95 ? "pass" : r >= 80 ? "indet" : "fail";
2075
+ cells += `<span class="cell ${cls}" title="${esc(cid)} × ${esc(co)}: ${cell.pass}/${tot} pass">${r}</span>`;
2076
+ }
2077
+ return cells;
2078
+ });
2079
+ // UI-3 — wrap in a horizontal-scroll container so long cohort names / many cohorts
2080
+ // never overflow the page; the grid columns use minmax(0,…) so a long unbreakable
2081
+ // header word can't force the track wider than its share (it ellipsis-clips instead).
2082
+ return (
2083
+ `<div class="hm-scroll">` +
2084
+ `<div class="hm" style="grid-template-columns:150px repeat(${cohortKeys.length},minmax(58px,1fr))">` +
2085
+ head +
2086
+ rows.join("") +
2087
+ `</div>` +
2088
+ `</div>`
2089
+ );
2090
+ }
2091
+
2092
+ /**
2093
+ * UI-5 — the success-rate + key-metrics SUMMARY row under the §3 heatmap. The heatmap
2094
+ * alone "dangled" into the subcards with no headline numbers; this anchors it with a
2095
+ * compact metrics strip (criteria pass-rate · trajectory pass-rate · indeterminate ·
2096
+ * incomplete · grounded%). All numbers derive from the scorecard gate + the folded
2097
+ * coverage + judge-health — nothing fabricated; an empty source renders 0/—. PURE.
2098
+ */
2099
+ function scorecardMetrics(input: EvalReportInput): string {
2100
+ const g = input.scorecard.gate;
2101
+ const bv = input.coverage?.byVerdict ?? {};
2102
+ const judged = input.coverage?.judged ?? input.ledger?.length ?? 0;
2103
+ const critPassRate = g.total > 0 ? Math.round((100 * g.passCount) / g.total) : 100;
2104
+ const trajPass = bv.PASS ?? 0;
2105
+ const trajRate = judged > 0 ? Math.round((100 * trajPass) / judged) : 0;
2106
+ const grounded = input.judgeHealth?.groundedPct;
2107
+ const tile = (label: string, value: string, cls: string, note: string): string =>
2108
+ `<div class="scm ${cls}"><div class="scm-v">${esc(value)}</div><div class="scm-l">${esc(label)}</div><div class="scm-n">${esc(note)}</div></div>`;
2109
+ return (
2110
+ `<div class="sc-metrics">` +
2111
+ tile("criteria pass-rate", `${critPassRate}%`, rateClass(critPassRate), `${g.passCount}/${g.total} criteria pass`) +
2112
+ tile("trajectory pass-rate", `${trajRate}%`, rateClass(trajRate), `${trajPass}/${judged} trajectories pass`) +
2113
+ tile("needs-evidence", String(bv.INDETERMINATE ?? 0), (bv.INDETERMINATE ?? 0) > 0 ? "rate-mid" : "rate-ok", "precondition present, undecided (N/A excluded)") +
2114
+ tile("incomplete", String(bv.INCOMPLETE ?? 0), (bv.INCOMPLETE ?? 0) > 0 ? "rate-mid" : "rate-ok", "too truncated to judge") +
2115
+ // UI-12-B — grounded is HONEST about capture: 0 / undefined (judge emitted 0
2116
+ // structured refs) renders "capture-unavailable" (data-pending), NEVER a silent
2117
+ // green 0%; a real >0 value is colour-coded by rate (low = critical).
2118
+ (grounded === undefined || grounded === 0
2119
+ ? tile("grounded", "capture-unavailable", "rate-na pending", "judge emitted 0 structured refs — capture-unavailable")
2120
+ : tile("grounded", `${grounded}%`, rateClass(grounded), "verdicts citing evidence")) +
2121
+ `</div>`
2122
+ );
2123
+ }
2124
+
2125
+ /**
2126
+ * WS-3 follow-up — a compact per-criterion KEY rendered DIRECTLY UNDER the §3
2127
+ * heatmap. The heatmap rows are raw technical criterion ids (e.g.
2128
+ * `send-delivery-success`); this legend maps EVERY criterion on the scorecard axis —
2129
+ * gating AND non-gating — to its one-line plain-language gloss (reusing the same
2130
+ * `plainExplainer` the Findings cards use), so no scorecard criterion is left without
2131
+ * "what it measures · why it matters". Covers ALL `input.criteria` (not the gating
2132
+ * subset). PURE — empty criteria ⇒ empty string.
2133
+ */
2134
+ function criterionLegend(input: EvalReportInput): string {
2135
+ const crits = input.criteria ?? [];
2136
+ if (crits.length === 0) return "";
2137
+ const rows = crits
2138
+ .map((c) => {
2139
+ const sev = sevCls(c.severity);
2140
+ return (
2141
+ `<div class="plain plain-leg">` +
2142
+ `<span class="pl-id mono">${esc(c.id)}</span>` +
2143
+ `<span class="sev ${sev}">${esc(String(c.severity ?? "med").toUpperCase())}</span>` +
2144
+ `<span class="pl-tx">${esc(plainExplainer(c))}</span>` +
2145
+ `</div>`
2146
+ );
2147
+ })
2148
+ .join("");
2149
+ return (
2150
+ `<div class="plain-legend">` +
2151
+ `<div class="pl-h">what each criterion measures — plain-language key (all ${crits.length})</div>` +
2152
+ rows +
2153
+ `</div>`
2154
+ );
2155
+ }
2156
+
2157
+ function subcardsHtml(input: EvalReportInput): string {
2158
+ const gating = input.gatingCriteria ?? [];
2159
+ if (gating.length === 0) return `<div class="sub">no gating criteria to calibrate.</div>`;
2160
+ return gating
2161
+ .map((d) => {
2162
+ const sev = sevCls(d.severity);
2163
+ const c = criterionById(input, d.criterion);
2164
+ // UI-6 — the card colour is driven by SUCCESS RATE (green-ish high · amber mid ·
2165
+ // red-ish low), on a purplish base — NOT a uniform severity red. Severity stays
2166
+ // legible as its own pill in the header.
2167
+ const rate = rateClass(d.passRateOverApplicable);
2168
+ // WS-5 — the indeterminate RESOLUTION split: N/A (precondition absent, excluded
2169
+ // from the denominator) vs needs-evidence (precondition present, undecided). No
2170
+ // bare "indeterminate".
2171
+ const whyAb =
2172
+ (d.indeterminate ?? 0) > 0 || (d.na ?? 0) > 0
2173
+ ? `<div class="nest"><div class="nest-h">▾ indeterminate resolution</div><div class="row">` +
2174
+ `<span class="rz na">N/A ${esc(d.na ?? 0)}</span> precondition absent — excluded from denominator · ` +
2175
+ `<span class="rz ne">needs-evidence ${esc(d.indeterminate ?? 0)}</span> precondition present, undecided</div></div>`
2176
+ : "";
2177
+ return (
2178
+ `<div class="subc ${rate}"><div class="subc-h"><span class="sev ${sev}">${esc((d.severity ?? "med").toUpperCase())}</span>` +
2179
+ `<b>${esc(d.criterion)}</b> ${critHover(c)} ${provChip(c)} ${methodChip(c)}<span class="chip">gate</span>` +
2180
+ `<span class="vp" style="margin-left:auto;color:${rateCol(d.passRateOverApplicable)};border-color:${rateCol(d.passRateOverApplicable)}">${esc(d.passRateOverApplicable)}%</span></div>` +
2181
+ // OPERATOR-REQUESTED: the one-line plain-language "Measures / Why it matters" gloss
2182
+ // INLINE in every gating subcard (not only in the §3 legend) — so reading a gating
2183
+ // criterion's detail explains, in plain language, what it measures right there.
2184
+ plainBanner(c) +
2185
+ // UI-7 — the full criterion DEFINITION in-card (statement · pass condition ·
2186
+ // dimension/severity/level · judged-from inputs · provenance · mined details).
2187
+ `<div class="nest"><div class="nest-h">▾ criterion definition — what it is &amp; how it's judged</div>` +
2188
+ criterionDefn(c, d.criterion) +
2189
+ `</div>` +
2190
+ `<div class="nest"><div class="nest-h">▾ grounding</div><div class="row"><span class="ref">obs:</span> ${esc(d.fail)}/${esc(d.applicable)} applicable fail · ${esc((d.denominatorNote ?? "").slice(0, 90))}</div></div>` +
2191
+ `<div class="nest"><div class="nest-h">▾ verdict reasoning</div><div class="row">${esc((d.root ?? "all applicable pass").slice(0, 160))}</div></div>` +
2192
+ whyAb +
2193
+ // UI-8 — calibrate tags grouped for MUTUAL EXCLUSION (radio semantics): the
2194
+ // keep/revise/retire group and the verify/eliminate group each allow ONE active
2195
+ // selection (the client JS clears siblings within a `data-calgroup`).
2196
+ `<div class="cal"><span class="l">calibrate</span>` +
2197
+ `<span class="calgroup" data-calgroup="keep-revise-retire"><b>keep</b><b>revise</b><b>retire</b></span>` +
2198
+ `<span style="color:var(--dim)">·</span>` +
2199
+ `<span class="calgroup" data-calgroup="verify-eliminate"><b>verify</b><b>eliminate</b></span>` +
2200
+ `</div></div>`
2201
+ );
2202
+ })
2203
+ .join("");
2204
+ }
2205
+
2206
+ /** WS-5 — a readable label for a chain next-action. */
2207
+ function nextActionLabel(a: string): string {
2208
+ return a === "code-recheck"
2209
+ ? "code-recheck"
2210
+ : a === "2nd-judge"
2211
+ ? "2nd-judge"
2212
+ : a === "revise-criterion"
2213
+ ? "revise-criterion"
2214
+ : "HITL-spot-check";
2215
+ }
2216
+
2217
+ /**
2218
+ * WS-5 — the per-criterion INDETERMINATE resolution chain (§3). For EVERY criterion
2219
+ * (gating + non-gating) it shows the denominator-honest split:
2220
+ * applicable · pass · fail · N/A (precondition absent) · needs-evidence (+ next-action)
2221
+ * N/A is shown SEPARATELY and is NEVER in the applicable/fail denominator. A
2222
+ * needs-evidence count carries its aggregated chain next-actions + the missing-signal
2223
+ * reason — so no criterion ever shows a bare, actionless "indeterminate". PURE.
2224
+ */
2225
+ function criterionResolutionTable(input: EvalReportInput): string {
2226
+ const rows = input.criterionResolutions ?? [];
2227
+ if (rows.length === 0) return "";
2228
+ const body = rows
2229
+ .map((r) => {
2230
+ const sev = sevCls(r.severity);
2231
+ const naCell =
2232
+ r.na > 0
2233
+ ? `<span class="rz na">N/A ${esc(r.na)}</span>` +
2234
+ (r.naReason ? `<span class="rz-reason">precondition absent — ${esc(r.naReason.slice(0, 120))}</span>` : "")
2235
+ : `<span class="rz na zero">N/A 0</span>`;
2236
+ const neCell =
2237
+ r.needsEvidence > 0
2238
+ ? `<span class="rz ne">needs-evidence ${esc(r.needsEvidence)}</span>` +
2239
+ r.nextActions
2240
+ .map(
2241
+ (a) =>
2242
+ `<span class="rz-act">→ ${esc(nextActionLabel(a.action))} ×${esc(a.count)}` +
2243
+ `<span class="rz-reason">${esc(a.reason.slice(0, 120))}</span></span>`,
2244
+ )
2245
+ .join("")
2246
+ : `<span class="rz ne zero">needs-evidence 0</span>`;
2247
+ return (
2248
+ `<div class="rzrow ${sev}">` +
2249
+ `<div class="rz-h"><span class="sev ${sev}">${esc(String(r.severity ?? "med").toUpperCase())}</span>` +
2250
+ `<b class="mono">${esc(r.criterion)}</b>` +
2251
+ `<span class="rz-rate" style="color:${rateCol(r.passRateOverApplicable)}">${esc(r.passRateOverApplicable)}% pass / applicable</span></div>` +
2252
+ `<div class="rz-stats">` +
2253
+ `<span class="rz applicable">applicable ${esc(r.applicable)}</span>` +
2254
+ `<span class="rz pass">pass ${esc(r.pass)}</span>` +
2255
+ `<span class="rz fail">fail ${esc(r.fail)}</span>` +
2256
+ naCell +
2257
+ neCell +
2258
+ `</div></div>`
2259
+ );
2260
+ })
2261
+ .join("");
2262
+ return (
2263
+ `<h3>indeterminate resolution chain — applicable · pass · fail · N/A (precondition absent) · needs-evidence</h3>` +
2264
+ `<div class="sub">every indeterminate is RESOLVED by a deterministic precondition gate: <b>N/A</b> (the criterion's trigger/precondition was absent in the trace — DROPPED from the applicable denominator) vs <b>needs-evidence</b> (precondition present but undecided — carries a concrete next-action: code-recheck · 2nd-judge · revise-criterion · HITL-spot-check). No bare indeterminate.</div>` +
2265
+ `<div class="rztable">${body}</div>`
2266
+ );
2267
+ }
2268
+
2269
+ function scorecardTab(input: EvalReportInput): string {
2270
+ return (
2271
+ `<h2>③ Eval Scorecard</h2>` +
2272
+ tabDesc("What this tab shows: the DEFINED criteria × results. Each criterion carries its STATEMENT, its provenance (defined vs source-mined), and a hover with the full definition; the heatmap is criteria × route cohort, with nested subcards + inline calibration on the applicable-denominator-honest pass-rate.") +
2273
+ `<div class="sub">cohort heatmap · nested subcards · inline calibration · GA-D2b applicable-denominator honest</div>` +
2274
+ verdictLegend() +
2275
+ `<h3>criteria × route cohort — cell = % pass over (pass+fail+indet)</h3>` +
2276
+ heatmapHtml(input) +
2277
+ // WS-3 follow-up — the per-criterion plain-language KEY directly under the heatmap,
2278
+ // covering ALL criteria (gating + non-gating) so every technical heatmap id has a
2279
+ // "what it measures · why it matters" gloss.
2280
+ criterionLegend(input) +
2281
+ // UI-5 — anchor the heatmap with a success-rate + key-metrics summary row (no dangle).
2282
+ scorecardMetrics(input) +
2283
+ `<h3>gating criteria — nested subcards + HITL calibration</h3>` +
2284
+ subcardsHtml(input) +
2285
+ // WS-5 — the INDETERMINATE resolution chain for ALL criteria (gating + non-gating):
2286
+ // splits the single indeterminate bucket into N/A (precondition absent, excluded)
2287
+ // vs needs-evidence (carries a next-action). interest-logging-integrity's 283 land here.
2288
+ criterionResolutionTable(input)
2289
+ );
2290
+ }
2291
+
2292
+ // ── §4 Findings (verbatim evidence + judge chain + agree/revise/refute) ───────
2293
+
2294
+ const KIND_ORDER: Record<string, number> = { context: 0, examine: 1, detect: 2, bind: 3, ground: 4, critique: 5, decide: 6, verify: 7 };
2295
+
2296
+ /** One §4 finding from the top-findings roll; uses DR-2 / judge-walk for evidence. */
2297
+ function findingFromTop(input: EvalReportInput, fd: TopFinding): string {
2298
+ const crit = criterionById(input, fd.criterion);
2299
+ const sev = sevCls(fd.severity ?? crit?.severity);
2300
+ const ex = fd.exampleTraceId ?? "";
2301
+ const row = input.ledger?.find((r) => r.trajectoryId === ex);
2302
+ const grounds = (row?.judgeSteps ?? []).filter((s) => s.ref);
2303
+ // a judge-step ref may be a string OR a structured {obs,path,value} — coerce.
2304
+ const refToStr = (rf: JudgeStep["ref"]): string =>
2305
+ rf === undefined ? "" : typeof rf === "string" ? rf : [rf.obs, rf.path, rf.value].filter(Boolean).join(":");
2306
+ const evItems =
2307
+ grounds.length > 0
2308
+ ? grounds
2309
+ .slice(0, 4)
2310
+ .map((s) => { const r = refToStr(s.ref); return `<li><span class="ref">${esc(r.split(":")[0])}</span> ${esc(r.split(":").slice(1).join(":").slice(0, 120))}</li>`; })
2311
+ .join("")
2312
+ : crit?.discovery && crit.discovery.evidence.refs.length > 0
2313
+ ? crit.discovery.evidence.refs
2314
+ .slice(0, 4)
2315
+ .map((r) => `<li><span class="ref">${esc(r.obs)}</span> ${esc(((r.path ? r.path + ": " : "") + r.value).slice(0, 120))}</li>`)
2316
+ .join("")
2317
+ : `<li><span class="ref">trace</span> <code>${esc(ex || "(suite)")}</code> — search §2 for the judge walk</li>`;
2318
+ const chain =
2319
+ (row?.judgeSteps ?? []).length > 0
2320
+ ? [...(row?.judgeSteps ?? [])]
2321
+ .filter((s) => s.kind !== "context")
2322
+ .sort((a, b) => (KIND_ORDER[a.kind] ?? 9) - (KIND_ORDER[b.kind] ?? 9))
2323
+ .map((s) => `<span class="step">${esc(s.kind)}</span>`)
2324
+ .join(" → ")
2325
+ : "detect → ground → critique → decide → verify";
2326
+ const link =
2327
+ row?.judgeSteps && row.judgeSteps.length > 0 ? ` <span class="lnk" data-drill="${esc(ex)}">[open §2 side-by-side ▸]</span>` : "";
2328
+ const gate = sev === "crit" || sev === "high" ? `<span class="gatetag">GATING</span>` : "";
2329
+ const prev = fd.prevalencePctOverApplicable;
2330
+ const assumptionsLine =
2331
+ crit?.discovery && crit.discovery.assumptions.length > 0
2332
+ ? crit.discovery.assumptions.map((a) => `${esc(a.text)} <span class="badge b-skip">${esc(a.status)}</span>`).join(" · ")
2333
+ : `<span class="none">grounded</span> — judge-health surfaced (see §5)`;
2334
+ const whyProblem = crit?.discovery ? `<div class="k">why a<br>problem</div><div class="v">${esc(crit.discovery.why_problem)}</div>` : "";
2335
+ return (
2336
+ `<div class="find ${sev === "crit" || sev === "high" ? "gate" : ""}">` +
2337
+ `<div class="find-h"><span class="sev ${sev}">${esc((fd.severity ?? "med").toUpperCase())}</span><b>${esc(fd.criterion)}</b>${gate}` +
2338
+ `<span class="verd fail" style="margin-left:auto">${esc(fd.failCount)}/${esc(fd.applicable)} fail · ${esc(prev)}%</span></div>` +
2339
+ // WS-3 — plain-language gloss (what · why) above the technical claim/evidence grid.
2340
+ plainBanner(crit) +
2341
+ `<div class="fg">` +
2342
+ `<div class="k">claim</div><div class="v">Criterion <code>${esc(fd.criterion)}</code> fails on ${esc(fd.failCount)} of ${esc(fd.applicable)} applicable trajectories.</div>` +
2343
+ whyProblem +
2344
+ `<div class="k">evidence<br>(verbatim)</div><div class="v" data-pii="evidence"><ul>${evItems}</ul></div>` +
2345
+ `<div class="k">judge<br>reasoning</div><div class="v reason">${chain}${link}</div>` +
2346
+ `<div class="k">root</div><div class="v">${esc(fd.root ?? "")}</div>` +
2347
+ `<div class="k">prevalence</div><div class="v"><b>${esc(prev)}%</b> over applicable (${esc(fd.applicable)}) <span class="prev"><i style="width:${Math.min(prev, 100)}%"></i></span> <span class="mono na-note">na excluded (GA-D2b)</span></div>` +
2348
+ `<div class="k">assumptions</div><div class="v">${assumptionsLine}</div>` +
2349
+ `</div>` +
2350
+ `<div class="areview"><span class="l">⊕ alignment review:</span><b class="agree">✓ agree</b><b>~ revise</b><b class="refute">✗ refute</b><input placeholder="reviewer note…"></div>` +
2351
+ `</div>`
2352
+ );
2353
+ }
2354
+
2355
+ /** Fallback DR-2 finding card (no ledger / no top-findings — mined criteria only). */
2356
+ function findingFromCriterion(c: ReportCriterion, verdict: CriterionVerdict | undefined): string {
2357
+ const sev = sevCls(c.severity);
2358
+ const d = c.discovery;
2359
+ if (!d) {
2360
+ return (
2361
+ `<div class="find"><div class="find-h"><span class="sev ${sev}">${esc(c.severity)}</span><b>${esc(c.id)}</b>` +
2362
+ `${verdict ? `<span class="verd ${verdictCls(resultToken(verdict.result))}" style="margin-left:auto">${esc(resultToken(verdict.result))}</span>` : ""}</div>` +
2363
+ plainBanner(c) +
2364
+ `<div class="fg"><div class="k">statement</div><div class="v">${esc(c.statement)}</div>` +
2365
+ `${verdict ? `<div class="k">critique</div><div class="v">${esc(verdict.critique)}</div>` : ""}</div></div>`
2366
+ );
2367
+ }
2368
+ const ev = d.evidence;
2369
+ const refs = ev.refs.length > 0 ? `<li><span class="ref">refs</span> <code>${esc(refsText(ev.refs))}</code></li>` : "";
2370
+ const assumptions = d.assumptions.map((a) => `${esc(a.text)} <span class="badge b-skip">${esc(a.status)}</span>`).join(" · ");
2371
+ return (
2372
+ `<div class="find ${sev === "crit" || sev === "high" ? "gate" : ""}">` +
2373
+ `<div class="find-h"><span class="sev ${sev}">${esc(c.severity)}</span><b>${esc(c.id)}</b>` +
2374
+ `${verdict ? `<span class="verd ${verdictCls(resultToken(verdict.result))}" style="margin-left:auto">${esc(resultToken(verdict.result))}</span>` : ""}</div>` +
2375
+ plainBanner(c) +
2376
+ `<div class="fg">` +
2377
+ `<div class="k">targets</div><div class="v">${esc(d.targets)}</div>` +
2378
+ `<div class="k">why a<br>problem</div><div class="v">${esc(d.why_problem)}</div>` +
2379
+ `<div class="k">evidence<br>(verbatim)</div><div class="v" data-pii="evidence">grounding <strong>${esc(ev.grounding)}</strong> · seen-in-traces ${esc(ev.seen_in_traces)} · prevalence <strong>${esc(ev.prevalence)}</strong><ul>${refs}</ul></div>` +
2380
+ `<div class="k">judge<br>reasoning</div><div class="v reason">${esc(d.reasoning)}</div>` +
2381
+ `<div class="k">assumptions</div><div class="v">${assumptions}</div>` +
2382
+ `</div>` +
2383
+ `<div class="areview"><span class="l">⊕ alignment review:</span><b class="agree">✓ agree</b><b>~ revise</b><b class="refute">✗ refute</b><input placeholder="reviewer note…"></div>` +
2384
+ `</div>`
2385
+ );
2386
+ }
2387
+
2388
+ function findingsTab(input: EvalReportInput): string {
2389
+ const top = input.topFindings ?? [];
2390
+ let body: string;
2391
+ if (top.length > 0) {
2392
+ body = top.map((fd) => findingFromTop(input, fd)).join("");
2393
+ } else {
2394
+ // no ledger-derived findings — fall back to DR-2 cards for failing/at-risk criteria.
2395
+ const verdictById = new Map(input.verdicts.map((v) => [v.criterionId, v]));
2396
+ const failing = input.criteria.filter((c) => {
2397
+ const v = verdictById.get(c.id);
2398
+ return v ? v.result !== OutcomeVerdict.Pass : c.discovery !== undefined;
2399
+ });
2400
+ const pool = failing.length > 0 ? failing : input.criteria;
2401
+ body = pool.map((c) => findingFromCriterion(c, verdictById.get(c.id))).join("");
2402
+ }
2403
+ // §9.4.4 R3/M5 — DETECTED-but-unmatched flags, CLEARLY SEPARATED from the
2404
+ // fails-on-existing-criteria above. These are detections the judge flagged with NO
2405
+ // matching criterion — routed to *discover-evals, never minted into evals mid-judging.
2406
+ const flags = input.detectedFlags ?? [];
2407
+ const refToStr = (rf: DetectedFlag["ref"]): string =>
2408
+ rf === undefined ? "" : typeof rf === "string" ? rf : [rf.obs, rf.path, rf.value].filter(Boolean).join(":");
2409
+ const detectedSection =
2410
+ flags.length > 0
2411
+ ? `<h3 class="detected-h">⚠ Detected — unmatched (no defined criterion) <span class="badge b-skip">NOT SCORED</span></h3>` +
2412
+ `<div class="sub">M5 JUDGE-WHAT-IS: these behaviours were DETECTED + FLAGGED but have no matching criterion — routed to <code>*discover-evals</code> / <code>*build-dataset</code>, never scored or minted into evals mid-judging.</div>` +
2413
+ flags
2414
+ .map((fl) => {
2415
+ const r = refToStr(fl.ref);
2416
+ return (
2417
+ `<div class="detected"><div class="detected-top"><span class="dkind">${esc(fl.kind)}</span>` +
2418
+ `<b>${esc(fl.detection)}</b><span class="dtrace mono">${esc(fl.trajectoryId)}${fl.anchor !== undefined ? " · step " + esc(fl.anchor) : ""}</span></div>` +
2419
+ (r ? `<div class="detected-ev mono"><span class="ref">${esc(r.split(":")[0])}</span> ${esc(r.split(":").slice(1).join(":"))}</div>` : "") +
2420
+ `</div>`
2421
+ );
2422
+ })
2423
+ .join("")
2424
+ : `<h3 class="detected-h">⚠ Detected — unmatched (no defined criterion)</h3><div class="sub">none — every detected behaviour had a matching defined criterion this run.</div>`;
2425
+
2426
+ return (
2427
+ `<h2>④ Findings</h2>` +
2428
+ tabDesc("What this tab shows: TWO clearly separated groups — (1) FAILURES on the existing DEFINED criteria (with verbatim evidence + judge reasoning chain), and (2) DETECTED-but-unmatched flags the judge raised with no matching criterion (routed to discover, never scored).") +
2429
+ `<div class="sub">alignment-auditable — verbatim evidence + judge reasoning chain</div>` +
2430
+ `<h3 class="fails-h">✗ Failures on defined criteria</h3>` +
2431
+ body +
2432
+ detectedSection +
2433
+ `<div class="note"><span class="tag">★ AUDITABLE</span>&nbsp;Each finding: verbatim evidence (obs/diff refs from the example trace's judge walk), the judge reasoning chain, prevalence over the applicable denominator, agree/revise/refute to overturn. Open §2 to see the full side-by-side.</div>`
2434
+ );
2435
+ }
2436
+
2437
+ // ── §5 Self-Eval · Calibration [INTERNAL — stripped on publish] ───────────────
2438
+
2439
+ const PHASE_LENS_ORDER = ["gather", "expect", "context", "examine", "detect", "bind", "ground", "critique", "decide", "verify", "localize"];
2440
+
2441
+ /** §9.4.4 R2/M1 — the subject-profile card for the INTERNAL calibration lens. */
2442
+ function subjectProfileCard(p: SubjectProfile | undefined): string {
2443
+ if (p === undefined) {
2444
+ return `<div class="sub">no subject profile supplied for this run — the judge reconstructed identity at reason-time.</div>`;
2445
+ }
2446
+ const inferred = new Set(p.inferredFields ?? []);
2447
+ const tag = (field: string): string => (inferred.has(field) ? ` <span class="badge b-skip">inferred</span>` : "");
2448
+ const rows: [string, string, string][] = [
2449
+ ["identity", p.identity, "identity"],
2450
+ ["purpose", p.purpose, "purpose"],
2451
+ ["scope", p.scope, "scope"],
2452
+ ["skill", p.skill ?? "—", "skill"],
2453
+ ["tools", (p.tools ?? []).join(", ") || "—", "tools"],
2454
+ ["harness", p.harness, "harness"],
2455
+ ["version", p.version ?? "—", "version"],
2456
+ ];
2457
+ const prov = `<span class="prov ${p.provenance === "given" ? "defined" : "source"}">${esc(p.provenance)}</span>`;
2458
+ const body = rows
2459
+ .map(([k, v, field]) => `<div class="pk">${esc(k)}${tag(field)}</div><div class="pv">${esc(v)}</div>`)
2460
+ .join("");
2461
+ return (
2462
+ `<div class="sub">M1 — who the judge understood the agent to be (${prov}; <span class="badge b-skip">inferred</span> = reconstructed, not given · harness <code>unknown</code> is MARKED, never confabulated).</div>` +
2463
+ `<div class="profile-grid">${body}</div>`
2464
+ );
2465
+ }
2466
+
2467
+ /** §9.4.4 R2/M4 — the PHASE-BLOCK lens: the judge's reasoning grouped by DAG node
2468
+ * across the whole run — the calibration surface the builder reads like an AI eng. */
2469
+ function phaseLens(input: EvalReportInput): string {
2470
+ const ledger = input.ledger ?? [];
2471
+ const byPhase = new Map<string, { count: number; samples: string[] }>();
2472
+ for (const r of ledger) {
2473
+ for (const s of r.judgeSteps ?? []) {
2474
+ const k = s.kind;
2475
+ const entry = byPhase.get(k) ?? { count: 0, samples: [] };
2476
+ entry.count += 1;
2477
+ if (s.text && entry.samples.length < 3) entry.samples.push(s.text);
2478
+ byPhase.set(k, entry);
2479
+ }
2480
+ }
2481
+ if (byPhase.size === 0) {
2482
+ return `<div class="sub">no judge_steps emitted this run — phase-block lens unavailable (scorecard-only render).</div>`;
2483
+ }
2484
+ const orderedKinds = [
2485
+ ...PHASE_LENS_ORDER.filter((k) => byPhase.has(k)),
2486
+ ...[...byPhase.keys()].filter((k) => !PHASE_LENS_ORDER.includes(k)),
2487
+ ];
2488
+ return (
2489
+ `<div class="phase-lens">` +
2490
+ orderedKinds
2491
+ .map((k) => {
2492
+ const e = byPhase.get(k)!;
2493
+ const samples = e.samples.map((t) => `<div class="ps-row">${esc(t.slice(0, 140))}</div>`).join("");
2494
+ return (
2495
+ `<div class="phase-blk"><div class="phase-h"><span class="k ${esc(k)}">${esc(k)}</span><span class="phase-n">${esc(e.count)} step(s)</span></div>${samples}</div>`
2496
+ );
2497
+ })
2498
+ .join("") +
2499
+ `</div>`
2500
+ );
2501
+ }
2502
+
2503
+ /** §9.4.4 R2/M2+M3 — an example node-0 understanding (train-of-thought) + node-0.5
2504
+ * expected-trajectory, from the first trajectory that carries them. */
2505
+ function reasoningExample(input: EvalReportInput): string {
2506
+ const ledger = input.ledger ?? [];
2507
+ const withU = ledger.find((r) => r.understanding !== undefined);
2508
+ const withE = ledger.find((r) => r.expectedTrajectory && r.expectedTrajectory.length > 0);
2509
+ if (withU === undefined && withE === undefined) {
2510
+ return `<div class="sub">no node-0 understanding / node-0.5 expected-trajectory emitted this run.</div>`;
2511
+ }
2512
+ let out = "";
2513
+ if (withU?.understanding !== undefined) {
2514
+ const u = withU.understanding;
2515
+ const given = (u.given ?? []).map((g) => `<li>${esc(g)}</li>`).join("");
2516
+ const inferred = (u.inferred ?? []).map((g) => `<li>${esc(g)}</li>`).join("");
2517
+ out +=
2518
+ `<div class="re-blk"><div class="re-h">node-0 GATHER — understanding (M2) · <span class="mono">${esc(withU.trajectoryId)}</span></div>` +
2519
+ `<div class="re-rephrase">“${esc(u.rephrase)}”</div>` +
2520
+ (given ? `<div class="re-sub">given</div><ul class="re-ul">${given}</ul>` : "") +
2521
+ (inferred ? `<div class="re-sub">inferred</div><ul class="re-ul">${inferred}</ul>` : "") +
2522
+ `</div>`;
2523
+ }
2524
+ if (withE?.expectedTrajectory !== undefined) {
2525
+ const steps = withE.expectedTrajectory
2526
+ .map((s: ExpectedStep, i: number) => `<li><b>${esc(s.step ?? i + 1)}.</b> ${esc(s.expected)}${s.rationale ? ` <span class="re-rat">— ${esc(s.rationale)}</span>` : ""}</li>`)
2527
+ .join("");
2528
+ out +=
2529
+ `<div class="re-blk"><div class="re-h">node-0.5 EXPECTED-TRAJECTORY (M3) · <span class="mono">${esc(withE.trajectoryId)}</span></div>` +
2530
+ `<div class="re-sub">how the target SHOULD have acted — built BEFORE examine</div><ol class="re-ul">${steps}</ol></div>`;
2531
+ }
2532
+ return out;
2533
+ }
2534
+
2535
+ // (UI-12-B groundedHealthCell removed in the §5 self-eval rebuild (WS-3): the per-cell
2536
+ // judge-health grounding readout was superseded by the aggregate Reference Integrity
2537
+ // panel (§5.1), which keeps the same no-false-green honesty. `rateClass` stays — used
2538
+ // by the §3 pass-rate tiles + the per-criterion rows.)
2539
+
2540
+ /**
2541
+ * WS-4 — one parsed routed-failure. The EV-051 handover encodes each routed item as a
2542
+ * deterministic acceptance string (`routeFailures`):
2543
+ * `<criterionId> [<severity>/<flag>] FAILED on trace <traceId>: <critique>`
2544
+ * so the §5 handover is reconstructed STRUCTURALLY (WHAT · WHY · WHERE · TARGET) from
2545
+ * it — no new data, just a parse. A string that doesn't match the format degrades to
2546
+ * `{criterionId: <raw>}` (rendered as WHAT, never dropped).
2547
+ */
2548
+ interface RoutedItem {
2549
+ criterionId: string;
2550
+ severity: string;
2551
+ flag: string;
2552
+ traceId: string;
2553
+ critique: string;
2554
+ raw: string;
2555
+ }
2556
+ function parseRoutedCriterion(c: string): RoutedItem {
2557
+ const m = c.match(/^(.+?)\s+\[(.+?)\/(.+?)\]\s+FAILED on trace\s+(.+?):\s+([\s\S]*)$/);
2558
+ if (m) return { criterionId: m[1], severity: m[2], flag: m[3], traceId: m[4], critique: m[5].trim(), raw: c };
2559
+ return { criterionId: c, severity: "", flag: "", traceId: "", critique: "", raw: c };
2560
+ }
2561
+
2562
+ /**
2563
+ * WS-4 — the refined EV-051 "Routed to diagnostics" handover. Restructures the raw
2564
+ * acceptance-criteria dump into a clean, COPY-PASTEABLE handover block: a meta strip
2565
+ * (subject · target stage · escalation · produced-by) + one card per routed item with
2566
+ * WHAT (the criterion/finding) · WHY (the judge critique) · WHERE (the locus — trace +
2567
+ * dimension) · TARGET (diagnostics). Each card carries a per-item "copy" button (this
2568
+ * item as markdown) and the block ends with a "copy full handover as markdown" button —
2569
+ * the operator hands the bundle straight to diagnostics. PURE; deterministic markdown.
2570
+ */
2571
+ function routedHandover(input: EvalReportInput): string {
2572
+ const h = input.handover;
2573
+ if (h === null || h === undefined) {
2574
+ return `<div class="sub">No failures routed — nothing handed to diagnostics this run (judge-only: the evaluator flags + routes, it never fixes).</div>`;
2575
+ }
2576
+ const subjName = h.subject?.name ?? input.subject.name;
2577
+ const target = h.intent?.command ?? "*diagnose";
2578
+ const items = h.acceptance.criteria.map(parseRoutedCriterion);
2579
+
2580
+ // per-item markdown (one routed finding, copy-pasteable into diagnostics).
2581
+ const itemMd = (p: RoutedItem, crit: ReportCriterion | undefined): string =>
2582
+ [
2583
+ `## ${p.criterionId}${p.severity ? ` [${p.severity}/${p.flag}]` : ""}`,
2584
+ `- **WHAT:** ${crit?.statement ?? p.criterionId}`,
2585
+ `- **WHY:** ${p.critique || "(no critique recorded)"}`,
2586
+ `- **WHERE:** ${p.traceId ? `trace ${p.traceId}` : "—"}${crit?.dimension ? ` · ${crit.dimension}` : ""}`,
2587
+ `- **TARGET:** diagnostics (${target})`,
2588
+ ].join("\n");
2589
+
2590
+ const cards = items
2591
+ .map((p, i) => {
2592
+ const crit = criterionById(input, p.criterionId);
2593
+ const sev = sevCls(p.severity || crit?.severity);
2594
+ const sevTxt = String(p.severity || crit?.severity || "MED").toUpperCase();
2595
+ const where = p.traceId
2596
+ ? `trace <code>${esc(p.traceId)}</code>${crit?.dimension ? ` · <span class="hov-dim">${esc(crit.dimension)}</span>` : ""}`
2597
+ : "—";
2598
+ return (
2599
+ `<div class="hov">` +
2600
+ `<div class="hov-h"><span class="hov-n">#${i + 1}</span><span class="sev ${sev}">${esc(sevTxt)}</span>` +
2601
+ `<b>${esc(p.criterionId)}</b>${p.flag ? `<span class="chip">${esc(p.flag)}</span>` : ""}` +
2602
+ `<button class="copy-md hov-copy" data-md="${esc(itemMd(p, crit))}">copy item ▸</button></div>` +
2603
+ `<div class="hov-g">` +
2604
+ `<div class="hk">WHAT</div><div class="hv">${esc(crit?.statement ?? p.criterionId)}</div>` +
2605
+ `<div class="hk">WHY</div><div class="hv">${esc(p.critique || "—")}</div>` +
2606
+ `<div class="hk">WHERE</div><div class="hv">${where}</div>` +
2607
+ `<div class="hk">TARGET</div><div class="hv">→ diagnostics (<code>${esc(target)}</code>)</div>` +
2608
+ `</div></div>`
2609
+ );
2610
+ })
2611
+ .join("");
2612
+
2613
+ // full-bundle markdown (the whole routed set, ready to hand to diagnostics).
2614
+ const fullMd = [
2615
+ `# EV-051 — Routed to diagnostics`,
2616
+ ``,
2617
+ `**Subject:** ${subjName} · **Stage:** ${h.adl_stage} · **Escalation:** ${h.escalation_policy} · **By:** ${h.provenance.produced_by ?? "evaluator"}`,
2618
+ ``,
2619
+ `**Goal:** ${h.acceptance.goal}`,
2620
+ ``,
2621
+ ...items.flatMap((p) => [itemMd(p, criterionById(input, p.criterionId)), ``]),
2622
+ ].join("\n");
2623
+
2624
+ return (
2625
+ `<div class="sub">EV-051 — failing criteria ROUTED to diagnostics (judge-only; the evaluator flags + routes, it never fixes). Hand the block below straight to <code>*diagnose</code>.</div>` +
2626
+ `<div class="hov-meta">` +
2627
+ `<span class="hm-c"><span class="l">subject</span><span class="v">${esc(subjName)}</span></span>` +
2628
+ `<span class="hm-c"><span class="l">target stage</span><span class="v"><code>${esc(target)}</code></span></span>` +
2629
+ `<span class="hm-c"><span class="l">routed</span><span class="v">${items.length} finding(s)</span></span>` +
2630
+ `<span class="hm-c"><span class="l">escalation</span><span class="v">${esc(h.escalation_policy)}</span></span>` +
2631
+ `<span class="hm-c"><span class="l">produced by</span><span class="v">${esc(h.provenance.produced_by)}</span></span>` +
2632
+ `</div>` +
2633
+ `<div class="hov-list">${cards}</div>` +
2634
+ `<button class="copy-md" data-md="${esc(fullMd)}">⧉ Copy full handover as markdown</button>`
2635
+ );
2636
+ }
2637
+
2638
+ function selfEvalTab(input: EvalReportInput): string {
2639
+ const se = input.selfEval;
2640
+ const decisions = routedHandover(input);
2641
+ const header =
2642
+ `<h2>⑤ Self-Eval · Judge Calibration <span class="badge b-skip">INTERNAL</span></h2>` +
2643
+ tabDesc("What this tab shows [INTERNAL — stripped on publish]: the AGGREGATE judge-calibration surface — the human-in-the-loop for the JUDGE. It audits the METHODOLOGY the judge followed across the WHOLE population (not one trace): ① is the judge applying the method · ② do the criteria make sense / add value · ③ did it read enough traces (diligence) · ④ are results grounded · ⑤ are the refs real or fabricated. Accuracy-first ordering.") +
2644
+ `<div class="sub" data-strip="strip-for-client">internal — calibrate the judge · stripped on publish</div>`;
2645
+
2646
+ if (se === undefined) {
2647
+ // graceful fallback — no aggregate derived (older run): keep the EV-051 decisions.
2648
+ return header + `<h3>Routed to diagnostics (EV-051)</h3>` + decisions;
2649
+ }
2650
+
2651
+ const checksLegend =
2652
+ `<div class="se-checks"><div class="se-checks-h">the 5 operator checks this tab answers</div><div class="se-checks-g">` +
2653
+ [
2654
+ ["①", "methodology adherence", "is the judge applying the judging method (bind · ground · critique-before-verdict · abstain-on-silence)?"],
2655
+ ["②", "criterion value", "do the criteria make sense, apply, and discriminate — or are they dead weight?"],
2656
+ ["③", "diligence", "did the judge read enough of each trace, or pick one sample and stop?"],
2657
+ ["④", "grounded", "are the results anchored in real trace evidence (refs), not vibes?"],
2658
+ ["⑤", "refs real", "do the cited {obs,path,value} refs actually resolve in the trace — real or fabricated?"],
2659
+ ]
2660
+ .map(([n, k, v]) => `<div class="se-check"><span class="se-cn">${n}</span><span class="se-ck">${esc(k)}</span><span class="se-cv">${esc(v)}</span></div>`)
2661
+ .join("") +
2662
+ `</div></div>`;
2663
+
2664
+ return (
2665
+ header +
2666
+ checksLegend +
2667
+ selfEvalTrustBand(se) +
2668
+ selfEvalBehaviorMap(se) +
2669
+ selfEvalRefIntegrity(se) +
2670
+ selfEvalConfidence(se) +
2671
+ selfEvalPerCriterion(se) +
2672
+ selfEvalCriterionValue(se) +
2673
+ selfEvalDiligence(se) +
2674
+ selfEvalTransparency(se) +
2675
+ selfEvalEmit(se) +
2676
+ selfEvalSpotCheck(se) +
2677
+ selfEvalGroundTruthFuture() +
2678
+ // ── judge-reasoning DETAIL (M1 · M2 · M3 · M4) — the per-trace calibration lens
2679
+ // that backs the aggregate above (who the judge thinks the agent is, its
2680
+ // train-of-thought, the expected-trajectory it built). Retained below the
2681
+ // aggregate so a builder can drill from the population view into an example.
2682
+ `<h3 class="se-h"><span class="se-num">5.D</span> Judge reasoning detail (M1 · M2 · M3 · M4)</h3>` +
2683
+ `<div class="sub">the per-trace calibration lens behind the aggregate — who the judge understood the agent to be (M1), its phase-by-phase reasoning (M4), and an example understanding + expected-trajectory (M2 · M3).</div>` +
2684
+ subjectProfileCard(input.subjectProfile) +
2685
+ phaseLens(input) +
2686
+ reasoningExample(input) +
2687
+ `<h3 class="se-h">Routed to diagnostics (EV-051)</h3>` +
2688
+ decisions
2689
+ );
2690
+ }
2691
+
2692
+ // ── WS-3 §5 Self-Eval — section renderers (PURE string assembly) ──────────────
2693
+
2694
+ function seBar(pct: number, tone = "p"): string {
2695
+ const w = Math.max(0, Math.min(100, pct));
2696
+ return `<span class="sebar"><span class="sebar-f ${tone}" style="width:${w}%"></span></span>`;
2697
+ }
2698
+
2699
+ function selfEvalTrustBand(se: SelfEvalAggregate): string {
2700
+ const t = se.trust;
2701
+ const tone = t.score >= 85 ? "good" : t.score >= 60 ? "warn" : "bad";
2702
+ return (
2703
+ `<div class="se-trust ${tone}"><div class="se-trust-l"><div class="se-trust-score">${esc(t.score)}<span class="se-trust-d">/100</span></div><div class="se-trust-cap">judge trust</div></div>` +
2704
+ `<div class="se-trust-r"><div class="se-trust-label">${esc(t.label)}</div><div class="se-trust-basis">${esc(t.basis)}</div></div></div>`
2705
+ );
2706
+ }
2707
+
2708
+ function selfEvalBehaviorMap(se: SelfEvalAggregate): string {
2709
+ const b = se.behaviorMap;
2710
+ const clusters = b.clusters
2711
+ .map(
2712
+ (c) =>
2713
+ `<div class="se-cl ${esc(c.tone)}"><div class="se-cl-top"><span class="se-cl-n">${esc(c.count)}</span><span class="se-cl-l">${esc(c.label)}</span></div><div class="se-cl-note">${esc(c.note)}</div></div>`,
2714
+ )
2715
+ .join("");
2716
+ const conc = b.concentration.length
2717
+ ? `<div class="se-conc"><div class="se-conc-h">where fails CONCENTRATE</div>` +
2718
+ b.concentration
2719
+ .map(
2720
+ (c) =>
2721
+ `<div class="se-conc-r"><span class="se-conc-id" title="${esc(c.statement)}">${esc(c.id)}</span>${seBar(c.pct, "bad")}<span class="se-conc-v">${esc(c.fails)} fails · ${esc(c.pct)}%</span></div>`,
2722
+ )
2723
+ .join("") +
2724
+ `</div>`
2725
+ : `<div class="se-empty">no failing criteria — nothing concentrates.</div>`;
2726
+ return (
2727
+ `<h3 class="se-h"><span class="se-num">5.0</span> Judge Behavior Map — clusters &amp; where it falls short <span class="se-tag agg">AGGREGATE · MULTI-TRAJECTORY</span></h3>` +
2728
+ `<div class="sub">how the judge handled the POPULATION of ${esc(b.total)} trajectories — the cluster sizes + the fail concentration tell you where the judge (and the criteria) are working vs thin.</div>` +
2729
+ `<div class="se-clusters">${clusters}</div>` +
2730
+ conc
2731
+ );
2732
+ }
2733
+
2734
+ function selfEvalRefIntegrity(se: SelfEvalAggregate): string {
2735
+ const r = se.refIntegrity;
2736
+ // honesty (UI-12-B): with ZERO decided verdicts grounding is UNMEASURABLE — render
2737
+ // capture-unavailable (muted), NEVER a false-green default.
2738
+ if (r.decided === 0) {
2739
+ return (
2740
+ `<h3 class="se-h"><span class="se-num">5.1</span> Reference Integrity — real or fabricated? <span class="se-tag gt">check ⑤ · GROUND-TRUTH</span></h3>` +
2741
+ `<div class="se-row"><div class="se-metric"><div class="se-metric-v capture-na">capture-unavailable</div><div class="se-metric-l">no decided verdicts to ground</div></div></div>` +
2742
+ `<div class="se-note">${esc(r.note)}</div>`
2743
+ );
2744
+ }
2745
+ const tone = r.groundedPct >= 90 ? "good" : r.groundedPct >= 70 ? "warn" : "bad";
2746
+ return (
2747
+ `<h3 class="se-h"><span class="se-num">5.1</span> Reference Integrity — real or fabricated? <span class="se-tag gt">check ⑤ · GROUND-TRUTH</span></h3>` +
2748
+ `<div class="se-row"><div class="se-metric ${tone}"><div class="se-metric-v">${esc(r.groundedPct)}%</div><div class="se-metric-l">decided verdicts carry ≥1 structured ref</div></div>` +
2749
+ `<div class="se-kv"><div><b>${esc(r.grounded)}</b> grounded</div><div><b>${esc(r.ungrounded)}</b> ungrounded</div><div><b>${esc(r.decided)}</b> decided</div></div></div>` +
2750
+ `<div class="se-note">${esc(r.note)}</div>`
2751
+ );
2752
+ }
2753
+
2754
+ function selfEvalConfidence(se: SelfEvalAggregate): string {
2755
+ const rows = se.confidence
2756
+ .map(
2757
+ (c) =>
2758
+ `<div class="se-conf-r"><span class="se-conf-b">${esc(c.band)}</span>${seBar(c.pct, "p")}<span class="se-conf-v">${esc(c.count)} · ${esc(c.pct)}%</span></div>`,
2759
+ )
2760
+ .join("");
2761
+ return (
2762
+ `<h3 class="se-h"><span class="se-num">5.2</span> Confidence Calibration <span class="se-tag proxy">PROXY</span></h3>` +
2763
+ `<div class="sub">how the judge's stated confidence is distributed across decided verdicts. Tracking this against actual correctness (vs a 2nd judge / ground-truth) is the §5.X future extension.</div>` +
2764
+ `<div class="se-conf">${rows || '<div class="se-empty">no per-verdict confidence emitted.</div>'}</div>`
2765
+ );
2766
+ }
2767
+
2768
+ function selfEvalPerCriterion(se: SelfEvalAggregate): string {
2769
+ const rows = se.perCriterion
2770
+ .map((p) => {
2771
+ const flagCls = p.flag === "fails-present" ? "bad" : p.flag === "never-applies" ? "warn" : "good";
2772
+ return (
2773
+ `<tr><td class="se-cid" title="${esc(p.statement)}">${esc(p.id)}</td><td><span class="sev ${esc(p.severity)}">${esc(p.severity)}</span></td>` +
2774
+ `<td class="good">${esc(p.pass)}</td><td class="bad">${esc(p.fail)}</td><td class="warn">${esc(p.unc)}</td><td class="dim">${esc(p.na)}</td>` +
2775
+ `<td>${esc(p.groundedPct)}%</td><td><span class="se-flag ${flagCls}">${esc(p.flag)}</span></td></tr>`
2776
+ );
2777
+ })
2778
+ .join("");
2779
+ return (
2780
+ `<h3 class="se-h"><span class="se-num">5.3</span> Per-Criterion Reliability <span class="se-tag proxy">check ①④ · PROXY</span></h3>` +
2781
+ `<table class="se-table"><thead><tr><th>criterion</th><th>sev</th><th>pass</th><th>fail</th><th>indet</th><th>na</th><th>grnd</th><th>flag</th></tr></thead><tbody>${rows}</tbody></table>`
2782
+ );
2783
+ }
2784
+
2785
+ function selfEvalCriterionValue(se: SelfEvalAggregate): string {
2786
+ const rows = se.criterionValue
2787
+ .map((c, i) => {
2788
+ const cls = c.applicablePct === 0 ? "warn" : "good";
2789
+ return (
2790
+ `<tr><td class="se-cid" title="${esc(c.statement)}">${esc(c.id)}</td><td><span class="sev ${esc(c.severity)}">${esc(c.severity)}</span></td>` +
2791
+ `<td class="${cls}">${esc(c.applicablePct)}%</td><td>${esc(c.fails)} fails</td><td class="se-vverdict">${esc(c.verdict)}</td>` +
2792
+ `<td class="se-hitl"><span class="se-pick" data-vgroup="cval-${i}" data-v="keep">keep</span><span class="se-pick" data-vgroup="cval-${i}" data-v="revise">revise</span><span class="se-pick" data-vgroup="cval-${i}" data-v="retire">retire</span></td></tr>`
2793
+ );
2794
+ })
2795
+ .join("");
2796
+ return (
2797
+ `<h3 class="se-h"><span class="se-num">5.4</span> Criterion Value / Applicability Audit <span class="se-tag hitl">check ② · HITL</span></h3>` +
2798
+ `<div class="sub">does each criterion apply + discriminate, or is it dead weight? A criterion that NEVER applies or NEVER fails is a candidate to retire/revise. Your pick is captured below (export-ready).</div>` +
2799
+ `<table class="se-table"><thead><tr><th>criterion</th><th>sev</th><th>applicable</th><th>fails</th><th>value</th><th>operator</th></tr></thead><tbody>${rows}</tbody></table>`
2800
+ );
2801
+ }
2802
+
2803
+ function selfEvalDiligence(se: SelfEvalAggregate): string {
2804
+ const d = se.diligence;
2805
+ const pct = d.total > 0 ? Math.round((100 * d.examined) / d.total) : 0;
2806
+ const tone = pct >= 95 ? "good" : pct >= 80 ? "warn" : "bad";
2807
+ return (
2808
+ `<h3 class="se-h"><span class="se-num">5.5</span> Diligence / Trace Coverage <span class="se-tag proxy">check ③ · PROXY</span></h3>` +
2809
+ `<div class="se-row"><div class="se-metric ${tone}"><div class="se-metric-v">${esc(d.examined)}/${esc(d.total)}</div><div class="se-metric-l">trajectories examined</div></div>` +
2810
+ `<div class="se-metric"><div class="se-metric-v">${esc(d.avgGroundingRefs)}</div><div class="se-metric-l">avg grounding refs / examined trace</div></div></div>` +
2811
+ `<div class="se-note">${esc(d.note)}</div>`
2812
+ );
2813
+ }
2814
+
2815
+ function selfEvalTransparency(se: SelfEvalAggregate): string {
2816
+ const t = se.transparency;
2817
+ const block = t.blocked
2818
+ ? `<div class="se-blocked"><span class="se-blocked-tag">BLOCKED</span> ${esc(t.note)}</div>`
2819
+ : `<div class="se-note">${esc(t.note)}</div>`;
2820
+ return (
2821
+ `<h3 class="se-h"><span class="se-num">5.6</span> Judge Reasoning Transparency (walk · M2 · M3) <span class="se-tag">TRANSPARENCY · check ①</span></h3>` +
2822
+ `<div class="se-row"><div class="se-metric ${t.m2Pct > 0 ? "good" : "bad"}"><div class="se-metric-v">${esc(t.m2Pct)}%</div><div class="se-metric-l">M2 understanding</div></div>` +
2823
+ `<div class="se-metric ${t.m3Pct > 0 ? "good" : "bad"}"><div class="se-metric-v">${esc(t.m3Pct)}%</div><div class="se-metric-l">M3 expected-trajectory</div></div>` +
2824
+ `<div class="se-metric ${t.walkPct > 0 ? "good" : "bad"}"><div class="se-metric-v">${esc(t.walkPct)}%</div><div class="se-metric-l">judge walk emitted</div></div></div>` +
2825
+ block
2826
+ );
2827
+ }
2828
+
2829
+ function selfEvalEmit(se: SelfEvalAggregate): string {
2830
+ const e = se.emit;
2831
+ const meter = (label: string, present: number, total: number): string => {
2832
+ const pct = total > 0 ? Math.round((100 * present) / total) : 0;
2833
+ const tone = pct >= 90 ? "good" : pct >= 40 ? "warn" : "bad";
2834
+ return `<div class="se-emit-r"><span class="se-emit-l">${esc(label)}</span>${seBar(pct, tone === "good" ? "p" : tone)}<span class="se-emit-v">${esc(present)}/${esc(total)}</span></div>`;
2835
+ };
2836
+ const fieldRow = (f: { field: string; present: number }): string =>
2837
+ meter(f.field, f.present, e.eligible);
2838
+ return (
2839
+ `<h3 class="se-h"><span class="se-num">5.7</span> Emit-Completeness — self-honesty meter <span class="se-tag">HONESTY</span></h3>` +
2840
+ `<div class="sub">does the judge persist what it reasoned? A dropped walk does NOT change a verdict — but it STARVES this tab + §2. The engine gate (assessEmitCompleteness) tracks this every run.</div>` +
2841
+ `<div class="se-emit">` +
2842
+ meter("verdict + critique", e.eligible, e.eligible) +
2843
+ e.fields.map(fieldRow).join("") +
2844
+ `</div>` +
2845
+ `<div class="se-note">complete emits (all of M2+M3+agentSteps+judgeSteps): <b>${esc(e.completeEmits)}/${esc(e.eligible)}</b> (${esc(e.completePct)}%).` +
2846
+ (e.exemptIncomplete > 0 ? ` ${esc(e.exemptIncomplete)} INCOMPLETE traces exempt (node-1 short-circuit).` : "") +
2847
+ `</div>`
2848
+ );
2849
+ }
2850
+
2851
+ function selfEvalSpotCheck(se: SelfEvalAggregate): string {
2852
+ if (se.spotCheck.length === 0) {
2853
+ return (
2854
+ `<h3 class="se-h"><span class="se-num">5.8</span> Spot-Check / Disagreement Queue <span class="se-tag hitl">check ④ · HITL</span></h3>` +
2855
+ `<div class="se-empty">no failing or low-confidence verdicts queued — nothing brittle to spot-check.</div>`
2856
+ );
2857
+ }
2858
+ const rows = se.spotCheck
2859
+ .map(
2860
+ (s, i) =>
2861
+ `<div class="se-spot"><span class="se-spot-t mono" title="${esc(s.criterion)}">${esc(s.trace.slice(0, 14))}</span>` +
2862
+ `<span class="se-spot-c">${esc(s.criterion.slice(0, 54))}</span>` +
2863
+ `<span class="verd ${s.verdict === "FAIL" ? "fail" : "inc"}">${esc(s.verdict)}</span>` +
2864
+ `<span class="se-spot-conf">conf ${esc(s.conf)}</span><span class="se-spot-why">${esc(s.reason)}</span>` +
2865
+ `<span class="se-spot-act"><span class="se-pick" data-vgroup="spot-${i}" data-v="agree">agree</span><span class="se-pick" data-vgroup="spot-${i}" data-v="disagree">disagree</span></span></div>`,
2866
+ )
2867
+ .join("");
2868
+ return (
2869
+ `<h3 class="se-h"><span class="se-num">5.8</span> Spot-Check / Disagreement Queue <span class="se-tag hitl">check ④ · HITL</span></h3>` +
2870
+ `<div class="sub">the brittle calls — fails + low-confidence verdicts — sorted weakest-confidence-first. Your agree/disagree is captured (and becomes the ground-truth seed for §5.X).</div>` +
2871
+ `<div class="se-spots">${rows}</div>`
2872
+ );
2873
+ }
2874
+
2875
+ function selfEvalGroundTruthFuture(): string {
2876
+ return (
2877
+ `<h3 class="se-h"><span class="se-num">5.X</span> Judge Accuracy vs Ground-Truth <span class="se-tag future">FUTURE — not built</span></h3>` +
2878
+ `<div class="se-future"><div class="se-future-h">documented extension</div>` +
2879
+ `<div class="se-future-b">The strongest calibration is judge-verdict vs a TRUSTED label. We do not have ground-truth labels in this run, so this is DOCUMENTED, not computed. The seed already exists: §5.8 Spot-Check collects operator agree/disagree per verdict — accumulate those into a labelled set and this panel becomes a real accuracy/precision/recall readout against the judge. Until then, §5.1 (refs real) + §5.2 (confidence) + §5.3 (per-criterion) are the PROXY signals.</div></div>`
2880
+ );
2881
+ }
2882
+
2883
+ // ── report-component CSS (references theme.css tokens) ────────────────────────
2884
+
2885
+ const REPORT_CSS = `
2886
+ .mono{font-family:var(--mono)}
2887
+ /* status-acuity — color-code the OUTCOME big-stat tiles; COUNT tiles stay neutral (faint cyan) */
2888
+ /* ALIGNMENT FIX (parity w/ discover): theme.css ships .big-stat as content-sized flex
2889
+ * (min-width:96px) → ragged tile widths. Override to an EQUAL-column grid so every tile
2890
+ * is the same width AND height (grid row-stretch). */
2891
+ .big-stat{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:12px;align-items:start}
2892
+ .big-stat .s{min-width:0;min-height:84px;display:flex;flex-direction:column;justify-content:center}
2893
+ .big-stat .s .v{line-height:1.15}
2894
+ .big-stat .s{border-left:4px solid var(--border-strong)}
2895
+ .big-stat .s.count{border-left-color:var(--cyan);opacity:.95}
2896
+ .big-stat .s.pass{background:var(--pass-bg);border-color:var(--pass);border-left-color:var(--pass)}
2897
+ .big-stat .s.pass .v{color:var(--pass)}
2898
+ .big-stat .s.fail{background:var(--fail-bg);border-color:var(--fail);border-left-color:var(--fail)}
2899
+ .big-stat .s.fail .v{color:var(--fail)}
2900
+ .big-stat .s.indet{background:var(--warn-bg);border-color:var(--warn);border-left-color:var(--warn)}
2901
+ .big-stat .s.indet .v{color:var(--warn)}
2902
+ /* Overview-redesign — entity HERO (full-width subject identity) */
2903
+ /* §9.4.5 E4 — entity-AS-HERO: a primary left-accent + tighter rhythm so the subject
2904
+ * card reads as the page anchor (diagnostics-grade density, dark/branded preserved). */
2905
+ .hero{border:1px solid var(--border-strong);border-left:3px solid var(--primary);background:var(--surf);margin:10px 0 14px;padding:0}
2906
+ .hero-top{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:11px 14px;border-bottom:1px solid var(--border);background:var(--surf-2)}
2907
+ .hero .hname{font-size:var(--fs-xl);font-weight:700;color:var(--fg-strong);font-family:var(--mono)}
2908
+ .hero .hchip{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;padding:2px 8px;border:1px solid var(--border);color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
2909
+ .hero .hchip.type{color:var(--cyan);border-color:var(--cyan);background:rgba(69,184,204,.10)}
2910
+ .hero .hchip.access{margin-left:auto}
2911
+ .hero .hchip.access.given{color:var(--pass);border-color:var(--pass);background:var(--pass-bg)}
2912
+ .hero .hchip.access.recon{color:var(--warn);border-color:var(--warn);background:var(--warn-bg)}
2913
+ .hero-grid{display:grid;grid-template-columns:max-content 1fr;gap:1px;background:var(--border)}
2914
+ .hero-grid .hk{background:var(--surf);font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);text-transform:uppercase;letter-spacing:.04em;padding:7px 12px}
2915
+ .hero-grid .hv{background:var(--surf-2);font-size:var(--fs-sm);color:var(--fg);padding:7px 13px;line-height:1.5}
2916
+ .hero .unk{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--dim);text-transform:uppercase;border:1px dashed var(--border-strong);padding:0 5px}
2917
+ .hero .inf{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;color:var(--warn);background:var(--warn-bg);padding:1px 5px;text-transform:uppercase}
2918
+ .hero .inf.given{color:var(--pass);background:var(--pass-bg)}
2919
+ .hero .hsys{margin:9px 12px;border:1px solid var(--border)}
2920
+ .hero .hsys summary{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;color:var(--cyan);cursor:pointer;padding:6px 10px;background:var(--surf);display:flex;gap:8px;align-items:center}
2921
+ .hero .hsys-prov{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;padding:1px 6px;text-transform:uppercase;letter-spacing:.03em}
2922
+ .hero .hsys-prov.recon{color:var(--warn);background:var(--warn-bg);border:1px solid var(--warn)}
2923
+ .hero .hsys-prov.given{color:var(--pass);background:var(--pass-bg);border:1px solid var(--pass)}
2924
+ .hero .hsys pre{margin:0;padding:9px 11px;font-family:var(--mono);font-size:var(--fs-xs);color:var(--fg);white-space:pre-wrap;word-break:break-word;line-height:1.5;background:var(--bg);max-height:420px;overflow:auto}
2925
+ .hero .hsys-na{margin:9px 12px;font-family:var(--mono);font-size:var(--fs-2xs);color:var(--dim)}
2926
+ .hero .hhonesty{padding:8px 13px;border-top:1px solid var(--border);font-size:var(--fs-2xs);color:var(--muted);line-height:1.5}
2927
+ /* §9.4.5 E1 — trace-only honesty: the reconstructed banner + the harness/prompt UNAVAILABLE note */
2928
+ .hero .hrecon{padding:7px 14px;border-bottom:1px solid var(--border);background:var(--warn-bg);font-family:var(--mono);font-size:var(--fs-2xs);font-weight:600;color:var(--warn);letter-spacing:.03em;line-height:1.5}
2929
+ .hero .recon-note{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--warn);text-transform:uppercase;letter-spacing:.03em}
2930
+ /* §9.4.5 E2 — tools as diagnostics-style chips with a per-tool observed call-count */
2931
+ .hero .tchips{display:flex;gap:5px;flex-wrap:wrap;align-items:center}
2932
+ .hero .tchip{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--fg);background:var(--surf-3);border:1px solid var(--border-strong);padding:2px 7px;display:inline-flex;gap:5px;align-items:center;white-space:nowrap}
2933
+ .hero .tchip .obs{font-weight:700;color:var(--cyan);font-size:var(--fs-2xs)}
2934
+ .hero .tchip .obs.none{color:var(--dim)}
2935
+ /* Overview-redesign — provenance META-STRIP (run-config + judge substrate) */
2936
+ .provstrip{display:flex;gap:7px 14px;flex-wrap:wrap;align-items:center;border:1px solid var(--border);border-left:3px solid var(--primary);background:var(--surf-2);padding:7px 12px;margin:10px 0}
2937
+ .provstrip .ps-item{display:inline-flex;gap:5px;align-items:baseline}
2938
+ .provstrip .ps-k{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
2939
+ .provstrip .ps-v{font-family:var(--mono);font-size:var(--fs-xs);color:var(--fg-strong)}
2940
+ .provstrip .unk{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--dim);text-transform:uppercase}
2941
+ .provstrip .ps-badge{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;padding:2px 8px;border:1px solid;text-transform:uppercase}
2942
+ .provstrip .ps-badge.on{color:var(--primary-soft);border-color:var(--primary);background:rgba(126,71,215,.12)}
2943
+ .provstrip .ps-badge.off{color:var(--dim);border-color:var(--border)}
2944
+ /* Overview-redesign — segmented coverage FUNNEL (INCOMPLETE first-class) */
2945
+ .funnel{display:flex;align-items:stretch;gap:6px;flex-wrap:wrap;margin:14px 0 6px}
2946
+ .funnel .fstage{flex:1;min-width:118px;border:1px solid var(--border);background:var(--surf);padding:9px 11px}
2947
+ .funnel .fstage .fn{font-family:var(--mono);font-size:var(--fs-2xl);font-weight:700;color:var(--fg-strong);line-height:1.05}
2948
+ .funnel .fstage .fl{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.05em;color:var(--cyan);margin-top:3px}
2949
+ .funnel .fstage .fnote{font-size:var(--fs-2xs);color:var(--dim);margin-top:3px;line-height:1.4}
2950
+ .funnel .farrow{display:flex;align-items:center;color:var(--dim);font-size:var(--fs-lg);font-family:var(--mono)}
2951
+ .funnel .fstage.outc{flex:1.6;min-width:200px}
2952
+ .funnel .foutc{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}
2953
+ .funnel .fpill{font-family:var(--mono);font-size:var(--fs-2xs);padding:2px 8px;border:1px solid var(--border);color:var(--muted)}
2954
+ .funnel .fpill b{color:var(--fg-strong)}
2955
+ .funnel .fpill.pass{border-color:var(--pass);color:var(--pass)}.funnel .fpill.pass b{color:var(--pass)}
2956
+ .funnel .fpill.fail{border-color:var(--fail);color:var(--fail)}.funnel .fpill.fail b{color:var(--fail)}
2957
+ .funnel .fpill.indet{border-color:var(--warn);color:var(--warn)}.funnel .fpill.indet b{color:var(--warn)}
2958
+ .funnel .fpill.inc{border-color:var(--border-strong);color:var(--muted);border-style:dashed}
2959
+ /* §9.4.5 E3 — eval-HEALTH temporal heatmap (correctness over time, NOT latency) */
2960
+ .ehm{display:flex;gap:4px;flex-wrap:wrap;align-items:flex-start;margin:6px 0 4px}
2961
+ .ehm-col{display:flex;flex-direction:column;align-items:center;gap:3px}
2962
+ .ehm-cell{min-width:30px;height:30px;padding:0 6px;display:flex;align-items:center;justify-content:center;border:1px solid rgba(0,0,0,.35);font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;color:#0a0a12}
2963
+ .ehm-cell.pass{background:var(--hm-pass,rgba(67,195,154,.50))}.ehm-cell.fail{background:var(--hm-fail,rgba(224,102,102,.54))}.ehm-cell.indet{background:var(--hm-indet,rgba(232,166,77,.52))}.ehm-cell.skip{background:var(--surf-3);color:var(--dim)}
2964
+ .ehm-lab{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);text-align:center;line-height:1.25}
2965
+ .ehm-legend{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);margin:4px 0;display:flex;gap:4px;align-items:center;flex-wrap:wrap}
2966
+ .ehm-legend .sw{display:inline-block;width:11px;height:11px;border:1px solid rgba(0,0,0,.35);vertical-align:middle;margin:0 2px}
2967
+ .ehm-legend .sw.pass{background:var(--hm-pass,rgba(67,195,154,.50))}.ehm-legend .sw.fail{background:var(--hm-fail,rgba(224,102,102,.54))}.ehm-legend .sw.indet{background:var(--hm-indet,rgba(232,166,77,.52))}
2968
+ .ehm-pending{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--dim);border:1px dashed var(--border-strong);background:var(--surf);padding:7px 11px;margin:2px 0 6px;line-height:1.5}
2969
+ /* note callout */
2970
+ .note{border-left:3px solid var(--recommend);background:var(--recommend-bg);padding:7px 11px;font-size:var(--fs-xs);margin:14px 0}
2971
+ .note .tag{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;color:var(--recommend)}
2972
+ /* severity pills + verd */
2973
+ .sev{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;padding:2px 7px;margin-right:4px}
2974
+ .sev.crit{color:var(--fail);background:var(--fail-bg)}.sev.high{color:var(--warn);background:var(--warn-bg)}.sev.med{color:var(--cyan);background:rgba(69,184,204,.10)}.sev.low{color:var(--muted);background:var(--surf-3)}
2975
+ .verd{font-family:var(--mono);font-size:var(--fs-xs);font-weight:700;padding:2px 8px;border:1px solid}
2976
+ .verd.fail{color:var(--fail);border-color:var(--fail)}.verd.pass{color:var(--pass);border-color:var(--pass)}.verd.inc{color:var(--warn);border-color:var(--warn)}
2977
+ .chip{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);border:1px solid var(--border);padding:1px 6px;white-space:nowrap}
2978
+ /* teaser */
2979
+ .teaser-row{display:flex;gap:8px;align-items:baseline;margin:4px 0;font-size:var(--fs-sm)}
2980
+ .teaser-num{color:var(--fail);font-family:var(--mono);font-size:var(--fs-xs)}
2981
+ /* §2 ledger */
2982
+ .lfilter{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:9px 0}
2983
+ .lfilter b{font-family:var(--mono);font-size:var(--fs-2xs);padding:3px 8px;border:1px solid var(--border);color:var(--fg);opacity:.8;cursor:pointer}
2984
+ .lfilter b.on{border-color:var(--primary);color:var(--primary-soft);background:rgba(126,71,215,.12)}
2985
+ .lfilter input{background:var(--surf-2);border:1px solid var(--border);color:var(--fg);font-family:var(--mono);font-size:var(--fs-xs);padding:3px 8px;flex:1;max-width:240px}
2986
+ .lfilter .sp{margin-left:auto;font-family:var(--mono);font-size:var(--fs-2xs);color:var(--dim)}
2987
+ .virt{font-size:var(--fs-2xs);color:var(--dim);text-align:center;padding:8px}
2988
+ table.ledger td{cursor:pointer;font-family:var(--mono);font-size:var(--fs-xs)}
2989
+ table.ledger tr:hover td{background:var(--surf-2)}table.ledger tr.sel td{background:rgba(126,71,215,.10)}
2990
+ /* status-acuity — verdict-tinted ledger rows + a status left-accent (eye-catch at a glance) */
2991
+ table.ledger tr.vrow.FAIL td{background:var(--fail-bg)}table.ledger tr.vrow.FAIL td.tid{box-shadow:inset 3px 0 0 var(--fail)}
2992
+ table.ledger tr.vrow.INDETERMINATE td{background:var(--warn-bg)}table.ledger tr.vrow.INDETERMINATE td.tid{box-shadow:inset 3px 0 0 var(--warn)}
2993
+ table.ledger tr.vrow.PASS td.tid{box-shadow:inset 3px 0 0 var(--pass)}
2994
+ table.ledger tr.vrow.INCOMPLETE td.tid{box-shadow:inset 3px 0 0 var(--dim)}
2995
+ table.ledger tr.vrow:hover td{background:var(--surf-2)}table.ledger tr.vrow.sel td{background:rgba(126,71,215,.10)}
2996
+ .tid{color:var(--cyan)}.haswalk{color:var(--primary-soft);font-size:var(--fs-2xs)}
2997
+ .mini{font-family:var(--mono);font-size:var(--fs-2xs);padding:1px 5px}
2998
+ .mini.PASS{color:var(--pass);background:var(--pass-bg)}.mini.FAIL{color:var(--fail);background:var(--fail-bg)}.mini.INDETERMINATE{color:var(--warn);background:var(--warn-bg)}.mini.INCOMPLETE{color:var(--muted);background:var(--surf-3)}
2999
+ /* WS-2 — resolution badge (ledger column) + drill routing line. Makes a no-walk trace
3000
+ HONEST: judged, not unjudged. walk=cyan · walk-dropped=amber · truncated=dim. */
3001
+ .resbadge{font-family:var(--mono);font-size:var(--fs-2xs);padding:1px 6px;border:1px solid;white-space:nowrap;cursor:help}
3002
+ .resbadge.res-walk{color:var(--cyan);border-color:var(--cyan);background:rgba(69,184,204,.10)}
3003
+ .resbadge.res-nowalk{color:var(--warn);border-color:var(--warn);background:var(--warn-bg)}
3004
+ .resbadge.res-trunc{color:var(--muted);border-color:var(--border-strong);background:var(--surf-3)}
3005
+ .routing{display:flex;gap:9px;align-items:baseline;flex-wrap:wrap;margin:0 0 9px;padding:8px 11px;border-left:3px solid var(--border-strong);background:var(--surf-2)}
3006
+ .routing.res-walk{border-left-color:var(--cyan)}
3007
+ .routing.res-nowalk{border-left-color:var(--warn)}
3008
+ .routing.res-trunc{border-left-color:var(--dim)}
3009
+ .routing-k{flex:0 0 auto;font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.05em;color:var(--dim)}
3010
+ .routing-b{flex:0 0 auto;font-family:var(--mono);font-size:var(--fs-2xs);padding:1px 6px;border:1px solid}
3011
+ .routing-b.res-walk{color:var(--cyan);border-color:var(--cyan)}
3012
+ .routing-b.res-nowalk{color:var(--warn);border-color:var(--warn)}
3013
+ .routing-b.res-trunc{color:var(--muted);border-color:var(--border-strong)}
3014
+ .routing-v{flex:1 1 200px;font-size:var(--fs-sm);color:var(--fg);line-height:1.5}
3015
+ /* §2 side-by-side */
3016
+ .ctx{border:1px solid var(--border);background:var(--surf-2);margin:8px 0 4px}
3017
+ .ctx-h{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--cyan);text-transform:uppercase;letter-spacing:.06em;padding:6px 11px;border-bottom:1px solid var(--border)}
3018
+ .ctx-g{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border)}.ctx-c{background:var(--surf);padding:8px 11px}
3019
+ .ctx-c .l{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);text-transform:uppercase}.ctx-c .v{font-size:var(--fs-xs);margin-top:2px}
3020
+ /* Gap A — raw triggering INPUT in the §2 drill (input + scenario cell). font floor 11px. */
3021
+ .ctx-c .iraw{margin:0;font-family:var(--mono);font-size:var(--fs-2xs);line-height:1.5;color:var(--fg);white-space:pre-wrap;word-break:break-word;background:var(--bg);border:1px solid var(--border);padding:6px 8px}
3022
+ .ctx-c .iraw.clamp{max-height:120px;overflow:auto}
3023
+ .ctx-c .iexp summary{cursor:pointer;font-family:var(--mono);font-size:var(--fs-2xs);color:var(--cyan);text-transform:uppercase;letter-spacing:.04em;padding:2px 0}
3024
+ .ctx-c .iexp[open] summary{margin-bottom:4px}
3025
+ .ctx-c .iscn{margin-top:4px;font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted)}
3026
+ .ctx-c .ival{font-size:var(--fs-xs);color:var(--fg)}
3027
+ .lanehdr{display:grid;grid-template-columns:1fr 42px 1fr}.lanehdr div{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.05em;padding:5px 8px;border-bottom:1px solid var(--border-strong)}
3028
+ .lanehdr .a{color:var(--fg-strong)}.lanehdr .x{text-align:center;color:var(--dim)}.lanehdr .j{color:var(--primary-soft)}
3029
+ .grid2{display:grid;grid-template-columns:1fr 42px 1fr;align-items:stretch}
3030
+ .band{grid-column:1/-1;border:1px solid var(--border);margin:8px 0;padding:7px 11px;background:var(--surf)}.band.loc{border-left:3px solid var(--fail);background:var(--fail-bg)}.band .bh{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;color:var(--fail)}
3031
+ .node{display:flex;flex-direction:column;align-items:center}.node .n{width:22px;height:22px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--fs-xs);font-weight:600;border:1px solid var(--border-strong);background:var(--surf-2);margin-top:12px}.node .ln{flex:1;width:1px;background:var(--border)}
3032
+ .node.error .n,.node.false-success .n{border-color:var(--fail);color:var(--fail)}.node.warn .n{border-color:var(--warn);color:var(--warn)}.node.ok .n{border-color:var(--pass);color:var(--pass)}
3033
+ .evb{border:1px solid var(--border);background:var(--surf-2);padding:6px 9px;margin:8px 8px 8px 0}.evb.r{margin:8px 0 8px 8px}
3034
+ .evb .top{display:flex;align-items:center;gap:6px}.evb .tool{font-family:var(--mono);font-size:var(--fs-xs);color:var(--fg-strong)}.evb .st{font-family:var(--mono);font-size:var(--fs-2xs);padding:1px 6px;margin-left:auto}
3035
+ .evb .st.ok{color:var(--pass);background:var(--pass-bg)}.evb .st.error,.evb .st.false-success{color:var(--fail);background:var(--fail-bg)}.evb .st.warn{color:var(--warn);background:var(--warn-bg)}
3036
+ .evb .det{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);margin-top:3px;line-height:1.4}
3037
+ .jstep{display:flex;gap:6px;align-items:baseline;font-family:var(--mono);font-size:var(--fs-2xs);margin-top:4px}
3038
+ .jstep .k{font-size:var(--fs-2xs);font-weight:600;padding:1px 5px;min-width:54px;text-align:center}
3039
+ .k.context{color:var(--cyan);background:rgba(69,184,204,.10)}.k.examine,.k.detect{color:var(--warn);background:var(--warn-bg)}.k.bind{color:var(--cyan);background:rgba(69,184,204,.10)}.k.ground{color:var(--pass);background:var(--pass-bg)}.k.critique{color:var(--primary-soft);background:rgba(126,71,215,.12)}.k.decide{color:var(--fail);background:var(--fail-bg)}.k.verify{color:var(--fg-strong);background:var(--surf-3)}
3040
+ .jstep .t{color:var(--fg);opacity:.92;line-height:1.4}.jstep .t .ref{color:var(--cyan)}
3041
+ .jstep.noexam .t{color:var(--dim);font-style:italic}
3042
+ /* §2 judge lane — per-step eval-coverage entry (which criterion examined this step). */
3043
+ .jcov{border-left:2px solid var(--border-strong);background:var(--surf);padding:5px 8px;margin-top:6px}
3044
+ .jcov.pass{border-left-color:var(--pass)}.jcov.fail{border-left-color:var(--fail)}
3045
+ .jcov.uncertain,.jcov.indeterminate{border-left-color:var(--warn)}.jcov.na{border-left-color:var(--border)}
3046
+ .jcov-h{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:3px}
3047
+ .jcov .jm{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:600;letter-spacing:.04em;padding:0 5px;border:1px solid var(--border-strong)}
3048
+ .jcov .jm.code{color:var(--cyan);border-color:var(--cyan)}.jcov .jm.judge{color:var(--primary-soft);border-color:var(--primary-soft)}
3049
+ .jcov .jcid{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--fg-strong);word-break:break-all}
3050
+ .jcov .jcrit{font-size:var(--fs-xs);color:var(--fg);opacity:.92;line-height:1.45}.jcov .jcrit .dim{color:var(--dim)}
3051
+ .jcov .cvrefs{margin-top:4px}
3052
+ .health{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--border);margin-top:8px}.health .hc{background:var(--surf);padding:8px 10px}.health .hc .l{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);text-transform:uppercase}.health .hc .v{font-size:var(--fs-lg);font-weight:600;margin-top:2px}.health .hc .v.good{color:var(--pass)}.health .hc .v.warn{color:var(--warn)}
3053
+ .drillbox{border:1px solid var(--border-strong);background:var(--surf-2);margin-top:10px;padding:11px}
3054
+ /* §3 heatmap + subcards + calibration */
3055
+ .hm{display:grid;gap:3px;font-family:var(--mono);font-size:var(--fs-2xs);margin-top:6px}
3056
+ .hm .lab{color:var(--fg);opacity:.82;text-align:right;padding-right:5px;line-height:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3057
+ .hm .colh{color:var(--muted);text-align:center;font-size:var(--fs-2xs);line-height:13px}
3058
+ .hm .cell{height:18px;border:1px solid rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;color:#0a0a12;font-size:var(--fs-2xs);font-weight:700}
3059
+ /* status-acuity — heatmap fills nudged up for contrast (still toned, not neon) */
3060
+ .hm .cell.pass{background:var(--hm-pass,rgba(67,195,154,.50))}.hm .cell.fail{background:var(--hm-fail,rgba(224,102,102,.54))}.hm .cell.indet{background:var(--hm-indet,rgba(232,166,77,.52))}.hm .cell.skip{background:var(--surf-3)}
3061
+ /* UI-6 — criteria cards: a PURPLISH base (brand primary), accent driven by SUCCESS RATE
3062
+ * (green-ish high · amber mid · red-ish low) — NOT a uniform severity red. */
3063
+ .subc{border:1px solid var(--border);border-left:4px solid var(--primary);background:linear-gradient(180deg,rgba(126,71,215,.12),rgba(126,71,215,.045));margin-top:10px}
3064
+ .subc.rate-ok{border-left-color:var(--pass)}
3065
+ .subc.rate-mid{border-left-color:var(--warn)}
3066
+ .subc.rate-low{border-left-color:var(--fail)}
3067
+ .subc-h{display:flex;gap:8px;align-items:center;padding:8px 11px;border-bottom:1px solid var(--border)}.subc-h b{font-size:var(--fs-sm);color:var(--fg-strong)}.subc-h .vp{font-family:var(--mono);font-size:var(--fs-xs);font-weight:700;padding:1px 7px;border:1px solid}
3068
+ .nest{border:1px solid var(--border);background:var(--surf);margin:7px 11px}.nest-h{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);text-transform:uppercase;padding:4px 9px;border-bottom:1px solid var(--border)}
3069
+ .row{font-family:var(--mono);font-size:var(--fs-xs);color:var(--fg);opacity:.92;margin:4px 9px;line-height:1.5}.row .ref{color:var(--cyan)}.row .w{color:var(--warn)}
3070
+ .cal{display:flex;gap:5px;align-items:center;padding:8px 11px;border-top:1px solid var(--border)}.cal .l{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);margin-right:3px}.cal b{font-family:var(--mono);font-size:var(--fs-2xs);padding:2px 8px;border:1px solid var(--border);color:var(--fg);opacity:.85;cursor:pointer}.cal b.on{border-color:var(--primary);color:var(--primary-soft);background:rgba(126,71,215,.12)}
3071
+ /* §4 findings */
3072
+ .find{border:1px solid var(--border);background:var(--surf-2);margin-top:11px}.find.gate{border-top:2px solid var(--fail);border-left:4px solid var(--fail);background:var(--fail-bg)}
3073
+ .find-h{display:flex;gap:9px;align-items:center;padding:9px 12px;border-bottom:1px solid var(--border);flex-wrap:wrap}.find-h b{font-size:var(--fs-sm);color:var(--fg-strong)}.find-h .gatetag{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--fail);border:1px solid var(--fail);padding:1px 6px}
3074
+ .fg{display:grid;grid-template-columns:92px 1fr;gap:1px;background:var(--border)}.fg .k{background:var(--surf);font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);text-transform:uppercase;padding:8px 10px}
3075
+ .fg .v{background:var(--surf-2);padding:8px 11px;font-size:var(--fs-sm);color:var(--fg)}.fg .v code{font-family:var(--mono);font-size:var(--fs-xs);background:var(--bg);padding:0 4px}.fg .v .ref{color:var(--cyan);font-family:var(--mono);font-size:var(--fs-xs)}.fg .v .none{color:var(--pass)}.fg .v ul{margin:2px 0;padding-left:15px}.fg .v li{margin:2px 0;font-size:var(--fs-xs);font-family:var(--mono)}
3076
+ .fg .v.reason .step{color:var(--primary-soft)}.fg .v .lnk{color:var(--primary-soft);font-family:var(--mono);font-size:var(--fs-2xs);cursor:pointer}
3077
+ .na-note{font-size:var(--fs-2xs);color:var(--dim)}
3078
+ .prev{display:inline-block;background:var(--surf-3);height:8px;width:110px;vertical-align:middle}.prev i{display:block;height:100%;background:var(--fail)}
3079
+ .areview{display:flex;gap:6px;align-items:center;padding:9px 12px;border-top:1px solid var(--border-strong);background:var(--surf)}.areview .l{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--cyan)}.areview b{font-family:var(--mono);font-size:var(--fs-2xs);padding:2px 9px;border:1px solid var(--border);color:var(--fg);opacity:.85;cursor:pointer}.areview b.agree.on{border-color:var(--pass);color:var(--pass)}.areview b.refute.on{border-color:var(--fail);color:var(--fail)}.areview input{flex:1;background:var(--surf-2);border:1px solid var(--border);color:var(--fg);font-family:var(--mono);font-size:var(--fs-xs);padding:3px 7px}
3080
+ /* §4/§2 per-trajectory scorecard grid */
3081
+ .scgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:5px;margin-top:6px}
3082
+ .scc{border:1px solid var(--border);background:var(--surf);padding:5px 8px;font-family:var(--mono);font-size:var(--fs-2xs);display:flex;gap:6px;align-items:center}.scc.fail{border-left:2px solid var(--fail)}.scc.uncertain,.scc.indeterminate{border-left:2px solid var(--warn)}.scc .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3083
+ /* WS-3 §5 self-eval AGGREGATE — judge-calibration surface */
3084
+ .se-h{font-size:var(--fs-md);margin:22px 0 4px;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}
3085
+ .se-num{font-family:var(--mono);font-weight:700;color:var(--primary-soft)}
3086
+ .se-tag{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;padding:1px 6px;border:1px solid var(--border-strong);color:var(--muted);text-transform:uppercase;letter-spacing:.03em}
3087
+ .se-tag.agg{color:var(--cyan);border-color:var(--cyan)}.se-tag.gt{color:var(--pass);border-color:var(--pass)}.se-tag.proxy{color:var(--warn);border-color:var(--warn)}.se-tag.hitl{color:var(--primary-soft);border-color:var(--primary)}.se-tag.future{color:var(--dim);border-color:var(--border)}
3088
+ .se-checks{border:1px solid var(--border);border-left:3px solid var(--primary);background:var(--surf-2);margin:10px 0 4px;padding:9px 12px}
3089
+ .se-checks-h{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.05em;color:var(--dim);margin-bottom:6px}
3090
+ .se-checks-g{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:6px}
3091
+ .se-check{display:flex;gap:7px;align-items:baseline}.se-cn{color:var(--primary-soft);font-family:var(--mono);font-weight:700}.se-ck{font-weight:600;color:var(--fg-strong);font-size:var(--fs-sm);white-space:nowrap}.se-cv{color:var(--muted);font-size:var(--fs-xs);line-height:1.4}
3092
+ .se-trust{display:flex;gap:16px;align-items:center;border:1px solid var(--border-strong);background:var(--surf);margin:12px 0;padding:14px 16px;border-left:3px solid var(--primary)}
3093
+ .se-trust.good{border-left-color:var(--pass)}.se-trust.warn{border-left-color:var(--warn)}.se-trust.bad{border-left-color:var(--fail)}
3094
+ .se-trust-score{font-family:var(--mono);font-size:var(--fs-2xl);font-weight:700;color:var(--fg-strong);line-height:1}.se-trust-d{font-size:var(--fs-md);color:var(--dim)}
3095
+ .se-trust-cap{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.05em;color:var(--dim);margin-top:3px}
3096
+ .se-trust.good .se-trust-score{color:var(--pass)}.se-trust.warn .se-trust-score{color:var(--warn)}.se-trust.bad .se-trust-score{color:var(--fail)}
3097
+ .se-trust-label{font-weight:600;color:var(--fg-strong);font-size:var(--fs-md)}.se-trust-basis{color:var(--muted);font-size:var(--fs-sm);margin-top:4px;line-height:1.5}
3098
+ .se-clusters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:9px;margin:10px 0}
3099
+ .se-cl{border:1px solid var(--border);border-top:3px solid var(--border-strong);background:var(--surf);padding:9px 11px}
3100
+ .se-cl.good{border-top-color:var(--pass)}.se-cl.bad{border-top-color:var(--fail)}.se-cl.warn{border-top-color:var(--warn)}.se-cl.neutral{border-top-color:var(--dim)}
3101
+ .se-cl-top{display:flex;gap:8px;align-items:baseline}.se-cl-n{font-family:var(--mono);font-size:var(--fs-xl);font-weight:700;color:var(--fg-strong)}.se-cl-l{font-size:var(--fs-sm);font-weight:600;color:var(--fg)}
3102
+ .se-cl-note{font-size:var(--fs-xs);color:var(--muted);margin-top:4px;line-height:1.45}
3103
+ .se-conc{border:1px solid var(--border);background:var(--surf-2);padding:9px 12px;margin:6px 0}
3104
+ .se-conc-h{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.05em;color:var(--dim);margin-bottom:6px}
3105
+ .se-conc-r,.se-conf-r,.se-emit-r{display:flex;gap:9px;align-items:center;margin:3px 0;font-size:var(--fs-xs)}
3106
+ .se-conc-id{font-family:var(--mono);color:var(--cyan);flex:0 0 200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3107
+ .se-conc-v,.se-conf-v,.se-emit-v{font-family:var(--mono);color:var(--muted);flex:0 0 auto;white-space:nowrap}
3108
+ .sebar{flex:1 1 auto;height:11px;background:var(--bg);border:1px solid var(--border);position:relative;overflow:hidden}
3109
+ .sebar-f{position:absolute;left:0;top:0;bottom:0}.sebar-f.p{background:linear-gradient(90deg,#3a2d63,var(--primary))}.sebar-f.good{background:var(--pass)}.sebar-f.warn{background:var(--warn)}.sebar-f.bad{background:var(--fail)}
3110
+ .se-row{display:flex;gap:10px;flex-wrap:wrap;margin:8px 0}
3111
+ .se-metric{border:1px solid var(--border);background:var(--surf);padding:9px 14px;min-width:140px;border-left:3px solid var(--border-strong)}
3112
+ .se-metric.good{border-left-color:var(--pass)}.se-metric.warn{border-left-color:var(--warn)}.se-metric.bad{border-left-color:var(--fail)}
3113
+ .se-metric-v{font-family:var(--mono);font-size:var(--fs-xl);font-weight:700;color:var(--fg-strong)}
3114
+ .se-metric.good .se-metric-v{color:var(--pass)}.se-metric.warn .se-metric-v{color:var(--warn)}.se-metric.bad .se-metric-v{color:var(--fail)}
3115
+ .se-metric-l{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);text-transform:uppercase;margin-top:3px}
3116
+ .se-kv{display:flex;gap:16px;align-items:center;font-size:var(--fs-sm);color:var(--muted)}.se-kv b{color:var(--fg-strong);font-family:var(--mono)}
3117
+ .se-note{font-size:var(--fs-sm);color:var(--muted);margin:5px 0 2px;line-height:1.5}
3118
+ .se-empty{font-family:var(--mono);font-size:var(--fs-xs);color:var(--dim);border:1px dashed var(--border-strong);padding:12px;text-align:center;margin:6px 0}
3119
+ .se-conf-b{font-family:var(--mono);font-size:var(--fs-xs);color:var(--fg);flex:0 0 64px}
3120
+ .se-table{width:100%;border-collapse:collapse;margin:8px 0;font-size:var(--fs-xs)}
3121
+ .se-table th{text-align:left;font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;color:var(--dim);border-bottom:1px solid var(--border);padding:5px 8px}
3122
+ .se-table td{padding:5px 8px;border-bottom:1px solid var(--border);font-family:var(--mono)}
3123
+ .se-table td.good{color:var(--pass)}.se-table td.bad{color:var(--fail)}.se-table td.warn{color:var(--warn)}.se-table td.dim{color:var(--dim)}
3124
+ .se-cid{color:var(--cyan);max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3125
+ .se-flag{font-size:var(--fs-2xs);padding:1px 6px;border:1px solid}.se-flag.good{color:var(--pass);border-color:var(--pass)}.se-flag.bad{color:var(--fail);border-color:var(--fail)}.se-flag.warn{color:var(--warn);border-color:var(--warn)}
3126
+ .se-vverdict{color:var(--muted)}
3127
+ .se-hitl,.se-spot-act{display:flex;gap:4px}
3128
+ .se-pick{font-family:var(--mono);font-size:var(--fs-2xs);padding:1px 7px;border:1px solid var(--border);color:var(--muted);cursor:pointer;user-select:none}
3129
+ .se-pick:hover{border-color:var(--primary);color:var(--fg)}
3130
+ .se-pick.on{border-color:var(--primary);color:var(--primary-soft);background:rgba(126,71,215,.14);font-weight:700}
3131
+ .se-blocked{border:1px solid var(--fail);background:var(--fail-bg);padding:9px 12px;margin:6px 0;font-size:var(--fs-sm);color:var(--fg);line-height:1.5}
3132
+ .se-blocked-tag{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;color:var(--fail);border:1px solid var(--fail);padding:1px 6px;margin-right:6px}
3133
+ .se-emit{border:1px solid var(--border);background:var(--surf-2);padding:10px 12px;margin:6px 0}
3134
+ .se-emit-l{font-family:var(--mono);font-size:var(--fs-xs);color:var(--fg);flex:0 0 160px}
3135
+ .se-spots{display:flex;flex-direction:column;gap:5px;margin:6px 0}
3136
+ .se-spot{display:flex;gap:9px;align-items:center;flex-wrap:wrap;border:1px solid var(--border);border-left:3px solid var(--warn);background:var(--surf);padding:6px 10px;font-size:var(--fs-xs)}
3137
+ .se-spot-t{color:var(--cyan);flex:0 0 auto}.se-spot-c{color:var(--fg);flex:1 1 200px}.se-spot-conf{font-family:var(--mono);color:var(--muted)}.se-spot-why{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--warn)}.se-spot-act{margin-left:auto}
3138
+ .se-future{border:1px dashed var(--border-strong);background:var(--surf-2);padding:10px 13px;margin:6px 0;opacity:.92}
3139
+ .se-future-h{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.05em;color:var(--dim);margin-bottom:5px}
3140
+ .se-future-b{font-size:var(--fs-sm);color:var(--muted);line-height:1.55}
3141
+ /* §5 self-eval health */
3142
+ .hsc{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);margin-top:10px}.hsc .hc{background:var(--surf);padding:10px 12px}.hsc .hc .l{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);text-transform:uppercase}.hsc .hc .v{font-size:var(--fs-xl);font-weight:700;margin-top:3px;font-family:var(--mono)}.hsc .hc .v.good{color:var(--pass)}.hsc .hc .v.warn{color:var(--warn)}
3143
+ /* UI-12-B — honest grounded health: rate-coloured (low=critical), capture-na=muted (never green) */
3144
+ .hsc .hc .v.rate-ok{color:var(--pass)}.hsc .hc .v.rate-mid{color:var(--warn)}.hsc .hc .v.rate-low{color:var(--fail)}
3145
+ .hsc .hc .v.capture-na{color:var(--dim);font-size:var(--fs-sm);font-weight:600;cursor:help}
3146
+ .copy-md{font-family:var(--mono);font-size:var(--fs-xs);padding:4px 10px;border:1px solid var(--border);background:var(--surf-2);color:var(--fg);cursor:pointer;margin-top:8px}
3147
+ .copy-md:hover{border-color:var(--cyan);color:var(--cyan)}
3148
+ .copy-md.copied{border-color:var(--pass);color:var(--pass)}
3149
+ .copy-md.copyfail{border-color:var(--warn);color:var(--warn)}
3150
+ /* WS-4 — refined EV-051 "routed to diagnostics" handover block */
3151
+ .hov-meta{display:flex;flex-wrap:wrap;gap:7px;margin:8px 0 10px}
3152
+ .hov-meta .hm-c{display:flex;flex-direction:column;gap:1px;border:1px solid var(--border);border-left:3px solid var(--primary);background:var(--surf);padding:5px 10px;min-width:90px}
3153
+ .hov-meta .hm-c .l{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.04em;color:var(--cyan)}
3154
+ .hov-meta .hm-c .v{font-size:var(--fs-sm);color:var(--fg-strong)}
3155
+ .hov-list{display:flex;flex-direction:column;gap:8px;margin:8px 0}
3156
+ .hov{border:1px solid var(--border);border-left:3px solid var(--fail);background:var(--surf)}
3157
+ .hov-h{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:7px 11px;border-bottom:1px solid var(--border);background:var(--surf-2)}
3158
+ .hov-h .hov-n{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted)}
3159
+ .hov-h b{font-family:var(--mono);font-size:var(--fs-sm);color:var(--fg-strong)}
3160
+ .hov-copy{margin:0 0 0 auto;padding:2px 9px;font-size:var(--fs-2xs)}
3161
+ .hov-g{display:grid;grid-template-columns:64px 1fr;gap:5px 11px;padding:9px 11px}
3162
+ .hov-g .hk{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--cyan);padding-top:2px}
3163
+ .hov-g .hv{font-size:var(--fs-sm);color:var(--fg);line-height:1.5;min-width:0;word-break:break-word}
3164
+ .hov-dim{color:var(--muted);font-family:var(--mono);font-size:var(--fs-2xs)}
3165
+ /* §9.4.4 R3 per-tab description */
3166
+ .tabdesc{border:1px solid var(--border);border-left:3px solid var(--cyan);background:var(--surf-2);padding:7px 11px;font-size:var(--fs-xs);color:var(--fg);opacity:.92;line-height:1.5;margin:8px 0 12px}
3167
+ .tabdesc .td-i{color:var(--cyan);font-weight:700;margin-right:5px}
3168
+ /* §9.4.4 R4 criterion provenance + hover + statement */
3169
+ .prov{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;padding:1px 6px;border:1px solid;text-transform:uppercase;cursor:help}
3170
+ .prov.defined{color:var(--cyan);border-color:var(--cyan);background:rgba(69,184,204,.10)}
3171
+ .prov.source{color:var(--primary-soft);border-color:var(--primary);background:rgba(126,71,215,.12)}
3172
+ /* UI-10 — CODE vs JUDGE method chip (how a criterion is evaluated) */
3173
+ .method{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;padding:1px 6px;border:1px solid;text-transform:uppercase;letter-spacing:.03em;cursor:help;white-space:nowrap}
3174
+ .method.code{color:var(--warn);border-color:var(--warn);background:rgba(214,158,46,.12)}
3175
+ .method.hybrid{color:var(--cyan);border-color:var(--cyan);background:rgba(69,184,204,.10)}
3176
+ .method.judge{color:var(--muted);border-color:var(--border-strong);background:var(--surf-2)}
3177
+ .ihover{color:var(--cyan);font-size:var(--fs-xs);cursor:help;border-bottom:1px dotted var(--cyan)}
3178
+ .cstmt{font-size:var(--fs-xs);color:var(--muted);margin-top:3px;line-height:1.4;white-space:normal}
3179
+ td .cstmt{font-family:var(--sans,inherit)}
3180
+ /* §9.4.4 R3/M5 detected-but-unmatched */
3181
+ .fails-h,.detected-h{font-size:var(--fs-sm);margin-top:14px}
3182
+ .detected-h{color:var(--warn)}
3183
+ .detected{border:1px solid var(--warn);border-left:3px solid var(--warn);background:var(--warn-bg);margin-top:8px;padding:7px 11px}
3184
+ .detected-top{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}.detected-top b{font-size:var(--fs-sm);color:var(--fg-strong)}
3185
+ .dkind{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;color:var(--warn);border:1px solid var(--warn);padding:1px 6px;text-transform:uppercase}
3186
+ .dtrace{font-size:var(--fs-2xs);color:var(--muted);margin-left:auto}
3187
+ .detected-ev{font-size:var(--fs-2xs);color:var(--muted);margin-top:4px}.detected-ev .ref{color:var(--cyan)}
3188
+ /* §9.4.4 R2/M1 internal subject-profile card */
3189
+ .profile-grid{display:grid;grid-template-columns:max-content 1fr;gap:1px;background:var(--border);border:1px solid var(--border);margin:8px 0}
3190
+ .profile-grid .pk{background:var(--surf);font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);text-transform:uppercase;padding:6px 10px}
3191
+ .profile-grid .pv{background:var(--surf-2);font-size:var(--fs-xs);color:var(--fg);padding:6px 11px}
3192
+ /* §9.4.4 R2/M4 phase-block lens */
3193
+ .phase-lens{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:6px;margin:8px 0}
3194
+ .phase-blk{border:1px solid var(--border);background:var(--surf);padding:7px 9px}
3195
+ .phase-h{display:flex;gap:6px;align-items:center;margin-bottom:4px}.phase-n{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);margin-left:auto}
3196
+ .ps-row{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--fg);opacity:.88;line-height:1.4;margin-top:3px;border-top:1px solid var(--border);padding-top:3px}
3197
+ .phase-h .k.gather{color:var(--cyan);background:rgba(69,184,204,.10)}.phase-h .k.expect{color:var(--primary-soft);background:rgba(126,71,215,.12)}
3198
+ /* §9.4.4 R2/M2+M3 reasoning example */
3199
+ .re-blk{border:1px solid var(--border);background:var(--surf-2);padding:8px 11px;margin-top:8px}
3200
+ .re-h{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;color:var(--cyan);margin-bottom:5px}
3201
+ .re-rephrase{font-size:var(--fs-sm);color:var(--fg-strong);font-style:italic;line-height:1.5}
3202
+ .re-sub{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;color:var(--muted);margin-top:6px}
3203
+ .re-ul{margin:3px 0;padding-left:18px}.re-ul li{font-size:var(--fs-xs);margin:2px 0;line-height:1.4}.re-rat{color:var(--muted)}
3204
+ /* UI-2 — verdict-state legend (Overview + Scorecard) */
3205
+ .vlegend{border:1px solid var(--border);border-left:3px solid var(--cyan);background:var(--surf-2);padding:8px 11px;margin:10px 0}
3206
+ .vlegend .vl-h{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.05em;color:var(--cyan);margin-bottom:6px}
3207
+ .vlegend .vl-item{display:flex;gap:9px;align-items:baseline;margin:4px 0;line-height:1.45}
3208
+ .vlegend .vl-k{font-family:var(--mono);font-size:var(--fs-2xs);font-weight:700;padding:1px 7px;border:1px solid;min-width:108px;text-align:center;flex:0 0 auto}
3209
+ .vlegend .vl-v{font-size:var(--fs-xs);color:var(--fg);opacity:.92}
3210
+ .vlegend .vl-k.pass{color:var(--pass);border-color:var(--pass);background:var(--pass-bg)}
3211
+ .vlegend .vl-k.fail{color:var(--fail);border-color:var(--fail);background:var(--fail-bg)}
3212
+ .vlegend .vl-k.inc{color:var(--muted);border-color:var(--border-strong);background:var(--surf-3)}
3213
+ .vlegend .vl-k.indet{color:var(--warn);border-color:var(--warn);background:var(--warn-bg)}
3214
+ .vlegend .vl-k.na{color:var(--dim);border-color:var(--border);background:var(--surf)}
3215
+ /* UI-3 — heatmap horizontal-scroll container (no page overflow) */
3216
+ .hm-scroll{overflow-x:auto;max-width:100%;padding-bottom:2px}
3217
+ .hm .colh{word-break:break-word;white-space:normal}
3218
+ /* UI-5 — success-rate + key-metrics summary row under the heatmap */
3219
+ .sc-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:6px;margin:10px 0 6px}
3220
+ .scm{border:1px solid var(--border);border-top:3px solid var(--border-strong);background:var(--surf);padding:9px 11px}
3221
+ .scm-v{font-family:var(--mono);font-size:var(--fs-2xl);font-weight:700;line-height:1.05;color:var(--fg-strong)}
3222
+ .scm-l{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.04em;color:var(--cyan);margin-top:3px}
3223
+ .scm-n{font-size:var(--fs-2xs);color:var(--dim);margin-top:3px;line-height:1.4}
3224
+ .scm.rate-ok{border-top-color:var(--pass)}.scm.rate-ok .scm-v{color:var(--pass)}
3225
+ .scm.rate-mid{border-top-color:var(--warn)}.scm.rate-mid .scm-v{color:var(--warn)}
3226
+ .scm.rate-low{border-top-color:var(--fail)}.scm.rate-low .scm-v{color:var(--fail)}
3227
+ .scm.rate-na{border-top-color:var(--border)}
3228
+ /* UI-12-B — capture-unavailable grounded tile: long honest label, not a big green number */
3229
+ .scm.pending .scm-v{font-size:var(--fs-sm);color:var(--dim);word-break:break-word}
3230
+ /* UI-7 — criterion-definition meta line */
3231
+ .nest .row.defn-meta{color:var(--cyan);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.03em}
3232
+ /* UI-8 — calibrate groups (mutually-exclusive radio semantics) */
3233
+ .cal .calgroup{display:inline-flex;gap:5px;align-items:center}
3234
+ /* UI-4 — judge-reasoning bands in the §2 drill (why this verdict + full why-chain) */
3235
+ .band.whyverdict{border-left:3px solid var(--primary);background:rgba(126,71,215,.08)}
3236
+ .band.whyverdict .wv-t{font-size:var(--fs-sm);color:var(--fg);line-height:1.5;margin-top:4px}
3237
+ .ctx.whychain{margin:8px 0}
3238
+ .ctx.whychain .wc-body{padding:7px 11px;display:flex;flex-direction:column;gap:2px}
3239
+ .why-note{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);margin-top:4px;line-height:1.45}.why-note .ref{color:var(--cyan)}
3240
+ /* WS-5 — indeterminate resolution chain (§3): per-criterion applicable·pass·fail·N/A·needs-evidence */
3241
+ .rztable{display:flex;flex-direction:column;gap:8px;margin:8px 0}
3242
+ .rzrow{border:1px solid var(--border);border-left:3px solid var(--border-strong);background:var(--surf);padding:8px 11px}
3243
+ .rzrow.crit,.rzrow.high{border-left-color:var(--fail)}.rzrow.med{border-left-color:var(--warn)}.rzrow.low{border-left-color:var(--border-strong)}
3244
+ .rz-h{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;margin-bottom:5px}
3245
+ .rz-h b{font-size:var(--fs-sm);color:var(--fg-strong)}
3246
+ .rz-rate{margin-left:auto;font-family:var(--mono);font-size:var(--fs-2xs)}
3247
+ .rz-stats{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
3248
+ .rz{font-family:var(--mono);font-size:var(--fs-2xs);padding:2px 7px;border:1px solid var(--border)}
3249
+ .rz.applicable{color:var(--fg-strong);border-color:var(--border-strong)}
3250
+ .rz.pass{color:var(--pass);border-color:var(--pass)}
3251
+ .rz.fail{color:var(--fail);border-color:var(--fail)}
3252
+ .rz.na{color:var(--cyan);border-color:var(--cyan);background:rgba(108,196,212,.08)}
3253
+ .rz.ne{color:var(--warn);border-color:var(--warn);background:rgba(214,158,46,.08)}
3254
+ .rz.zero{color:var(--dim);border-color:var(--border);background:none}
3255
+ .rz-reason{display:inline-block;font-size:var(--fs-2xs);color:var(--muted);font-style:italic;margin-left:4px}
3256
+ .rz-act{display:inline-flex;flex-direction:column;font-family:var(--mono);font-size:var(--fs-2xs);color:var(--warn);border:1px dashed var(--warn);padding:2px 7px}
3257
+ .rz-act .rz-reason{color:var(--muted);font-family:var(--sans,inherit)}
3258
+ /* WS-3 — one-line plain-language gloss (what it measures · why it matters), muted,
3259
+ sits ABOVE the technical block in BOTH §3 scorecard subcards and §4 finding cards */
3260
+ .plain{font-size:var(--fs-xs);color:var(--muted);line-height:1.5;margin:6px 0 8px;padding:6px 9px;border-left:2px solid var(--cyan);background:rgba(108,196,212,.06);font-style:italic}
3261
+ /* WS-3 follow-up — per-criterion plain-language KEY directly under the §3 heatmap (ALL criteria) */
3262
+ .plain-legend{margin:8px 0 4px}
3263
+ .plain-legend .pl-h{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.04em;color:var(--cyan);margin:0 0 5px}
3264
+ .plain-leg{display:flex;gap:8px;align-items:baseline;margin:0 0 4px;flex-wrap:wrap}
3265
+ .plain-leg .pl-id{font-style:normal;color:var(--fg-strong);font-size:var(--fs-2xs);flex:0 0 auto}
3266
+ .plain-leg .pl-tx{font-style:italic;color:var(--muted);flex:1 1 240px;min-width:0}
3267
+ /* WS-1 — "how the judge reasoned" per-criterion critique block (side-by-side + scorecard drill) */
3268
+ .cvblock{margin:8px 0}
3269
+ .cvblock .cvbody{display:flex;flex-direction:column;gap:6px;padding:7px 9px}
3270
+ .cvrow{border:1px solid var(--border);border-left:3px solid var(--border-strong);background:var(--surf);padding:7px 9px}
3271
+ .cvrow.pass{border-left-color:var(--pass)}.cvrow.fail{border-left-color:var(--fail)}
3272
+ .cvrow.uncertain,.cvrow.indeterminate{border-left-color:var(--warn)}.cvrow.na{border-left-color:var(--border)}
3273
+ .cvh{display:flex;gap:7px;align-items:center;flex-wrap:wrap;margin-bottom:3px}
3274
+ .cvb{font-family:var(--mono);font-size:var(--fs-2xs);text-transform:uppercase;letter-spacing:.03em;padding:1px 6px;border:1px solid var(--border-strong)}
3275
+ .cvb.pass{color:var(--pass);border-color:var(--pass)}.cvb.fail{color:var(--fail);border-color:var(--fail)}
3276
+ .cvb.uncertain,.cvb.indeterminate{color:var(--warn);border-color:var(--warn)}.cvb.na{color:var(--dim)}
3277
+ .cvid{font-family:var(--mono);font-size:var(--fs-xs);color:var(--fg-strong)}
3278
+ .cvconf{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--dim);margin-left:auto}
3279
+ .cvcrit{font-size:var(--fs-sm);color:var(--fg);line-height:1.5}.cvcrit.dim{color:var(--dim)}
3280
+ .cvrefs{display:flex;flex-wrap:wrap;gap:5px;margin-top:5px}
3281
+ .cvref{font-family:var(--mono);font-size:var(--fs-2xs);color:var(--cyan);border:1px solid var(--border);padding:1px 5px;word-break:break-all}
3282
+ `;
3283
+
3284
+ // ── the embedded client JS (ledger filter/search + drill + tabs + toggles) ───
3285
+
3286
+ /**
3287
+ * The embedded client JS — ledger filter/search + click-row drill (side-by-side
3288
+ * when judge_steps exist, else the per-trajectory scorecard) + tab switching +
3289
+ * calibration/alignment toggles + copy-as-markdown. The data (ledger `L`,
3290
+ * criteria `C`, judge-walks `J`) is injected as deterministic JSON.
3291
+ */
3292
+ function clientScript(ledgerJson: string, critJson: string, walksJson: string, resMetaJson: string): string {
3293
+ return `
3294
+ const L=${ledgerJson},C=${critJson},J=${walksJson},RES=${resMetaJson};let F='all';
3295
+ function resBadge(res){var m=RES[res];if(!m)return '';return '<span class="resbadge '+m.cls+'" title="'+esc(m.routing)+'">'+esc(m.badge)+'</span>';}
3296
+ function resRouting(res){var m=RES[res];if(!m)return '';return '<div class="routing '+m.cls+'"><span class="routing-k">resolution</span><span class="routing-b '+m.cls+'">'+esc(m.badge)+'</span><span class="routing-v">'+esc(m.routing)+'</span></div>';}
3297
+ function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
3298
+ function wireTabs(){var btns=document.querySelectorAll('nav.tabs button'),panels=document.querySelectorAll('main .panel');
3299
+ btns.forEach(function(b){b.addEventListener('click',function(){var key=b.getAttribute('data-tab');
3300
+ btns.forEach(function(x){x.classList.remove('active');});panels.forEach(function(p){p.classList.remove('active');});
3301
+ b.classList.add('active');var p=document.getElementById(key);if(p)p.classList.add('active');});});}
3302
+ function cnt(c){var p=0,f=0,n=0;for(var k in c){if(c[k]=='pass')p++;else if(c[k]=='fail')f++;else if(c[k]=='uncertain'||c[k]=='indeterminate')n++;}return[p,f,n];}
3303
+ function render(){var qi=document.getElementById('q');if(!qi)return;var q=(qi.value||'').toLowerCase();
3304
+ var rows=L.filter(function(r){return (F=='all'||r.v==F)&&(!q||r.t.toLowerCase().indexOf(q)>=0||(r.r||'').toLowerCase().indexOf(q)>=0);});
3305
+ var cntEl=document.getElementById('cnt');if(cntEl)cntEl.textContent=rows.length+' / '+L.length+' trajectories';
3306
+ var cap=rows.slice(0,150);
3307
+ document.getElementById('lrows').innerHTML=cap.map(function(r){var c=cnt(r.c),p=c[0],f=c[1],n=c[2];
3308
+ return '<tr data-id="'+esc(r.t)+'" class="vrow '+esc(r.v)+'"><td class="tid">'+esc(r.t.slice(0,14))+(r.js?' <span class=haswalk>✦</span>':'')+'</td><td>'+esc(r.r||'all')+'</td><td><span class="mini '+esc(r.v)+'">'+esc(r.v)+'</span></td><td>'+resBadge(r.res)+'</td><td>'+p+'</td><td>'+f+'</td><td>'+n+'</td><td style="color:var(--fail)">'+esc((r.f||[]).join(', '))+'</td></tr>';}).join('');
3309
+ var virt=document.getElementById('virt');if(virt)virt.textContent=rows.length>150?('▾ showing 150 of '+rows.length+' — filter/search to narrow'):'';
3310
+ document.querySelectorAll('table.ledger tbody tr').forEach(function(tr){tr.addEventListener('click',function(){drill(tr.getAttribute('data-id'),tr);});});
3311
+ /* Surface the Agent‖Judge side-by-side BY DEFAULT: on the first render, auto-open the
3312
+ first JUDGED trace (one that has a judge walk) so the two-lane trajectory shows without
3313
+ a click. Once only — never hijacks the drill after the user starts navigating. */
3314
+ if(!window._autoDrilled){var jr=null;for(var i=0;i<cap.length;i++){if(cap[i].js){jr=cap[i];break;}}jr=jr||cap[0];if(jr){var atr=document.querySelector('table.ledger tbody tr[data-id="'+jr.t+'"]');if(atr){window._autoDrilled=true;drill(jr.t,atr);}}}}
3315
+ // WS-1 — the "How the Judge Reasoned" per-criterion block: result badge +
3316
+ // critique-before-verdict + grounding refs, read off the verdict file's verdicts[].
3317
+ // Shared by BOTH the side-by-side drill (walk traces) and the per-trajectory
3318
+ // scorecard drill (no-walk traces) so NO judged trace renders an empty panel.
3319
+ function cvRefs(refs){if(!refs||!refs.length)return '';
3320
+ return '<div class="cvrefs">'+refs.map(function(rf){var s=(typeof rf==='string')?rf:[rf.obs,rf.path,rf.value].filter(Boolean).join(' · ');return s?'<span class="cvref">'+esc(s)+'</span>':'';}).join('')+'</div>';}
3321
+ function critiqueBlock(cvs){if(!cvs||!cvs.length)return '';
3322
+ var order={fail:0,uncertain:1,indeterminate:1,pass:2,na:3};
3323
+ var rows=cvs.slice().sort(function(a,b){return (order[a.result]==null?9:order[a.result])-(order[b.result]==null?9:order[b.result]);}).map(function(v){
3324
+ var res=v.result||'na';var disp=res==='uncertain'?'indeterminate':res;
3325
+ var conf=(v.confidence!=null)?'<span class="cvconf">conf '+esc(v.confidence)+(v.confidenceBand?' · '+esc(v.confidenceBand):'')+'</span>':'';
3326
+ var crit=v.critique?'<div class="cvcrit">'+esc(v.critique)+'</div>':'<div class="cvcrit dim">— no critique recorded for this criterion</div>';
3327
+ return '<div class="cvrow '+esc(res)+'"><div class="cvh"><span class="cvb '+esc(res)+'">'+esc(disp)+'</span><span class="cvid">'+esc(v.criterionId||'')+'</span>'+conf+'</div>'+crit+cvRefs(v.refs)+'</div>';}).join('');
3328
+ return '<div class="ctx cvblock"><div class="ctx-h">◇ how the judge reasoned — per-criterion verdict · critique-before-verdict · grounding refs ('+cvs.length+')</div><div class="cvbody">'+rows+'</div></div>';}
3329
+ function sideBySide(d){var ctx=d.context||{};
3330
+ // Gap A — the §2 "input + scenario" cell renders the RAW triggering INPUT (the
3331
+ // thing that fired the agent) ABOVE the judge's scenario LABEL. Long inputs
3332
+ // collapse into a <details> (lean, no JS) so the cell stays compact; short ones
3333
+ // render inline (clamped). ABSENT input ⇒ the cell shows the scenario alone (or
3334
+ // "—"). Font stays at the --fs-2xs (11px) floor + brand mono.
3335
+ var inputCell=function(raw,scen){
3336
+ var sc=scen?'<div class="iscn">scenario · '+esc(scen)+'</div>':'';
3337
+ if(!raw)return (scen?'<div class="ival">'+esc(scen)+'</div>':'—');
3338
+ var long=String(raw).length>180;
3339
+ var body=long
3340
+ ? '<details class="iexp"><summary>raw input · '+String(raw).length+' chars</summary><pre class="iraw">'+esc(raw)+'</pre></details>'
3341
+ : '<pre class="iraw clamp">'+esc(raw)+'</pre>';
3342
+ return body+sc;};
3343
+ var refStr=function(rf){if(!rf)return '';if(typeof rf==='string')return rf;return [rf.obs,rf.path,rf.value].filter(Boolean).join(':');};
3344
+ // UI-4 — surface the judge's REASONING (why the verdict + why the exit-states were
3345
+ // concluded) from the EXISTING judge walk: an ordered why-chain band + the decide/bind
3346
+ // text inlined where it explains a conclusion. No emit-contract change — read-only over
3347
+ // the judge_steps already on the verdict file.
3348
+ var KORD={gather:0,context:1,examine:2,detect:3,bind:4,ground:5,critique:6,decide:7,verify:8};
3349
+ var allJs=(d.judgeSteps||[]).slice();
3350
+ var stepText=function(kind){return allJs.filter(function(s){return s.kind===kind;}).map(function(s){return s.text||'';}).filter(Boolean).join(' · ');};
3351
+ var decideWhy=stepText('decide')||stepText('critique');
3352
+ var stateWhy=[stepText('bind'),stepText('gather')].filter(Boolean).join(' · ');
3353
+ var chainSteps=allJs.slice().sort(function(a,b){var x=KORD[a.kind];var y=KORD[b.kind];return (x==null?9:x)-(y==null?9:y);});
3354
+ var whyChain=chainSteps.length?('<div class="ctx whychain"><div class="ctx-h">◇ judge reasoning — full why-chain (gather → bind → ground → decide)</div><div class="wc-body">'+chainSteps.map(function(s){var rs=refStr(s.ref);return '<div class="jstep"><span class="k '+esc(s.kind)+'">'+esc(s.kind)+'</span><span class="t">'+esc(s.text||'')+(rs?' <span class=ref>'+esc(rs)+'</span>':'')+'</span></div>';}).join('')+'</div></div>'):'';
3355
+ var verdictWhy=decideWhy?('<div class="band whyverdict"><div class="bh" style="color:var(--primary-soft)">◇ why this verdict</div><div class="wv-t">'+esc(decideWhy)+'</div></div>'):'';
3356
+ // -- §2 judge lane = per-step EVAL COVERAGE --
3357
+ // The judge lane (.step-r) used to filter judgeSteps by anchor === a.n -- but
3358
+ // judges never emit an anchor, so EVERY step rendered a bare dash. Instead we
3359
+ // map each agent step -> the per-criterion verdicts (d.criterionVerdicts) whose
3360
+ // grounding refs EXAMINED that step: a precise ref.obs === step.obs match plus
3361
+ // the tool-name fallback (ref.path === 'name' && ref.value === step.tool).
3362
+ // Each examining criterion renders one compact entry -- result + CODE/JUDGE tag
3363
+ // + criterionId + the judge reasoning (critique). A step no criterion references
3364
+ // says 'not examined by any eval' (honest), never a bare dash. Any judge step
3365
+ // that DOES carry a real anchor is still honored (future-proof).
3366
+ var cvAll=(d.criterionVerdicts||[]);
3367
+ var refExaminesStep=function(rf,a){
3368
+ if(!rf||typeof rf==='string')return false;
3369
+ if(a.obs&&rf.obs&&String(rf.obs)===String(a.obs))return true;
3370
+ if(rf.path==='name'&&rf.value!=null&&a.tool&&String(rf.value)===String(a.tool))return true;
3371
+ return false;};
3372
+ var covEntry=function(v,a){
3373
+ var res=v.result||'na';var disp=res==='uncertain'?'indeterminate':res;
3374
+ // the router carries TWO vocabularies: matrix-derived ('deterministic') and
3375
+ // mined ('code-based'); 'hybrid' is shared. A '[code-eval ...]'-prefixed critique
3376
+ // is the deterministic-logic fallback when the method is unset.
3377
+ var method=((typeof C!=='undefined'&&C[v.criterionId])||{}).m||'';
3378
+ var isCode=method==='deterministic'||method==='code-based'||method==='hybrid'||String(v.critique||'').trim().indexOf('[code-eval')===0;
3379
+ var tag=method==='hybrid'?'HYBRID':(isCode?'CODE':'JUDGE');
3380
+ var matched=(v.refs||[]).filter(function(rf){return typeof rf!=='string'&&refExaminesStep(rf,a);});
3381
+ var crit=v.critique?esc(v.critique):'<span class="dim">— no critique recorded</span>';
3382
+ return '<div class="jcov '+esc(res)+'"><div class="jcov-h"><span class="cvb '+esc(res)+'">'+esc(disp)+'</span><span class="jm '+(isCode?'code':'judge')+'" title="'+(isCode?'deterministically checked by a code-eval (not LLM-judged)':'reasoned by the LLM judge')+'">'+esc(tag)+'</span><span class="jcid">'+esc(v.criterionId||'')+'</span></div><div class="jcrit">'+crit+'</div>'+cvRefs(matched)+'</div>';};
3383
+ var rowsHtml='';(d.agentSteps||[]).forEach(function(a){
3384
+ // future-proof: a judge step that carries a REAL anchor still renders.
3385
+ var js=(d.judgeSteps||[]).filter(function(s){return s.anchor!=null&&String(s.anchor)===String(a.n)&&s.kind!=='context';});
3386
+ var anchoredHtml=js.map(function(s){var rs=refStr(s.ref);return '<div class="jstep"><span class="k '+esc(s.kind)+'">'+esc(s.kind)+'</span><span class="t">'+esc(s.text||'')+' '+(rs?'<span class=ref>'+esc(rs)+'</span>':'')+'</span></div>';}).join('');
3387
+ var examiners=cvAll.filter(function(v){return (v.refs||[]).some(function(rf){return refExaminesStep(rf,a);});});
3388
+ var covHtml=examiners.map(function(v){return covEntry(v,a);}).join('');
3389
+ var jhtml=anchoredHtml+covHtml;
3390
+ if(!jhtml)jhtml='<div class="jstep noexam"><span class="t">— not examined by any eval</span></div>';
3391
+ rowsHtml+='<div class="step-l"><div class="evb"><div class="top"><span class="tool">'+esc(a.tool||'')+'</span><span class="st '+esc(a.status||'')+'">'+esc(a.status||'')+'</span></div><div class="det">'+esc(a.detail||'')+'</div></div></div><div class="node '+esc(a.status||'')+'"><div class="ln"></div><div class="n">'+esc(a.n)+'</div><div class="ln"></div></div><div class="step-r"><div class="evb r">'+jhtml+'</div></div>';});
3392
+ var h=d.health||{};
3393
+ var sp=d.subjectProfile;
3394
+ var spHtml=sp?'<div class="ctx"><div class="ctx-h">◇ judge · subject profile (M1) · '+esc(sp.provenance||'')+'</div><div class="ctx-g"><div class="ctx-c"><div class="l">identity</div><div class="v">'+esc(sp.identity||'—')+'</div></div><div class="ctx-c"><div class="l">purpose</div><div class="v">'+esc(sp.purpose||'—')+'</div></div><div class="ctx-c"><div class="l">scope · harness</div><div class="v">'+esc((sp.scope||'—'))+' · harness: '+esc(sp.harness||'—')+'</div></div></div></div>':'';
3395
+ var u=d.understanding;
3396
+ var uHtml=u?'<div class="band"><div class="bh" style="color:var(--cyan)">◇ node-0 GATHER — understanding (M2)</div><div class="re-rephrase" style="margin-top:4px">“'+esc(u.rephrase||'')+'”</div></div>':'';
3397
+ var et=d.expectedTrajectory||[];
3398
+ var etHtml=et.length?'<div class="band"><div class="bh" style="color:var(--primary-soft)">◇ node-0.5 EXPECTED-TRAJECTORY (M3) — how it SHOULD have acted</div><ol class="re-ul" style="margin-top:4px">'+et.map(function(s,i){return '<li><b>'+esc(s.step||i+1)+'.</b> '+esc(s.expected||'')+(s.rationale?' <span class=re-rat>— '+esc(s.rationale)+'</span>':'')+'</li>';}).join('')+'</ol></div>':'';
3399
+ return '<div class="drillbox"><div style="display:flex;gap:9px;align-items:center;flex-wrap:wrap;margin-bottom:6px"><b class="mono">'+esc(d.traceId)+'</b><span class="chip">'+esc(d.route||'all')+'</span><span class="verd '+(d.verdict=='FAIL'?'fail':d.verdict=='PASS'?'pass':'inc')+'">'+esc(d.verdict)+'</span></div>'+
3400
+ resRouting(d.res||'judge-walk')+
3401
+ spHtml+
3402
+ verdictWhy+
3403
+ critiqueBlock(d.criterionVerdicts)+
3404
+ '<div class="ctx"><div class="ctx-h">◇ judge · gather context</div><div class="ctx-g"><div class="ctx-c"><div class="l">harness</div><div class="v">'+esc(ctx.harness||'—')+'</div></div><div class="ctx-c"><div class="l">input + scenario</div><div class="v">'+inputCell(d.input,ctx.scenario)+'</div></div><div class="ctx-c"><div class="l">exit states</div><div class="v">'+esc(ctx.exitStates||'—')+(stateWhy?'<div class="why-note"><span class="ref">why concluded:</span> '+esc(stateWhy)+'</div>':'')+'</div></div></div></div>'+
3405
+ whyChain+
3406
+ uHtml+etHtml+
3407
+ '<div class="lanehdr"><div class="a">target agent — what it did</div><div class="x">step</div><div class="j">judge — eval coverage (which criteria examined this step)</div></div>'+
3408
+ '<div class="grid2">'+rowsHtml+'<div class="band loc"><div class="bh">↯ localize (root, not symptom)</div><div style="font-size:var(--fs-sm);margin-top:4px">'+esc(d.localize||'—')+'</div></div></div>'+
3409
+ '<div class="health"><div class="hc"><div class="l">context</div><div class="v good">'+(h.contextGathered?'✓':'—')+'</div></div><div class="hc"><div class="l">grounded</div><div class="v good">'+esc(h.grounded||0)+'</div></div><div class="hc"><div class="l">assumed</div><div class="v '+((h.assumed||0)>0?'warn':'good')+'">'+esc(h.assumed||0)+'</div></div><div class="hc"><div class="l">root vs symptom</div><div class="v good">'+(h.stoppedAtSymptom?'symptom':'✓ root')+'</div></div></div></div>';}
3410
+ function scorecard(r){var order={fail:0,uncertain:1,indeterminate:1,pass:2,na:3};
3411
+ var cells=Object.keys(r.c).sort(function(a,b){return (order[r.c[a]]||9)-(order[r.c[b]]||9);}).map(function(k){var v=r.c[k];var disp=v=='uncertain'?'indeterminate':v;
3412
+ return '<div class="scc '+esc(v)+'"><span class="nm" title="'+esc((C[k]||{}).n||k)+'">'+esc(k)+'</span><span style="margin-left:auto;color:var(--muted)">'+esc(disp)+'</span></div>';}).join('');
3413
+ return '<div class="drillbox"><div style="display:flex;gap:9px;align-items:center;margin-bottom:6px"><b class="mono">'+esc(r.t)+'</b><span class="chip">'+esc(r.r||'all')+'</span><span class="verd '+(r.v=='PASS'?'pass':r.v=='FAIL'?'fail':'inc')+'">'+esc(r.v)+'</span></div>'+
3414
+ resRouting(r.res||'judged-walk-not-captured')+
3415
+ (r.root?'<div class="note" style="margin:0 0 8px"><span class="tag">★ ROOT</span>&nbsp;'+esc(r.root)+'</div>':'')+
3416
+ (r.g?'<div class="mono" style="font-size:var(--fs-xs);color:var(--muted);margin-bottom:4px">grounding: '+esc(r.g)+'</div>':'')+
3417
+ '<div style="font-family:var(--mono);font-size:var(--fs-2xs);color:var(--muted);text-transform:uppercase;margin:6px 0">per-trajectory scorecard — '+Object.keys(r.c).length+' criteria</div>'+
3418
+ '<div class="scgrid">'+cells+'</div>'+
3419
+ // WS-1 — no-walk traces still surface their judge reasoning (per-criterion
3420
+ // critique + refs) here, so a trace WITHOUT a judge_steps walk is never an
3421
+ // empty "How the Judge Reasoned" panel when its verdict file carries verdicts.
3422
+ critiqueBlock(r.cv)+'</div>';}
3423
+ function drill(id,el){document.querySelectorAll('table.ledger tr').forEach(function(t){t.classList.remove('sel');});if(el)el.classList.add('sel');
3424
+ var box=document.getElementById('drill');if(!box)return;
3425
+ var lrow=L.filter(function(x){return x.t==id;})[0];
3426
+ if(J[id]){var d=J[id];
3427
+ // WS-1 — graft the per-criterion verdicts (carried on the ledger row for EVERY
3428
+ // trace) onto the walk object so the side-by-side drill also shows the per-
3429
+ // criterion critique + refs, not just the step-anchored judge walk.
3430
+ if(lrow&&(!d.criterionVerdicts||!d.criterionVerdicts.length))d.criterionVerdicts=lrow.cv||[];
3431
+ if(lrow&&!d.res)d.res=lrow.res;
3432
+ box.innerHTML=sideBySide(d);}
3433
+ else if(lrow){box.innerHTML=scorecard(lrow);}
3434
+ box.scrollIntoView({behavior:'smooth',block:'nearest'});}
3435
+ function wireFilters(){document.querySelectorAll('.lfilter b').forEach(function(b){b.addEventListener('click',function(){F=b.getAttribute('data-flt');document.querySelectorAll('.lfilter b').forEach(function(x){x.classList.remove('on');});b.classList.add('on');render();});});
3436
+ var q=document.getElementById('q');if(q)q.addEventListener('input',render);}
3437
+ function wireToggles(){
3438
+ // UI-8 — calibrate tags are MUTUALLY EXCLUSIVE within a group (radio semantics):
3439
+ // selecting one clears the others in the same [data-calgroup]; clicking the active
3440
+ // one again clears it. The agree/revise/refute alignment row stays free-toggle.
3441
+ document.querySelectorAll('.calgroup').forEach(function(g){var btns=[].slice.call(g.querySelectorAll('b'));
3442
+ btns.forEach(function(b){b.addEventListener('click',function(){var wasOn=b.classList.contains('on');
3443
+ btns.forEach(function(x){x.classList.remove('on');});if(!wasOn)b.classList.add('on');});});});
3444
+ document.querySelectorAll('.areview b').forEach(function(b){b.addEventListener('click',function(){b.classList.toggle('on');});});
3445
+ // WS-3 — §5 self-eval HITL picks (5.4 keep/revise/retire · 5.8 agree/disagree):
3446
+ // mutually-exclusive within a [data-vgroup]; click the active one again to clear.
3447
+ var seGroups={};document.querySelectorAll('.se-pick').forEach(function(p){var g=p.getAttribute('data-vgroup');(seGroups[g]=seGroups[g]||[]).push(p);});
3448
+ Object.keys(seGroups).forEach(function(g){var picks=seGroups[g];picks.forEach(function(p){p.addEventListener('click',function(){var wasOn=p.classList.contains('on');picks.forEach(function(x){x.classList.remove('on');});if(!wasOn)p.classList.add('on');});});});
3449
+ document.querySelectorAll('.lnk[data-drill]').forEach(function(l){l.addEventListener('click',function(){var t2=document.querySelector('nav.tabs button[data-tab="t2"]');if(t2)t2.click();setTimeout(function(){drill(l.getAttribute('data-drill'));},60);});});
3450
+ // WS-4 — wire EVERY copy-as-markdown button (the §5 full-handover button, each
3451
+ // per-routed-item button, and any other): each copies its OWN data-md payload and
3452
+ // flashes a "copied ✓" confirmation. Was querySelector (first-only) → querySelectorAll.
3453
+ // Restricted clipboard contexts (headless / some file://) REJECT writeText — the
3454
+ // rejection AND any synchronous throw are CAUGHT so a denied clipboard NEVER raises an
3455
+ // uncaught page error; it just shows a "copy failed" hint and restores. ZERO errors.
3456
+ document.querySelectorAll('.copy-md').forEach(function(cp){cp.addEventListener('click',function(){
3457
+ var md=cp.getAttribute('data-md')||'';
3458
+ var prev=cp.textContent;
3459
+ var done=function(ok){cp.textContent=ok?'copied ✓':'copy failed';cp.classList.add(ok?'copied':'copyfail');
3460
+ setTimeout(function(){cp.textContent=prev;cp.classList.remove('copied');cp.classList.remove('copyfail');},1200);};
3461
+ try{
3462
+ if(navigator.clipboard&&navigator.clipboard.writeText){
3463
+ navigator.clipboard.writeText(md).then(function(){done(true);},function(){done(false);});
3464
+ }else{done(false);}
3465
+ }catch(e){done(false);}
3466
+ });});}
3467
+ wireTabs();wireFilters();wireToggles();render();
3468
+ `;
3469
+ }
3470
+
3471
+ // ── the full 5-tab report ────────────────────────────────────────────────────
3472
+
3473
+ /**
3474
+ * Render the 5-tab HTML eval-report (the operator-APPROVED component spec). Pure
3475
+ * string assembly; the only I/O is reading the bundled brand assets. The injected
3476
+ * `generatedAt` is the only non-deterministic input → masked-byte-identical.
3477
+ */
3478
+ export function renderEvalReport(input: EvalReportInput): string {
3479
+ const theme = readFileSync(join(BRAND_DIR, "theme.css"), "utf8");
3480
+ const wordmark = readFileSync(join(BRAND_DIR, "wordmark.html"), "utf8");
3481
+
3482
+ const headerTitle = "evaluator · Eval Report";
3483
+ const headerMeta =
3484
+ `<span class="mk">subject</span> <span class="mv">${esc(input.subject.name)}</span>` +
3485
+ `<span class="sep">·</span><span class="mk">generated</span> <span class="mv">${esc(input.generatedAt)}</span>`;
3486
+ const header = wordmark.replaceAll("{{HEADER_TITLE}}", esc(headerTitle)).replaceAll("{{HEADER_META}}", headerMeta);
3487
+
3488
+ // deterministic embedded data for the §2 ledger + drill
3489
+ const ledger = input.ledger ?? [];
3490
+ const rootByTrace = new Map((input.topFindings ?? []).filter((tf) => tf.exampleTraceId).map((tf) => [tf.exampleTraceId as string, tf.root ?? ""]));
3491
+ const compactLedger = ledger.map((r) => ({
3492
+ t: r.trajectoryId,
3493
+ r: r.route ?? "all",
3494
+ v: r.verdict,
3495
+ c: r.perCriterion,
3496
+ f: r.failingCriteria,
3497
+ root: rootByTrace.get(r.trajectoryId) ?? "",
3498
+ g: r.grounding ?? "",
3499
+ js: r.judgeSteps && r.judgeSteps.length > 0 ? 1 : 0,
3500
+ // WS-2 — how this trajectory resolved (judge-walk · judged-walk-not-captured ·
3501
+ // truncated) → the ledger resolution badge + the drill routing line.
3502
+ res: r.resolution ?? "",
3503
+ // WS-1 — the per-criterion JUDGE verdicts (result · critique · refs) for THIS
3504
+ // trace. Carried on EVERY ledger row so the §2 drill renders "How the Judge
3505
+ // Reasoned" for every judged trace — the walk traces graft it onto the side-by-
3506
+ // side; the no-walk traces render it under the per-trajectory scorecard. Empty []
3507
+ // only when the verdict file genuinely carried no per-criterion verdict.
3508
+ cv: r.criterionVerdicts ?? [],
3509
+ }));
3510
+ // `m` = the code-vs-judge router (`checkMethod`) so the §2 judge lane can tag a
3511
+ // step's eval-coverage entry CODE (deterministic) / HYBRID / JUDGE (llm).
3512
+ const critMap: Record<string, { n: string; s: string; m: string }> = {};
3513
+ for (const c of input.criteria) critMap[c.id] = { n: c.statement, s: c.severity, m: c.checkMethod ?? "" };
3514
+ const walks: Record<string, unknown> = {};
3515
+ for (const r of ledger) {
3516
+ if (r.judgeSteps && r.judgeSteps.length > 0) {
3517
+ walks[r.trajectoryId] = {
3518
+ traceId: r.trajectoryId,
3519
+ route: r.route ?? "all",
3520
+ verdict: r.verdict,
3521
+ // Gap A — the raw triggering INPUT (ground-truth from the trace), rendered
3522
+ // ABOVE the judge's scenario LABEL in the §2 drill. "" ⇒ drill shows "—".
3523
+ input: r.input ?? "",
3524
+ context: r.context ?? {},
3525
+ agentSteps: r.agentSteps ?? [],
3526
+ judgeSteps: r.judgeSteps ?? [],
3527
+ localize: r.localize ?? "",
3528
+ health: r.health ?? {},
3529
+ // §9.4.4 v2.2 — node-0 understanding · node-0.5 expected-trajectory · M1 profile.
3530
+ understanding: r.understanding ?? null,
3531
+ expectedTrajectory: r.expectedTrajectory ?? [],
3532
+ subjectProfile: r.subjectProfile ?? input.subjectProfile ?? null,
3533
+ };
3534
+ }
3535
+ }
3536
+ const ledgerJson = JSON.stringify(compactLedger);
3537
+ const critJson = JSON.stringify(critMap);
3538
+ const walksJson = JSON.stringify(walks);
3539
+ // WS-2 — the resolution badge label + routing explanation per class (server-derived,
3540
+ // injected so the client row badge + drill routing line read ONE source of truth).
3541
+ const resMetaJson = JSON.stringify({
3542
+ "judge-walk": resolutionMeta("judge-walk"),
3543
+ "judged-walk-not-captured": resolutionMeta("judged-walk-not-captured"),
3544
+ truncated: resolutionMeta("truncated"),
3545
+ });
3546
+
3547
+ return `<!DOCTYPE html>
3548
+ <html lang="en" data-theme="dark">
3549
+ <head>
3550
+ <meta charset="UTF-8">
3551
+ <meta name="viewport" content="width=device-width,initial-scale=1">
3552
+ <title>${esc(headerTitle)} — ${esc(input.subject.name)}</title>
3553
+ <link rel="preconnect" href="https://fonts.googleapis.com">
3554
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
3555
+ <style>
3556
+ ${theme}
3557
+ ${REPORT_CSS}
3558
+ </style>
3559
+ </head>
3560
+ <body>
3561
+ ${header}
3562
+ <nav class="tabs">
3563
+ <button class="tab-btn active" data-tab="t1">① Overview</button>
3564
+ <button class="tab-btn" data-tab="t2">② Trajectory · Judge Behaviour</button>
3565
+ <button class="tab-btn" data-tab="t3">③ Eval Scorecard</button>
3566
+ <button class="tab-btn" data-tab="t4">④ Findings</button>
3567
+ <button class="tab-btn internal" data-tab="t5">⑤ Self-Eval [INTERNAL]</button>
3568
+ </nav>
3569
+ <main>
3570
+ <section class="panel active" id="t1">${overviewTab(input)}</section>
3571
+ <section class="panel" id="t2">${trajectoryTab(input)}</section>
3572
+ <section class="panel" id="t3">${scorecardTab(input)}</section>
3573
+ <section class="panel" id="t4">${findingsTab(input)}</section>
3574
+ <section class="panel" id="t5">${selfEvalTab(input)}</section>
3575
+ </main>
3576
+ <script data-pii="ledger">
3577
+ ${clientScript(ledgerJson, critJson, walksJson, resMetaJson)}
3578
+ </script>
3579
+ </body>
3580
+ </html>
3581
+ `;
3582
+ }
3583
+
3584
+ // ── WS-1 file-based re-render entry (models discover's writeDiscoverReportFromFiles) ──
3585
+
3586
+ /** Injected I/O for the file-based eval-report entry (keeps the function pure of fs). */
3587
+ export interface EvalReportFilesIO {
3588
+ readFile: (p: string) => string;
3589
+ writeFile: (p: string, s: string) => void;
3590
+ readDir: (p: string) => string[];
3591
+ }
3592
+
3593
+ /** The data-completeness probe the harness reports after a re-render. */
3594
+ export interface EvalReportCompleteness {
3595
+ tracesInVerdicts: number;
3596
+ tracesRenderedWithJudgeSteps: number;
3597
+ /** WS-1 — the honest completeness metric: traces whose drill surfaces ≥1 per-
3598
+ * criterion judge verdict (critique-before-verdict). Should == every JUDGED trace. */
3599
+ tracesRenderedWithCritique: number;
3600
+ /** traces whose verdict file carried renderable judge data (verdicts[] or a walk)
3601
+ * but the drill would render an EMPTY "How the Judge Reasoned" panel. Goal: 0. */
3602
+ emptyUIrows: number;
3603
+ /** WS-6 — char length of the reconstructed subject system prompt (0 ⇒ UNAVAILABLE). */
3604
+ systemPromptChars: number;
3605
+ /** WS-6 — true when the entity hero renders a reconstructed system prompt. */
3606
+ systemPromptReconstructed: boolean;
3607
+ /** WS-4 — number of true-FAIL criteria routed into the §5 EV-051 diagnostics handover. */
3608
+ routedFailures: number;
3609
+ }
3610
+
3611
+ /**
3612
+ * WS-1 — re-render the §2 trajectory report DIRECTLY from on-disk artifacts (the
3613
+ * RICH per-trajectory `verdicts/*.json` + the folded `AGG-RESULT.json` + the
3614
+ * criteria/suite), binding the per-criterion judge reasoning (critique + refs) and
3615
+ * the judge_steps walk (where present) to EVERY evaluated trace. This is the eval
3616
+ * analogue of `writeDiscoverReportFromFiles`: a pure shipped-function call over the
3617
+ * file set, with NO strict-schema gate that would drop the rich verdict files (the
3618
+ * verdict files that carry an object `exitStates` / omit `subjectProfile.tools` —
3619
+ * exactly the ones with the walk — survive here, where production's strict
3620
+ * `parseMatrixVerdictFile` would have rejected them).
3621
+ *
3622
+ * Returns the report path + the data-completeness probe. PURE except the injected I/O.
3623
+ */
3624
+ export function writeEvalReportFromFiles(
3625
+ params: {
3626
+ verdictsDir: string;
3627
+ aggPath: string;
3628
+ criteriaPath: string;
3629
+ outPath: string;
3630
+ subjectName: string;
3631
+ subjectSource?: string;
3632
+ generatedAt: string;
3633
+ /** WS-6 — OPTIONAL trace batch (or a single representative trace) used ONLY to
3634
+ * reconstruct the subject SYSTEM PROMPT for the entity hero. The harness streams
3635
+ * one trace off the raw ndjson and passes it here; the system prompt is identical
3636
+ * across the batch so one trace suffices. ABSENT ⇒ system prompt stays UNAVAILABLE. */
3637
+ traces?: EvalTrace[];
3638
+ },
3639
+ io: EvalReportFilesIO,
3640
+ ): { report: string; completeness: EvalReportCompleteness } {
3641
+ // 1) read the RICH verdict files TOLERANTLY (raw JSON.parse — never the strict
3642
+ // schema gate, which rejects exactly the walk-bearing files). NO trace dropped.
3643
+ const verdictFiles = io
3644
+ .readDir(params.verdictsDir)
3645
+ .filter((f) => f.endsWith(".json"))
3646
+ .map((f) => {
3647
+ try {
3648
+ return JSON.parse(io.readFile(join(params.verdictsDir, f))) as MatrixVerdictFile;
3649
+ } catch {
3650
+ return null;
3651
+ }
3652
+ })
3653
+ .filter((v): v is MatrixVerdictFile => v !== null && typeof (v as { trajectoryId?: unknown }).trajectoryId === "string");
3654
+
3655
+ // 2) the folded scorecard (AGG-RESULT.maskedScorecard = gate + variance) + the
3656
+ // per-criterion folded verdicts (summary.perCriterion), with a representative
3657
+ // critique grafted from a matching per-trajectory verdict (for the gating rolls).
3658
+ const agg = JSON.parse(io.readFile(params.aggPath)) as {
3659
+ summary?: { perCriterion?: Array<{ criterionId: string; folded: string; confidence?: number }> };
3660
+ maskedScorecard?: Scorecard;
3661
+ };
3662
+ const scorecard = (agg.maskedScorecard ?? { gate: { total: 0, passCount: 0, failedCriteria: [], gatedBy: [], indeterminateBy: [], passed: true, runVerdict: "pass" } }) as Scorecard;
3663
+ const repCritique = new Map<string, { critique: string; refs?: unknown[]; traceId: string }>();
3664
+ for (const f of verdictFiles) {
3665
+ for (const v of (f as { verdicts?: Array<{ criterionId: string; result: string; critique?: string; refs?: unknown[] }> }).verdicts ?? []) {
3666
+ if (v.critique && !repCritique.has(`${v.criterionId}:${v.result}`)) {
3667
+ repCritique.set(`${v.criterionId}:${v.result}`, { critique: v.critique, refs: v.refs, traceId: (f as { trajectoryId: string }).trajectoryId });
3668
+ }
3669
+ }
3670
+ }
3671
+ const verdicts: CriterionVerdict[] = (agg.summary?.perCriterion ?? []).map((pc) => {
3672
+ const rep = repCritique.get(`${pc.criterionId}:${pc.folded}`);
3673
+ return {
3674
+ criterionId: pc.criterionId,
3675
+ traceId: rep?.traceId ?? "(suite)",
3676
+ result: pc.folded as CriterionVerdict["result"],
3677
+ confidence: pc.confidence ?? 0,
3678
+ critique: rep?.critique ?? "",
3679
+ ...(rep?.refs ? { refs: rep.refs as CriterionVerdict["refs"] } : {}),
3680
+ };
3681
+ });
3682
+
3683
+ // 3) the criteria (discover/suite shape) → ReportCriterion[].
3684
+ const rawCrit = JSON.parse(io.readFile(params.criteriaPath)) as unknown;
3685
+ const critArr: Array<Record<string, unknown>> = Array.isArray(rawCrit)
3686
+ ? (rawCrit as Array<Record<string, unknown>>)
3687
+ : ((rawCrit as { entries?: Array<Record<string, unknown>> }).entries ?? []);
3688
+ const criteria: ReportCriterion[] = critArr.map((c) => {
3689
+ const meta = (c.metadata ?? {}) as Record<string, unknown>;
3690
+ const severity = String((c.severity as string) ?? (meta.severity as string) ?? "MED");
3691
+ return {
3692
+ id: String(c.id ?? c.criterionId ?? ""),
3693
+ statement: String(c.statement ?? ""),
3694
+ severity,
3695
+ gating: GATING_SEVERITIES.has(severity),
3696
+ ...(meta.dimension !== undefined ? { dimension: String(meta.dimension) } : {}),
3697
+ ...(Array.isArray(c.judgeInputs) ? { judgeInputs: c.judgeInputs as string[] } : {}),
3698
+ checkMethod: String((c.judgeKind as string) ?? (meta.check_method as string) ?? "llm-judge"),
3699
+ provenance: { kind: "defined" as const, label: "defined eval-matrix criterion" },
3700
+ };
3701
+ });
3702
+
3703
+ // 3b) WS-4 — reconstruct the EV-051 diagnostics HANDOVER from the folded gate, using
3704
+ // the SHIPPED `routeFailures` (no hand-rolled routing). Each failed criterion →
3705
+ // a FailureRef enriched with its folded result + a representative critique/trace
3706
+ // (from the verdict files). `routeFailures` then partitions: only TRUE FAILs
3707
+ // (result === "fail") route to diagnostics — uncertain/indeterminate go to the
3708
+ // calibration loop and are excluded — so the §5 handover shows exactly the routed
3709
+ // count, matching the run's `summary.routedFailures`. NEVER invents a failure.
3710
+ const foldedById = new Map((agg.summary?.perCriterion ?? []).map((pc) => [pc.criterionId, pc.folded]));
3711
+ const failedCriteria = (scorecard.gate?.failedCriteria ?? []) as Array<{ criterionId: string; severity: string }>;
3712
+ const failures: FailureRef[] = failedCriteria.map((fc) => {
3713
+ const folded = foldedById.get(fc.criterionId) ?? "fail";
3714
+ const rep = repCritique.get(`${fc.criterionId}:${folded}`) ?? repCritique.get(`${fc.criterionId}:fail`);
3715
+ return {
3716
+ criterionId: fc.criterionId,
3717
+ severity: fc.severity,
3718
+ flag: CriterionFlag.EvalWorthy,
3719
+ traceId: rep?.traceId ?? "(suite)",
3720
+ result: folded as FailureRef["result"],
3721
+ critique: rep?.critique ?? "(no critique recorded)",
3722
+ };
3723
+ });
3724
+ // routeFailures internally filters to true FAILs (partitionRouting); pass them all.
3725
+ const diagnoseFailures = failures.filter((f) => f.result === OutcomeVerdict.Fail);
3726
+ const handover: HandoverBundle | null =
3727
+ diagnoseFailures.length > 0
3728
+ ? routeFailures({
3729
+ subject: { kind: "agent", name: params.subjectName, path: `subjects/${params.subjectName}` },
3730
+ failures: diagnoseFailures,
3731
+ artifacts: [],
3732
+ producedBy: "evaluator",
3733
+ producedAt: params.generatedAt,
3734
+ })
3735
+ : null;
3736
+
3737
+ // 4) build the rich input + render.
3738
+ const input = buildEvalReportInput({
3739
+ subject: { name: params.subjectName, ...(params.subjectSource ? { source: params.subjectSource } : {}) },
3740
+ scorecard,
3741
+ verdicts,
3742
+ criteria,
3743
+ matrixVerdictFiles: verdictFiles,
3744
+ handover,
3745
+ generatedAt: params.generatedAt,
3746
+ // WS-6 — forward the trace(s) so the system prompt is reconstructed for the hero.
3747
+ ...(params.traces !== undefined ? { traces: params.traces } : {}),
3748
+ });
3749
+ io.writeFile(params.outPath, renderEvalReport(input));
3750
+
3751
+ // 5) the data-completeness probe over the BOUND ledger (what the UI will render).
3752
+ const ledger = input.ledger ?? [];
3753
+ const completeness: EvalReportCompleteness = {
3754
+ tracesInVerdicts: verdictFiles.length,
3755
+ tracesRenderedWithJudgeSteps: ledger.filter((r) => (r.judgeSteps?.length ?? 0) > 0).length,
3756
+ tracesRenderedWithCritique: ledger.filter((r) => (r.criterionVerdicts?.length ?? 0) > 0).length,
3757
+ // an empty UI row = the verdict file carried judge data (a walk OR ≥1 per-criterion
3758
+ // verdict) yet the drill would bind NOTHING. With per-criterion critique bound to
3759
+ // every judged row, this is 0 unless a verdict file is genuinely empty.
3760
+ emptyUIrows: ledger.filter(
3761
+ (r) => (r.judgeSteps?.length ?? 0) === 0 && (r.criterionVerdicts?.length ?? 0) === 0,
3762
+ ).length,
3763
+ systemPromptChars: (input.subjectProfile?.systemPrompt ?? "").length,
3764
+ systemPromptReconstructed: (input.subjectProfile?.inferredFields ?? []).includes("systemPrompt"),
3765
+ routedFailures: input.handover?.acceptance.criteria.length ?? 0,
3766
+ };
3767
+ return { report: params.outPath, completeness };
3768
+ }
3769
+
3770
+ // ── auto-open helper (built; fired by the orchestrator, NOT here) ────────────
3771
+
3772
+ /**
3773
+ * The cross-platform command to open a rendered report. PURE — returns the
3774
+ * command string; it NEVER spawns a process (the orchestrator fires it
3775
+ * post-render; a test must never auto-open). macOS `open` · Linux `xdg-open` ·
3776
+ * Windows `start`.
3777
+ */
3778
+ export function autoOpenCommand(platform: string, filePath: string): string {
3779
+ if (platform === "darwin") return `open ${filePath}`;
3780
+ if (platform === "win32") return `cmd /c start "" ${filePath}`;
3781
+ return `xdg-open ${filePath}`;
3782
+ }
3783
+
3784
+ // ── CLI entrypoint ──────────────────────────────────────────────────────────
3785
+ //
3786
+ // bun scripts/render-eval-report.ts <input.json> [out.html] [runId]
3787
+ // Reads an EvalReportInput (the rich, already-built shape), prints the D-1
3788
+ // terminal cards, writes the 5-tab HTML report, and prints the cross-platform
3789
+ // auto-open command (does NOT fire it — the orchestrator opens post-render).
3790
+
3791
+ declare const Bun: { argv: string[] } | undefined;
3792
+
3793
+ async function main(): Promise<void> {
3794
+ const argv = typeof Bun !== "undefined" ? Bun.argv.slice(2) : process.argv.slice(2);
3795
+ const [inputPath, outArg, runIdArg] = argv;
3796
+ if (!inputPath) {
3797
+ console.error("usage: render-eval-report.ts <input.json> [out.html] [runId]");
3798
+ process.exit(2);
3799
+ return;
3800
+ }
3801
+ const { readFileSync: rf, writeFileSync, mkdirSync } = await import("node:fs");
3802
+ const { reportDir } = await import("./artifact-paths.ts");
3803
+ const { join: pjoin } = await import("node:path");
3804
+ const input = JSON.parse(rf(inputPath, "utf8")) as EvalReportInput;
3805
+ // P8: a bare run DEFAULTS the report under the localized dot-root
3806
+ // `.mutagent/evaluator/reports/<runId>/report.html` (never /tmp or .memory).
3807
+ const runId = runIdArg ?? input.subject.name;
3808
+ const outPath = outArg ?? pjoin(reportDir(runId), "report.html");
3809
+ mkdirSync(pjoin(outPath, ".."), { recursive: true });
3810
+ console.info(renderEvalCards(input));
3811
+ writeFileSync(outPath, renderEvalReport(input));
3812
+ const plat = typeof process !== "undefined" ? process.platform : "linux";
3813
+ console.info(`\nreport written: ${outPath}`);
3814
+ console.info(`auto-open (orchestrator fires this): ${autoOpenCommand(plat, outPath)}`);
3815
+ process.exit(0);
3816
+ }
3817
+
3818
+ const isMain =
3819
+ typeof import.meta !== "undefined" &&
3820
+ (import.meta as unknown as { main?: boolean }).main === true;
3821
+ if (isMain) {
3822
+ void main();
3823
+ }