@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,1155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render-discover-report-v3 — the W3 FROZEN-CONTRACT discovery-report renderer.
|
|
3
|
+
*
|
|
4
|
+
* The template (`assets/templates/discover-report.template.html`) IS the
|
|
5
|
+
* operator-signed mock (discovery-report-mock.html), derived mechanically by
|
|
6
|
+
* `scripts/release/build-discover-template.ts` with named data slots — structure,
|
|
7
|
+
* styles, the six tabs, the funnel, the heat grid, the criterion cards, the
|
|
8
|
+
* trace-peek and the adoption handoff are the frozen contract verbatim. This
|
|
9
|
+
* module ONLY computes the data: it maps the REAL `*discover` artifacts
|
|
10
|
+
* (MinedCriterion[] + TraceAnnotation[] + the leaf mining report + EvalTrace[] +
|
|
11
|
+
* dataset candidates + living-suite provenance) into the template's `ASTER`
|
|
12
|
+
* shape and fills the slots.
|
|
13
|
+
*
|
|
14
|
+
* HONESTY RULES (hard — mirrors render-eval-report-v3):
|
|
15
|
+
* - nothing is invented — every number comes from a run artifact;
|
|
16
|
+
* - what the run does NOT produce renders as a NAMED absence (the sampling-strategy
|
|
17
|
+
* census, the saturation curve, the per-trace 3-lens tagging), never a
|
|
18
|
+
* fabricated number and never a silent blank;
|
|
19
|
+
* - caps are declared in-render ("showing first N"), never silent;
|
|
20
|
+
* - the sibling-eval-report deep link renders ONLY when that artifact exists.
|
|
21
|
+
*
|
|
22
|
+
* The v2 renderer (render-discover-report.ts) is untouched, and so is the v1
|
|
23
|
+
* `*audit` world.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import type { DiscoverCategory, DiscoverMiningReport } from "./aggregate-discover.ts";
|
|
28
|
+
import { writeDiscoverReportFromFiles, type DiscoverIo } from "./render-discover-report.ts";
|
|
29
|
+
import type { TraceAnnotation } from "./discover-criteria.ts";
|
|
30
|
+
import type { DatasetCase } from "./contracts/dataset.ts";
|
|
31
|
+
import {
|
|
32
|
+
NearDuplicateStatus,
|
|
33
|
+
type NearDuplicateDecision,
|
|
34
|
+
type NearDuplicateFinding,
|
|
35
|
+
type NearDuplicateLedger,
|
|
36
|
+
} from "./merge-criteria.ts";
|
|
37
|
+
import { OutcomeVerdict, type EvalTrace, type MinedCriterion } from "./contracts/eval-types.ts";
|
|
38
|
+
// V11 audience contract — the ONE implementation, shared with the eval renderer.
|
|
39
|
+
// This surface had NO audience concept at all, so its ⑥ Methodology [INT] tab
|
|
40
|
+
// always shipped. See scripts/audience.ts for the full rule.
|
|
41
|
+
import { isExternal, stripInternalSurface, type Audience } from "./audience.ts";
|
|
42
|
+
|
|
43
|
+
const TEMPLATE_PATH = join(import.meta.dir, "../assets/templates/discover-report.template.html");
|
|
44
|
+
|
|
45
|
+
/* ── small pure helpers ────────────────────────────────────────────────────── */
|
|
46
|
+
const esc = (s: unknown): string =>
|
|
47
|
+
String(s ?? "")
|
|
48
|
+
.replace(/&/g, "&")
|
|
49
|
+
.replace(/</g, "<")
|
|
50
|
+
.replace(/>/g, ">")
|
|
51
|
+
.replace(/"/g, """);
|
|
52
|
+
const num = (n: number): string => n.toLocaleString("en-US");
|
|
53
|
+
/**
|
|
54
|
+
* Display ids are shortened for the frozen contract's narrow chips — ALL-OR-
|
|
55
|
+
* NOTHING across the corpus. Two real traces must never collapse onto one label
|
|
56
|
+
* (`t-healthy-1`/`t-healthy-2` → `t-health` would make the peek open the wrong
|
|
57
|
+
* session), so a single collision keeps EVERY id full: a per-id decision yields
|
|
58
|
+
* a mixed strip where `t-card-e` sits next to `t-refund-unguarded-01` and the
|
|
59
|
+
* shortened ones have lost the very words that told them apart. Opaque run ids
|
|
60
|
+
* (uuids/hashes) all shorten; semantic ids all stay whole.
|
|
61
|
+
*/
|
|
62
|
+
function buildIdMap(ids: string[]): Map<string, string> {
|
|
63
|
+
const shortOf = (id: string): string => (id.length > 10 ? id.slice(0, 8) : id);
|
|
64
|
+
const shortened = new Set(ids.map(shortOf));
|
|
65
|
+
const collides = shortened.size !== new Set(ids).size;
|
|
66
|
+
return new Map(ids.map((id) => [id, collides ? id : shortOf(id)]));
|
|
67
|
+
}
|
|
68
|
+
const disp = (map: Map<string, string>, id: string): string => map.get(id) ?? id;
|
|
69
|
+
/** a NAMED absence — the run did not produce this; say so, never fake it. */
|
|
70
|
+
const absent = (what: string, why: string): string =>
|
|
71
|
+
`<div class="nest"><b>${esc(what)}: not recorded by this run</b> — NAMED absence. ${esc(why)}</div>`;
|
|
72
|
+
|
|
73
|
+
/** one `.bar` row of the frozen contract (label · proportional track · value). */
|
|
74
|
+
function bar(label: string, value: string, frac: number, cls = ""): string {
|
|
75
|
+
const w = Math.max(0, Math.min(100, Math.round(frac * 100)));
|
|
76
|
+
return `<div class="bar"><span class="lab">${esc(label)}</span><div class="tr"><div class="fill${cls ? ` ${cls}` : ""}" style="width:${w}%"></div></div><b>${esc(value)}</b></div>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ── the template's client-side data shape ─────────────────────────────────── */
|
|
80
|
+
interface TemplateCriterion {
|
|
81
|
+
id: string;
|
|
82
|
+
slug: string;
|
|
83
|
+
layer: string;
|
|
84
|
+
cls: string;
|
|
85
|
+
sev: string;
|
|
86
|
+
statement: string;
|
|
87
|
+
passDef: string;
|
|
88
|
+
failDef: string;
|
|
89
|
+
k: number;
|
|
90
|
+
n: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface TemplateStep {
|
|
94
|
+
tool: string;
|
|
95
|
+
args: string;
|
|
96
|
+
output: string;
|
|
97
|
+
verdicts: { crit: string; verdict: string; ref: { obs: string; path: string; value: string } | null }[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface TemplateTrace {
|
|
101
|
+
id: string;
|
|
102
|
+
fullId: string;
|
|
103
|
+
scenario: string;
|
|
104
|
+
gate: string;
|
|
105
|
+
outcome: { verdict: string; expected: string; actual: string };
|
|
106
|
+
steps: TemplateStep[];
|
|
107
|
+
divergence?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface DiscoverV3Input {
|
|
111
|
+
subjectName: string;
|
|
112
|
+
runId: string;
|
|
113
|
+
/**
|
|
114
|
+
* V11 — who the render is FOR. `external` strips the ⑥ Methodology surface (panel,
|
|
115
|
+
* tab-bar entry AND its HTML comment marker) plus run-internal detail (run ids, the
|
|
116
|
+
* gitignored artifact path). Verdicts, evidence, criteria and the dataset stay.
|
|
117
|
+
* ABSENT ⇒ `internal` (the safe default for a dev-loop render; a client render must
|
|
118
|
+
* ASK for external rather than getting it by omission).
|
|
119
|
+
*/
|
|
120
|
+
audience?: Audience;
|
|
121
|
+
/** the mined criteria — `aggregateDiscover().criteria`. */
|
|
122
|
+
criteria: MinedCriterion[];
|
|
123
|
+
/** the joined per-(category × trace) annotations — `aggregateDiscover().annotations`. */
|
|
124
|
+
annotations: TraceAnnotation[];
|
|
125
|
+
/** the leaf mining report — the per-trace ✓/✗ labels + the emergent categories. */
|
|
126
|
+
miningReport?: DiscoverMiningReport;
|
|
127
|
+
/** the INGESTED traces — the peek lane's ground truth (tool observations). */
|
|
128
|
+
traces?: EvalTrace[];
|
|
129
|
+
/** `aggregateDiscover().datasetCandidates` — the proposed regression cases. */
|
|
130
|
+
datasetCandidates?: DatasetCase[];
|
|
131
|
+
/** the grown living suite's provenance (append-only delta). */
|
|
132
|
+
suite?: { version: number; total: number; lastAppended: number };
|
|
133
|
+
/**
|
|
134
|
+
* criterion id → `new` | `reinforced`, judged against the suite as it stood
|
|
135
|
+
* BEFORE this run (`aggregateDiscover().criterionStates`). ABSENT ⇒ the report
|
|
136
|
+
* renders a NAMED absence rather than calling everything new — a re-run that
|
|
137
|
+
* only reinforced existing criteria must never read as fresh discovery.
|
|
138
|
+
*/
|
|
139
|
+
criterionStates?: Record<string, string>;
|
|
140
|
+
/**
|
|
141
|
+
* Near-duplicate pairs the merge deliberately did NOT collapse
|
|
142
|
+
* (`aggregateDiscover().mergePlan.findings`) — surfaced for the reader to rule
|
|
143
|
+
* on, because a silent merge of two criteria whose evidence disagrees would
|
|
144
|
+
* misrepresent both. Superseded by `nearDuplicateLedger` when that is supplied
|
|
145
|
+
* (the ledger is the durable view and includes pairs from EARLIER runs).
|
|
146
|
+
*/
|
|
147
|
+
nearDuplicates?: NearDuplicateFinding[];
|
|
148
|
+
/**
|
|
149
|
+
* The DURABLE near-duplicate decision ledger
|
|
150
|
+
* (`aggregateDiscover().nearDuplicateLedger`). Preferred over `nearDuplicates`:
|
|
151
|
+
* it carries pairs surfaced in PREVIOUS runs that are still unruled, plus how
|
|
152
|
+
* many runs each has recurred for — the "nothing forgotten" surface.
|
|
153
|
+
*/
|
|
154
|
+
nearDuplicateLedger?: NearDuplicateLedger;
|
|
155
|
+
/** size of the SOURCE corpus before sampling — absent ⇒ NAMED absence in the funnel. */
|
|
156
|
+
corpusTotal?: number;
|
|
157
|
+
/** per-strategy sampling census — absent ⇒ NAMED absence in tab ②. */
|
|
158
|
+
samplingCensus?: { strategy: string; n: number }[];
|
|
159
|
+
/** the saturation batches — absent ⇒ NAMED absence in tab ②. */
|
|
160
|
+
saturation?: { window: string; newKinds: number }[];
|
|
161
|
+
/** the corpus source label (e.g. "unitf-jsonl"). */
|
|
162
|
+
source?: string;
|
|
163
|
+
/** ISO timestamp of the run — rendered top-right in the header. */
|
|
164
|
+
generatedAt?: string;
|
|
165
|
+
/** deep link to the sibling evaluation report — set ONLY when that file exists. */
|
|
166
|
+
evalReportHref?: string;
|
|
167
|
+
/** DEV ONLY: keep the feedback bar. Default OFF — user reports carry no feedback bar. */
|
|
168
|
+
devFeedback?: boolean;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ── data mapping: real artifacts → the template's ASTER shape ─────────────── */
|
|
172
|
+
|
|
173
|
+
/** `prevalence` is an honest "k/n sampled" string; parse it, never invent it. */
|
|
174
|
+
function parsePrevalence(p: string, fallbackK: number): { k: number; n: number } {
|
|
175
|
+
const m = /^\s*(\d+)\s*\/\s*(\d+)/.exec(p);
|
|
176
|
+
if (m !== null) return { k: Number.parseInt(m[1]!, 10), n: Number.parseInt(m[2]!, 10) };
|
|
177
|
+
return { k: fallbackK, n: fallbackK };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function mapCriteria(criteria: MinedCriterion[]): TemplateCriterion[] {
|
|
181
|
+
return criteria.map((c, i) => {
|
|
182
|
+
const { k, n } = parsePrevalence(c.discovery.evidence.prevalence, c.supportCount);
|
|
183
|
+
const cls =
|
|
184
|
+
c.metadata.check_method === "deterministic" ? "code" : c.metadata.check_method === "hybrid" ? "hybrid" : "judge";
|
|
185
|
+
// `statement` is the binary "Pass = …" sentence. The card already prints it in
|
|
186
|
+
// full under "The check", so restating it verbatim as the pass definition just
|
|
187
|
+
// says the same thing twice. A SEPARATE pass wording only exists if the mined
|
|
188
|
+
// statement carried one; otherwise the absence is NAMED, never padded out.
|
|
189
|
+
const passDef = c.statement.replace(/^\s*pass\s*=\s*/i, "").trim();
|
|
190
|
+
const restates = passDef.length === 0 || c.statement.includes(passDef);
|
|
191
|
+
// `discovery.targets` is auto-derived as "the '<id>' behavior/failure mode"
|
|
192
|
+
// whenever the leaf authored no distinct target, which nests the criterion's
|
|
193
|
+
// own name back into its fail definition ("… the guarded failure mode is
|
|
194
|
+
// 'the X behavior/failure mode'"). Quote it only when it genuinely differs.
|
|
195
|
+
const targets = c.discovery.targets;
|
|
196
|
+
const derivedTarget = targets === `the '${c.id}' behavior/failure mode`;
|
|
197
|
+
return {
|
|
198
|
+
id: `C${i + 1}`,
|
|
199
|
+
slug: c.id,
|
|
200
|
+
layer: c.metadata.level,
|
|
201
|
+
cls,
|
|
202
|
+
sev: c.metadata.severity,
|
|
203
|
+
statement: c.statement,
|
|
204
|
+
passDef: restates
|
|
205
|
+
? "exactly the condition stated above — this run mined one binary statement; no separate pass wording was authored"
|
|
206
|
+
: passDef,
|
|
207
|
+
failDef: derivedTarget
|
|
208
|
+
? "the session does not satisfy that condition"
|
|
209
|
+
: `the session does not satisfy that condition — the guarded failure mode is "${targets}"`,
|
|
210
|
+
k,
|
|
211
|
+
n,
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** serialize a real observation value for the peek lane (bounded, cap declared). */
|
|
217
|
+
function laneText(v: unknown, cap = 400): string {
|
|
218
|
+
if (v === null || v === undefined) return "";
|
|
219
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
220
|
+
return s.length > cap ? `${s.slice(0, cap)} … [+${s.length - cap} chars]` : s;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface LabelMeta {
|
|
224
|
+
verdict: string;
|
|
225
|
+
firstThingWrong: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function mapTraces(
|
|
229
|
+
input: DiscoverV3Input,
|
|
230
|
+
crits: TemplateCriterion[],
|
|
231
|
+
labels: Map<string, LabelMeta>,
|
|
232
|
+
idMap: Map<string, string>,
|
|
233
|
+
): TemplateTrace[] {
|
|
234
|
+
const bySlug = new Map(crits.map((c) => [c.slug, c]));
|
|
235
|
+
// criterion ← trace bindings come from the JOINED annotations (category === criterion id).
|
|
236
|
+
const annByTrace = new Map<string, TraceAnnotation[]>();
|
|
237
|
+
for (const a of input.annotations) {
|
|
238
|
+
const list = annByTrace.get(a.traceId) ?? [];
|
|
239
|
+
list.push(a);
|
|
240
|
+
annByTrace.set(a.traceId, list);
|
|
241
|
+
}
|
|
242
|
+
const traceById = new Map((input.traces ?? []).map((t) => [t.id, t]));
|
|
243
|
+
// every trace the run READ gets a row: labelled traces first, then any ingested
|
|
244
|
+
// trace without a label (named as unread rather than silently dropped).
|
|
245
|
+
const ids = [...new Set([...labels.keys(), ...traceById.keys()])];
|
|
246
|
+
|
|
247
|
+
return ids.map((id) => {
|
|
248
|
+
const t = traceById.get(id);
|
|
249
|
+
const meta = labels.get(id);
|
|
250
|
+
const verdict = meta?.verdict ?? "unread";
|
|
251
|
+
const gate =
|
|
252
|
+
t?.incomplete === true || verdict === OutcomeVerdict.Uncertain
|
|
253
|
+
? "incomplete"
|
|
254
|
+
: verdict === OutcomeVerdict.Fail
|
|
255
|
+
? "fail"
|
|
256
|
+
: verdict === OutcomeVerdict.Pass
|
|
257
|
+
? "pass"
|
|
258
|
+
: "incomplete";
|
|
259
|
+
|
|
260
|
+
// REAL tool observations — the intake stores them reverse-chronologically.
|
|
261
|
+
const toolObs = (t?.observations ?? []).filter((o) => o.type === "TOOL").reverse();
|
|
262
|
+
// an observation that carried no input/output renders a NAMED absence, never
|
|
263
|
+
// a silent blank (the peek must not imply "called with nothing").
|
|
264
|
+
const steps: TemplateStep[] = toolObs.map((o) => ({
|
|
265
|
+
tool: o.name ?? "(unnamed tool)",
|
|
266
|
+
args: laneText(o.input, 160) || "(no input recorded on this observation)",
|
|
267
|
+
output: laneText(o.output) || "(no output recorded on this observation)",
|
|
268
|
+
verdicts: [],
|
|
269
|
+
}));
|
|
270
|
+
if (steps.length === 0)
|
|
271
|
+
steps.push({
|
|
272
|
+
tool: t === undefined ? "(trace body not carried into this report)" : "(no tool calls in this session)",
|
|
273
|
+
output: laneText(t?.output?.response) || "—",
|
|
274
|
+
args: "",
|
|
275
|
+
verdicts: [],
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// bind the criterion verdicts onto the terminal step (the decision surface):
|
|
279
|
+
// *discover labels a SESSION, not a step, so there is no per-step anchor to claim.
|
|
280
|
+
const last = steps[steps.length - 1]!;
|
|
281
|
+
for (const a of annByTrace.get(id) ?? []) {
|
|
282
|
+
const tc = a.category !== undefined ? bySlug.get(a.category) : undefined;
|
|
283
|
+
if (tc === undefined) continue;
|
|
284
|
+
if (a.label === OutcomeVerdict.Pass) continue; // the criterion did not fire here
|
|
285
|
+
const r = (a.refs ?? [])[0];
|
|
286
|
+
last.verdicts.push({
|
|
287
|
+
crit: tc.id,
|
|
288
|
+
verdict: a.label === OutcomeVerdict.Uncertain ? "uncertain" : "fail",
|
|
289
|
+
ref: r !== undefined ? { obs: disp(idMap, r.obs), path: r.path, value: r.value } : null,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const firstWrong = meta?.firstThingWrong ?? "";
|
|
294
|
+
const failed = verdict === OutcomeVerdict.Fail || verdict === OutcomeVerdict.Uncertain;
|
|
295
|
+
return {
|
|
296
|
+
id: disp(idMap, id),
|
|
297
|
+
fullId: id,
|
|
298
|
+
scenario: t?.name ?? "(no scenario label recorded on the trace)",
|
|
299
|
+
gate,
|
|
300
|
+
outcome: {
|
|
301
|
+
verdict: verdict === OutcomeVerdict.Pass ? "pass" : verdict === OutcomeVerdict.Fail ? "fail" : verdict,
|
|
302
|
+
expected: failed
|
|
303
|
+
? "(no expected-exit is modeled by *discover — the outcome layer belongs to *evaluate)"
|
|
304
|
+
: "no first-thing-wrong found by the determiner",
|
|
305
|
+
actual: firstWrong.length > 0 ? firstWrong : "(no first-thing-wrong text recorded)",
|
|
306
|
+
},
|
|
307
|
+
steps,
|
|
308
|
+
...(failed && firstWrong.length > 0 ? { divergence: firstWrong } : {}),
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* ── HTML islands (server-rendered slots) ──────────────────────────────────── */
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* EV-051 — a mined criterion carrying `fix_or_eval: "fixable->diagnostics"` is a
|
|
317
|
+
* FIX handed to diagnostics, NOT an eval to adopt. It is mined, named and shown
|
|
318
|
+
* in the cluster table, but it never enters the ④ "add these checks" catalog.
|
|
319
|
+
*/
|
|
320
|
+
const isRouted = (c: MinedCriterion): boolean =>
|
|
321
|
+
c.discovery.fix_or_eval === "fixable->diagnostics" || c.flag === "fixable";
|
|
322
|
+
|
|
323
|
+
/* ── NEW · REINFORCED · MERGED — the redundancy story ──────────────────────── */
|
|
324
|
+
|
|
325
|
+
/** The three states a reader must be able to tell apart at a glance. */
|
|
326
|
+
interface RedundancyTally {
|
|
327
|
+
/** minted by this run (id not in the pre-run suite). */
|
|
328
|
+
fresh: number;
|
|
329
|
+
/** already in the suite; this run attached further evidence. */
|
|
330
|
+
reinforced: number;
|
|
331
|
+
/** criteria that absorbed ≥1 near-duplicate. */
|
|
332
|
+
merged: number;
|
|
333
|
+
/** total ids folded away by those merges. */
|
|
334
|
+
absorbed: number;
|
|
335
|
+
/** OPEN near-duplicate questions awaiting a human ruling (durable). */
|
|
336
|
+
pending: number;
|
|
337
|
+
/** whether the run supplied per-criterion states at all (else NAMED absence). */
|
|
338
|
+
stated: boolean;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function tallyRedundancy(input: DiscoverV3Input, criteria: MinedCriterion[]): RedundancyTally {
|
|
342
|
+
const states = input.criterionStates;
|
|
343
|
+
const merged = criteria.filter((c) => (c.mergedFrom ?? []).length > 0);
|
|
344
|
+
const ledger = input.nearDuplicateLedger;
|
|
345
|
+
return {
|
|
346
|
+
fresh: states !== undefined ? criteria.filter((c) => states[c.id] === "new").length : 0,
|
|
347
|
+
reinforced: states !== undefined ? criteria.filter((c) => states[c.id] === "reinforced").length : 0,
|
|
348
|
+
merged: merged.length,
|
|
349
|
+
absorbed: merged.reduce((n, c) => n + (c.mergedFrom ?? []).length, 0),
|
|
350
|
+
pending:
|
|
351
|
+
ledger !== undefined
|
|
352
|
+
? ledger.decisions.filter((d) => d.status === NearDuplicateStatus.Pending).length
|
|
353
|
+
: (input.nearDuplicates ?? []).length,
|
|
354
|
+
stated: states !== undefined,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** the state chip for one criterion — NEW / REINFORCED (+ MERGED when it absorbed). */
|
|
359
|
+
function stateChips(input: DiscoverV3Input, c: MinedCriterion): string {
|
|
360
|
+
const out: string[] = [];
|
|
361
|
+
const st = input.criterionStates?.[c.id];
|
|
362
|
+
if (st === "new") out.push(`<span class="gb" style="color:var(--pass);background:var(--pass-bg)">NEW</span>`);
|
|
363
|
+
else if (st === "reinforced")
|
|
364
|
+
out.push(`<span class="gb" style="color:var(--primarySoft);background:rgba(126,71,215,.18)">REINFORCED</span>`);
|
|
365
|
+
const from = c.mergedFrom ?? [];
|
|
366
|
+
if (from.length > 0)
|
|
367
|
+
out.push(
|
|
368
|
+
`<span class="gb" style="color:var(--cyan);background:rgba(69,184,204,.16)">MERGED ← ${esc(from.join(", "))}</span>`,
|
|
369
|
+
);
|
|
370
|
+
return out.join(" ");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The PENDING near-duplicate decisions — the fourth state, alongside NEW /
|
|
375
|
+
* REINFORCED / MERGED. These are pairs a guard refused to merge because merging
|
|
376
|
+
* could misrepresent them, and they are DURABLE: recorded in the suite data with
|
|
377
|
+
* a stable identity, so closing this report does not lose them and the next run
|
|
378
|
+
* does not re-raise them as fresh discoveries.
|
|
379
|
+
*
|
|
380
|
+
* Prefers the ledger (which includes still-unruled pairs from EARLIER runs) and
|
|
381
|
+
* falls back to this run's findings when only those were supplied.
|
|
382
|
+
*/
|
|
383
|
+
function pendingSection(input: DiscoverV3Input): string {
|
|
384
|
+
const ledger = input.nearDuplicateLedger;
|
|
385
|
+
const fromLedger: NearDuplicateDecision[] =
|
|
386
|
+
ledger !== undefined ? ledger.decisions.filter((d) => d.status === NearDuplicateStatus.Pending) : [];
|
|
387
|
+
// fallback shape when only this run's findings were passed (no durable ledger).
|
|
388
|
+
const rows: {
|
|
389
|
+
pair: string;
|
|
390
|
+
similarity: number;
|
|
391
|
+
guard: string;
|
|
392
|
+
detail: string;
|
|
393
|
+
sides: NearDuplicateFinding["sides"];
|
|
394
|
+
times: number | null;
|
|
395
|
+
}[] =
|
|
396
|
+
ledger !== undefined
|
|
397
|
+
? fromLedger.map((d) => ({
|
|
398
|
+
pair: `${d.pair[0]} ~ ${d.pair[1]}`,
|
|
399
|
+
similarity: d.similarity,
|
|
400
|
+
guard: d.guardSummary,
|
|
401
|
+
detail: d.detail,
|
|
402
|
+
sides: d.sides,
|
|
403
|
+
times: d.timesSurfaced,
|
|
404
|
+
}))
|
|
405
|
+
: (input.nearDuplicates ?? []).map((f) => ({
|
|
406
|
+
pair: `${f.a} ~ ${f.b}`,
|
|
407
|
+
similarity: f.similarity,
|
|
408
|
+
guard: f.guardSummary,
|
|
409
|
+
detail: f.detail,
|
|
410
|
+
sides: f.sides,
|
|
411
|
+
times: null,
|
|
412
|
+
}));
|
|
413
|
+
|
|
414
|
+
if (rows.length === 0) {
|
|
415
|
+
return ledger === undefined && (input.nearDuplicates ?? []).length === 0
|
|
416
|
+
? `<div style="font-family:var(--mono);font-size:12.5px;color:var(--primarySoft);margin:20px 0 8px">Open near-duplicate questions</div>
|
|
417
|
+
<div class="nest">None. No pair of criteria was similar enough to raise a question this run.</div>`
|
|
418
|
+
: "";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const out: string[] = [];
|
|
422
|
+
out.push(
|
|
423
|
+
`<div style="font-family:var(--mono);font-size:12.5px;color:var(--warn);margin:22px 0 8px">⚖ PENDING — open near-duplicate questions (${rows.length}) · your call, not the tool's</div>`,
|
|
424
|
+
);
|
|
425
|
+
out.push(
|
|
426
|
+
`<p style="font-size:14px;color:var(--muted);margin:4px 0 10px;max-width:88ch">These pairs read alike, but a deterministic guard found a reason that merging them would MISREPRESENT the evidence. <b style="color:var(--fg)">Both criteria are kept and both are live</b> — nothing was collapsed and nothing was thrown away. Each row is a question only a human can settle, and it is <b style="color:var(--fg)">stored with the suite</b>, so it survives you closing this page and will not be re-raised as a new discovery next run.</p>`,
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const cells = rows.map((r) => {
|
|
430
|
+
const [x, y] = r.sides;
|
|
431
|
+
const side = (s: NearDuplicateFinding["sides"][0]): string =>
|
|
432
|
+
`<div style="margin:2px 0"><b>${esc(s.id)}</b> — “${esc(s.statement)}”<br><span style="color:var(--dim)">${esc(s.prevalence)} · ${s.refs.length} ref(s) · ${esc(s.traceIds.slice(0, 4).join(", "))}${s.traceIds.length > 4 ? ` +${s.traceIds.length - 4} more` : ""}</span></div>`;
|
|
433
|
+
return `<tr><td><b>${esc(r.pair)}</b>${r.times !== null && r.times > 1 ? `<br><span class="gb" style="color:var(--warn);background:var(--warn-bg)">seen in ${r.times} runs</span>` : ""}</td><td>${Math.round(r.similarity * 100)}%</td><td><b>${esc(r.guard)}</b></td><td>${side(x)}${side(y)}</td></tr>`;
|
|
434
|
+
});
|
|
435
|
+
out.push(`<table class="ct"><tr><th>open question</th><th>wording overlap</th><th>what makes them different</th><th>both checks, verbatim — and the evidence behind each</th></tr>
|
|
436
|
+
${cells.join("\n ")}
|
|
437
|
+
</table>`);
|
|
438
|
+
out.push(
|
|
439
|
+
`<div class="nest">Ruling on these is the <b>calibration loop</b>'s job (review → validate → calibrate) — <b>that loop is NOT built yet</b>, so nothing in this skill acts on a ruling today. This report records the questions and the evidence to answer them; applying an answer is a NAMED not-yet-wired surface, not an implied capability.</div>`,
|
|
440
|
+
);
|
|
441
|
+
return out.join("\n ");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Tab ③'s redundancy block: what was collapsed, what was deliberately KEPT
|
|
446
|
+
* apart, and why. This is the surface that lets a reader see the recommendations
|
|
447
|
+
* are not restatements of each other.
|
|
448
|
+
*/
|
|
449
|
+
function redundancySection(input: DiscoverV3Input, criteria: MinedCriterion[], t: RedundancyTally): string {
|
|
450
|
+
const out: string[] = [];
|
|
451
|
+
out.push(
|
|
452
|
+
`<div style="font-family:var(--mono);font-size:12.5px;color:var(--primarySoft);margin:24px 0 8px">Redundancy control — is this list restating itself?</div>`,
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
if (!t.stated) {
|
|
456
|
+
out.push(
|
|
457
|
+
absent(
|
|
458
|
+
"new-vs-reinforced split",
|
|
459
|
+
"This render was given no per-criterion state, so it cannot say which criteria are newly discovered and which already existed and merely gained evidence. The merge record below is still exact.",
|
|
460
|
+
),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const mergedCriteria = criteria.filter((c) => (c.mergedFrom ?? []).length > 0);
|
|
465
|
+
if (mergedCriteria.length > 0) {
|
|
466
|
+
const rows = mergedCriteria.map((c) => {
|
|
467
|
+
const from = c.mergedFrom ?? [];
|
|
468
|
+
return `<tr><td><b>${esc(c.id)}</b></td><td>${esc(from.join(" · "))}</td><td>${from.length}</td><td>${esc(
|
|
469
|
+
c.discovery.evidence.prevalence,
|
|
470
|
+
)} · ${c.discovery.evidence.refs.length} ref(s)</td></tr>`;
|
|
471
|
+
});
|
|
472
|
+
out.push(`<table class="ct"><tr><th>surviving criterion</th><th>merged from (absorbed ids)</th><th>n</th><th>evidence after merge</th></tr>
|
|
473
|
+
${rows.join("\n ")}
|
|
474
|
+
</table>`);
|
|
475
|
+
out.push(
|
|
476
|
+
`<div class="nest"><b>${t.absorbed} duplicate id(s) folded into ${t.merged} criteri${t.merged === 1 ? "on" : "a"}</b> — the FIRST-SEEN id survives; every absorbed id is kept as a live alias so an older report or dataset recipe naming it still resolves. Evidence refs were UNIONED and the prevalence k/n RECOMPUTED over the combined trace set, never string-added. Nothing was discarded.</div>`,
|
|
477
|
+
);
|
|
478
|
+
} else {
|
|
479
|
+
out.push(
|
|
480
|
+
`<div class="nest">No two mined criteria were near-duplicates of each other in this run — the merge ran and found nothing to collapse (this is a real result, not a skipped step).</div>`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
out.push(pendingSection(input));
|
|
485
|
+
out.push(
|
|
486
|
+
`<div class="nest">How the merge decides: deterministic token overlap of the binary statements (no LLM, no clock — the same batch always merges the same way), and a merge only proceeds when every guard clears. Similarity alone is never sufficient: "includes the account number" and "excludes the account number" overlap 60%, which is why the guards, not the score, hold the line.</div>`,
|
|
487
|
+
);
|
|
488
|
+
return out.join("\n ");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
interface Counts {
|
|
492
|
+
corpus: number | undefined;
|
|
493
|
+
sampled: number;
|
|
494
|
+
read: number;
|
|
495
|
+
pass: number;
|
|
496
|
+
fail: number;
|
|
497
|
+
uncertain: number;
|
|
498
|
+
unread: number;
|
|
499
|
+
clusters: number;
|
|
500
|
+
routed: number;
|
|
501
|
+
dropped: number;
|
|
502
|
+
observed: number;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function samplingSection(input: DiscoverV3Input, c: Counts): string {
|
|
506
|
+
const src = input.source ?? "the configured source (kind not recorded)";
|
|
507
|
+
const out: string[] = [];
|
|
508
|
+
out.push(`<div class="intro">
|
|
509
|
+
<h4>Where the traces came from</h4>
|
|
510
|
+
<p>${esc(input.subjectName)} sessions were read from <b>${esc(src)}</b> — the corpus is handed over to this skill
|
|
511
|
+
(the evaluator never fetches). ${c.corpus !== undefined ? `The source corpus holds <b>${num(c.corpus)} sessions</b>; ` : ""}<b>${num(c.sampled)} session(s)</b>
|
|
512
|
+
entered this discovery run and <b>${num(c.read)}</b> were deep-read by the determiner.</p>
|
|
513
|
+
</div>`);
|
|
514
|
+
|
|
515
|
+
out.push(`<div class="h4s">How we chose what to read</div>`);
|
|
516
|
+
if (input.samplingCensus !== undefined && input.samplingCensus.length > 0) {
|
|
517
|
+
const max = Math.max(...input.samplingCensus.map((s) => s.n), 1);
|
|
518
|
+
out.push(
|
|
519
|
+
`<p style="font-size:14px;color:var(--muted);margin:4px 0 12px;max-width:84ch">Each strategy hunts a different kind of interesting session; together they keep the mined criteria from skewing to one failure shape.</p>`,
|
|
520
|
+
);
|
|
521
|
+
for (const s of input.samplingCensus) out.push(bar(s.strategy, String(s.n), s.n / max));
|
|
522
|
+
} else {
|
|
523
|
+
out.push(
|
|
524
|
+
absent(
|
|
525
|
+
"sampling strategy census",
|
|
526
|
+
"This run received its trace batch already selected, so no per-strategy selection census was written. The batch actually read is fully accounted for below.",
|
|
527
|
+
),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
out.push(`<div class="h4s">Success/failure balance of what we read</div>
|
|
532
|
+
<p style="font-size:14px;color:var(--muted);margin:4px 0 10px;max-width:84ch">Failures teach what breaks; successes teach what "correct" looks like — including restraint criteria (things the agent rightly did NOT do).</p>`);
|
|
533
|
+
const readMax = Math.max(c.read, 1);
|
|
534
|
+
out.push(bar("agent succeeded ✓", String(c.pass), c.pass / readMax, "g"));
|
|
535
|
+
out.push(bar("agent failed ✗", String(c.fail), c.fail / readMax, "r"));
|
|
536
|
+
if (c.uncertain > 0) out.push(bar("undecided ?", String(c.uncertain), c.uncertain / readMax, "c"));
|
|
537
|
+
if (c.unread > 0) out.push(bar("sampled, not deep-read", String(c.unread), c.unread / readMax));
|
|
538
|
+
|
|
539
|
+
out.push(`<div class="h4s">When we stopped reading — saturation</div>`);
|
|
540
|
+
if (input.saturation !== undefined && input.saturation.length > 0) {
|
|
541
|
+
const max = Math.max(...input.saturation.map((s) => s.newKinds), 1);
|
|
542
|
+
for (const s of input.saturation) out.push(bar(s.window, `${s.newKinds} new`, s.newKinds / max));
|
|
543
|
+
} else {
|
|
544
|
+
out.push(
|
|
545
|
+
absent(
|
|
546
|
+
"saturation curve",
|
|
547
|
+
"No batch-by-batch new-failure-kind curve was recorded for this run, so no stopping point can be claimed. The whole handed-over batch was read.",
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
return out.join("\n ");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function walkSection(
|
|
555
|
+
input: DiscoverV3Input,
|
|
556
|
+
crits: TemplateCriterion[],
|
|
557
|
+
c: Counts,
|
|
558
|
+
evalCriteria: MinedCriterion[],
|
|
559
|
+
tally: RedundancyTally,
|
|
560
|
+
): string {
|
|
561
|
+
const out: string[] = [];
|
|
562
|
+
// the discriminator this run ACTUALLY records per failure is the failure CLASS
|
|
563
|
+
// (behavioral vs infra) — the mock's 3 detect lenses are not a recorded field.
|
|
564
|
+
const cls = new Map<string, number>();
|
|
565
|
+
for (const a of input.annotations) {
|
|
566
|
+
if (a.label === OutcomeVerdict.Pass) continue;
|
|
567
|
+
const k = a.failureClass ?? "unclassified";
|
|
568
|
+
cls.set(k, (cls.get(k) ?? 0) + 1);
|
|
569
|
+
}
|
|
570
|
+
out.push(
|
|
571
|
+
`<div style="font-family:var(--mono);font-size:12.5px;color:var(--primarySoft);margin:4px 0 8px">Failure-class split (over the ${c.fail + c.uncertain} failed/undecided session(s))</div>`,
|
|
572
|
+
);
|
|
573
|
+
if (cls.size > 0) {
|
|
574
|
+
const max = Math.max(...cls.values(), 1);
|
|
575
|
+
const fill: Record<string, string> = { behavioral: "r", infra: "c", unclassified: "" };
|
|
576
|
+
for (const [k, n] of [...cls.entries()].sort((a, b) => b[1] - a[1]))
|
|
577
|
+
out.push(bar(`${k} findings`, String(n), n / max, fill[k] ?? ""));
|
|
578
|
+
} else {
|
|
579
|
+
out.push(absent("failure-class split", "No failed/undecided session carried a category annotation in this run."));
|
|
580
|
+
}
|
|
581
|
+
out.push(
|
|
582
|
+
absent(
|
|
583
|
+
"per-trace detect-lens tagging (drift · tool-output · missing-context)",
|
|
584
|
+
"The leaf mining report records a failure CLASS per category, not a lens per trace — the split above is the discriminator this run genuinely recorded.",
|
|
585
|
+
),
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
out.push(
|
|
589
|
+
`<div style="font-family:var(--mono);font-size:12.5px;color:var(--primarySoft);margin:24px 0 8px">Emergent clusters (${c.clusters}) → criteria (${crits.length}) + routed (${c.routed}) + dropped (${c.dropped})</div>`,
|
|
590
|
+
);
|
|
591
|
+
const cats = input.miningReport?.categories ?? [];
|
|
592
|
+
if (cats.length > 0) {
|
|
593
|
+
const bySlug = new Map(crits.map((x) => [x.slug, x]));
|
|
594
|
+
const rows = cats.map((cat: DiscoverCategory) => {
|
|
595
|
+
const tc = bySlug.get(cat.name);
|
|
596
|
+
const mined = input.criteria.find((x) => x.id === cat.name);
|
|
597
|
+
// a cluster whose id was ABSORBED by the merge did not vanish — say where it went.
|
|
598
|
+
const absorbedBy = input.criteria.find((x) => (x.mergedFrom ?? []).includes(cat.name));
|
|
599
|
+
const became =
|
|
600
|
+
tc !== undefined
|
|
601
|
+
? `${esc(tc.id)} ${esc(tc.slug)} <span class="gb ${esc(mined?.discovery.evidence.grounding === "observed" ? "observed" : "inferred")}">${esc(mined?.discovery.evidence.grounding ?? "observed")}</span> ${mined !== undefined ? stateChips(input, mined) : ""}`
|
|
602
|
+
: absorbedBy !== undefined
|
|
603
|
+
? `<span style="color:var(--cyan)">merged into <b>${esc(absorbedBy.id)}</b></span> — same behaviour under a second name; its evidence rides on that criterion`
|
|
604
|
+
: mined !== undefined
|
|
605
|
+
? `<span style="color:var(--warn)">→ diagnostics (EV-051) — mined, then routed as a FIX; deliberately NOT in the ④ catalog</span>`
|
|
606
|
+
: cat.fixOrEval === "fixable"
|
|
607
|
+
? `<span style="color:var(--warn)">→ diagnostics (infra, not eval-worthy — EV-051)</span>`
|
|
608
|
+
: `<span style="color:var(--dim)">dropped — no criterion minted</span>`;
|
|
609
|
+
return `<tr><td><b>${esc(cat.name)}</b></td><td>${cat.exampleTraceIds.length}</td><td>${esc(cat.class)}</td><td>${became}</td></tr>`;
|
|
610
|
+
});
|
|
611
|
+
out.push(
|
|
612
|
+
`<table class="ct"><tr><th>cluster</th><th>n</th><th>class</th><th>became</th></tr>\n ${rows.join("\n ")}\n </table>`,
|
|
613
|
+
);
|
|
614
|
+
} else {
|
|
615
|
+
out.push(
|
|
616
|
+
absent(
|
|
617
|
+
"emergent cluster table",
|
|
618
|
+
"No leaf mining report was supplied to this render, so the per-category clustering cannot be shown; the criteria below still carry their own evidence.",
|
|
619
|
+
),
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
out.push(
|
|
623
|
+
`<div class="nest">emergent-only guard: categories come from the leaf mining report's OWN clustering — the engine seeds no pre-defined category list (deriveCriteria groups by the emitted category name).</div>`,
|
|
624
|
+
);
|
|
625
|
+
if (c.routed > 0)
|
|
626
|
+
out.push(
|
|
627
|
+
`<div class="nest"><b>${c.routed} mined criteri${c.routed === 1 ? "on carries" : "a carry"} the fixable→diagnostics flag</b> — named here on purpose and deliberately ABSENT from the ④ catalog. EV-051: the evaluator judges and routes, it never adopts a fix as an eval.</div>`,
|
|
628
|
+
);
|
|
629
|
+
out.push(redundancySection(input, evalCriteria, tally));
|
|
630
|
+
return out.join("\n ");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function layerCoverage(
|
|
634
|
+
evalCriteria: MinedCriterion[],
|
|
635
|
+
crits: TemplateCriterion[],
|
|
636
|
+
t: RedundancyTally,
|
|
637
|
+
): string {
|
|
638
|
+
const byLayer = new Map<string, number>();
|
|
639
|
+
for (const c of crits) byLayer.set(c.layer, (byLayer.get(c.layer) ?? 0) + 1);
|
|
640
|
+
// the redundancy read comes FIRST — it is the question a reader asks of a
|
|
641
|
+
// recommendation list before they read any single card.
|
|
642
|
+
const state: string[] = [];
|
|
643
|
+
if (t.stated) {
|
|
644
|
+
state.push(
|
|
645
|
+
`<i style="border-color:var(--pass);color:var(--pass)">NEW this run <b style="color:var(--pass)">${t.fresh}</b></i>`,
|
|
646
|
+
);
|
|
647
|
+
state.push(
|
|
648
|
+
`<i style="border-color:rgba(126,71,215,.6);color:var(--primarySoft)">REINFORCED <b style="color:var(--primarySoft)">${t.reinforced}</b></i>`,
|
|
649
|
+
);
|
|
650
|
+
} else {
|
|
651
|
+
state.push(`<i>new-vs-reinforced split <b>not recorded by this run</b> — NAMED absence</i>`);
|
|
652
|
+
}
|
|
653
|
+
state.push(
|
|
654
|
+
t.merged > 0
|
|
655
|
+
? `<i style="border-color:var(--cyan);color:var(--cyan)">MERGED near-duplicates <b style="color:var(--cyan)">${t.merged}</b> (absorbed ${t.absorbed} duplicate id${t.absorbed === 1 ? "" : "s"})</i>`
|
|
656
|
+
: `<i>near-duplicates merged <b>0</b> — no criterion restated another</i>`,
|
|
657
|
+
);
|
|
658
|
+
if (t.pending > 0)
|
|
659
|
+
state.push(
|
|
660
|
+
`<i style="border-color:var(--warn);color:var(--warn)">PENDING your ruling <b style="color:var(--warn)">${t.pending}</b> (kept apart, kept open — tab ③)</i>`,
|
|
661
|
+
);
|
|
662
|
+
const codeish = evalCriteria.filter(
|
|
663
|
+
(c) => c.metadata.check_method === "deterministic" || c.metadata.check_method === "hybrid",
|
|
664
|
+
);
|
|
665
|
+
const chips = [...byLayer.entries()].map(([l, n]) => `<i>${esc(l)} <b>${n}</b></i>`);
|
|
666
|
+
if (chips.length === 0) chips.push(`<i>no criterion carries an evidence level — NAMED absence</i>`);
|
|
667
|
+
const code =
|
|
668
|
+
codeish.length > 0
|
|
669
|
+
? `<i style="border-color:var(--cyan);color:var(--cyan)">+ code-checkable candidates <b style="color:var(--cyan)">${codeish.length}</b> (${esc(codeish.map((c) => c.id).slice(0, 3).join(" · "))}${codeish.length > 3 ? ` +${codeish.length - 3} more` : ""} → deterministic/hybrid check_method)</i>`
|
|
670
|
+
: `<i>code-checkable candidates <b>0</b> — every mined criterion routed to the judge track</i>`;
|
|
671
|
+
return `<i>how this list relates to the suite you already have:</i>
|
|
672
|
+
${state.join("")}
|
|
673
|
+
<i>checks discovered per evidence layer:</i>
|
|
674
|
+
${chips.join("")}
|
|
675
|
+
${code}`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function datasetSection(
|
|
679
|
+
input: DiscoverV3Input,
|
|
680
|
+
labels: Map<string, LabelMeta>,
|
|
681
|
+
idMap: Map<string, string>,
|
|
682
|
+
c: Counts,
|
|
683
|
+
evalCount: number,
|
|
684
|
+
): string {
|
|
685
|
+
const out: string[] = [];
|
|
686
|
+
const cases = input.datasetCandidates ?? [];
|
|
687
|
+
const CAP = 25;
|
|
688
|
+
if (cases.length > 0) {
|
|
689
|
+
const rows = cases.slice(0, CAP).map((cs) => {
|
|
690
|
+
const origin = cs.originTraceId;
|
|
691
|
+
const meta = origin !== undefined ? labels.get(origin) : undefined;
|
|
692
|
+
const judged =
|
|
693
|
+
meta?.verdict === OutcomeVerdict.Fail
|
|
694
|
+
? `<span class="sev CRIT" style="background:var(--fail-bg);color:var(--fail)">✗ FAILED</span>`
|
|
695
|
+
: meta?.verdict === OutcomeVerdict.Uncertain
|
|
696
|
+
? `<span class="sev HIGH" style="background:var(--warn-bg);color:var(--warn)">? UNDECIDED</span>`
|
|
697
|
+
: meta?.verdict === OutcomeVerdict.Pass
|
|
698
|
+
? `<span class="sev LOW">✓ passed</span>`
|
|
699
|
+
: `<span class="sev LOW">(label not carried)</span>`;
|
|
700
|
+
const originCell =
|
|
701
|
+
origin !== undefined
|
|
702
|
+
? `<span class="origin" onclick="peekTrace(event,'${esc(disp(idMap, origin))}')">${esc(disp(idMap, origin))} ▸</span>`
|
|
703
|
+
: "<i>(synthetic — no origin trace)</i>";
|
|
704
|
+
const happened =
|
|
705
|
+
meta !== undefined && meta.firstThingWrong.length > 0
|
|
706
|
+
? esc(meta.firstThingWrong)
|
|
707
|
+
: "<i>no first-thing-wrong text recorded for this origin session — NAMED absence</i>";
|
|
708
|
+
// the selector's own wording already fills the LAST column — repeating it
|
|
709
|
+
// here turned the widest column into a duplicate of the narrowest one.
|
|
710
|
+
const prevents =
|
|
711
|
+
cs.rationale !== undefined && cs.rationale.length > 0
|
|
712
|
+
? esc(cs.rationale)
|
|
713
|
+
: `<i>no authored rationale — this case was picked deterministically by the selector named in the last column; the judgment of what it prevents is not written by this run</i>`;
|
|
714
|
+
const recipe = Object.entries(cs.tuple as Record<string, unknown>)
|
|
715
|
+
.map(([k, v]) => `${k}: ${String(v)}`)
|
|
716
|
+
.join(" · ");
|
|
717
|
+
return `<tr><td>${originCell}</td><td>${judged}</td><td>${happened}</td><td>${prevents}</td><td>${esc(recipe) || "<i>(no tuple recorded)</i>"}</td><td>${esc(cs.selectedBy ?? "—")}</td></tr>`;
|
|
718
|
+
});
|
|
719
|
+
out.push(`<table class="ct"><tr><th>origin</th><th>judged as</th><th>what happened in the origin session</th><th>what the test case will represent & prevent</th><th>case recipe — the ingredients the synthetic case combines</th><th>selected by</th></tr>
|
|
720
|
+
${rows.join("\n ")}
|
|
721
|
+
</table>`);
|
|
722
|
+
if (cases.length > CAP)
|
|
723
|
+
out.push(
|
|
724
|
+
`<div class="nest">showing the first ${CAP} of ${cases.length} candidate cases — a DECLARED cap; the full set rides in <b>dataset-candidates.json</b>.</div>`,
|
|
725
|
+
);
|
|
726
|
+
} else {
|
|
727
|
+
out.push(
|
|
728
|
+
absent(
|
|
729
|
+
"regression-case candidates",
|
|
730
|
+
"No ✗/undecided session in this batch carried a replayable input, so no regression case could be distilled.",
|
|
731
|
+
),
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
out.push(
|
|
735
|
+
`<div style="font-family:var(--mono);font-size:12.5px;color:var(--primarySoft);margin:24px 0 8px">Living-suite delta (append-only)</div>`,
|
|
736
|
+
);
|
|
737
|
+
const s = input.suite;
|
|
738
|
+
// the suite grows by EVERY mined criterion — including the ones routed to
|
|
739
|
+
// diagnostics. Naming the split stops "+N appended" from reading as "N evals".
|
|
740
|
+
const split =
|
|
741
|
+
c.routed > 0 ? ` — ${evalCount} eval-worthy + ${c.routed} routed to diagnostics (both kept, with provenance)` : "";
|
|
742
|
+
out.push(
|
|
743
|
+
s !== undefined
|
|
744
|
+
? `<div class="row"><span class="ok">+${s.lastAppended}</span> criteria appended${esc(split)} · suite total ${s.total} · version ${s.version} · monotonic-growth assert ✓ (growLivingSuite is append-only)</div>`
|
|
745
|
+
: absent("living-suite delta", "No suite provenance was passed to this render — the append-only growth cannot be claimed."),
|
|
746
|
+
);
|
|
747
|
+
return out.join("\n ");
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function methodologySection(
|
|
751
|
+
input: DiscoverV3Input,
|
|
752
|
+
crits: TemplateCriterion[],
|
|
753
|
+
c: Counts,
|
|
754
|
+
labels: Map<string, LabelMeta>,
|
|
755
|
+
idMap: Map<string, string>,
|
|
756
|
+
): string {
|
|
757
|
+
const src = input.source ?? "(source kind not recorded)";
|
|
758
|
+
const mono = (t: string, mt = 24): string =>
|
|
759
|
+
`<div style="font-family:var(--mono);font-size:12.5px;color:var(--primarySoft);margin:${mt}px 0 8px">${t}</div>`;
|
|
760
|
+
const rows: string[] = [
|
|
761
|
+
`<tr><td>1</td><td>collect representative traces</td><td>${c.corpus !== undefined ? `${num(c.corpus)} in the source corpus; ` : ""}${num(c.sampled)} handed over via ${esc(src)} (the skill never fetches)</td><td>corpus bound by role from config sources</td></tr>`,
|
|
762
|
+
`<tr><td>2</td><td>sample for deep read</td><td>${input.samplingCensus !== undefined ? `${input.samplingCensus.length} strategies (tab ②)` : "<b>no selection census recorded</b> — NAMED absence (tab ②)"}</td><td>${num(c.read)} deep-read</td></tr>`,
|
|
763
|
+
`<tr><td>3</td><td>determine outcome per trace</td><td>#mode-discover leaf subagents, host runtime, pinned temperature 0</td><td>${c.pass} ✓ · ${c.fail} ✗${c.uncertain > 0 ? ` · ${c.uncertain} ?` : ""}</td></tr>`,
|
|
764
|
+
`<tr><td>4</td><td>classify each failure</td><td>failure CLASS per category (behavioral vs infra); the 3-lens per-trace tagging is a NAMED absence (tab ③)</td><td>${c.fail + c.uncertain} failure read(s) classified</td></tr>`,
|
|
765
|
+
`<tr><td>5</td><td>cluster emergent categories</td><td>categories come from the leaf mining report's own clustering — no pre-seeded list</td><td>${c.clusters} cluster(s)</td></tr>`,
|
|
766
|
+
`<tr><td>6</td><td>fix-worthy vs eval-worthy</td><td>EV-051: <b>fixable</b> categories are routed to diagnostics, never minted as evals</td><td>${crits.length} criteri${crits.length === 1 ? "on" : "a"} + ${c.routed} routed${c.dropped > 0 ? ` + ${c.dropped} dropped` : ""}</td></tr>`,
|
|
767
|
+
`<tr><td>7</td><td>iterate to saturation</td><td>${input.saturation !== undefined ? "saturation curve recorded (tab ②)" : "<b>no saturation curve recorded</b> — NAMED absence; the whole handed-over batch was read"}</td><td>${c.unread === 0 ? "batch fully read" : `${c.unread} sampled session(s) not deep-read`}</td></tr>`,
|
|
768
|
+
];
|
|
769
|
+
|
|
770
|
+
// one WORKED example — composed ONLY from fields the run actually recorded.
|
|
771
|
+
const failing = input.annotations.find((a) => a.label === OutcomeVerdict.Fail);
|
|
772
|
+
const worked =
|
|
773
|
+
failing !== undefined
|
|
774
|
+
? (() => {
|
|
775
|
+
const meta = labels.get(failing.traceId);
|
|
776
|
+
const ref = (failing.refs ?? [])[0];
|
|
777
|
+
const tc = crits.find((x) => x.slug === failing.category);
|
|
778
|
+
return `<div class="nest" style="font-family:var(--font);font-size:14px;line-height:1.65">
|
|
779
|
+
<b>${esc(disp(idMap, failing.traceId))}.</b> Determiner verdict: <b>✗ fail</b>. First thing wrong: ${esc(meta?.firstThingWrong ?? failing.note ?? "(not recorded)")}
|
|
780
|
+
Failure class: ${esc(failing.failureClass ?? "unclassified")}. ${ref !== undefined ? `Ref bound: <b>${esc(ref.obs)}</b> ${esc(ref.path)} = "${esc(String(ref.value).slice(0, 120))}" (re-resolved by exact match ✓).` : "No structured ref was cited for this trace — the criterion's grounding rests on its other refs."}
|
|
781
|
+
Cluster: <b>${esc(failing.category ?? "(uncategorized)")}</b>. Emitted → ${tc !== undefined ? `<b>${esc(tc.id)} ${esc(tc.slug)}</b>, severity ${esc(tc.sev)}, checked by ${esc(tc.cls)}, layer ${esc(tc.layer)}` : "no criterion (routed or dropped)"}.
|
|
782
|
+
</div>`;
|
|
783
|
+
})()
|
|
784
|
+
: absent("worked trace read", "No ✗-labelled session with an annotation is present in this run.");
|
|
785
|
+
|
|
786
|
+
const observedPct = crits.length > 0 ? Math.round((100 * c.observed) / crits.length) : 0;
|
|
787
|
+
return `${mono("Process as executed — the 7-step error analysis, this run", 6)}
|
|
788
|
+
<table class="ct"><tr><th>#</th><th>step</th><th>this run</th><th>outcome</th></tr>
|
|
789
|
+
${rows.join("\n ")}
|
|
790
|
+
</table>
|
|
791
|
+
|
|
792
|
+
${mono("Provenance — which machinery produced which number")}
|
|
793
|
+
<table class="ct"><tr><th>figure</th><th>producer</th><th>kind</th></tr>
|
|
794
|
+
<tr><td>funnel + heat counts (①)</td><td>render-discover-report-v3 over aggregateDiscover() output</td><td>deterministic fold</td></tr>
|
|
795
|
+
<tr><td>read balance (②)</td><td>per-trace determiner verdict files (#mode-discover leaves)</td><td>LLM judge (pinned, temp 0)</td></tr>
|
|
796
|
+
<tr><td>cluster map (③)</td><td>leaf mining report categories · parseDiscoverAnnotations join</td><td>LLM clustering + deterministic join</td></tr>
|
|
797
|
+
<tr><td>criterion cards (④)</td><td>deriveMinedCriteria (§5b metadata + §5c DR-2) · parseMinedCriterion evidence gate</td><td>deterministic fold of judge output</td></tr>
|
|
798
|
+
<tr><td>dataset candidates (⑤)</td><td>collectDatasetCandidates → deriveRegressionCases (EV-052 selectors)</td><td>deterministic selectors</td></tr>
|
|
799
|
+
<tr><td>living-suite Δ (⑤)</td><td>growLivingSuite — appendOnly + assertMonotonicGrowth</td><td>deterministic, append-only</td></tr>
|
|
800
|
+
</table>
|
|
801
|
+
|
|
802
|
+
${mono("Proof of work — one fully worked trace read")}
|
|
803
|
+
${worked}
|
|
804
|
+
|
|
805
|
+
${mono("Self-checks and honest gaps")}
|
|
806
|
+
<div class="row"><span class="ok">P1</span> evidence-first gate: every mined criterion re-validated through parseMinedCriterion (OBSERVED ⇒ refs ∧ k>0) — ${c.observed}/${crits.length} observed (${observedPct}%)</div>
|
|
807
|
+
<div class="row"><span class="ok">P2</span> judge-only (EV-051): ${c.routed} mined criteri${c.routed === 1 ? "on" : "a"} flagged fixable→diagnostics and kept OUT of the ④ catalog — this skill fixes nothing</div>
|
|
808
|
+
<div class="row"><span class="ok">P3</span> determinism: the AGGREGATE fold is pure (no clock/random/network) — the same verdict files re-render byte-identically</div>
|
|
809
|
+
<div class="row"><span class="w">G1</span> gap: ${num(c.read)} session(s) deep-read in a single batch — criteria are ${esc(input.subjectName)}-specific until a second-batch transfer check</div>
|
|
810
|
+
${input.samplingCensus === undefined ? `<div class="row"><span class="w">G2</span> gap: no sampling-strategy census — selection bias of the handed-over batch cannot be audited from this artifact</div>` : ""}
|
|
811
|
+
${input.saturation === undefined ? `<div class="row"><span class="w">G3</span> gap: no saturation curve — this run cannot claim the discovery reached saturation</div>` : ""}`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** the adoption handoff markdown — the mock's shape, this run's real content. */
|
|
815
|
+
function adoptionMd(input: DiscoverV3Input, crits: TemplateCriterion[], c: Counts): string {
|
|
816
|
+
const L: string[] = [];
|
|
817
|
+
L.push(`# Eval Adoption Handoff — ${input.subjectName}${isExternal(input) ? "" : ` · ${input.runId}`}`);
|
|
818
|
+
L.push(`**From:** discovery run · **To:** operator / eval-suite maintainer`);
|
|
819
|
+
L.push(
|
|
820
|
+
`**Deliverable:** ${crits.length} binary criteria + ${(input.datasetCandidates ?? []).length} regression-case candidate(s) + ${c.routed} diagnostics route(s)`,
|
|
821
|
+
);
|
|
822
|
+
L.push("");
|
|
823
|
+
const bySlug = new Map(input.criteria.map((m) => [m.id, m]));
|
|
824
|
+
for (const t of crits) {
|
|
825
|
+
const m = bySlug.get(t.slug);
|
|
826
|
+
if (m === undefined) continue;
|
|
827
|
+
const st = input.criterionStates?.[m.id];
|
|
828
|
+
const from = m.mergedFrom ?? [];
|
|
829
|
+
const badge = [
|
|
830
|
+
st === "new" ? "NEW" : st === "reinforced" ? "REINFORCED (already in your suite; gained evidence this run)" : null,
|
|
831
|
+
from.length > 0 ? `MERGED from ${from.join(", ")} (those ids still resolve to this criterion)` : null,
|
|
832
|
+
].filter((x) => x !== null);
|
|
833
|
+
L.push(`## ${t.id} — ${t.slug}`);
|
|
834
|
+
if (badge.length > 0) L.push(`**Status:** ${badge.join(" · ")}`);
|
|
835
|
+
L.push(`**Check:** ${t.statement}`);
|
|
836
|
+
L.push(
|
|
837
|
+
`**Layer:** ${t.layer} · **Checked by:** ${t.cls} · **Severity:** ${t.sev} · **Gates:** ${t.sev === "CRIT" || t.sev === "HIGH" ? "YES" : "no (advisory)"}`,
|
|
838
|
+
);
|
|
839
|
+
L.push(`**Guards against:** ${m.discovery.targets}`);
|
|
840
|
+
L.push(`**Why it matters:** ${m.discovery.why_problem}`);
|
|
841
|
+
L.push(
|
|
842
|
+
`**Evidence:** ${m.discovery.evidence.grounding} · prevalence ${m.discovery.evidence.prevalence} · ${m.discovery.evidence.refs.length} structured ref(s)`,
|
|
843
|
+
);
|
|
844
|
+
L.push("");
|
|
845
|
+
}
|
|
846
|
+
L.push("## Routed to diagnostics (NOT evals — EV-051)");
|
|
847
|
+
const routed = input.criteria.filter(isRouted);
|
|
848
|
+
L.push(
|
|
849
|
+
routed.length > 0
|
|
850
|
+
? routed.map((x) => `- ${x.id} — ${x.discovery.targets} (mined, routed as a fix; never adopted as an eval)`).join("\n")
|
|
851
|
+
: "- none in this run",
|
|
852
|
+
);
|
|
853
|
+
L.push("");
|
|
854
|
+
// PENDING travels with the handoff — the questions must not be trapped in the
|
|
855
|
+
// HTML, since this markdown is what gets pasted into an issue or a review.
|
|
856
|
+
const pending =
|
|
857
|
+
input.nearDuplicateLedger !== undefined
|
|
858
|
+
? input.nearDuplicateLedger.decisions.filter((d) => d.status === NearDuplicateStatus.Pending)
|
|
859
|
+
: [];
|
|
860
|
+
L.push("## Open near-duplicate questions (PENDING your ruling)");
|
|
861
|
+
if (pending.length > 0) {
|
|
862
|
+
for (const d of pending) {
|
|
863
|
+
L.push(
|
|
864
|
+
`- **${d.pair[0]} ~ ${d.pair[1]}** — ${Math.round(d.similarity * 100)}% wording overlap, kept apart (${d.guardSummary})${d.timesSurfaced > 1 ? ` · seen in ${d.timesSurfaced} runs` : ""}`,
|
|
865
|
+
);
|
|
866
|
+
for (const s of d.sides) L.push(` - \`${s.id}\`: "${s.statement}" (${s.prevalence}, ${s.refs.length} ref(s))`);
|
|
867
|
+
}
|
|
868
|
+
L.push("");
|
|
869
|
+
L.push(
|
|
870
|
+
" Both criteria in each pair are LIVE — nothing was merged or dropped. These are stored with the suite and stay open until ruled on.",
|
|
871
|
+
);
|
|
872
|
+
} else if (input.nearDuplicateLedger !== undefined) {
|
|
873
|
+
L.push("- none open");
|
|
874
|
+
} else {
|
|
875
|
+
L.push("- not recorded by this run — NAMED absence (no near-duplicate ledger was passed to this render)");
|
|
876
|
+
}
|
|
877
|
+
L.push("");
|
|
878
|
+
L.push("### Next steps");
|
|
879
|
+
L.push("1. Approve criteria → living suite append (provenance kept)");
|
|
880
|
+
L.push("2. `*build-dataset` from the candidate recipes");
|
|
881
|
+
L.push("3. `*evaluate` with the grown suite (pinned) → baseline scorecard");
|
|
882
|
+
return L.join("\n");
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/* ── the renderer ──────────────────────────────────────────────────────────── */
|
|
886
|
+
export function renderDiscoverReportV3(input: DiscoverV3Input): string {
|
|
887
|
+
const template = readFileSync(TEMPLATE_PATH, "utf8");
|
|
888
|
+
// EV-051 split: only eval-worthy criteria are RECOMMENDED (the ④ catalog); the
|
|
889
|
+
// routed ones stay visible in the cluster table + counts, never in the catalog.
|
|
890
|
+
const evalCriteria = input.criteria.filter((c) => !isRouted(c));
|
|
891
|
+
const routedCriteria = input.criteria.filter(isRouted);
|
|
892
|
+
const crits = mapCriteria(evalCriteria);
|
|
893
|
+
const tally = tallyRedundancy(input, evalCriteria);
|
|
894
|
+
|
|
895
|
+
// per-trace labels — the leaf mining report is the canonical ✓/✗ record.
|
|
896
|
+
const labels = new Map<string, LabelMeta>();
|
|
897
|
+
for (const l of input.miningReport?.labels ?? [])
|
|
898
|
+
labels.set(l.traceId, { verdict: l.verdict, firstThingWrong: l.firstThingWrong });
|
|
899
|
+
// annotations cover traces a category referenced; they never overwrite a label.
|
|
900
|
+
for (const a of input.annotations)
|
|
901
|
+
if (!labels.has(a.traceId)) labels.set(a.traceId, { verdict: a.label, firstThingWrong: a.note ?? "" });
|
|
902
|
+
|
|
903
|
+
const idMap = buildIdMap([...new Set([...labels.keys(), ...(input.traces ?? []).map((t) => t.id)])]);
|
|
904
|
+
const traces = mapTraces(input, crits, labels, idMap);
|
|
905
|
+
const vals = [...labels.values()];
|
|
906
|
+
const pass = vals.filter((v) => v.verdict === OutcomeVerdict.Pass).length;
|
|
907
|
+
const fail = vals.filter((v) => v.verdict === OutcomeVerdict.Fail).length;
|
|
908
|
+
const uncertain = vals.filter((v) => v.verdict === OutcomeVerdict.Uncertain).length;
|
|
909
|
+
const sampled = Math.max(labels.size, (input.traces ?? []).length);
|
|
910
|
+
const cats = input.miningReport?.categories ?? [];
|
|
911
|
+
const counts: Counts = {
|
|
912
|
+
corpus: input.corpusTotal,
|
|
913
|
+
sampled,
|
|
914
|
+
read: labels.size,
|
|
915
|
+
pass,
|
|
916
|
+
fail,
|
|
917
|
+
uncertain,
|
|
918
|
+
unread: Math.max(0, sampled - labels.size),
|
|
919
|
+
clusters: cats.length > 0 ? cats.length : new Set(input.criteria.map((x) => x.id)).size,
|
|
920
|
+
routed: routedCriteria.length,
|
|
921
|
+
// a cluster that minted NO criterion at all (neither eval nor routed).
|
|
922
|
+
dropped: cats.filter((x) => !input.criteria.some((m) => m.id === x.name)).length,
|
|
923
|
+
observed: evalCriteria.filter((x) => x.discovery.evidence.grounding === "observed").length,
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
const heat = { readp: pass, readf: fail + uncertain, samp: counts.unread };
|
|
927
|
+
const funnelStages: [string, string, string][] = [
|
|
928
|
+
[
|
|
929
|
+
"corpus",
|
|
930
|
+
counts.corpus !== undefined ? num(counts.corpus) : num(sampled),
|
|
931
|
+
counts.corpus !== undefined ? `all ${esc(input.source ?? "source")} traces` : "source corpus size not recorded",
|
|
932
|
+
],
|
|
933
|
+
["sampled", num(sampled), "handed to *discover"],
|
|
934
|
+
["deep-read", num(counts.read), `✓${pass} / ✗${fail}${uncertain > 0 ? ` / ?${uncertain}` : ""}`],
|
|
935
|
+
["clusters", num(counts.clusters), cats.length > 0 ? "emergent" : "from mined criteria"],
|
|
936
|
+
["criteria", num(crits.length), `binary · ${counts.observed} observed`],
|
|
937
|
+
["→ diagnostics", num(counts.routed), "EV-051 routed (not evals)"],
|
|
938
|
+
];
|
|
939
|
+
|
|
940
|
+
const clusters: Record<string, string> = {};
|
|
941
|
+
for (const t of crits) clusters[t.id] = t.slug;
|
|
942
|
+
|
|
943
|
+
const data = {
|
|
944
|
+
criteria: crits,
|
|
945
|
+
traces,
|
|
946
|
+
clusters,
|
|
947
|
+
funnelStages,
|
|
948
|
+
heat,
|
|
949
|
+
adoptionMd: adoptionMd(input, crits, counts),
|
|
950
|
+
...(input.evalReportHref !== undefined ? { evalReportHref: input.evalReportHref } : {}),
|
|
951
|
+
feedbackTitle: isExternal(input) ? `Discovery report — ${input.subjectName}` : `Discovery report · ${input.runId}`,
|
|
952
|
+
artifactPath: isExternal(input) ? "" : `.mutagent/evaluator/reports/${input.runId}/discovery-report.html`,
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
const gatesCount = crits.filter((x) => x.sev === "CRIT" || x.sev === "HIGH").length;
|
|
956
|
+
const pchip = 'class="chip" style="color:var(--primarySoft);border-color:rgba(126,71,215,.6)"';
|
|
957
|
+
const slots: Record<string, string> = {
|
|
958
|
+
"@@TITLE@@": `Discovery Report — ${esc(input.subjectName)}${isExternal(input) ? "" : ` · ${esc(input.runId)}`}`,
|
|
959
|
+
"@@HEADER_RIGHT@@": `<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>`,
|
|
960
|
+
"@@HERO_TITLE@@": `Discovery Report — ${esc(input.subjectName)}`,
|
|
961
|
+
"@@HERO_LEDE@@": `<b>Which evals should you add?</b> ${num(counts.read)} real session(s) of ${esc(input.subjectName)} were deep-read, every failure was traced to its first thing wrong, and <b>${crits.length} binary criteri${crits.length === 1 ? "on" : "a"}</b> ${counts.observed === crits.length ? "— all evidence-backed —" : `(${counts.observed} evidence-backed)`} were mined, plus ${(input.datasetCandidates ?? []).length} regression-case candidate(s) and ${counts.routed} infrastructure finding(s) routed to diagnostics. This report shows the criteria AND the proof of how they were found.`,
|
|
962
|
+
"@@IDENTITY_STRIP@@": `<b style="font-size:15px">${esc(input.subjectName)}</b>
|
|
963
|
+
${isExternal(input) ? "" : `<span ${pchip}>run ${esc(input.runId)}</span>`}<span ${pchip}>source: ${esc(input.source ?? "not recorded")} · ${num(sampled)} trace(s)</span>
|
|
964
|
+
<span ${pchip}>${counts.read} deep-read · ${counts.clusters} cluster(s)</span>
|
|
965
|
+
<span class="chip" style="margin-left:auto;color:var(--pass);border-color:var(--pass)">${crits.length} criteri${crits.length === 1 ? "on" : "a"} discovered</span>`,
|
|
966
|
+
"@@OVERVIEW_INTRO@@": `${esc(input.subjectName)}'s real sessions were mined for <b>evaluation criteria</b> — the binary checks this report recommends you add. Each read session was judged ✓/✗ by a pinned determiner, failures were clustered into emergent categories, and only checks backed by <b>quoted evidence from real sessions</b> were minted. The pipeline below is the whole story left to right.`,
|
|
967
|
+
"@@HEAT_HEADING@@": `The sampled corpus at a glance — ${num(sampled)} session(s), one cell each`,
|
|
968
|
+
"@@HEAT_LEGEND@@": `<i><span class="sw" style="background:rgba(67,195,154,.55)"></span><b style="color:var(--fg)">${pass}</b> deep-read · succeeded</i>
|
|
969
|
+
<i><span class="sw" style="background:rgba(224,102,102,.6)"></span><b style="color:var(--fg)">${fail + uncertain}</b> deep-read · failed or undecided → produced the criteria</i>
|
|
970
|
+
<i><span class="sw" style="background:rgba(126,71,215,.45)"></span><b style="color:var(--fg)">${counts.unread}</b> sampled, not deep-read</i>`,
|
|
971
|
+
"@@KPI@@": `<i class="p">criteria recommended <b>${crits.length}</b></i><i class="pass">evidence-backed <b>${counts.observed}/${crits.length}</b></i><i>evidence layers covered <b>${new Set(crits.map((x) => x.layer)).size}</b></i>
|
|
972
|
+
<i class="warn">infra issues → diagnostics <b>${counts.routed}</b></i><i>regression cases proposed <b>${(input.datasetCandidates ?? []).length}</b></i><i>living-suite growth <b>+${input.suite?.lastAppended ?? crits.length} entries</b></i>
|
|
973
|
+
${tally.stated ? `<i class="pass">new this run <b>${tally.fresh}</b></i><i class="p">reinforced existing <b>${tally.reinforced}</b></i>` : `<i>new vs reinforced <b>not recorded</b></i>`}<i>near-duplicates merged <b>${tally.merged}</b></i>${tally.pending > 0 ? `<i class="warn">open near-dup questions <b>${tally.pending}</b></i>` : ""}`,
|
|
974
|
+
"@@BOTTOM_LINE@@": `Bottom line for the AI engineer: <b style="color:var(--fg)">add the ${crits.length} criteri${crits.length === 1 ? "on" : "a"} in tab ④</b> — ${counts.observed === crits.length ? "every one was seen failing in a real session" : `${counts.observed} of ${crits.length} were seen failing in a real session (the rest are inferred guards and say so on their card)`}, and ${gatesCount === 1 ? "1 of them gates" : `${gatesCount} of them gate`} a release (CRIT/HIGH). ${counts.routed > 0 ? `A further ${counts.routed} mined criteri${counts.routed === 1 ? "on was" : "a were"} flagged fixable→diagnostics and handed over as a FIX rather than adopted as an eval (EV-051 — this skill judges, it never fixes); ${counts.routed === 1 ? "it is" : "they are"} named in tab ③.` : "No mined criterion was flagged fixable — every one is eval-worthy."} ${tally.stated ? `Of the ${crits.length}, <b style="color:var(--fg)">${tally.fresh} ${tally.fresh === 1 ? "is" : "are"} new</b> and ${tally.reinforced} already existed in your suite and only gained evidence here.` : ""}${tally.merged > 0 ? ` <b style="color:var(--fg)">${tally.absorbed} duplicate id${tally.absorbed === 1 ? "" : "s"}</b> ${tally.absorbed === 1 ? "was" : "were"} merged away, so no two recommendations below restate one another (tab ③ shows what merged into what).` : ""}${tally.pending > 0 ? ` <b style="color:var(--warn)">${tally.pending} look-alike pair${tally.pending === 1 ? "" : "s"} need${tally.pending === 1 ? "s" : ""} your ruling</b> — kept apart because merging them could misrepresent the evidence; both checks stay live and the question is stored, not just drawn (tab ③).` : ""}`,
|
|
975
|
+
"@@SAMPLING_SECTION@@": samplingSection(input, counts),
|
|
976
|
+
"@@WALK_SECTION@@": walkSection(input, crits, counts, evalCriteria, tally),
|
|
977
|
+
"@@CATALOG_COUNT@@": `${crits.length} discovered criteri${crits.length === 1 ? "on" : "a"}${tally.stated ? ` · ${tally.fresh} new · ${tally.reinforced} reinforced` : ""}${tally.merged > 0 ? ` · ${tally.merged} merged` : ""} · evidence + rationale`,
|
|
978
|
+
"@@LAYER_COVERAGE@@": layerCoverage(evalCriteria, crits, tally),
|
|
979
|
+
"@@DATASET_SECTION@@": datasetSection(input, labels, idMap, counts, crits.length),
|
|
980
|
+
"@@METHODOLOGY_SECTION@@": methodologySection(input, crits, counts, labels, idMap),
|
|
981
|
+
"@@FOOT@@": isExternal(input)
|
|
982
|
+
? `🧬 MutagenT · evaluator discovery report · ${esc(input.subjectName)}`
|
|
983
|
+
: `🧬 MutagenT · evaluator discovery report · run ${esc(input.runId)} · artifact → .mutagent/evaluator/reports/${esc(input.runId)} (gitignored)`,
|
|
984
|
+
"@@FEEDBACK_LABEL@@": `Feedback — discovery report${isExternal(input) ? "" : ` · ${esc(input.runId)}`} · Copy MD bundles your notes`,
|
|
985
|
+
"@@DATA_JSON@@": JSON.stringify(data).replace(/</g, "\\u003c"),
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
let html = template;
|
|
989
|
+
for (const [k, v] of Object.entries(slots)) {
|
|
990
|
+
if (!html.includes(k)) throw new Error(`TEMPLATE SLOT MISSING: ${k}`);
|
|
991
|
+
// a FUNCTION replacement, never the string form: `$&` / `$'` / "$$" inside
|
|
992
|
+
// real subject text (a quoted reply, a criterion statement) are substitution
|
|
993
|
+
// patterns to String.replace and would silently corrupt the emitted page.
|
|
994
|
+
html = html.replace(k, () => v);
|
|
995
|
+
}
|
|
996
|
+
// The feedback bar is a DEV-LOOP affordance only: user-produced reports never
|
|
997
|
+
// carry it (same rule as the eval report, operator round 13).
|
|
998
|
+
if (input.devFeedback !== true) {
|
|
999
|
+
const start = html.indexOf('<div id="fbpanel"');
|
|
1000
|
+
const endAnchor = "</div></div>";
|
|
1001
|
+
const end = start >= 0 ? html.indexOf(endAnchor, start) : -1;
|
|
1002
|
+
if (start < 0 || end < 0) throw new Error("feedback-panel strip anchor missing");
|
|
1003
|
+
html = html.slice(0, start) + html.slice(end + endAnchor.length);
|
|
1004
|
+
}
|
|
1005
|
+
// ── V11 AUDIENCE STRIP — the ⑥ INTERNAL methodology surface ──
|
|
1006
|
+
// Runs AFTER slot-fill on purpose: the ⑥ panel holds `@@METHODOLOGY_SECTION@@`, so
|
|
1007
|
+
// cutting it first would trip the fail-loud "UNFILLED SLOT" guard below. ⑥ is the
|
|
1008
|
+
// LAST tab, so removing index 5 leaves the remaining indices contiguous and the
|
|
1009
|
+
// template's index-based `tab()` switcher keeps working.
|
|
1010
|
+
if (isExternal(input)) {
|
|
1011
|
+
html = stripInternalSurface(html, { commentMarker: "<!-- ⑥ METHODOLOGY -->", tabIndex: 5, label: "⑥ Methodology" });
|
|
1012
|
+
}
|
|
1013
|
+
const leftover = /@@[A-Z_]+@@/.exec(html);
|
|
1014
|
+
if (leftover !== null) throw new Error(`UNFILLED SLOT: ${leftover[0]}`);
|
|
1015
|
+
return html;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Write the v3 frozen-contract discovery report for a run. The sibling
|
|
1020
|
+
* evaluation-report deep link is wired ONLY when that artifact actually exists
|
|
1021
|
+
* next to this one (an absent sibling renders no link, never a dead one).
|
|
1022
|
+
* Returns the artifact path.
|
|
1023
|
+
*/
|
|
1024
|
+
export function writeDiscoverReportV3(input: DiscoverV3Input, repoRoot: string): string {
|
|
1025
|
+
const dir = join(repoRoot, ".mutagent/evaluator/reports", input.runId);
|
|
1026
|
+
const out = join(dir, "discovery-report.html");
|
|
1027
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
1028
|
+
const sibling = join(dir, "evaluation-report.html");
|
|
1029
|
+
const withLink: DiscoverV3Input =
|
|
1030
|
+
input.evalReportHref === undefined && existsSync(sibling)
|
|
1031
|
+
? { ...input, evalReportHref: "evaluation-report.html" }
|
|
1032
|
+
: input;
|
|
1033
|
+
writeFileSync(out, renderDiscoverReportV3(withLink));
|
|
1034
|
+
return out;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** the run-dir data the v3 report needs; anything not supplied is read from disk. */
|
|
1038
|
+
export type DiscoverRunData = Partial<
|
|
1039
|
+
Pick<
|
|
1040
|
+
DiscoverV3Input,
|
|
1041
|
+
| "criteria"
|
|
1042
|
+
| "annotations"
|
|
1043
|
+
| "miningReport"
|
|
1044
|
+
| "traces"
|
|
1045
|
+
| "datasetCandidates"
|
|
1046
|
+
| "suite"
|
|
1047
|
+
| "corpusTotal"
|
|
1048
|
+
| "samplingCensus"
|
|
1049
|
+
| "saturation"
|
|
1050
|
+
| "criterionStates"
|
|
1051
|
+
| "nearDuplicates"
|
|
1052
|
+
| "nearDuplicateLedger"
|
|
1053
|
+
>
|
|
1054
|
+
>;
|
|
1055
|
+
|
|
1056
|
+
function readJson<T>(io: DiscoverIo, path: string): T | null {
|
|
1057
|
+
try {
|
|
1058
|
+
return JSON.parse(io.readFile(path)) as T;
|
|
1059
|
+
} catch {
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* The W3 discover RUN composer — the analogue of run-evaluate's `writeRunReport`.
|
|
1066
|
+
* Given a run DIR it writes BOTH reports:
|
|
1067
|
+
* - `discovery-report.html` → the PRODUCTION frozen-contract v3 render
|
|
1068
|
+
* - `discovery-report.v2.html` → the previous renderer's output, kept as the
|
|
1069
|
+
* transition fallback (v2 module untouched)
|
|
1070
|
+
* Returns the PRODUCTION path.
|
|
1071
|
+
*
|
|
1072
|
+
* The AGGREGATE caller should pass what it already holds in memory (`data`); every
|
|
1073
|
+
* field it omits is read from the run dir when present, and each genuinely-absent
|
|
1074
|
+
* companion becomes a NAMED absence in the report — never a fabricated number.
|
|
1075
|
+
* `criteria.json` is the one hard requirement (fail-loud).
|
|
1076
|
+
*/
|
|
1077
|
+
export function writeDiscoverRunReportV3(
|
|
1078
|
+
args: {
|
|
1079
|
+
dir: string;
|
|
1080
|
+
runId: string;
|
|
1081
|
+
subjectName: string;
|
|
1082
|
+
subjectSource?: string;
|
|
1083
|
+
generatedAt?: string;
|
|
1084
|
+
devFeedback?: boolean;
|
|
1085
|
+
},
|
|
1086
|
+
io: DiscoverIo,
|
|
1087
|
+
data: DiscoverRunData = {},
|
|
1088
|
+
): string {
|
|
1089
|
+
const { dir } = args;
|
|
1090
|
+
const p = (name: string): string => join(dir, name);
|
|
1091
|
+
|
|
1092
|
+
// v2 fallback FIRST — the previous renderer, byte-for-byte, at the .v2 path.
|
|
1093
|
+
writeDiscoverReportFromFiles(
|
|
1094
|
+
{
|
|
1095
|
+
criteriaPath: p("criteria.json"),
|
|
1096
|
+
outPath: p("discovery-report.v2.html"),
|
|
1097
|
+
groundingPath: p("grounding-check.json"),
|
|
1098
|
+
triagePath: p("triage-summary.json"),
|
|
1099
|
+
verdictsDir: p("verdicts"),
|
|
1100
|
+
datasetPath: p("dataset-candidates.json"),
|
|
1101
|
+
profilePath: p("subject-profile.json"),
|
|
1102
|
+
subjectName: args.subjectName,
|
|
1103
|
+
...(args.subjectSource !== undefined ? { subjectSource: args.subjectSource } : {}),
|
|
1104
|
+
...(args.generatedAt !== undefined ? { generatedAt: args.generatedAt } : {}),
|
|
1105
|
+
},
|
|
1106
|
+
io,
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
const criteria = data.criteria ?? readJson<MinedCriterion[]>(io, p("criteria.json"));
|
|
1110
|
+
if (criteria === null) throw new Error(`writeDiscoverRunReportV3: criteria.json unreadable at ${p("criteria.json")}`);
|
|
1111
|
+
// the leaf mining report lives at `discover/<batch_id>.json` (agent contract);
|
|
1112
|
+
// absent ⇒ the cluster table renders as a NAMED absence, never invented rows.
|
|
1113
|
+
let mining = data.miningReport ?? null;
|
|
1114
|
+
if (mining === null && io.readDir !== undefined) {
|
|
1115
|
+
try {
|
|
1116
|
+
const batch = io.readDir(p("discover")).find((f) => f.endsWith(".json"));
|
|
1117
|
+
if (batch !== undefined) mining = readJson<DiscoverMiningReport>(io, join(p("discover"), batch));
|
|
1118
|
+
} catch {
|
|
1119
|
+
mining = null;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const input: DiscoverV3Input = {
|
|
1124
|
+
subjectName: args.subjectName,
|
|
1125
|
+
runId: args.runId,
|
|
1126
|
+
criteria,
|
|
1127
|
+
annotations: data.annotations ?? readJson<TraceAnnotation[]>(io, p("annotations.json")) ?? [],
|
|
1128
|
+
...(mining !== null ? { miningReport: mining } : {}),
|
|
1129
|
+
traces: data.traces ?? readJson<EvalTrace[]>(io, p("traces.json")) ?? [],
|
|
1130
|
+
datasetCandidates: data.datasetCandidates ?? readJson<DatasetCase[]>(io, p("dataset-candidates.json")) ?? [],
|
|
1131
|
+
...(data.suite !== undefined ? { suite: data.suite } : {}),
|
|
1132
|
+
// absent ⇒ the redundancy strip renders a NAMED absence; it never guesses
|
|
1133
|
+
// that everything is new (a reinforcing re-run would read as fresh discovery).
|
|
1134
|
+
...(data.criterionStates !== undefined ? { criterionStates: data.criterionStates } : {}),
|
|
1135
|
+
...(data.nearDuplicates !== undefined ? { nearDuplicates: data.nearDuplicates } : {}),
|
|
1136
|
+
...(data.nearDuplicateLedger !== undefined ? { nearDuplicateLedger: data.nearDuplicateLedger } : {}),
|
|
1137
|
+
...(data.corpusTotal !== undefined ? { corpusTotal: data.corpusTotal } : {}),
|
|
1138
|
+
...(data.samplingCensus !== undefined ? { samplingCensus: data.samplingCensus } : {}),
|
|
1139
|
+
...(data.saturation !== undefined ? { saturation: data.saturation } : {}),
|
|
1140
|
+
...(args.subjectSource !== undefined ? { source: args.subjectSource } : {}),
|
|
1141
|
+
...(args.generatedAt !== undefined ? { generatedAt: args.generatedAt } : {}),
|
|
1142
|
+
...(args.devFeedback !== undefined ? { devFeedback: args.devFeedback } : {}),
|
|
1143
|
+
};
|
|
1144
|
+
// the sibling eval-report link renders ONLY when that artifact really exists.
|
|
1145
|
+
let sibling = false;
|
|
1146
|
+
try {
|
|
1147
|
+
io.readFile(p("evaluation-report.html"));
|
|
1148
|
+
sibling = true;
|
|
1149
|
+
} catch {
|
|
1150
|
+
sibling = false;
|
|
1151
|
+
}
|
|
1152
|
+
const out = p("discovery-report.html");
|
|
1153
|
+
io.writeFile(out, renderDiscoverReportV3(sibling ? { ...input, evalReportHref: "evaluation-report.html" } : input));
|
|
1154
|
+
return out;
|
|
1155
|
+
}
|