@mutagent/evaluator 0.1.0-alpha.6 → 0.2.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/mutagent-evaluator/SKILL.md +23 -25
- package/.claude/skills/mutagent-evaluator/assets/agents/dataset-builder.md +6 -5
- package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +77 -14
- 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/references/data-registry.md +43 -0
- package/.claude/skills/mutagent-evaluator/references/eval-layers.md +52 -0
- package/.claude/skills/mutagent-evaluator/references/eval-stage.md +1 -1
- 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/code-eval-library.ts +201 -0
- package/.claude/skills/mutagent-evaluator/scripts/contracts/agentspec-evals.ts +101 -39
- package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +287 -3
- package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-types.ts +30 -4
- 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/merge-criteria.ts +849 -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 +2 -1
- package/.claude/skills/mutagent-evaluator/scripts/render-review-report-v3.ts +691 -0
- package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +10 -7
- package/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +46 -1
- package/.claude/skills/mutagent-evaluator/scripts/run-review.ts +266 -0
- package/.claude/skills/mutagent-evaluator/scripts/verify-render.ts +728 -0
- package/package.json +2 -2
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# golden/judge-trajectory.prose.md — the C-PIN prose surface (VERSIONED anchor)
|
|
2
|
+
|
|
3
|
+
> The #mode-judge-trajectory judge constructs its prompt AT REASON-TIME from the
|
|
4
|
+
> packet + this mode's prose — so THIS prose is the load-bearing C-PIN surface.
|
|
5
|
+
> Any change to the workflow below is a judge-behavior change and MUST bump the
|
|
6
|
+
> version here (the W-gate byte-identity proof anchors against this snapshot).
|
|
7
|
+
|
|
8
|
+
## v3.1 — 2026-07-24 · REF-CONTRACT HARDENING (the first-real-run verify-surface flip)
|
|
9
|
+
|
|
10
|
+
Change vs v3.0 (ONE workflow line — GATHER): the ref contract is made explicit
|
|
11
|
+
after the first real run showed judge-cited packet-shaped refs reading as DEAD
|
|
12
|
+
at deterministic re-verify (both verifier-UPHELD HIGH fails silently downgraded
|
|
13
|
+
→ gate flipped fail→incomplete). Now: `ref.obs` = the packet's `trajectoryId`
|
|
14
|
+
(NEVER an observation name — batch-order first-match can locate the wrong
|
|
15
|
+
trajectory), `ref.path` = packet-shaped `trajectory.N`/`transcript.N` OR
|
|
16
|
+
trace-shaped `observations.N` (both re-resolve — the verifier reconstructs the
|
|
17
|
+
packet views via `verifySituationViews`), dot form canonical, brackets
|
|
18
|
+
tolerated (`readPath` normalizes). VERBATIM snapshot of the mode workflow
|
|
19
|
+
follows.
|
|
20
|
+
|
|
21
|
+
```yaml
|
|
22
|
+
- evaluate:
|
|
23
|
+
meta:
|
|
24
|
+
what: "Score ONE trajectory against the WHOLE eval matrix (was the eval-matrix-judge agent). The DEFAULT/headline judging cell."
|
|
25
|
+
does: "Reads a MatrixPacket (the whole matrix + one trajectory + transcript + pinned envelope), BUILDS its judging prompt from the packet + the shared write-judge-prompt.md, scores every criterion for that trajectory, writes a per-trajectory MatrixVerdictFile."
|
|
26
|
+
why: "Per-TRAJECTORY fan-out (one judge scores the whole matrix for one session) = high throughput across many sessions. EV-048. This is the headline *evaluate path."
|
|
27
|
+
how: "Unlike the criterion axis, NO script renders the prompt — the parent's matrix-judge.ts PREPs only a DATA packet (keyed by trajectoryKey); THIS mode constructs the prompt at reason-time. Its prompt-construction prose is therefore the load-bearing C-PIN surface (golden/judge-trajectory.prose.md)."
|
|
28
|
+
display: "Score one trajectory against the WHOLE eval matrix (headline)"
|
|
29
|
+
description: |
|
|
30
|
+
PURPOSE: the DEFAULT headline judging cell — one judge per trajectory scores
|
|
31
|
+
the entire eval matrix for that session → per-criterion verdicts. Critique-
|
|
32
|
+
before-verdict, binary, inaction-can-be-success, whole-matrix-per-trajectory.
|
|
33
|
+
|
|
34
|
+
USAGE: dispatched by the parent, MASS-PARALLEL (one per trajectory). Reads
|
|
35
|
+
<trajectory_key>.packet.json (a MatrixPacket — DATA, NOT a rendered prompt);
|
|
36
|
+
BUILDS the prompt from it + write-judge-prompt.md; writes the MatrixVerdictFile.
|
|
37
|
+
dispatch: { mode: judge, axis: trajectory }
|
|
38
|
+
pre_gate.loads:
|
|
39
|
+
- "references/write-judge-prompt.md" # SHARED 4-component contract (both judge axes; now BIND-before-judge)
|
|
40
|
+
- "references/grounded-adjudication.md" # GA doctrine: bind · gather refs · typed assumptions · abstain · verify
|
|
41
|
+
- "references/workflows/orchestrator-protocol.md" # Step 2 dispatch FSM
|
|
42
|
+
workflow:
|
|
43
|
+
- "Pre-read references/write-judge-prompt.md (the 4-component judging contract — your lens) + references/grounded-adjudication.md"
|
|
44
|
+
- "Read your assigned <trajectory_key>.packet.json (MatrixPacket: subject · trajectoryId · WHOLE matrix · trajectory · transcript · pinned envelope) — judge exactly this, never re-derive the data"
|
|
45
|
+
- "Frame the trajectory in its ROUTE/intended-outcome (CONTEXT) before scoring"
|
|
46
|
+
- "v3 LAYERED WALK (D1=B+, operator-frozen): the walk IS a fixed layer sequence you run INSIDE this one dispatch. Honor `packet.layerScope` when present (absent/empty = FULL walk). The phases: (L0 code checks) if the code-eval library is present at scripts/code-eval-library.ts, RUN it yourself via Bash over the packet's trajectory JSON and record each hit as codeEvalHits[{pattern,anchor,detail}] — a fired pattern is evidence, not a verdict; (L1 outcome) classify the SCENARIO from the inbound intent + tool inventory, derive its expectedExit[] terminal actions, glance ONLY at end-state evidence and emit layerVerdicts[{layer:'L1',verdict,scenario,expectedExit,refs,note}]; (STOPPING RULES) if L1 passes AND every criterion is answerable from evidence gathered so far → you may set earlyExit:'L1' and mark L2-L4 as {verdict:'skipped'} — but a criterion may NEVER go unverdicted because the agent did the right thing; if ANY criterion is unanswerable OR L1 fails/undecidable → descend; (L2 trajectory) actual-vs-expected decision path, read from the END, emit layerVerdicts L2 (+ divergence in localize); (L3 tool outputs) per-observation structural validity + completeness — external-data correctness is UNVERIFIABLE (fence), a failed tool makes everything downstream suspect; emit layerVerdicts L3 (+firstProblemOrigin); (L4 context) for each critical operational send: was required context retrieved AND present in history AT THAT TIME (chronology-sorted!) — distinguish gapKind retrieval (never fetched) vs attention (present, ignored); emit layerVerdicts L4. Record layersEngaged. CONFLICT DISCIPLINE (OT-1): when your own layers disagree (e.g. L1 pass vs L2 fail) emit BOTH verdicts honestly with a note — NEVER reconcile them yourself; the aggregate surfaces the conflict for a human."
|
|
47
|
+
- "Score EVERY criterion in the matrix for THIS trajectory: read only what the row needs (judgeInputs); compare against statement + passCondition — criteria are the SUPER-LAYER: they may bind evidence from ANY walk phase"
|
|
48
|
+
- "BIND (L1) per row: each criterion TERM must resolve in THIS trajectory; an unbound term ⇒ uncertain + blockedBy:{kind:factual-intent} (INDETERMINATE), ABSTAIN — never fail"
|
|
49
|
+
- "GATHER: cite a structured ref {obs,path,value} for the claim AND any absence (ref.obs = the packet's trajectoryId, NEVER an observation name; ref.path = packet-shaped trajectory.N/transcript.N OR trace-shaped observations.N — dot form canonical, brackets tolerated); surface TYPED assumptions for any ungroundable premise → uncertain(blockedBy)"
|
|
50
|
+
- "Critique BEFORE verdict; commit to result ∈ {pass,fail} (uncertain when the INPUTS can't decide — abstain-on-silence); binary, severity is the row's"
|
|
51
|
+
- "Inaction-can-be-success: a correct HOLD (no send during a non-critical outbound_guard) is a PASS even with zero tool calls"
|
|
52
|
+
- "Write <trajectory_key>.verdict.json (a MatrixVerdictFile {trajectoryId, judgeModel, temperature:0, verdicts[]} — each verdict may carry refs?/assumptions?/blockedBy?)"
|
|
53
|
+
- "EMIT-CONTRACT (HARD, WS-1): on every COMPLETE-fidelity trajectory you MUST also persist the §9.4 walk — `understanding` (M2), `expectedTrajectory` (M3), `agentSteps` (the target step lane), and `judgeSteps` (your anchored reasoning). These are NOT optional: the parent runs a machine `assessEmitCompleteness` gate (scripts/emit-completeness.ts) that LOUDLY flags any wholly-dropped field, and a verdict missing them STARVES the report's Trajectory (§2) + Self-Eval (§5) tabs. `agentSteps` is factual trace data (reconstruct it from the ordered tool steps you were given — order by startTime, the observations array is reverse-chronological); M2/M3/judgeSteps are YOUR reasoning. Only an INCOMPLETE (node-1) trajectory is exempt (it emits verdicts:[] and no walk)."
|
|
54
|
+
- "v3 EMIT-CONTRACT ADDITIONS (E-NEW): alongside the §9.4 walk, persist layerVerdicts[] (one per engaged layer, skipped ones marked), earlyExit?, layersEngaged, codeEvalHits[] (when the library ran), and the emissions SELF-MANIFEST {emitted[], missing[{key,reason}]} — list honestly what you gathered vs what this contract expects; a missing emission with a stated reason is a NAMED degrade, a silent drop is a defect (MR-5 catches it same-run)."
|
|
55
|
+
- "Verdicts are independently VERIFIED (#mode-verify · ≠ judge · downgrade-only); a CRIT/HIGH uncertain rolls the run up to INCOMPLETE at the gate (no false-green)"
|
|
56
|
+
compresses:
|
|
57
|
+
- "read MatrixPacket DATA; frame in ROUTE (CONTEXT)"
|
|
58
|
+
- "build judging prompt from packet + write-judge-prompt.md"
|
|
59
|
+
- "BIND terms · GATHER refs + typed assumptions · abstain on silence"
|
|
60
|
+
- "score whole matrix per trajectory, critique-before-verdict"
|
|
61
|
+
- "emit MatrixVerdictFile (then independent VERIFY, downgrade-only)"
|
|
62
|
+
preserves: "the eval-matrix-judge discipline (the headline cell; prompt-construction prose at golden/judge-trajectory.prose.md) — folded INLINE as 'Mode: judge — axis trajectory' (below); the standalone assets/agents/eval-matrix-judge.md was RETIRED in the 5→3 consolidation (Phase 3a, df6a6e8c8). This file is its canonical home."
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## history
|
|
66
|
+
|
|
67
|
+
- v3.1 (2026-07-24): GATHER ref-contract hardening — obs=trajectoryId, packet-OR-trace-shaped paths, dot canonical/brackets tolerated (fixes the verify-surface fail→incomplete flip found on the first real-trace run).
|
|
68
|
+
- v3.0 (2026-07-24): layered walk + v3 emit-contract (evaluator-v3, operator-frozen).
|
|
69
|
+
- v2.2 and earlier: prose lived inline in evaluator.md only; no on-disk snapshot existed (this file materializes the long-referenced golden anchor).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# The data registry — every entity this skill can produce or consume
|
|
2
|
+
|
|
3
|
+
> The agent's general knowledge of available entities (the resilience mechanism from
|
|
4
|
+
> the data-availability audit §⓪). Purpose: so that when an expected entity is ABSENT,
|
|
5
|
+
> the absence is **NAMED in words** — never a silent blank, never a fabricated value.
|
|
6
|
+
> A renderer that cannot source a field says so ("not recorded by this run"); a judge
|
|
7
|
+
> that cannot gather an emission lists it in its `emissions.missing[]` self-manifest.
|
|
8
|
+
|
|
9
|
+
## Run artifacts (the `.mutagent/evaluator/runs/<runId>/` dot-root, gitignored)
|
|
10
|
+
|
|
11
|
+
| entity | produced by | consumed by | absence → |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| `run-input.full.json` | run PREP (`run-evaluate`) | AGGREGATE, the report renderers | a run cannot aggregate — fail loud |
|
|
14
|
+
| `packets/<trajectoryKey>.packet.json` | `prepMatrixPackets` | one dispatched judge each | the readiness gate throws (no silent partial roll-up) |
|
|
15
|
+
| `verdicts/<trajectoryKey>.verdict.json` | a dispatched `#mode-judge-trajectory` agent | AGGREGATE | same — every judged trajectory MUST have one |
|
|
16
|
+
| `verdicts/*.verify.json` | a dispatched `#mode-verify` reviewer | the T3 independent-verify ledger | self-verify fallback (a NAMED degrade, not a silent downgrade) |
|
|
17
|
+
| `near-duplicate-decisions.json` | `aggregateDiscover` | the next run's brief (no re-proposal) | first run — key absent, never an empty claim |
|
|
18
|
+
| `.mutagent/evaluator/reports/<runId>/{evaluation,discovery,review}-report.html` | the renderers | the operator | the run wrote no report — fail loud |
|
|
19
|
+
|
|
20
|
+
## Judge emissions (the verdict-file fields — E-NEW)
|
|
21
|
+
|
|
22
|
+
| field | shape | honesty rule |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `layerVerdicts[]` | `{layer, verdict, refs?, note?, scenario?, expectedExit?, gapKind?, firstProblemOrigin?, divergence?}` | skipped ≠ undecidable; L0 may be `fired` (a candidate, never a verdict) |
|
|
25
|
+
| `verdicts[]` | per-criterion `{critique, result, confidence, refs?, assumptions?, blockedBy?}` | a DECIDED verdict carries ≥1 re-resolvable ref; an `uncertain` carries `blockedBy` (na for grounding) |
|
|
26
|
+
| `denseMap` | `{criterionId: pass\|fail\|uncertain\|na}` | EVERY criterion appears; `na` ≠ fail |
|
|
27
|
+
| `naRationale` | `{criterionId: why}` | the reason a cell is `na` — the completeness law's third path carries its rationale |
|
|
28
|
+
| `emissions` | `{emitted[], missing[{key,reason}]}` | a missing emission WITH a stated reason is a NAMED degrade; a silent drop is a defect (MR-5 catches it) |
|
|
29
|
+
| `codeEvalHits[]` | `{pattern, anchor, detail}` | L0 fired patterns — evidence, not a verdict |
|
|
30
|
+
| `localize` | `{root, evidence?, criteria?, independentRoots?, conflict?, routing?, …}` | the root-not-symptom; `conflict` is the judge's OT-1 statement — surfaced, never reconciled |
|
|
31
|
+
|
|
32
|
+
## Subject profile + criteria
|
|
33
|
+
|
|
34
|
+
| entity | produced by | note |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| subject profile | `buildSubjectProfile` | identity · purpose · tools · scope — GIVEN (code access) or reconstructed |
|
|
37
|
+
| `criteria[]` (the eval matrix) | the suite / `*discover` | each may carry `layer` (which evidence layer it binds to) — additive, optional, defaults to `"criteria"` |
|
|
38
|
+
| living suite + provenance | `growLivingSuite` | append-only, monotonic; near-duplicates MERGE (no data loss) |
|
|
39
|
+
| pending near-duplicate decisions | `merge-criteria` | durable, order-independent identity; stays open until a ruling |
|
|
40
|
+
|
|
41
|
+
**The rule that binds every row of this registry:** an absence is stated, never inferred
|
|
42
|
+
into a number. `0` and "not recorded" are different facts, and a report that shows the
|
|
43
|
+
first when it means the second is manufacturing evidence.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# The evidence layers (L0–L4) — how the layered judge reads a trajectory
|
|
2
|
+
|
|
3
|
+
> The know-how reference for the layered walk. Load when you need to reason about
|
|
4
|
+
> **what a layer can and cannot decide**, why the judge descends, and what the
|
|
5
|
+
> stopping rules permit. The judge emits one `LayerVerdict` per engaged layer; this
|
|
6
|
+
> file is the map between a layer id and its meaning. Live consumers: the judge's
|
|
7
|
+
> `layerVerdicts`, the aggregate's `foldLayerVerdicts` / `detectLayerConflicts`, and
|
|
8
|
+
> the report's layer matrix.
|
|
9
|
+
|
|
10
|
+
## The ladder
|
|
11
|
+
|
|
12
|
+
The judge walks **cheapest-first, descending only when evidence requires**. Each layer
|
|
13
|
+
answers a narrower question than the one above; a layer's verdict is *evidence*, never
|
|
14
|
+
the run verdict on its own (the GATE is driven by criteria severity — D2).
|
|
15
|
+
|
|
16
|
+
| layer | name | question it can settle | what it CANNOT decide (the fence) |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| **L0** | code checks | did a deterministic pattern fire? (fault passthrough · error output · malformed structure · unguarded send · out-of-order gate) | a fired pattern is a **candidate**, not a verdict — "code-detectable ≠ incorrect". Emits `codeEvalHits`, verdict `fired` — never pass/fail. |
|
|
19
|
+
| **L1** | outcome | did the agent's terminal action belong to the scenario's expected exit set? (scenario → `expectedExit[]`) | anything about the *path*: a correct outcome can hide an unguarded send, a skipped review, an ignored flag. |
|
|
20
|
+
| **L2** | trajectory | did the actual decision path match the expected one? (actual-vs-expected, read from the END) | whether a *tool's* output was trustworthy — that is L3. A divergence here is the first sign the outcome misleads. |
|
|
21
|
+
| **L3** | tool outputs | were the tool outputs structurally valid + complete? (per-observation) | **external-data correctness is UNVERIFIABLE** — a well-formed output can be factually wrong. A failed tool makes everything downstream suspect. |
|
|
22
|
+
| **L4** | context | was required context retrieved AND present in history at the moment of the decision? (chronology-sorted) | distinguish **`gapKind: retrieval`** (never fetched) from **`attention`** (present, ignored) — the fixes differ. |
|
|
23
|
+
|
|
24
|
+
## Criteria are the super-layer
|
|
25
|
+
|
|
26
|
+
Criteria do not live *in* one layer — they are the **super-layer** and may bind evidence
|
|
27
|
+
from any phase (e.g. a reply-grounding criterion reads L3 tool outputs AND L1 the reply).
|
|
28
|
+
Two hard rules:
|
|
29
|
+
|
|
30
|
+
- **a criterion may NEVER go unverdicted because the agent did the right thing** — `na`
|
|
31
|
+
(not-applicable) is a real accounting path with a rationale, never a silent skip
|
|
32
|
+
(`assertNoUnverdictedCriterion` THROWS on a missing cell).
|
|
33
|
+
- **`na` is not `uncertain`.** `na` = the criterion's scoping predicate is positively
|
|
34
|
+
falsified for this trajectory (never fail). `uncertain` = the inputs could not decide
|
|
35
|
+
(a CRIT/HIGH uncertain rolls the run INCOMPLETE-wards, never green).
|
|
36
|
+
|
|
37
|
+
## The stopping rules (when the walk may exit early)
|
|
38
|
+
|
|
39
|
+
- If **L1 passes AND every criterion is answerable** from evidence gathered so far → the
|
|
40
|
+
walk may set `earlyExit: 'L1'` and mark L2–L4 `skipped`. `skipped ≠ undecidable`.
|
|
41
|
+
- If **any** criterion is unanswerable, OR L1 fails / is undecidable → **descend.** A
|
|
42
|
+
walk must not stop while a criterion is unanswerable (that is how a false-green forms).
|
|
43
|
+
- `layersEngaged` records how deep the walk actually went (the economy stat — MR-3).
|
|
44
|
+
|
|
45
|
+
## Conflict discipline (OT-1)
|
|
46
|
+
|
|
47
|
+
When layers disagree — e.g. **L1 pass but L2 fail** (the outcome was right, the path
|
|
48
|
+
unguarded) — the judge emits **both** verdicts honestly with a note and **never
|
|
49
|
+
reconciles them itself**. The aggregate (`detectLayerConflicts`) surfaces the conflict
|
|
50
|
+
to a human calibration queue; the GATE never reads it (it is driven by criteria
|
|
51
|
+
severity). The judge's own statement of the tension lives in `localize.conflict`; the
|
|
52
|
+
aggregate's cross-check is the layer notes. Both render; neither is auto-resolved.
|
|
@@ -21,7 +21,7 @@ spine (`error-analysis.md` · `write-judge-prompt.md` · `validate-evaluator.md`
|
|
|
21
21
|
| Input | Shape | Used for |
|
|
22
22
|
|-------|-------|----------|
|
|
23
23
|
| the built agent | a target-framework impl (e.g. a Mastra/TS agent) OR a harness agent | the eval SUBJECT |
|
|
24
|
-
| `agentspec.
|
|
24
|
+
| `agentspec.spec.evaluation` | `{ criteria[], scenarios[], datasets[] }` (0.3.0, D18/D19 — datasets carry dataset-local `categories[]` + `caseDimensions` + real `items[]`) | dataset materialization (F8) + criteria |
|
|
25
25
|
| `agentspec.build` | `{ target_framework, runtime, target_eval_framework }` | the target-conditional engine fork (F9) |
|
|
26
26
|
|
|
27
27
|
The skill is **standalone** — it NEVER imports the agentspec schema. It consumes
|
|
@@ -17,15 +17,22 @@
|
|
|
17
17
|
* categories[] : { name, definition, class, fixOrEval, exampleTraceIds, … }
|
|
18
18
|
*
|
|
19
19
|
* AGGREGATE JOINS them into `TraceAnnotation[]` (NO hand-rolled annotations in
|
|
20
|
-
* the path),
|
|
20
|
+
* the path), MERGES near-duplicate categories (`merge-criteria.ts` — deterministic,
|
|
21
|
+
* no LLM), then `deriveMinedCriteria` (§5b metadata + §5c DR-2) → `growLivingSuite`
|
|
21
22
|
* (append-only, monotonic). FAIL-LOUD: a missing verdict file (mirrors
|
|
22
23
|
* `missingVerdictKeys`) or a category referencing an undispatched trace THROWS —
|
|
23
24
|
* a dispatch gap is never a silently-fabricated annotation.
|
|
24
25
|
*
|
|
26
|
+
* The merge runs BEFORE derivation on purpose: relabelling an absorbed category's
|
|
27
|
+
* annotations onto its survivor lets support / prevalence / refs / grounding be
|
|
28
|
+
* recomputed from the real union of per-trace evidence, rather than adding up
|
|
29
|
+
* already-derived strings. Nothing is dropped — a near-duplicate pair the guards
|
|
30
|
+
* refuse to merge is REPORTED (`mergePlan.findings`), not discarded.
|
|
31
|
+
*
|
|
25
32
|
* PURE except for fs reads (the verdict files). No clock / random / network — the
|
|
26
33
|
* mined criteria + suite are byte-identical for the same inputs (C-PIN).
|
|
27
34
|
*/
|
|
28
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
29
36
|
import { dirname, join } from "node:path";
|
|
30
37
|
import { type Static, Type } from "@sinclair/typebox";
|
|
31
38
|
import { Value } from "@sinclair/typebox/value";
|
|
@@ -44,8 +51,12 @@ import type { DatasetCase } from "./contracts/dataset.ts";
|
|
|
44
51
|
import type { CandidateItem, MatrixVerdictFile } from "./contracts/eval-matrix.ts";
|
|
45
52
|
import {
|
|
46
53
|
CodeEvalSpecSchema,
|
|
54
|
+
Generality,
|
|
47
55
|
JudgeKind,
|
|
56
|
+
MetricDimension,
|
|
57
|
+
MetricLevel,
|
|
48
58
|
OutcomeVerdict,
|
|
59
|
+
Severity,
|
|
49
60
|
assertGroundingHonest,
|
|
50
61
|
type DiscoveryRef,
|
|
51
62
|
type EvalTrace,
|
|
@@ -55,6 +66,16 @@ import {
|
|
|
55
66
|
} from "./contracts/eval-types.ts";
|
|
56
67
|
import type { CodeEvalSpec } from "./code-eval.ts";
|
|
57
68
|
import { applyDiffDiscrimination, type CriterionFireSignals } from "./diff-discriminate.ts";
|
|
69
|
+
import {
|
|
70
|
+
applyCriterionMerges,
|
|
71
|
+
buildExistingSuiteBrief,
|
|
72
|
+
emptyNearDuplicateLedger,
|
|
73
|
+
planCriterionMerges,
|
|
74
|
+
recordNearDuplicateFindings,
|
|
75
|
+
stampMergeProvenance,
|
|
76
|
+
type MergePlan,
|
|
77
|
+
type NearDuplicateLedger,
|
|
78
|
+
} from "./merge-criteria.ts";
|
|
58
79
|
|
|
59
80
|
// ── The leaf's per-batch mining report (real #mode-discover output shape) ────
|
|
60
81
|
|
|
@@ -66,6 +87,29 @@ export const DiscoverLabelSchema = Type.Object(
|
|
|
66
87
|
Type.Literal(OutcomeVerdict.Fail),
|
|
67
88
|
Type.Literal(OutcomeVerdict.Uncertain),
|
|
68
89
|
]),
|
|
90
|
+
/**
|
|
91
|
+
* The ROOT cause of the failure — NOT the first visible symptom.
|
|
92
|
+
*
|
|
93
|
+
* The name is historical and the SEMANTICS OVERRIDE IT: per the
|
|
94
|
+
* `root_not_symptom` invariant the leaf traces each failure to its root with
|
|
95
|
+
* judgement, because the first thing that looks wrong is usually downstream
|
|
96
|
+
* of the real cause. Read this field as "root cause"; keep one criterion per
|
|
97
|
+
* ROOT, and let independent roots produce separate criteria.
|
|
98
|
+
*
|
|
99
|
+
* ⚠️ DO NOT RENAME to `rootCause` casually. That rename was decided once,
|
|
100
|
+
* never executed, and left the agent contract asserting a field this closed
|
|
101
|
+
* schema rejects — a leaf that followed the contract literally hard-threw the
|
|
102
|
+
* whole AGGREGATE (`Expected required property firstThingWrong`, plus
|
|
103
|
+
* `additionalProperties:false` refusing the extra key). The contract was
|
|
104
|
+
* deliberately aligned to this shipped name RATHER THAN performing the rename,
|
|
105
|
+
* because the rename is a BREAKING change to the on-disk leaf-artifact
|
|
106
|
+
* contract — every stored mining report would need migrating — while the
|
|
107
|
+
* intent it was meant to express already lives in the `root_not_symptom`
|
|
108
|
+
* invariant, in the workflow step "Trace each Fail to its ROOT", and in this
|
|
109
|
+
* comment. The field NAME was never what carried the semantics. If the rename
|
|
110
|
+
* is genuinely wanted, schedule it as its own breaking change WITH an artifact
|
|
111
|
+
* migration; do not slip it into unrelated work.
|
|
112
|
+
*/
|
|
69
113
|
firstThingWrong: Type.String(),
|
|
70
114
|
evidencePointer: Type.String(),
|
|
71
115
|
/** GA-1 — optional STRUCTURED grounding refs `{obs,path,value}` the leaf
|
|
@@ -92,10 +136,40 @@ export const DiscoverCategorySchema = Type.Object(
|
|
|
92
136
|
fixOrEval: Type.Union([Type.Literal("eval-worthy"), Type.Literal("fixable")]),
|
|
93
137
|
exampleTraceIds: Type.Array(Type.String({ minLength: 1 })),
|
|
94
138
|
// OPTIONAL §5b metadata proposals (additive agent-contract extension).
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
139
|
+
//
|
|
140
|
+
// CLOSED UNIONS, not free strings. These are the leaf's PROPOSALS and
|
|
141
|
+
// `parseDiscoverAnnotations` carries them through to the mined criterion, so
|
|
142
|
+
// a typo (`level: "outputs"`) must fail LOUD at parse time rather than be
|
|
143
|
+
// silently ignored — silently ignoring is precisely the bug that pinned every
|
|
144
|
+
// mined criterion to `level: output` and made layer-coverage unreadable.
|
|
145
|
+
dimension: Type.Optional(
|
|
146
|
+
Type.Union([
|
|
147
|
+
Type.Literal(MetricDimension.OperationCorrectness),
|
|
148
|
+
Type.Literal(MetricDimension.DataCorrectness),
|
|
149
|
+
Type.Literal(MetricDimension.OperationalDeviation),
|
|
150
|
+
]),
|
|
151
|
+
),
|
|
152
|
+
level: Type.Optional(
|
|
153
|
+
Type.Union([
|
|
154
|
+
Type.Literal(MetricLevel.Context),
|
|
155
|
+
Type.Literal(MetricLevel.Output),
|
|
156
|
+
Type.Literal(MetricLevel.CrossStage),
|
|
157
|
+
]),
|
|
158
|
+
),
|
|
159
|
+
generality: Type.Optional(
|
|
160
|
+
Type.Union([
|
|
161
|
+
Type.Literal(Generality.GeneralStructural),
|
|
162
|
+
Type.Literal(Generality.SpecificSemantic),
|
|
163
|
+
]),
|
|
164
|
+
),
|
|
165
|
+
severity: Type.Optional(
|
|
166
|
+
Type.Union([
|
|
167
|
+
Type.Literal(Severity.Crit),
|
|
168
|
+
Type.Literal(Severity.High),
|
|
169
|
+
Type.Literal(Severity.Med),
|
|
170
|
+
Type.Literal(Severity.Low),
|
|
171
|
+
]),
|
|
172
|
+
),
|
|
99
173
|
judgeInputs: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
100
174
|
/** UNIFORM STANDARD — the executable code-check the leaf emits for a
|
|
101
175
|
* deterministically-checkable category (validated against the canonical
|
|
@@ -218,6 +292,17 @@ export function parseDiscoverAnnotations(input: AggregateDiscoverInput): TraceAn
|
|
|
218
292
|
if (meta !== undefined && meta.evidencePointer.length > 0) ann.evidencePointer = meta.evidencePointer;
|
|
219
293
|
if (meta !== undefined && meta.refs !== undefined && meta.refs.length > 0) ann.refs = meta.refs;
|
|
220
294
|
if (cat.judgeInputs !== undefined) ann.judgeInputs = cat.judgeInputs;
|
|
295
|
+
// §5b PROPOSALS — carry the leaf's OWN metadata through to the annotation.
|
|
296
|
+
// `deriveMinedCriteria` treats these as the value and its heuristics as the
|
|
297
|
+
// FALLBACK (`firstDefined(list, key) ?? derive(...)`), so present ⇒ honored,
|
|
298
|
+
// absent ⇒ derived. Before this, the schema accepted them and the JOIN
|
|
299
|
+
// dropped them on the floor: every mined criterion came out `level: output`
|
|
300
|
+
// with a fail-count-derived severity no matter what the leaf proposed, and
|
|
301
|
+
// the report's layer-coverage could never read more than one layer.
|
|
302
|
+
if (cat.level !== undefined) ann.level = cat.level;
|
|
303
|
+
if (cat.severity !== undefined) ann.severity = cat.severity;
|
|
304
|
+
if (cat.dimension !== undefined) ann.dimension = cat.dimension;
|
|
305
|
+
if (cat.generality !== undefined) ann.generality = cat.generality;
|
|
221
306
|
// UNIFORM STANDARD — carry the leaf's executable code-check through to the
|
|
222
307
|
// annotation so deriveMinedCriteria lands it on MinedCriterion.codeEval.
|
|
223
308
|
if (cat.codeEval !== undefined) ann.codeEval = cat.codeEval as CodeEvalSpec;
|
|
@@ -229,12 +314,38 @@ export function parseDiscoverAnnotations(input: AggregateDiscoverInput): TraceAn
|
|
|
229
314
|
|
|
230
315
|
// ── Full AGGREGATE: annotations → mined criteria → living-suite ──────────────
|
|
231
316
|
|
|
317
|
+
/**
|
|
318
|
+
* How a mined criterion relates to the suite that existed BEFORE this run.
|
|
319
|
+
* `merged` is NOT a member: a criterion can be new-and-merged or
|
|
320
|
+
* reinforced-and-merged, so the merge is carried separately on
|
|
321
|
+
* `MinedCriterion.mergedFrom` and the report shows both facts.
|
|
322
|
+
*/
|
|
323
|
+
export const DiscoverCriterionState = {
|
|
324
|
+
/** minted by this run — no criterion with this id existed before. */
|
|
325
|
+
New: "new",
|
|
326
|
+
/** already in the suite; this run attached FURTHER evidence to it. */
|
|
327
|
+
Reinforced: "reinforced",
|
|
328
|
+
} as const;
|
|
329
|
+
export type DiscoverCriterionStateValue =
|
|
330
|
+
(typeof DiscoverCriterionState)[keyof typeof DiscoverCriterionState];
|
|
331
|
+
|
|
232
332
|
export interface AggregateDiscoverResult {
|
|
233
333
|
annotations: TraceAnnotation[];
|
|
234
334
|
criteria: MinedCriterion[];
|
|
235
335
|
suite: LivingSuite<MinedCriterion>;
|
|
236
336
|
/** T6 — failure/uncertain DATASET CANDIDATES (present iff `traces` supplied). */
|
|
237
337
|
datasetCandidates: DatasetCase[];
|
|
338
|
+
/** the near-duplicate MERGE plan actually applied (merges + surfaced findings). */
|
|
339
|
+
mergePlan: MergePlan;
|
|
340
|
+
/** criterion id → new | reinforced, judged against the PRE-run suite. */
|
|
341
|
+
criterionStates: Record<string, DiscoverCriterionStateValue>;
|
|
342
|
+
/**
|
|
343
|
+
* The DURABLE near-duplicate decision ledger, grown by this run. Pairs the
|
|
344
|
+
* guards refused to merge live here across runs with a stable identity until a
|
|
345
|
+
* human rules — "nothing destroyed, nothing forgotten". Persist it with
|
|
346
|
+
* `writeNearDuplicateLedger` and feed it back in on the next run.
|
|
347
|
+
*/
|
|
348
|
+
nearDuplicateLedger: NearDuplicateLedger;
|
|
238
349
|
}
|
|
239
350
|
|
|
240
351
|
/**
|
|
@@ -258,9 +369,27 @@ export function aggregateDiscover(
|
|
|
258
369
|
suite: LivingSuite<MinedCriterion>;
|
|
259
370
|
traces?: EvalTrace[];
|
|
260
371
|
fireSignals?: CriterionFireSignals[];
|
|
372
|
+
/** near-duplicate merge floor; omit for the measured default (0.4). */
|
|
373
|
+
mergeThreshold?: number;
|
|
374
|
+
/**
|
|
375
|
+
* The PRIOR durable near-duplicate ledger (from the last run). Omit on a
|
|
376
|
+
* first run. Supplying it is what stops an already-surfaced pair from being
|
|
377
|
+
* re-proposed as a fresh discovery every run.
|
|
378
|
+
*/
|
|
379
|
+
nearDuplicateLedger?: NearDuplicateLedger;
|
|
261
380
|
},
|
|
262
381
|
): AggregateDiscoverResult {
|
|
263
|
-
const
|
|
382
|
+
const parsed = parseDiscoverAnnotations(input);
|
|
383
|
+
|
|
384
|
+
// Near-duplicate MERGE — BEFORE derivation, so support / prevalence / refs /
|
|
385
|
+
// grounding are all recomputed over the real union of per-trace evidence
|
|
386
|
+
// instead of hand-recombining derived strings. Blocked pairs are surfaced on
|
|
387
|
+
// `mergePlan.findings`, never dropped and never silently merged. With no
|
|
388
|
+
// merges, `applyCriterionMerges` returns the input untouched (zero change).
|
|
389
|
+
const mergePlan = planCriterionMerges(parsed, {
|
|
390
|
+
...(input.mergeThreshold !== undefined ? { threshold: input.mergeThreshold } : {}),
|
|
391
|
+
});
|
|
392
|
+
const annotations = applyCriterionMerges(parsed, mergePlan);
|
|
264
393
|
let criteria = deriveMinedCriteria(annotations);
|
|
265
394
|
|
|
266
395
|
// GA-11 — observed-eligibility via broken∧¬healthy diff (deterministic).
|
|
@@ -273,6 +402,20 @@ export function aggregateDiscover(
|
|
|
273
402
|
for (const c of criteria) assertGroundingHonest(c, input.traces);
|
|
274
403
|
}
|
|
275
404
|
|
|
405
|
+
// MERGE provenance — `mergedFrom` (what was absorbed) + `aliases` (the absorbed
|
|
406
|
+
// ids kept resolvable, so an older report / dataset recipe / handoff naming a
|
|
407
|
+
// pre-merge id still finds its criterion).
|
|
408
|
+
criteria = stampMergeProvenance(criteria, mergePlan);
|
|
409
|
+
|
|
410
|
+
// NEW vs REINFORCED, judged against the suite as it stood BEFORE this run.
|
|
411
|
+
const knownBefore = new Set(input.suite.entries.map((c) => c.id));
|
|
412
|
+
const criterionStates: Record<string, DiscoverCriterionStateValue> = {};
|
|
413
|
+
for (const c of criteria) {
|
|
414
|
+
criterionStates[c.id] = knownBefore.has(c.id)
|
|
415
|
+
? DiscoverCriterionState.Reinforced
|
|
416
|
+
: DiscoverCriterionState.New;
|
|
417
|
+
}
|
|
418
|
+
|
|
276
419
|
const suite = growLivingSuite(input.suite, criteria, (c) => c.id);
|
|
277
420
|
|
|
278
421
|
// T6 — surface failure/uncertain DATASET CANDIDATES (reusing the EV-047 selectors)
|
|
@@ -282,7 +425,23 @@ export function aggregateDiscover(
|
|
|
282
425
|
? collectDatasetCandidates({ determinerSpecs: input.determinerSpecs, verdictDir: input.verdictDir, traces: input.traces })
|
|
283
426
|
: [];
|
|
284
427
|
|
|
285
|
-
|
|
428
|
+
// The undecidable band becomes DURABLE state, not a drawing: a pair a guard
|
|
429
|
+
// refused to merge is recorded (stable id, monotonic, rulings preserved) so it
|
|
430
|
+
// survives the reader closing the report and is not re-derived as new next run.
|
|
431
|
+
const nearDuplicateLedger = recordNearDuplicateFindings(
|
|
432
|
+
input.nearDuplicateLedger ?? emptyNearDuplicateLedger(),
|
|
433
|
+
mergePlan.findings,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
annotations,
|
|
438
|
+
criteria,
|
|
439
|
+
suite,
|
|
440
|
+
datasetCandidates,
|
|
441
|
+
mergePlan,
|
|
442
|
+
criterionStates,
|
|
443
|
+
nearDuplicateLedger,
|
|
444
|
+
};
|
|
286
445
|
}
|
|
287
446
|
|
|
288
447
|
// ── Report wiring: AGGREGATE result → report.html (mirrors writeRunReport) ───
|
|
@@ -343,6 +502,84 @@ export function writeDiscoverReport(input: WriteDiscoverReportInput): string {
|
|
|
343
502
|
return outPath;
|
|
344
503
|
}
|
|
345
504
|
|
|
505
|
+
// ── PREP-side: hand the EXISTING suite to the mining leaf (prevention) ───────
|
|
506
|
+
|
|
507
|
+
/** The file the `#mode-discover` leaf pre-reads to see what already exists. */
|
|
508
|
+
export const EXISTING_CRITERIA_FILE = "existing-criteria.json";
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Write the existing living suite into the run dir as `existing-criteria.json`
|
|
512
|
+
* so the dispatched `#mode-discover` leaf can see WHAT ALREADY EXISTS before it
|
|
513
|
+
* clusters. Prevention, where the merge above is only cleanup: a leaf that
|
|
514
|
+
* recognises a behaviour it already has a criterion for attaches the trace to
|
|
515
|
+
* that criterion's id as REINFORCING evidence instead of minting a restatement
|
|
516
|
+
* under a new name.
|
|
517
|
+
*
|
|
518
|
+
* INFORMS, never blocks (the agent contract is explicit): observing the same
|
|
519
|
+
* behaviour again is legitimate evidence, and the leaf still mints a genuinely
|
|
520
|
+
* new criterion whenever the traces show one. An EMPTY suite still writes the
|
|
521
|
+
* file — "nothing exists yet" is information, and a missing file would be
|
|
522
|
+
* indistinguishable from a wiring gap. Returns the written path.
|
|
523
|
+
*/
|
|
524
|
+
export function writeExistingSuiteBrief(
|
|
525
|
+
dir: string,
|
|
526
|
+
suite: LivingSuite<MinedCriterion>,
|
|
527
|
+
ledger?: NearDuplicateLedger,
|
|
528
|
+
): string {
|
|
529
|
+
mkdirSync(dir, { recursive: true });
|
|
530
|
+
const outPath = join(dir, EXISTING_CRITERIA_FILE);
|
|
531
|
+
writeFileSync(outPath, `${JSON.stringify(buildExistingSuiteBrief(suite, ledger), null, 2)}\n`);
|
|
532
|
+
return outPath;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ── The durable near-duplicate decision ledger (cross-run, like the suite) ───
|
|
536
|
+
|
|
537
|
+
/** The ledger filename. CROSS-RUN — lives beside the living suite, not under a run id. */
|
|
538
|
+
export const NEAR_DUPLICATE_LEDGER_FILE = "near-duplicate-decisions.json";
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Read the durable near-duplicate ledger from `dir`, or an EMPTY ledger when it
|
|
542
|
+
* does not exist yet (a first run legitimately has none). A malformed ledger
|
|
543
|
+
* THROWS rather than being silently reset — quietly starting over would drop
|
|
544
|
+
* every open question and every ruling, which is precisely the
|
|
545
|
+
* "nothing forgotten" guarantee this file exists to keep.
|
|
546
|
+
*/
|
|
547
|
+
export function readNearDuplicateLedger(dir: string): NearDuplicateLedger {
|
|
548
|
+
const path = join(dir, NEAR_DUPLICATE_LEDGER_FILE);
|
|
549
|
+
if (!existsSync(path)) return emptyNearDuplicateLedger();
|
|
550
|
+
const raw = readFileSync(path, "utf8");
|
|
551
|
+
let parsed: unknown;
|
|
552
|
+
try {
|
|
553
|
+
parsed = JSON.parse(raw);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
`readNearDuplicateLedger: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). ` +
|
|
557
|
+
"Refusing to silently start a fresh ledger — that would forget every open near-duplicate " +
|
|
558
|
+
"question AND every human ruling. Fix or explicitly remove the file.",
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
const l = parsed as Partial<NearDuplicateLedger>;
|
|
562
|
+
if (!Array.isArray(l.decisions) || typeof l.version !== "number") {
|
|
563
|
+
throw new Error(
|
|
564
|
+
`readNearDuplicateLedger: ${path} is not a NearDuplicateLedger ({decisions[], version}). ` +
|
|
565
|
+
"Refusing to silently start over (see above).",
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
return l as NearDuplicateLedger;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Persist the durable near-duplicate ledger. CROSS-RUN state (mirrors the living
|
|
573
|
+
* suite's location, NOT the run dir) — the whole point is that it outlives the
|
|
574
|
+
* run that surfaced it. Returns the written path.
|
|
575
|
+
*/
|
|
576
|
+
export function writeNearDuplicateLedger(dir: string, ledger: NearDuplicateLedger): string {
|
|
577
|
+
mkdirSync(dir, { recursive: true });
|
|
578
|
+
const outPath = join(dir, NEAR_DUPLICATE_LEDGER_FILE);
|
|
579
|
+
writeFileSync(outPath, `${JSON.stringify(ledger, null, 2)}\n`);
|
|
580
|
+
return outPath;
|
|
581
|
+
}
|
|
582
|
+
|
|
346
583
|
// ── T6 (B-U5) discover → DATASET CANDIDATES + unmatched-detection handoff ────
|
|
347
584
|
//
|
|
348
585
|
// `*discover-evals` mined CRITERIA only; T6 ALSO surfaces a failure/uncertain DATASET
|
|
@@ -449,6 +686,10 @@ export interface AggregateDiscoverCliResult {
|
|
|
449
686
|
suiteTotal: number;
|
|
450
687
|
lastAppended: number;
|
|
451
688
|
criterionIds: string[];
|
|
689
|
+
/** near-duplicate merges applied: `survivor <- absorbed, …` (empty when none). */
|
|
690
|
+
merged: string[];
|
|
691
|
+
/** near-duplicate pairs deliberately NOT merged, surfaced for review. */
|
|
692
|
+
nearDuplicateFindings: string[];
|
|
452
693
|
/** present iff a `report.html` out path was supplied (5th arg). */
|
|
453
694
|
reportPath?: string;
|
|
454
695
|
/** whether the GA-1 ground-gate ran in this CLI invocation (traces supplied). */
|
|
@@ -483,7 +724,7 @@ export function runAggregateDiscoverCli(
|
|
|
483
724
|
? (JSON.parse(io.readFile(tracesPath)) as EvalTrace[])
|
|
484
725
|
: undefined;
|
|
485
726
|
|
|
486
|
-
const { criteria, suite } = aggregateDiscover({
|
|
727
|
+
const { criteria, suite, mergePlan } = aggregateDiscover({
|
|
487
728
|
determinerSpecs,
|
|
488
729
|
verdictDir,
|
|
489
730
|
miningReport,
|
|
@@ -512,6 +753,10 @@ export function runAggregateDiscoverCli(
|
|
|
512
753
|
suiteTotal: suite.provenance.total,
|
|
513
754
|
lastAppended: suite.provenance.lastAppended,
|
|
514
755
|
criterionIds: criteria.map((c) => c.id),
|
|
756
|
+
merged: mergePlan.merges.map((m) => `${m.survivor} <- ${m.absorbed.join(", ")}`),
|
|
757
|
+
nearDuplicateFindings: mergePlan.findings.map(
|
|
758
|
+
(f) => `${f.a} ~ ${f.b} (${f.similarity.toFixed(2)}) NOT merged: ${f.kind}`,
|
|
759
|
+
),
|
|
515
760
|
...(writtenReport !== undefined ? { reportPath: writtenReport } : {}),
|
|
516
761
|
groundGateRan: traces !== undefined && traces.length > 0,
|
|
517
762
|
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audience — the V11 report AUDIENCE CONTRACT, shared by every v3 report renderer.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. `render-eval-report-v3` declared `audience: "internal" | "external"`
|
|
5
|
+
* and NEVER READ IT, so an external render was byte-identical to an internal one; the
|
|
6
|
+
* discovery renderer had no audience concept at all. Both therefore shipped their
|
|
7
|
+
* INTERNAL methodology surface to clients. This module holds the ONE implementation so
|
|
8
|
+
* the rule cannot drift between surfaces — and so the review surface, when it lands,
|
|
9
|
+
* inherits it instead of rediscovering it.
|
|
10
|
+
*
|
|
11
|
+
* ── WHAT AN `external` RENDER STRIPS ────────────────────────────────────────────
|
|
12
|
+
* 1. the INTERNAL methodology surface — the eval report's ⑤ Self-Eval (MR-1..5
|
|
13
|
+
* self-grading) and the discovery report's ⑥ Methodology (process-as-executed,
|
|
14
|
+
* provenance, proof-of-work, honest gaps) — INCLUDING:
|
|
15
|
+
* · its TAB-BAR entry (never leave a tab that opens onto nothing), and
|
|
16
|
+
* · its HTML COMMENT marker. A comment ships to the client exactly like any
|
|
17
|
+
* other byte, so removing the visible tab and leaving `<!-- ⑤ SELF-EVAL -->`
|
|
18
|
+
* behind is still a leak. This is not hypothetical: it is the case the
|
|
19
|
+
* verifier's comment-blind matching caught.
|
|
20
|
+
* 2. run-internal OPERATIONAL detail — the pinned judge-model string, internal run
|
|
21
|
+
* ids, and the gitignored artifact path printed in the footer.
|
|
22
|
+
*
|
|
23
|
+
* ── WHAT IT KEEPS ───────────────────────────────────────────────────────────────
|
|
24
|
+
* verdicts · quoted evidence · findings · criteria · the gate. The client gets the
|
|
25
|
+
* judgment and the proof, not how the judge graded itself. The determinism CLAIM
|
|
26
|
+
* survives (rendered as "pinned · temp 0") because reproducibility is
|
|
27
|
+
* client-relevant; the model identifier is not.
|
|
28
|
+
*
|
|
29
|
+
* ── ORDERING (a real trap) ──────────────────────────────────────────────────────
|
|
30
|
+
* Strip AFTER slot-fill. The internal panel contains a data slot, so cutting it
|
|
31
|
+
* first trips the renderer's fail-loud "TEMPLATE SLOT MISSING" guard. Fill every
|
|
32
|
+
* slot, then remove the surface.
|
|
33
|
+
*
|
|
34
|
+
* Enforced browser-free by `verify-render.ts`, in BOTH directions — an external
|
|
35
|
+
* render must contain none of the internal markers, an internal render must still
|
|
36
|
+
* contain them. A one-directional check passes vacuously the moment a strip
|
|
37
|
+
* over-reaches or the renderer emits nothing.
|
|
38
|
+
*
|
|
39
|
+
* PURE — no clock, no randomness, no network, no filesystem.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export type Audience = "internal" | "external";
|
|
43
|
+
|
|
44
|
+
/** True when this render is for a CLIENT audience and must carry no internal surface. */
|
|
45
|
+
export function isExternal(input: { audience?: Audience }): boolean {
|
|
46
|
+
return input.audience === "external";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Cut `startAnchor` … `endAnchor` out of `html`. FAIL-LOUD when either anchor is
|
|
51
|
+
* missing — a silent no-op here would ship the very surface being removed, which is
|
|
52
|
+
* the worst possible failure mode for a leak strip.
|
|
53
|
+
*
|
|
54
|
+
* `consumeEnd: false` stops immediately BEFORE the end anchor — needed when the anchor
|
|
55
|
+
* is markup that must survive (e.g. the container's closing tag after the last panel).
|
|
56
|
+
*
|
|
57
|
+
* NOTE on choosing an end anchor: do not reach for the panel's own closing tags. The
|
|
58
|
+
* cards inside these panels contain the same `</div></div>` sequence, so a first-match
|
|
59
|
+
* end anchor cuts one card and leaves the rest. Anchor on something UNIQUE that follows
|
|
60
|
+
* the panel (the footer marker) and stop before it.
|
|
61
|
+
*/
|
|
62
|
+
export function cutBlock(html: string, startAnchor: string, endAnchor: string, what: string, consumeEnd = true): string {
|
|
63
|
+
const start = html.indexOf(startAnchor);
|
|
64
|
+
const end = start >= 0 ? html.indexOf(endAnchor, start) : -1;
|
|
65
|
+
if (start < 0 || end < 0) throw new Error(`AUDIENCE STRIP anchor missing: ${what}`);
|
|
66
|
+
return html.slice(0, start) + html.slice(consumeEnd ? end + endAnchor.length : end);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The unique marker that follows the LAST panel in both v3 templates. */
|
|
70
|
+
export const FOOT_ANCHOR = ' </div>\n\n <div class="foot">';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Strip an internal methodology surface: its comment-marked panel plus its tab-bar
|
|
74
|
+
* entry. `tabIndex` is the `onclick="tab(N)"` index of the entry to remove — the
|
|
75
|
+
* internal surface is the LAST tab on both surfaces, so the remaining indices stay
|
|
76
|
+
* contiguous and the template's index-based `tab()` switcher keeps working.
|
|
77
|
+
*/
|
|
78
|
+
export function stripInternalSurface(
|
|
79
|
+
html: string,
|
|
80
|
+
opts: { commentMarker: string; tabIndex: number; label: string },
|
|
81
|
+
): string {
|
|
82
|
+
const withoutPanel = cutBlock(html, opts.commentMarker, FOOT_ANCHOR, `${opts.label} panel`, false);
|
|
83
|
+
return cutBlock(withoutPanel, `<s onclick="tab(${opts.tabIndex})">`, "</s>", `${opts.label} tab-bar entry`);
|
|
84
|
+
}
|