@mutagent/evaluator 0.1.0-alpha.5 → 0.2.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/mutagent-evaluator/SKILL.md +28 -30
- package/.claude/skills/mutagent-evaluator/assets/agents/dataset-builder.md +6 -5
- package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +93 -30
- package/.claude/skills/mutagent-evaluator/assets/code-quality-criteria.yaml +75 -0
- package/.claude/skills/mutagent-evaluator/assets/templates/discover-report.template.html +397 -0
- package/.claude/skills/mutagent-evaluator/assets/templates/eval-report.template.html +594 -0
- package/.claude/skills/mutagent-evaluator/assets/templates/review-report.template.html +560 -0
- package/.claude/skills/mutagent-evaluator/golden/judge-trajectory.prose.md +69 -0
- package/.claude/skills/mutagent-evaluator/lenses/methodology-critic-lens.md +1 -1
- package/.claude/skills/mutagent-evaluator/references/data-registry.md +43 -0
- package/.claude/skills/mutagent-evaluator/references/edd-loop.md +6 -6
- package/.claude/skills/mutagent-evaluator/references/eval-layers.md +52 -0
- package/.claude/skills/mutagent-evaluator/references/eval-stage.md +37 -4
- package/.claude/skills/mutagent-evaluator/references/memory-format.md +1 -1
- package/.claude/skills/mutagent-evaluator/schemas/methodology-review.schema.yaml +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/agent-dispatch.ts +0 -0
- package/.claude/skills/mutagent-evaluator/scripts/aggregate-discover.ts +254 -9
- package/.claude/skills/mutagent-evaluator/scripts/audience.ts +84 -0
- package/.claude/skills/mutagent-evaluator/scripts/build-review-ui.ts +320 -6
- package/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +32 -2
- package/.claude/skills/mutagent-evaluator/scripts/cli/profile-subject.ts +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/code-eval-library.ts +201 -0
- package/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +60 -68
- package/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +3 -3
- package/.claude/skills/mutagent-evaluator/scripts/contracts/agentspec-evals.ts +101 -39
- package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +326 -3
- package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-types.ts +30 -4
- package/.claude/skills/mutagent-evaluator/scripts/determine-outcome.ts +8 -0
- package/.claude/skills/mutagent-evaluator/scripts/edd/change-request.ts +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/edd/edd-types.ts +2 -2
- package/.claude/skills/mutagent-evaluator/scripts/judge-prompt-template.ts +144 -15
- package/.claude/skills/mutagent-evaluator/scripts/materialize-dataset.ts +130 -68
- package/.claude/skills/mutagent-evaluator/scripts/matrix-judge.ts +219 -1
- package/.claude/skills/mutagent-evaluator/scripts/memory/append.ts +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/memory/ratify.ts +165 -0
- package/.claude/skills/mutagent-evaluator/scripts/merge-criteria.ts +849 -0
- package/.claude/skills/mutagent-evaluator/scripts/prep-tasks.ts +15 -4
- package/.claude/skills/mutagent-evaluator/scripts/read-manifest.ts +120 -0
- package/.claude/skills/mutagent-evaluator/scripts/read-unitf-traces.ts +34 -2
- package/.claude/skills/mutagent-evaluator/scripts/render-build-cards.ts +37 -0
- package/.claude/skills/mutagent-evaluator/scripts/render-discover-report-v3.ts +1155 -0
- package/.claude/skills/mutagent-evaluator/scripts/render-eval-report-v3.ts +610 -0
- package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +11 -92
- package/.claude/skills/mutagent-evaluator/scripts/render-review-report-v3.ts +691 -0
- package/.claude/skills/mutagent-evaluator/scripts/report-fragments.ts +173 -0
- package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +12 -9
- package/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +46 -1
- package/.claude/skills/mutagent-evaluator/scripts/run-pipeline.ts +39 -4
- package/.claude/skills/mutagent-evaluator/scripts/run-review.ts +266 -0
- package/.claude/skills/mutagent-evaluator/scripts/subject-profile.ts +82 -0
- package/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +2 -2
- package/.claude/skills/mutagent-evaluator/scripts/unitf-to-evaltrace.ts +64 -21
- package/.claude/skills/mutagent-evaluator/scripts/verify-render.ts +728 -0
- package/bin/mutagent-cli.mjs +9 -5
- package/package.json +2 -2
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render-eval-report-v3 — the W3 FROZEN-CONTRACT eval-report renderer.
|
|
3
|
+
*
|
|
4
|
+
* The template (`assets/templates/eval-report.template.html`) IS the operator-signed
|
|
5
|
+
* mock (eval-report-mock.html), derived mechanically with named data slots — structure,
|
|
6
|
+
* styles, tabs, the two-lane walk, layer/criteria matrices, findings/self-eval cards and
|
|
7
|
+
* the markdown handoff are all the frozen contract verbatim. This module ONLY computes
|
|
8
|
+
* the data: it maps real run artifacts (EvaluateRunInput + MatrixAggregateResult +
|
|
9
|
+
* MatrixVerdictFile[]) into the template's data shape and fills the slots.
|
|
10
|
+
*
|
|
11
|
+
* HONESTY RULES (V13 data-completeness):
|
|
12
|
+
* - nothing is invented — every number/quote comes from a run artifact;
|
|
13
|
+
* - absent data renders as a NAMED absence (e.g. "layer not engaged"), never blank;
|
|
14
|
+
* - `na` cells keep their rationale (naRationale) as cell titles;
|
|
15
|
+
* - caps are declared in-render (e.g. "showing first N"), never silent.
|
|
16
|
+
*
|
|
17
|
+
* The v2 renderer (render-eval-report.ts) is untouched; v1 `*audit` world untouched.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
20
|
+
import { join, dirname } from "node:path";
|
|
21
|
+
import type { MatrixCriterion, MatrixVerdictFile, LayerVerdict } from "./contracts/eval-matrix.ts";
|
|
22
|
+
import { localizeText } from "./contracts/eval-matrix.ts";
|
|
23
|
+
// V11 audience contract — the ONE implementation, shared with the discovery renderer
|
|
24
|
+
// (and inherited by the review surface). See scripts/audience.ts for the full rule.
|
|
25
|
+
import { isExternal, stripInternalSurface } from "./audience.ts";
|
|
26
|
+
import {
|
|
27
|
+
detectLayerConflicts,
|
|
28
|
+
foldEmissionsScorecard,
|
|
29
|
+
foldLayerVerdicts,
|
|
30
|
+
layersEngagedCount,
|
|
31
|
+
type LayerConflict,
|
|
32
|
+
} from "./matrix-judge.ts";
|
|
33
|
+
|
|
34
|
+
const TEMPLATE_PATH = join(import.meta.dir, "../assets/templates/eval-report.template.html");
|
|
35
|
+
|
|
36
|
+
/* ── small pure helpers ────────────────────────────────────────────────────── */
|
|
37
|
+
const esc = (s: unknown): string =>
|
|
38
|
+
String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
39
|
+
const pct = (k: number, n: number): string => (n === 0 ? "—" : `${Math.round((100 * k) / n)}%`);
|
|
40
|
+
const short = (id: string): string => (id.length > 10 ? id.slice(0, 8) : id);
|
|
41
|
+
|
|
42
|
+
/** read/act split for the profile card — same SEED heuristic as the L0 library. */
|
|
43
|
+
function toolClass(name: string): "read" | "act" {
|
|
44
|
+
return /send|dispatch|publish|reply|post|delete|update|write|create|escalat|blacklist|approve/i.test(name)
|
|
45
|
+
? "act"
|
|
46
|
+
: "read";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface TemplateCriterion {
|
|
50
|
+
id: string;
|
|
51
|
+
slug: string;
|
|
52
|
+
layer: string;
|
|
53
|
+
cls: string;
|
|
54
|
+
sev: string;
|
|
55
|
+
grounding: string;
|
|
56
|
+
k: number;
|
|
57
|
+
n: number;
|
|
58
|
+
statement: string;
|
|
59
|
+
passDef: string;
|
|
60
|
+
failDef: string;
|
|
61
|
+
policy: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface RenderV3Input {
|
|
65
|
+
subjectName: string;
|
|
66
|
+
runId: string;
|
|
67
|
+
audience: "internal" | "external";
|
|
68
|
+
criteria: MatrixCriterion[];
|
|
69
|
+
files: MatrixVerdictFile[];
|
|
70
|
+
subjectProfile?: Record<string, unknown>;
|
|
71
|
+
pin: { model: string; temperature: 0 };
|
|
72
|
+
gate: {
|
|
73
|
+
passed: boolean;
|
|
74
|
+
runVerdict: string;
|
|
75
|
+
/** every non-pass FOLD (fails AND uncertains) — display-only, NOT the gate driver. */
|
|
76
|
+
failedCriteria: { criterionId: string; severity?: string }[];
|
|
77
|
+
/** the DECIDED fails that fire the gate. */
|
|
78
|
+
gatedBy?: { criterionId: string; severity?: string }[];
|
|
79
|
+
/** CRIT/HIGH uncertains that roll the run INCOMPLETE-wards. */
|
|
80
|
+
indeterminateBy?: { criterionId: string; severity?: string }[];
|
|
81
|
+
};
|
|
82
|
+
groundedPct?: number;
|
|
83
|
+
independentVerify: { criterionId: string; upheld: boolean; reason: string }[];
|
|
84
|
+
/** optional MR-2 note (e.g. the run's determinism evidence) — absent renders as NAMED not-run. */
|
|
85
|
+
determinismNote?: string;
|
|
86
|
+
/** NAMED scope note when the run judged a declared subset (never a silent cap). */
|
|
87
|
+
scopeNote?: string;
|
|
88
|
+
/** the run's INGESTED traces — the walk's left lane binds REAL tool inputs/outputs
|
|
89
|
+
* from these (so evidence refs highlight inside genuine observed strings); absent
|
|
90
|
+
* ⇒ the judge's step details render instead (a NAMED degrade in-lane). */
|
|
91
|
+
traces?: { id: string; observations?: { type?: string; name?: string; input?: unknown; output?: unknown }[] }[];
|
|
92
|
+
/** ISO timestamp of the run — rendered top-right in the header. */
|
|
93
|
+
generatedAt?: string;
|
|
94
|
+
/** DEV ONLY: keep the feedback bar (the operator's dev-loop feedback surface).
|
|
95
|
+
* Default OFF — user-produced reports carry no feedback affordance. */
|
|
96
|
+
devFeedback?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** serialize a real observation value for the walk's left lane (bounded). */
|
|
100
|
+
function laneText(v: unknown, cap = 420): string {
|
|
101
|
+
if (v === null || v === undefined) return "";
|
|
102
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
103
|
+
return s.length > cap ? `${s.slice(0, cap)} … [+${s.length - cap} chars]` : s;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ── data mapping: real artifacts → the template's ASTER shape ─────────────── */
|
|
107
|
+
function mapCriteria(input: RenderV3Input): { list: TemplateCriterion[]; byCid: Map<string, TemplateCriterion> } {
|
|
108
|
+
const byCid = new Map<string, TemplateCriterion>();
|
|
109
|
+
const list = input.criteria.map((c, i) => {
|
|
110
|
+
const cells = input.files.map((f) => cellOf(f, c.criterionId));
|
|
111
|
+
const applicable = cells.filter((x) => x !== "skip");
|
|
112
|
+
const k = applicable.filter((x) => x === "fail").length;
|
|
113
|
+
const t: TemplateCriterion = {
|
|
114
|
+
id: `C${i + 1}`,
|
|
115
|
+
slug: c.criterionId,
|
|
116
|
+
layer: (c as { layer?: string }).layer ?? "criteria",
|
|
117
|
+
cls: c.checkMethod === "deterministic" ? "code" : c.checkMethod === "hybrid" ? "hybrid" : "judge",
|
|
118
|
+
sev: String(c.severity),
|
|
119
|
+
grounding: "observed",
|
|
120
|
+
k,
|
|
121
|
+
n: applicable.length,
|
|
122
|
+
statement: c.statement,
|
|
123
|
+
passDef: c.passCondition,
|
|
124
|
+
failDef: (c as { failCondition?: string }).failCondition ?? "",
|
|
125
|
+
policy: `${c.statement} Pass condition: ${c.passCondition}`,
|
|
126
|
+
};
|
|
127
|
+
byCid.set(c.criterionId, t);
|
|
128
|
+
return t;
|
|
129
|
+
});
|
|
130
|
+
return { list, byCid };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function cellOf(f: MatrixVerdictFile, cid: string): "pass" | "fail" | "uncertain" | "skip" {
|
|
134
|
+
const dm = (f.denseMap ?? {}) as Record<string, string>;
|
|
135
|
+
const v = dm[cid] ?? f.verdicts.find((x) => x.criterionId === cid)?.result;
|
|
136
|
+
if (v === "pass" || v === "fail" || v === "uncertain") return v;
|
|
137
|
+
return "skip"; // na / absent → visually skip; rationale carried via title (naRationale)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function trajGate(f: MatrixVerdictFile, sevOf: Map<string, string>): "pass" | "fail" | "incomplete" {
|
|
141
|
+
if (f.fidelity?.complete === false) return "incomplete";
|
|
142
|
+
const gating = (r: string, cid: string): boolean => ["CRIT", "HIGH"].includes(sevOf.get(cid) ?? "");
|
|
143
|
+
if (f.verdicts.some((v) => v.result === "fail" && gating(v.result, v.criterionId))) return "fail";
|
|
144
|
+
if (f.verdicts.some((v) => v.result === "uncertain" && gating(v.result, v.criterionId))) return "incomplete";
|
|
145
|
+
return f.verdicts.some((v) => v.result === "fail") ? "fail" : "pass";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function mapTrace(
|
|
149
|
+
f: MatrixVerdictFile,
|
|
150
|
+
byCid: Map<string, TemplateCriterion>,
|
|
151
|
+
sevOf: Map<string, string>,
|
|
152
|
+
conflicts: LayerConflict[],
|
|
153
|
+
trace?: { observations?: { type?: string; name?: string; input?: unknown; output?: unknown }[] },
|
|
154
|
+
): Record<string, unknown> {
|
|
155
|
+
const lvs = (f.layerVerdicts ?? []) as LayerVerdict[];
|
|
156
|
+
const layer = (L: string): LayerVerdict | undefined => lvs.find((x) => x.layer === L);
|
|
157
|
+
const layers: Record<string, string> = {};
|
|
158
|
+
for (const L of ["L0", "L1", "L2", "L3", "L4"]) {
|
|
159
|
+
const v = layer(L)?.verdict;
|
|
160
|
+
layers[L] = v === "skipped" ? "skip" : (v ?? "skip");
|
|
161
|
+
}
|
|
162
|
+
const l1 = layer("L1");
|
|
163
|
+
// REAL tool observations (trace ground truth) — chronological. The trace's
|
|
164
|
+
// observations array is reverse-chronological in this intake; reverse it.
|
|
165
|
+
const toolObs = (trace?.observations ?? []).filter((o) => o.type === "TOOL").reverse();
|
|
166
|
+
const agentSteps = f.agentSteps ?? [];
|
|
167
|
+
const nRaw = Math.max(toolObs.length, agentSteps.length);
|
|
168
|
+
const steps: { tool: string; args: string; output: string; layers: string[]; verdicts: unknown[]; divergence: boolean }[] = [];
|
|
169
|
+
for (let i = 0; i < nRaw; i++) {
|
|
170
|
+
const o = toolObs[i];
|
|
171
|
+
const s = agentSteps[i];
|
|
172
|
+
steps.push(
|
|
173
|
+
o !== undefined
|
|
174
|
+
? {
|
|
175
|
+
// ground truth from the trace; the judge's status/detail annotates.
|
|
176
|
+
tool: o.name ?? s?.tool ?? "(unnamed tool)",
|
|
177
|
+
args: laneText(o.input, 160) + (s?.status !== undefined ? ` [${s.status}]` : ""),
|
|
178
|
+
output: laneText(o.output),
|
|
179
|
+
layers: [],
|
|
180
|
+
verdicts: [],
|
|
181
|
+
divergence: false,
|
|
182
|
+
}
|
|
183
|
+
: {
|
|
184
|
+
tool: s?.tool ?? "(no tool call)",
|
|
185
|
+
args: s?.status !== undefined ? `[${s.status}]` : "",
|
|
186
|
+
output: s?.detail ?? "(no detail emitted)",
|
|
187
|
+
layers: [],
|
|
188
|
+
verdicts: [],
|
|
189
|
+
divergence: false,
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (steps.length === 0)
|
|
194
|
+
steps.push({ tool: "(no tool calls in this session)", args: "", output: f.understanding?.rephrase ?? "—", layers: [], verdicts: [], divergence: false });
|
|
195
|
+
|
|
196
|
+
// bind each criterion verdict to the step its FIRST trajectory.N ref cites
|
|
197
|
+
// (packet index → chronological position); unanchored verdicts bind to the
|
|
198
|
+
// terminal step (the decision surface).
|
|
199
|
+
const nSteps = steps.length;
|
|
200
|
+
for (const v of f.verdicts) {
|
|
201
|
+
const tc = byCid.get(v.criterionId);
|
|
202
|
+
let pos = nSteps - 1;
|
|
203
|
+
for (const r of (v.refs ?? []) as { path?: string }[]) {
|
|
204
|
+
const m = /^trajectory[.[](\d+)/.exec(r.path ?? "");
|
|
205
|
+
if (m !== null) {
|
|
206
|
+
const packetIdx = Number.parseInt(m[1]!, 10);
|
|
207
|
+
pos = Math.min(Math.max(nSteps - 1 - packetIdx, 0), nSteps - 1);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const ref0 = (v.refs ?? [])[0] as { obs?: string; path?: string; value?: string } | undefined;
|
|
212
|
+
steps[pos]!.verdicts.push({
|
|
213
|
+
crit: tc?.id ?? v.criterionId,
|
|
214
|
+
verdict: v.result,
|
|
215
|
+
conf: v.confidence ?? 0.5,
|
|
216
|
+
critique: v.critique,
|
|
217
|
+
ref: ref0 !== undefined ? { obs: short(ref0.obs ?? ""), path: ref0.path ?? "", value: ref0.value ?? "" } : null,
|
|
218
|
+
});
|
|
219
|
+
if (v.result === "fail") steps[pos]!.divergence = true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const confs = f.verdicts.map((v) => v.confidence).filter((x): x is number => typeof x === "number");
|
|
223
|
+
const conflict = conflicts.find((c) => c.trajectoryId === f.trajectoryId);
|
|
224
|
+
const cells: Record<string, string> = {};
|
|
225
|
+
const naR = ((f as { naRationale?: Record<string, string> }).naRationale ?? {}) as Record<string, string>;
|
|
226
|
+
for (const [cid, tc] of byCid) cells[tc.id] = cellOf(f, cid);
|
|
227
|
+
const blocked = f.verdicts.find((v) => v.blockedBy !== undefined)?.blockedBy as { text?: string } | string | undefined;
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
id: short(f.trajectoryId),
|
|
231
|
+
fullId: f.trajectoryId,
|
|
232
|
+
scenario: l1?.scenario !== undefined ? String((l1.scenario as { intent?: string }).intent ?? l1.scenario) : "—",
|
|
233
|
+
gate: trajGate(f, sevOf),
|
|
234
|
+
earlyExit: f.earlyExit ?? (f.fidelity?.complete === false ? "fidelity" : null),
|
|
235
|
+
layers,
|
|
236
|
+
conflict: conflict !== undefined,
|
|
237
|
+
conflictNote: conflict !== undefined ? `${Object.entries(conflict.verdicts).map(([l, v]) => `${l} ${v}`).join(" ⚡ ")} — ${conflict.note ?? "cross-layer disagreement"}` : undefined,
|
|
238
|
+
judgeConf: confs.length > 0 ? confs.reduce((a, b) => a + b, 0) / confs.length : 0.5,
|
|
239
|
+
outcome: {
|
|
240
|
+
verdict: l1?.verdict ?? "—",
|
|
241
|
+
expected: Array.isArray(l1?.expectedExit) ? (l1!.expectedExit as string[]).join(" | ") : (l1?.expectedExit ?? "—"),
|
|
242
|
+
actual: l1?.note ?? "—",
|
|
243
|
+
},
|
|
244
|
+
understanding: f.understanding?.rephrase,
|
|
245
|
+
expectedLine: (f.expectedTrajectory ?? []).slice(0, 4).map((e) => (e as { expected: string }).expected).join(" → ") || undefined,
|
|
246
|
+
divergence: f.localize !== undefined ? localizeText(f.localize as never) : undefined,
|
|
247
|
+
gapKind: layer("L4")?.gapKind,
|
|
248
|
+
codeEvalHits: (f.codeEvalHits ?? []).map((h) => `${h.pattern} @ ${h.anchor}`),
|
|
249
|
+
fidelity: f.fidelity?.complete === false ? (f.fidelity.reason ?? "trace incomplete") : undefined,
|
|
250
|
+
blockedBy: typeof blocked === "string" ? blocked : blocked?.text,
|
|
251
|
+
cells,
|
|
252
|
+
naRationale: naR,
|
|
253
|
+
steps,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* ── HTML islands (server-rendered slots) ──────────────────────────────────── */
|
|
258
|
+
function metricsTiles(input: RenderV3Input, traces: Record<string, unknown>[], crits: TemplateCriterion[]): string {
|
|
259
|
+
const gateCls = input.gate.passed ? "ok" : input.gate.runVerdict === "incomplete" ? "mid" : "bad";
|
|
260
|
+
const gateTxt = input.gate.passed ? "PASS" : input.gate.runVerdict.toUpperCase();
|
|
261
|
+
const gatedBy = input.gate.gatedBy ?? input.gate.failedCriteria;
|
|
262
|
+
const indet = input.gate.indeterminateBy ?? [];
|
|
263
|
+
const failed =
|
|
264
|
+
(gatedBy.map((f) => f.criterionId).join(" + ") || "none") +
|
|
265
|
+
(indet.length > 0 ? ` (+${indet.length} CRIT/HIGH uncertain)` : "");
|
|
266
|
+
const critPass = crits.filter((c) => c.k === 0 && c.n > 0).length;
|
|
267
|
+
const tPass = traces.filter((t) => t["gate"] === "pass").length;
|
|
268
|
+
const tInc = traces.filter((t) => t["gate"] === "incomplete").length;
|
|
269
|
+
const tFail = traces.filter((t) => t["gate"] === "fail").length;
|
|
270
|
+
const grounded = input.groundedPct !== undefined ? `${Math.round(input.groundedPct)}%` : "—";
|
|
271
|
+
return [
|
|
272
|
+
`<div class="scm ${gateCls}"><div class="scm-v">${gateTxt}</div><div class="scm-l">release gate</div><div class="scm-n">${esc(failed)} ${input.gate.passed ? "" : "failed"}</div></div>`,
|
|
273
|
+
`<div class="scm ${critPass === crits.length ? "ok" : "bad"}"><div class="scm-v">${pct(critPass, crits.length)}</div><div class="scm-l">criteria pass-rate</div><div class="scm-n">${critPass} of ${crits.length} criteria pass across the corpus</div></div>`,
|
|
274
|
+
`<div class="scm ${tFail === 0 ? "ok" : "mid"}"><div class="scm-v">${pct(tPass, traces.length)}</div><div class="scm-l">trajectory pass-rate</div><div class="scm-n">${tPass} of ${traces.length} trajectories pass</div></div>`,
|
|
275
|
+
`<div class="scm ${tInc === 0 ? "ok" : "mid"}"><div class="scm-v">${tInc}</div><div class="scm-l">incomplete / needs evidence</div><div class="scm-n">${tInc === 0 ? "every trajectory fully judged" : `${tInc} trajectory(ies) abstained or truncated`}</div></div>`,
|
|
276
|
+
`<div class="scm ${input.groundedPct === 100 ? "ok" : "mid"}"><div class="scm-v">${grounded}</div><div class="scm-l">grounded</div><div class="scm-n">decided verdicts citing re-resolvable evidence</div></div>`,
|
|
277
|
+
].join("\n ");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function identityStrip(input: RenderV3Input, traces: Record<string, unknown>[]): string {
|
|
281
|
+
const gate = input.gate.passed
|
|
282
|
+
? '<span class="verd pass" style="margin-left:auto">GATE ✓ PASS</span>'
|
|
283
|
+
: input.gate.runVerdict === "incomplete"
|
|
284
|
+
? '<span class="verd inc" style="margin-left:auto">GATE ◐ INCOMPLETE</span>'
|
|
285
|
+
: '<span class="verd fail" style="margin-left:auto">GATE ✗ FAIL</span>';
|
|
286
|
+
const scen = new Set(traces.map((t) => String(t["scenario"]))).size;
|
|
287
|
+
const pchip = 'class="chip" style="color:var(--primarySoft);border-color:rgba(126,71,215,.6)"';
|
|
288
|
+
// external: no run id, no pinned judge-model string (run-internal operational detail).
|
|
289
|
+
const runChip = isExternal(input) ? "" : `<span ${pchip}>run ${esc(input.runId)}</span>`;
|
|
290
|
+
const judgeChip = isExternal(input)
|
|
291
|
+
? `<span ${pchip}>judge: pinned · temp 0</span>` // the DETERMINISM claim survives; the model id does not
|
|
292
|
+
: `<span ${pchip}>judge: ${esc(input.pin.model)} · temp 0</span>`;
|
|
293
|
+
return `<b style="font-size:15px">${esc(input.subjectName)}</b>
|
|
294
|
+
${runChip}<span ${pchip}>${traces.length} trajectories · ${scen} scenario kinds</span>
|
|
295
|
+
${judgeChip}
|
|
296
|
+
${gate}`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function criterionDetails(input: RenderV3Input, crits: TemplateCriterion[], files: MatrixVerdictFile[], byCid: Map<string, TemplateCriterion>): string {
|
|
300
|
+
// gate-RELEVANT criteria get a detail card: decided fails AND CRIT/HIGH
|
|
301
|
+
// uncertains (which roll the run INCOMPLETE-wards). The lede says exactly
|
|
302
|
+
// why these and not the others — passes live in the matrix above.
|
|
303
|
+
const hasHighUncertain = (c: TemplateCriterion): boolean =>
|
|
304
|
+
["CRIT", "HIGH"].includes(c.sev) &&
|
|
305
|
+
files.some((f) => f.verdicts.some((v) => byCid.get(v.criterionId)?.id === c.id && v.result === "uncertain"));
|
|
306
|
+
const relevant = crits.filter((c) => c.k > 0 || hasHighUncertain(c));
|
|
307
|
+
const head = `<div class="h4s">Criterion detail — the gate-relevant checks</div>
|
|
308
|
+
<p style="font-size:13.5px;color:var(--muted);margin:2px 0 8px;max-width:86ch">Only criteria that MOVED the gate get a detail card here: a <b style="color:var(--fail)">decided fail</b> fires the gate directly; a <b style="color:var(--warn)">CRIT/HIGH uncertain</b> (evidence could not decide) rolls the run INCOMPLETE-wards. Criteria that passed everywhere are already summarized — with scores — in the matrix above.</p>`;
|
|
309
|
+
if (relevant.length === 0)
|
|
310
|
+
return `${head}
|
|
311
|
+
<div class="subc"><div class="nest" style="margin:10px">No criterion moved the gate on this corpus — nothing to detail.</div></div>`;
|
|
312
|
+
const cidOf = (tid: string): string => short(tid);
|
|
313
|
+
return (
|
|
314
|
+
head +
|
|
315
|
+
relevant
|
|
316
|
+
.map((c) => {
|
|
317
|
+
const evid: string[] = [];
|
|
318
|
+
const verd: string[] = [];
|
|
319
|
+
let passCount = 0;
|
|
320
|
+
for (const f of files) {
|
|
321
|
+
for (const v of f.verdicts) {
|
|
322
|
+
if (byCid.get(v.criterionId)?.id !== c.id) continue;
|
|
323
|
+
if (v.result === "pass") { passCount++; continue; }
|
|
324
|
+
const conf = v.confidence !== undefined ? ` (.${Math.round(v.confidence * 100)})` : "";
|
|
325
|
+
verd.push(`${v.result} ${cidOf(f.trajectoryId)}${conf}`);
|
|
326
|
+
const r = (v.refs ?? [])[0] as { obs?: string; path?: string; value?: string } | undefined;
|
|
327
|
+
if (r?.value !== undefined && evid.length < 3)
|
|
328
|
+
evid.push(`${cidOf(f.trajectoryId)} ${esc(r.path)} = "${esc(String(r.value).slice(0, 60))}"`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const kindChip =
|
|
332
|
+
c.k > 0
|
|
333
|
+
? `<span class="chip" style="color:var(--fail);border-color:var(--fail)">decided fail ×${c.k} — fires the gate</span>`
|
|
334
|
+
: `<span class="chip" style="color:var(--warn);border-color:var(--warn)">CRIT/HIGH uncertain — rolls INCOMPLETE-wards</span>`;
|
|
335
|
+
return `<div class="subc" style="margin-top:12px"><div class="subc-h"><span class="sev ${esc(c.sev)}">${esc(c.sev)}</span><b>${esc(c.id)} · ${esc(c.slug)}</b>
|
|
336
|
+
${kindChip}<span class="chip">${esc(c.cls)}</span>
|
|
337
|
+
<span class="chip" style="margin-left:auto">score: ${c.n - c.k}/${c.n} applicable pass</span></div>
|
|
338
|
+
<div class="nest"><div class="nest-h">▾ what it checks</div>${esc(c.statement)} Passes when: ${esc(c.passDef)}.</div>
|
|
339
|
+
<div class="nest"><div class="nest-h">▾ grounding — the evidence behind the verdicts</div>${evid.length > 0 ? `<span class="ok">✓</span> ${evid.join(" · ")} — re-resolved by exact match` : "abstains carry blockedBy instead of refs (na for grounding — the missing premise is typed, not invented)"}</div>
|
|
340
|
+
<div class="nest"><div class="nest-h">▾ verdicts across the corpus</div>${esc(verd.join(" · "))}${passCount > 0 ? ` · pass ×${passCount}` : ""}</div>
|
|
341
|
+
</div>`;
|
|
342
|
+
})
|
|
343
|
+
.join("\n ")
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function conflictsHtml(conflicts: LayerConflict[], files: MatrixVerdictFile[]): string {
|
|
348
|
+
const head = `<div class="h4s">Cross-layer conflicts — for your calibration ruling</div>`;
|
|
349
|
+
if (conflicts.length === 0)
|
|
350
|
+
return `${head}
|
|
351
|
+
<div class="subc"><div class="nest" style="margin:10px">No cross-layer disagreement detected on this corpus (OT-1 detector ran on every walk). When one appears it renders here UNRESOLVED for a human ruling — the gate never reads it.</div></div>`;
|
|
352
|
+
const localizeOf = (trajectoryId: string): { root?: string; conflict?: string; independentRoots?: string[] } | undefined => {
|
|
353
|
+
const f = files.find((x) => x.trajectoryId === trajectoryId);
|
|
354
|
+
const l = (f as { localize?: unknown } | undefined)?.localize;
|
|
355
|
+
return l !== null && typeof l === "object" ? (l as { root?: string; conflict?: string; independentRoots?: string[] }) : undefined;
|
|
356
|
+
};
|
|
357
|
+
return (
|
|
358
|
+
head +
|
|
359
|
+
conflicts
|
|
360
|
+
.map((c) => {
|
|
361
|
+
const loc = localizeOf(c.trajectoryId);
|
|
362
|
+
// the judge's own OT-1 statement is authoritative when it emitted one; the
|
|
363
|
+
// aggregate's cross-check (layer notes) is the fallback. NEVER resolve — both
|
|
364
|
+
// sides render, for a human.
|
|
365
|
+
const statement = loc?.conflict ?? c.note;
|
|
366
|
+
const roots =
|
|
367
|
+
(loc?.independentRoots?.length ?? 0) > 0
|
|
368
|
+
? `<div style="margin-top:7px;color:var(--muted)">Independent root(s) the same judge surfaced alongside this conflict:<br>· ${esc((loc!.independentRoots ?? []).join("<br>· "))}</div>`
|
|
369
|
+
: "";
|
|
370
|
+
return `
|
|
371
|
+
<div class="subc"><div class="subc-h"><span class="confbadge">⚡ CONFLICT</span><b>${esc(short(c.trajectoryId))}</b><span class="chip">${esc(Object.entries(c.verdicts).map(([l, v]) => `${l} ${v}`).join(" vs "))}</span></div>
|
|
372
|
+
<div class="nest">${esc(statement ?? "The layers disagree on this trajectory.")}${loc?.root !== undefined ? `<div style="margin-top:7px"><b>Root, not symptom:</b> ${esc(loc.root)}</div>` : ""}${roots} <div style="margin-top:7px"><b>Neither verdict is auto-resolved</b> — this row exists so a human rules on it in the review UI. The gate is driven by criteria severity only.</div></div>
|
|
373
|
+
</div>`;
|
|
374
|
+
})
|
|
375
|
+
.join("")
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function findingsCards(input: RenderV3Input, crits: TemplateCriterion[], traces: Record<string, unknown>[], files: MatrixVerdictFile[], byCid: Map<string, TemplateCriterion>): { html: string; md: string } {
|
|
380
|
+
const cards: string[] = [];
|
|
381
|
+
const md: string[] = [];
|
|
382
|
+
md.push(`# Findings Handoff — ${input.subjectName}${isExternal(input) ? "" : ` · ${input.runId}`}`);
|
|
383
|
+
md.push(`**From:** evaluator (judge-only, EV-051) · **To:** diagnostics / operator`);
|
|
384
|
+
const mdGatedBy = input.gate.gatedBy ?? input.gate.failedCriteria;
|
|
385
|
+
md.push(`**Gate:** ${input.gate.passed ? "PASS" : input.gate.runVerdict.toUpperCase()}${mdGatedBy.length > 0 ? ` — driven by ${mdGatedBy.map((f) => `${f.criterionId} (${f.severity ?? "?"})`).join(" + ")}` : ""}${(input.gate.indeterminateBy ?? []).length > 0 ? ` · ${input.gate.indeterminateBy!.length} CRIT/HIGH uncertain (abstain, not fail)` : ""}`);
|
|
386
|
+
if (input.scopeNote !== undefined) md.push(`**Scope:** ${input.scopeNote}`);
|
|
387
|
+
md.push("");
|
|
388
|
+
const failCards: string[] = [];
|
|
389
|
+
const derivedCards: string[] = [];
|
|
390
|
+
let fi = 0;
|
|
391
|
+
for (const c of crits.filter((x) => x.k > 0)) {
|
|
392
|
+
fi++;
|
|
393
|
+
const trs = files.filter((f) => f.verdicts.some((v) => byCid.get(v.criterionId)?.id === c.id && v.result === "fail"));
|
|
394
|
+
const links = trs.map((f) => `<span class="tlink" onclick="gotoTrace('${short(f.trajectoryId)}')">${short(f.trajectoryId)} · open walk ▸</span>`).join("");
|
|
395
|
+
const worst = trs[0]?.verdicts.find((v) => byCid.get(v.criterionId)?.id === c.id && v.result === "fail");
|
|
396
|
+
const quote = ((worst?.refs ?? [])[0] as { value?: string } | undefined)?.value;
|
|
397
|
+
failCards.push(`<div class="fcard" data-component="mgt-finding-card"><div class="fcard-h"><span class="sev ${esc(c.sev)}">${esc(c.sev)}</span><b>F${fi} · ${esc(c.slug)}</b><span class="chip">${esc(c.id)}</span><span class="chip">${trs.length} trajectory(ies)</span><span class="chip" style="color:var(--warn);border-color:var(--warn)">→ diagnostics</span></div>
|
|
398
|
+
<div class="fcard-b">${esc(worst?.critique ?? c.statement)}${quote !== undefined ? ` Evidence (exact-match re-resolved): <b>"${esc(String(quote).slice(0, 90))}"</b>.` : ""}
|
|
399
|
+
<div style="margin-top:8px">${links}</div></div></div>`);
|
|
400
|
+
md.push(`## F${fi} — ${c.slug}`);
|
|
401
|
+
md.push(`**Criterion:** ${c.id} ${c.slug} · **Severity:** ${c.sev} · **Prevalence:** ${c.k}/${c.n} applicable`);
|
|
402
|
+
if (quote !== undefined) md.push(`**Evidence:** "${String(quote).slice(0, 120)}"`);
|
|
403
|
+
md.push(`**Route:** diagnostics (evaluator never fixes — EV-051)`);
|
|
404
|
+
md.push("");
|
|
405
|
+
}
|
|
406
|
+
// DERIVED items: detections the judges flagged that no criterion covers —
|
|
407
|
+
// derived from the walks, never scored, routed to the operator.
|
|
408
|
+
let qi = 0;
|
|
409
|
+
const candTotalAll = files.reduce((a, f) => a + ((f.candidates ?? []) as unknown[]).length, 0);
|
|
410
|
+
if (candTotalAll > 0) {
|
|
411
|
+
md.push("---");
|
|
412
|
+
md.push("## Derived observations (no matching criterion — for operator review)");
|
|
413
|
+
md.push("");
|
|
414
|
+
}
|
|
415
|
+
for (const f of files) {
|
|
416
|
+
for (const cand of (f.candidates ?? []) as { kind?: string; detection?: string }[]) {
|
|
417
|
+
if (qi >= 4) break;
|
|
418
|
+
qi++;
|
|
419
|
+
derivedCards.push(`<div class="fcard" data-component="mgt-finding-card"><div class="fcard-h"><span class="sev LOW">LOW</span><b>D${qi} · derived (${esc(cand.kind ?? "unmatched detection")})</b><span class="chip" style="color:var(--cyan);border-color:var(--cyan)">→ your calibration queue</span></div>
|
|
420
|
+
<div class="fcard-b">${esc(String(cand.detection ?? "").slice(0, 320))}
|
|
421
|
+
<div style="margin-top:8px"><span class="tlink" onclick="gotoTrace('${short(f.trajectoryId)}')">${short(f.trajectoryId)} · open walk ▸</span></div></div></div>`);
|
|
422
|
+
md.push(`### D${qi} — derived (${cand.kind ?? "unmatched detection"}; NOT a scored finding)`);
|
|
423
|
+
md.push(`${String(cand.detection ?? "").slice(0, 200)}`);
|
|
424
|
+
md.push(`**Route:** operator calibration queue`);
|
|
425
|
+
md.push("");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const candTotal = files.reduce((a, f) => a + ((f.candidates ?? []) as unknown[]).length, 0);
|
|
429
|
+
if (candTotal > qi) derivedCards.push(`<div class="fcard"><div class="fcard-b">+ ${candTotal - qi} further derived detections carried in the run artifacts (showing first ${qi} — declared cap, not a silent one).</div></div>`);
|
|
430
|
+
|
|
431
|
+
// assemble the two SEPARATED category sections (operator round 12).
|
|
432
|
+
cards.push(`<div class="h4s" style="margin-top:6px">Failures on defined criteria — routed to diagnostics</div>
|
|
433
|
+
<p style="font-size:13.5px;color:var(--muted);margin:2px 0 4px;max-width:86ch">Each failure below broke a criterion of YOUR suite. The evaluator never fixes (judge-only) — every card is handed to diagnostics with its evidence.</p>
|
|
434
|
+
${failCards.length > 0 ? failCards.join("\n\n ") : '<div class="fcard"><div class="fcard-b">No criterion failed on this corpus.</div></div>'}`);
|
|
435
|
+
cards.push(`<div class="h4s" style="margin-top:18px">Derived observations — no matching criterion, for your review</div>
|
|
436
|
+
<p style="font-size:13.5px;color:var(--muted);margin:2px 0 4px;max-width:86ch">The judges DETECTED these during the walks but no criterion in the suite covers them — so they are derived observations, never scored (detect-and-flag, never mint). They queue for your calibration ruling; accepted ones can become new criteria.</p>
|
|
437
|
+
${derivedCards.length > 0 ? derivedCards.join("\n\n ") : '<div class="fcard"><div class="fcard-b">No derived detections on this corpus.</div></div>'}`);
|
|
438
|
+
md.push("### Next steps");
|
|
439
|
+
md.push("1. Diagnostics consumes the findings → RCA + remedies (evaluator never fixes)");
|
|
440
|
+
md.push("2. Operator settles candidate/calibration items in the review UI");
|
|
441
|
+
md.push("3. `*evaluate` rerun (pinned) confirms deltas");
|
|
442
|
+
return { html: cards.join("\n\n "), md: md.join("\n") };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function selfEvalCards(input: RenderV3Input, files: MatrixVerdictFile[], traces: Record<string, unknown>[]): string {
|
|
446
|
+
const cells = input.criteria.length * files.filter((f) => f.fidelity?.complete !== false).length;
|
|
447
|
+
const em = foldEmissionsScorecard(files);
|
|
448
|
+
const engaged = files.map((f) => layersEngagedCount(f.layersEngaged as never)).filter((x): x is number => typeof x === "number");
|
|
449
|
+
const meanEngaged = engaged.length > 0 ? (engaged.reduce((a, b) => a + b, 0) / engaged.length).toFixed(1) : "—";
|
|
450
|
+
const iv = input.independentVerify;
|
|
451
|
+
const cards = [
|
|
452
|
+
`<div class="fcard" data-component="mgt-selfeval-card"><div class="fcard-h"><span class="verd pass">PASS</span><b>MR-1 · Completeness law</b></div>
|
|
453
|
+
<div class="fcard-b"><b>What it checks:</b> no criterion may be silently skipped — every (criterion × trajectory) cell must end decided, abstained-with-reason, or na-by-precondition. <b>Result:</b> ${cells}/${cells} cells accounted for (assertNoUnverdictedCriterion passed at aggregate); 0 silent skips.</div></div>`,
|
|
454
|
+
`<div class="fcard" data-component="mgt-selfeval-card"><div class="fcard-h"><span class="verd ${input.determinismNote !== undefined ? "pass" : "inc"}">${input.determinismNote !== undefined ? "PASS" : "NOT RUN"}</span><b>MR-2 · Rerun determinism</b></div>
|
|
455
|
+
<div class="fcard-b"><b>What it checks:</b> the same corpus re-judged with the pinned model at temperature 0 must produce identical verdicts. <b>Result:</b> ${esc(input.determinismNote ?? "no rerun executed for this report — NAMED absence, not a pass")}.</div></div>`,
|
|
456
|
+
`<div class="fcard" data-component="mgt-selfeval-card"><div class="fcard-h"><span class="verd inc">NOTE</span><b>MR-3 · Layer economy</b></div>
|
|
457
|
+
<div class="fcard-b"><b>What it checks:</b> the walk should stop early when the outcome settles everything — deep layers cost tokens. <b>Result:</b> mean ${meanEngaged} of 5 layers engaged over ${engaged.length} walks; ${traces.filter((t) => t["earlyExit"] !== null && t["earlyExit"] !== "fidelity").length} early exit(s).</div></div>`,
|
|
458
|
+
`<div class="fcard" data-component="mgt-selfeval-card"><div class="fcard-h"><span class="verd ${iv.length > 0 ? "pass" : "inc"}">${iv.length > 0 ? "PASS" : "N/A"}</span><b>MR-4 · Independent verification</b></div>
|
|
459
|
+
<div class="fcard-b"><b>What it checks:</b> every gate-firing FAIL is re-examined by a second, independent reviewer that can only DOWNGRADE — dead evidence or reasoning leaps demote to uncertain. <b>Result:</b> ${iv.length > 0 ? `${iv.length} gating fail(s) re-verified · ${iv.filter((x) => !x.upheld).length} downgraded · ${iv.filter((x) => x.upheld).length} upheld with evidence re-resolved` : "no gating fails eligible OR verify pass not yet run — NAMED absence"}.</div></div>`,
|
|
460
|
+
`<div class="fcard" data-component="mgt-selfeval-card"><div class="fcard-h"><span class="verd ${em.namedMissing.length >= 0 && em.manifestAbsent.length === 0 ? "pass" : "inc"}">${em.manifestAbsent.length === 0 ? "PASS" : "NOTE"}</span><b>MR-5 · Emissions scorecard</b></div>
|
|
461
|
+
<div class="fcard-b"><b>What it checks:</b> every walk self-manifests what it emitted vs missed — a missing emission with a stated reason is a NAMED degrade; a silent drop is a defect. <b>Result:</b> ${em.walksWithManifest}/${em.walksTotal} manifests present · ${em.namedMissing.length} named degrade(s) · ${em.manifestAbsent.length === 0 ? "0 silent drops" : `${em.manifestAbsent.length} walk(s) WITHOUT a manifest`}.</div></div>`,
|
|
462
|
+
];
|
|
463
|
+
return cards.join("\n ");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* ── the renderer ──────────────────────────────────────────────────────────── */
|
|
467
|
+
export function renderEvalReportV3(input: RenderV3Input): string {
|
|
468
|
+
const template = readFileSync(TEMPLATE_PATH, "utf8");
|
|
469
|
+
const sevOf = new Map(input.criteria.map((c) => [c.criterionId, String(c.severity)]));
|
|
470
|
+
const { list: crits, byCid } = mapCriteria(input);
|
|
471
|
+
const conflicts = detectLayerConflicts(input.files);
|
|
472
|
+
const traceById = new Map((input.traces ?? []).map((t) => [t.id, t]));
|
|
473
|
+
const traces = input.files.map((f) => mapTrace(f, byCid, sevOf, conflicts, traceById.get(f.trajectoryId)));
|
|
474
|
+
const fold = foldLayerVerdicts(input.files);
|
|
475
|
+
|
|
476
|
+
const scores: Record<string, string> = {};
|
|
477
|
+
for (const c of crits) scores[c.id] = `${c.n - c.k}/${c.n}`;
|
|
478
|
+
|
|
479
|
+
const prof = (input.subjectProfile ?? {}) as Record<string, unknown>;
|
|
480
|
+
const tools = ((prof["tools"] ?? []) as { name?: string }[]).map((t) => String(t.name ?? t));
|
|
481
|
+
const reads = tools.filter((t) => toolClass(t) === "read");
|
|
482
|
+
const acts = tools.filter((t) => toolClass(t) === "act");
|
|
483
|
+
const profile = {
|
|
484
|
+
name: input.subjectName,
|
|
485
|
+
desc: `${String(prof["identity"] ?? input.subjectName)} — ${String(prof["purpose"] ?? "purpose not supplied (NAMED absence)")}`,
|
|
486
|
+
gen: reads.join(" · ") || "(none classified)",
|
|
487
|
+
op: acts.join(" · ") || "(none classified)",
|
|
488
|
+
toolsHeading: `its ${tools.length} tools — reading vs acting (heuristic split)`,
|
|
489
|
+
corpus: `${traces.length} trajectories${isExternal(input) ? "" : ` · run ${input.runId}`} · unitf-jsonl (mutagent-cli export)`,
|
|
490
|
+
split: Object.entries(
|
|
491
|
+
traces.reduce<Record<string, number>>((acc, t) => {
|
|
492
|
+
const s = String(t["scenario"]);
|
|
493
|
+
acc[s] = (acc[s] ?? 0) + 1;
|
|
494
|
+
return acc;
|
|
495
|
+
}, {}),
|
|
496
|
+
)
|
|
497
|
+
.map(([s, n]) => `${s} ${n}`)
|
|
498
|
+
.join(" · "),
|
|
499
|
+
chips: [String(prof["skill"] ?? "agent"), String(prof["harness"] ?? "harness unknown"), String(prof["version"] ?? "version unknown")],
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const name = (f: { criterionId: string }): string => {
|
|
503
|
+
const t = byCid.get(f.criterionId);
|
|
504
|
+
return t !== undefined ? `${t.id} ${t.slug}` : f.criterionId;
|
|
505
|
+
};
|
|
506
|
+
const gatedBy = input.gate.gatedBy ?? input.gate.failedCriteria;
|
|
507
|
+
const indet = input.gate.indeterminateBy ?? [];
|
|
508
|
+
const bottom = input.gate.passed
|
|
509
|
+
? `The gate PASSED: all ${crits.length} criteria held over ${traces.length} trajectories. ${conflicts.length > 0 ? `${conflicts.length} cross-layer conflict(s) are queued for your calibration ruling — they do not move the gate.` : "No cross-layer conflicts were detected."}`
|
|
510
|
+
: `The gate ${esc(input.gate.runVerdict.toUpperCase())} — driven by ${gatedBy.length} decided fail(s): <b style="color:var(--fg)">${gatedBy.map((f) => esc(name(f))).join(", ")}</b>.${indet.length > 0 ? ` ${indet.length} CRIT/HIGH criterion/criteria ended <b style="color:var(--fg)">uncertain</b> (${indet.map((f) => esc(name(f))).join(", ")}) — abstains, not fails; they roll the run INCOMPLETE-wards, never green.` : ""} ${traces.filter((t) => t["gate"] === "incomplete").length} trajectory(ies) ended incomplete/abstained. ${conflicts.length > 0 ? `${conflicts.length} ⚡ conflict(s) queued for your calibration ruling — they do not move the gate.` : "No cross-layer conflicts detected."} ${input.scopeNote !== undefined ? esc(input.scopeNote) : ""}`;
|
|
511
|
+
|
|
512
|
+
const findings = findingsCards(input, crits, traces, input.files, byCid);
|
|
513
|
+
const openTrace = (traces.find((t) => t["gate"] === "fail") ?? traces[0])?.["id"] ?? null;
|
|
514
|
+
|
|
515
|
+
// V13 — the INGESTED-TRACE INVENTORY: every trace of the corpus, judged or not.
|
|
516
|
+
// An ingested-but-unjudged trace is a NAMED row, never a silent omission.
|
|
517
|
+
const judgedIds = new Set(input.files.map((f) => f.trajectoryId));
|
|
518
|
+
const ingested = input.traces ?? [];
|
|
519
|
+
const gateOf = new Map(traces.map((t) => [String(t["fullId"]), String(t["gate"])]));
|
|
520
|
+
const invRows = (ingested.length > 0 ? ingested.map((t) => t.id) : [...judgedIds]).map((id) => {
|
|
521
|
+
const judged = judgedIds.has(id);
|
|
522
|
+
const g = gateOf.get(id);
|
|
523
|
+
const badge = !judged
|
|
524
|
+
? '<span class="verd inc">NOT JUDGED</span>'
|
|
525
|
+
: g === "pass"
|
|
526
|
+
? '<span class="verd pass">pass</span>'
|
|
527
|
+
: g === "fail"
|
|
528
|
+
? '<span class="verd fail">fail</span>'
|
|
529
|
+
: '<span class="verd inc">incomplete</span>';
|
|
530
|
+
const note = judged
|
|
531
|
+
? "judged — full layered walk (drill in tab ②)"
|
|
532
|
+
: (input.scopeNote ?? "outside this run's declared judging scope");
|
|
533
|
+
return `<tr${judged ? ` style="cursor:pointer" onclick="gotoTrace('${esc(short(id))}')"` : ' style="cursor:default"'}><td><b>${esc(short(id))}</b></td><td style="font-family:var(--mono);font-size:12px">${esc(id)}</td><td>${badge}</td><td>${esc(note)}</td></tr>`;
|
|
534
|
+
});
|
|
535
|
+
const unjudgedCount = invRows.length - judgedIds.size;
|
|
536
|
+
const traceListHtml = `<p style="font-size:13.5px;color:var(--muted);margin:2px 0 8px;max-width:86ch">${invRows.length} trace(s) ingested · ${judgedIds.size} judged${unjudgedCount > 0 ? ` · <b style="color:var(--warn)">${unjudgedCount} NOT judged (each named below — a declared scope reduction, not a silent drop)</b>` : " · full corpus judged"}.</p>
|
|
537
|
+
<table class="ledger" data-component="mgt-trace-inventory"><thead><tr><th>trace</th><th>full id</th><th>status</th><th>note</th></tr></thead><tbody>${invRows.join("")}</tbody></table>`;
|
|
538
|
+
|
|
539
|
+
const data = {
|
|
540
|
+
criteria: crits,
|
|
541
|
+
traces,
|
|
542
|
+
scores,
|
|
543
|
+
profile,
|
|
544
|
+
handoffMd: findings.md,
|
|
545
|
+
openTrace,
|
|
546
|
+
feedbackTitle: isExternal(input) ? `Evaluation report — ${input.subjectName}` : `Evaluation report · ${input.runId}`,
|
|
547
|
+
artifactPath: isExternal(input) ? "" : `.mutagent/evaluator/reports/${input.runId}/evaluation-report.html`,
|
|
548
|
+
layerTotals: fold.totals,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const slots: Record<string, string> = {
|
|
552
|
+
"@@TITLE@@": `Evaluation Report — ${esc(input.subjectName)}${isExternal(input) ? "" : ` · ${esc(input.runId)}`}`,
|
|
553
|
+
"@@RUN_BADGE@@": `<span style="margin-left:auto;font-family:var(--mono);font-size:11.5px;color:var(--muted)">${isExternal(input) ? "" : `run ${esc(input.runId)}`}${input.generatedAt !== undefined ? `${isExternal(input) ? "" : " · "}${esc(input.generatedAt.slice(0, 10))}` : ""}</span>`,
|
|
554
|
+
"@@HERO_TITLE@@": `Evaluation Report — ${esc(input.subjectName)}`,
|
|
555
|
+
"@@HERO_LEDE@@": `<b>Did the agent behave correctly?</b> ${traces.length} real sessions were judged in evidence layers — code checks, outcome, trajectory, tool outputs, context — against ${crits.length} binary criteria. Every verdict cites exact quoted evidence; a failed CRIT/HIGH criterion fails the gate.`,
|
|
556
|
+
"@@IDENTITY_STRIP@@": identityStrip(input, traces),
|
|
557
|
+
"@@METRICS_TILES@@": metricsTiles(input, traces, crits),
|
|
558
|
+
"@@SCORECARD_SCORES@@": metricsTiles(input, traces, crits),
|
|
559
|
+
"@@SUITE_LEDE@@": `${crits.length} binary criteria. Each is answered true/false per trajectory with quoted evidence — never a score. na = the criterion's scoping predicate is positively falsified for that trajectory (≠ fail).`,
|
|
560
|
+
"@@BOTTOM_LINE@@": bottom,
|
|
561
|
+
"@@TRACE_LIST@@": traceListHtml,
|
|
562
|
+
"@@CRITERION_DETAILS@@": criterionDetails(input, crits, input.files, byCid),
|
|
563
|
+
"@@CONFLICTS@@": conflictsHtml(conflicts, input.files),
|
|
564
|
+
"@@FINDINGS_CARDS@@": findings.html,
|
|
565
|
+
"@@SELFEVAL_CARDS@@": selfEvalCards(input, input.files, traces),
|
|
566
|
+
"@@FOOT@@": isExternal(input)
|
|
567
|
+
? `🧬 MutagenT · evaluator report · ${esc(input.subjectName)}`
|
|
568
|
+
: `🧬 MutagenT · evaluator report · run ${esc(input.runId)} · artifact → .mutagent/evaluator/reports/${esc(input.runId)} (gitignored)`,
|
|
569
|
+
"@@FEEDBACK_LABEL@@": `Feedback — evaluation report${isExternal(input) ? "" : ` · ${esc(input.runId)}`} · Copy MD bundles your notes`,
|
|
570
|
+
"@@DATA_JSON@@": JSON.stringify(data).replace(/</g, "\\u003c"),
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
let html = template;
|
|
574
|
+
for (const [k, v] of Object.entries(slots)) {
|
|
575
|
+
if (!html.includes(k)) throw new Error(`TEMPLATE SLOT MISSING: ${k}`);
|
|
576
|
+
// a FUNCTION replacement, never the string form: `$&` / `$'` / `` $` `` / `$$`
|
|
577
|
+
// / `$1` inside real subject text (a quoted reply, a criterion statement, a
|
|
578
|
+
// judge critique, the markdown handoff) are substitution patterns to
|
|
579
|
+
// String.replace and would silently corrupt the emitted page — `$&` even
|
|
580
|
+
// re-emits the slot marker itself. Mirrors render-discover-report-v3.
|
|
581
|
+
html = html.replace(k, () => v);
|
|
582
|
+
}
|
|
583
|
+
// The feedback bar is a DEV-LOOP affordance only (operator rule, round 13):
|
|
584
|
+
// user-produced reports never carry it, regardless of audience.
|
|
585
|
+
if (input.devFeedback !== true) {
|
|
586
|
+
const start = html.indexOf('<div id="fbpanel"');
|
|
587
|
+
const endAnchor = "</div></div>";
|
|
588
|
+
const end = start >= 0 ? html.indexOf(endAnchor, start) : -1;
|
|
589
|
+
if (start < 0 || end < 0) throw new Error("feedback-panel strip anchor missing");
|
|
590
|
+
html = html.slice(0, start) + html.slice(end + endAnchor.length);
|
|
591
|
+
}
|
|
592
|
+
// ── V11 AUDIENCE STRIP — the ⑤ INTERNAL surface (see the contract block above) ──
|
|
593
|
+
// Runs AFTER slot-fill on purpose: the ⑤ panel holds `@@SELFEVAL_CARDS@@`, so
|
|
594
|
+
// cutting it first would trip the fail-loud "TEMPLATE SLOT MISSING" guard. Order:
|
|
595
|
+
// fill every slot, then remove the whole internal surface.
|
|
596
|
+
if (isExternal(input)) {
|
|
597
|
+
html = stripInternalSurface(html, { commentMarker: "<!-- ⑤ SELF-EVAL -->", tabIndex: 4, label: "⑤ Self-Eval" });
|
|
598
|
+
}
|
|
599
|
+
const leftover = /@@[A-Z_]+@@/.exec(html);
|
|
600
|
+
if (leftover !== null) throw new Error(`UNFILLED SLOT: ${leftover[0]}`);
|
|
601
|
+
return html;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Write the v3 frozen-contract report for a run. Returns the artifact path. */
|
|
605
|
+
export function writeEvalReportV3(input: RenderV3Input, repoRoot: string): string {
|
|
606
|
+
const out = join(repoRoot, ".mutagent/evaluator/reports", input.runId, "evaluation-report.html");
|
|
607
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
608
|
+
writeFileSync(out, renderEvalReportV3(input));
|
|
609
|
+
return out;
|
|
610
|
+
}
|