@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.
Files changed (55) hide show
  1. package/.claude/skills/mutagent-evaluator/SKILL.md +28 -30
  2. package/.claude/skills/mutagent-evaluator/assets/agents/dataset-builder.md +6 -5
  3. package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +93 -30
  4. package/.claude/skills/mutagent-evaluator/assets/code-quality-criteria.yaml +75 -0
  5. package/.claude/skills/mutagent-evaluator/assets/templates/discover-report.template.html +397 -0
  6. package/.claude/skills/mutagent-evaluator/assets/templates/eval-report.template.html +594 -0
  7. package/.claude/skills/mutagent-evaluator/assets/templates/review-report.template.html +560 -0
  8. package/.claude/skills/mutagent-evaluator/golden/judge-trajectory.prose.md +69 -0
  9. package/.claude/skills/mutagent-evaluator/lenses/methodology-critic-lens.md +1 -1
  10. package/.claude/skills/mutagent-evaluator/references/data-registry.md +43 -0
  11. package/.claude/skills/mutagent-evaluator/references/edd-loop.md +6 -6
  12. package/.claude/skills/mutagent-evaluator/references/eval-layers.md +52 -0
  13. package/.claude/skills/mutagent-evaluator/references/eval-stage.md +37 -4
  14. package/.claude/skills/mutagent-evaluator/references/memory-format.md +1 -1
  15. package/.claude/skills/mutagent-evaluator/schemas/methodology-review.schema.yaml +1 -1
  16. package/.claude/skills/mutagent-evaluator/scripts/agent-dispatch.ts +0 -0
  17. package/.claude/skills/mutagent-evaluator/scripts/aggregate-discover.ts +254 -9
  18. package/.claude/skills/mutagent-evaluator/scripts/audience.ts +84 -0
  19. package/.claude/skills/mutagent-evaluator/scripts/build-review-ui.ts +320 -6
  20. package/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +32 -2
  21. package/.claude/skills/mutagent-evaluator/scripts/cli/profile-subject.ts +1 -1
  22. package/.claude/skills/mutagent-evaluator/scripts/code-eval-library.ts +201 -0
  23. package/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +60 -68
  24. package/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +3 -3
  25. package/.claude/skills/mutagent-evaluator/scripts/contracts/agentspec-evals.ts +101 -39
  26. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +326 -3
  27. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-types.ts +30 -4
  28. package/.claude/skills/mutagent-evaluator/scripts/determine-outcome.ts +8 -0
  29. package/.claude/skills/mutagent-evaluator/scripts/edd/change-request.ts +1 -1
  30. package/.claude/skills/mutagent-evaluator/scripts/edd/edd-types.ts +2 -2
  31. package/.claude/skills/mutagent-evaluator/scripts/judge-prompt-template.ts +144 -15
  32. package/.claude/skills/mutagent-evaluator/scripts/materialize-dataset.ts +130 -68
  33. package/.claude/skills/mutagent-evaluator/scripts/matrix-judge.ts +219 -1
  34. package/.claude/skills/mutagent-evaluator/scripts/memory/append.ts +1 -1
  35. package/.claude/skills/mutagent-evaluator/scripts/memory/ratify.ts +165 -0
  36. package/.claude/skills/mutagent-evaluator/scripts/merge-criteria.ts +849 -0
  37. package/.claude/skills/mutagent-evaluator/scripts/prep-tasks.ts +15 -4
  38. package/.claude/skills/mutagent-evaluator/scripts/read-manifest.ts +120 -0
  39. package/.claude/skills/mutagent-evaluator/scripts/read-unitf-traces.ts +34 -2
  40. package/.claude/skills/mutagent-evaluator/scripts/render-build-cards.ts +37 -0
  41. package/.claude/skills/mutagent-evaluator/scripts/render-discover-report-v3.ts +1155 -0
  42. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report-v3.ts +610 -0
  43. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +11 -92
  44. package/.claude/skills/mutagent-evaluator/scripts/render-review-report-v3.ts +691 -0
  45. package/.claude/skills/mutagent-evaluator/scripts/report-fragments.ts +173 -0
  46. package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +12 -9
  47. package/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +46 -1
  48. package/.claude/skills/mutagent-evaluator/scripts/run-pipeline.ts +39 -4
  49. package/.claude/skills/mutagent-evaluator/scripts/run-review.ts +266 -0
  50. package/.claude/skills/mutagent-evaluator/scripts/subject-profile.ts +82 -0
  51. package/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +2 -2
  52. package/.claude/skills/mutagent-evaluator/scripts/unitf-to-evaltrace.ts +64 -21
  53. package/.claude/skills/mutagent-evaluator/scripts/verify-render.ts +728 -0
  54. package/bin/mutagent-cli.mjs +9 -5
  55. package/package.json +2 -2
@@ -48,20 +48,94 @@ function promptOf(trace: EvalTrace): string {
48
48
 
49
49
  // ── Determiner (EV-042) prompt — MIRRORS assets/agents/error-analyst.md ──────
50
50
 
51
+ /**
52
+ * EV-1 — the compact SUBJECT FRAME the determiner reads so it derives THIS subject's
53
+ * expected outcome (identity · purpose · tools + reversibility), instead of judging
54
+ * against a hardcoded email-agent signal block. Trajectory-LIGHT by design — it never
55
+ * carries the full step-by-step trajectory (that is the per-criterion judge's job).
56
+ */
57
+ export interface SubjectFrame {
58
+ identity: string;
59
+ purpose: string;
60
+ tools: string[];
61
+ toolReversibility?: { name: string; reversibility: string }[];
62
+ }
63
+
64
+ /** The distinct tool names actually invoked in a trace, in first-seen order (compact summary). */
65
+ function keyToolsUsed(trace: EvalTrace, limit = 8): string[] {
66
+ const seen: string[] = [];
67
+ for (const o of trace.observations) {
68
+ if (o.type === "TOOL" && typeof o.name === "string" && !seen.includes(o.name)) {
69
+ seen.push(o.name);
70
+ if (seen.length >= limit) break;
71
+ }
72
+ }
73
+ return seen;
74
+ }
75
+
76
+ /** Render the subject frame block for the determiner (EV-1). Trajectory-light. */
77
+ function subjectFrameBlock(subject: SubjectFrame | undefined): string[] {
78
+ if (subject === undefined) {
79
+ return [
80
+ "SUBJECT: not supplied — RECONSTRUCT what the agent is from the input event + the",
81
+ "tools it called, and derive the expected outcome from that. Do NOT assume any",
82
+ "particular domain (e.g. email).",
83
+ "",
84
+ ];
85
+ }
86
+ const rev = new Map((subject.toolReversibility ?? []).map((t) => [t.name, t.reversibility]));
87
+ const toolLine =
88
+ subject.tools.length > 0
89
+ ? subject.tools
90
+ .map((n) => {
91
+ const r = rev.get(n);
92
+ return r !== undefined && r !== "unknown" ? `${n} [${r}]` : n;
93
+ })
94
+ .join(", ")
95
+ : "(none)";
96
+ const irreversible = (subject.toolReversibility ?? [])
97
+ .filter((t) => t.reversibility === "irreversible-external")
98
+ .map((t) => t.name);
99
+ return [
100
+ "SUBJECT (derive the EXPECTED outcome for THIS subject before you judge):",
101
+ ` identity: ${subject.identity}`,
102
+ ` purpose: ${subject.purpose}`,
103
+ ` tools: ${toolLine}`,
104
+ ...(irreversible.length > 0
105
+ ? [` ⚠ irreversible-external tools: ${irreversible.join(", ")} — a failure that also fired one is materially worse.`]
106
+ : []),
107
+ "",
108
+ ];
109
+ }
110
+
51
111
  /**
52
112
  * Render the determiner judge prompt for the in-house/export substrate. The
53
113
  * AUTHORITATIVE "inaction can be success" rubric is `error-analyst.md`; this is
54
114
  * its provider-callable mirror. No decision is made here.
115
+ *
116
+ * EV-1 — subject-aware: given a `subject` frame the determiner derives the expected
117
+ * outcome from the subject's identity/purpose/tools (not a fixed email-agent signal
118
+ * block), and reads a COMPACT outcome summary (event · did-it-act · key tools ·
119
+ * terminal state) — deliberately NOT the full trajectory (the per-criterion judge's
120
+ * job). The domain-specific signals (send / guard / recovery) render ONLY when the
121
+ * subject vocab actually defines them, so a non-email subject no longer sees dead lines.
55
122
  */
56
123
  export function buildOutcomePrompt(
57
124
  trace: EvalTrace,
58
125
  signals: OutcomeSignals,
59
126
  vocab: SubjectVocab,
127
+ subject?: SubjectFrame,
60
128
  ): { system: string; user: string } {
61
129
  const system = [
62
130
  "You are a success/failure determiner for an autonomous agent session.",
63
131
  "Decide whether the session REACHED THE GOAL implied by its input event.",
64
132
  "",
133
+ "Derive the EXPECTED outcome from the SUBJECT (its identity · purpose · tools) +",
134
+ "the input event — do NOT assume any particular domain (e.g. email). This is a",
135
+ "FAST input→output outcome check: read the goal (input), the result (output/self-",
136
+ "summary) and the compact summary below — NOT the full step-by-step trajectory",
137
+ "(that is the per-criterion judge's job).",
138
+ "",
65
139
  "CRITICAL RULE — inaction can be success. Holding (sending nothing, calling",
66
140
  "no tool) is the CORRECT outcome when the event is a restraint directive",
67
141
  "(e.g. a guard/hold directive) or when acting would be wrong. You MUST NOT",
@@ -74,23 +148,39 @@ export function buildOutcomePrompt(
74
148
  "Reason first in `critique`, then commit to `result`.",
75
149
  ].join("\n");
76
150
 
77
- // Generic guard-counter label the attribute NAME comes from the subject vocab.
78
- const guardLabel = vocab.guardCounterAttr ?? "guard counter";
151
+ const responseText = typeof trace.output?.response === "string" ? trace.output.response : "";
152
+ const keyTools = keyToolsUsed(trace);
153
+
154
+ // EV-1 — domain signals render ONLY when the subject vocab defines them (else they
155
+ // were email-agent noise for every subject). honest-null preserved.
156
+ const conditionalSignals: string[] = [];
157
+ if (vocab.guardCounterAttr !== null) {
158
+ conditionalSignals.push(` Guard ${vocab.guardCounterAttr}: ${signals.guardConsecutive ?? "n/a"}`);
159
+ }
160
+ if (vocab.sendTool.length > 0) {
161
+ conditionalSignals.push(
162
+ ` Sent a message: ${signals.sentMessage === null ? "unknown" : signals.sentMessage}`,
163
+ ` Send succeeded: ${signals.sendSucceeded ?? "n/a (no send)"}`,
164
+ );
165
+ }
166
+ if (vocab.recoveryTools.length > 0) {
167
+ conditionalSignals.push(` Recovery tool present: ${signals.recoveryPresent}`);
168
+ }
169
+
79
170
  const user = [
80
- `Event kind: ${signals.eventKind}`,
81
- `Guard ${guardLabel}: ${signals.guardConsecutive ?? "n/a"}`,
82
- `Tools called: ${signals.toolCount}`,
83
- // EV-049 honest-null: render UNKNOWN as "unknown" (the subject's send tool is
84
- // unset/uninferred) never coerce it to "false", which would lie to the judge.
85
- `Sent a message: ${signals.sentMessage === null ? "unknown" : signals.sentMessage}`,
86
- `Send succeeded: ${signals.sendSucceeded ?? "n/a (no send)"}`,
87
- `Recovery tool present: ${signals.recoveryPresent}`,
171
+ ...subjectFrameBlock(subject),
172
+ "OUTCOME SUMMARY (compact NOT the full trajectory):",
173
+ ` Event kind: ${signals.eventKind}`,
174
+ ` Tools called: ${signals.toolCount}${keyTools.length > 0 ? ` · key tools: ${keyTools.join(", ")}` : ""}`,
175
+ ` Did the agent act: ${signals.toolCount > 0 ? "yes (called tools)" : "no (no tool calls)"}`,
176
+ ` Terminal state: ${responseText.length > 0 ? "produced a self-summary/response" : "no self-summary"}`,
177
+ ...conditionalSignals,
88
178
  "",
89
179
  "Input event prompt:",
90
180
  promptOf(trace),
91
181
  "",
92
182
  "Agent self-summary (output.response):",
93
- typeof trace.output?.response === "string" ? trace.output.response : "(none)",
183
+ responseText.length > 0 ? responseText : "(none)",
94
184
  ].join("\n");
95
185
 
96
186
  return { system, user };
@@ -107,10 +197,13 @@ export async function determineOutcome(
107
197
  trace: EvalTrace,
108
198
  judge: JudgeInvoke,
109
199
  vocab: SubjectVocab,
200
+ subject?: SubjectFrame,
110
201
  ): Promise<OutcomeResult> {
111
202
  const signals = extractOutcomeSignals(trace, vocab);
112
- const { system, user } = buildOutcomePrompt(trace, signals, vocab);
113
- const raw = await judge(system, user);
203
+ const { system, user } = buildOutcomePrompt(trace, signals, vocab, subject);
204
+ // INF-3 pass the trace id so the determiner verdict is keyed PER-TRACE (two
205
+ // sessions with an identical determiner prompt never share one verdict).
206
+ const raw = await judge(system, user, trace.id);
114
207
  const verdict = parseCritiqueVerdict(raw);
115
208
  return {
116
209
  traceId: trace.id,
@@ -153,7 +246,8 @@ function profilePreamble(profile?: SubjectProfile): string[] {
153
246
  ` identity: ${profile.identity}`,
154
247
  ` purpose: ${profile.purpose}`,
155
248
  ` scope: ${profile.scope}`,
156
- ` tools: ${(profile.tools ?? []).join(", ") || "(none observed)"}`,
249
+ ` tools: ${renderToolLine(profile)}`,
250
+ ...reversibilityCallout(profile),
157
251
  profile.skill !== undefined ? ` skill: ${profile.skill}` : "",
158
252
  ` harness: ${profile.harness}`,
159
253
  ` provenance: ${profile.provenance}${profile.version !== undefined ? ` · version ${profile.version}` : ""}`,
@@ -161,6 +255,36 @@ function profilePreamble(profile?: SubjectProfile): string[] {
161
255
  ].filter((l) => l !== "");
162
256
  }
163
257
 
258
+ /** Render the tools line, annotating each tool with its EV-5 reversibility when known. */
259
+ function renderToolLine(profile: SubjectProfile): string {
260
+ const tools = profile.tools ?? [];
261
+ if (tools.length === 0) return "(none observed)";
262
+ const rev = new Map((profile.toolReversibility ?? []).map((t) => [t.name, t.reversibility]));
263
+ return tools
264
+ .map((name) => {
265
+ const r = rev.get(name);
266
+ return r !== undefined && r !== "unknown" ? `${name} [${r}]` : name;
267
+ })
268
+ .join(", ");
269
+ }
270
+
271
+ /**
272
+ * EV-5 — a one-line callout naming the tools that take IRREVERSIBLE EXTERNAL actions,
273
+ * so a failure that ALSO fired one weighs heavier than a read-only failure. Absent
274
+ * reversibility data ⇒ no callout (the judge still reads the bare tool list).
275
+ */
276
+ function reversibilityCallout(profile: SubjectProfile): string[] {
277
+ const irreversible = (profile.toolReversibility ?? [])
278
+ .filter((t) => t.reversibility === "irreversible-external")
279
+ .map((t) => t.name);
280
+ if (irreversible.length === 0) return [];
281
+ return [
282
+ ` ⚠ irreversible-external tools: ${irreversible.join(", ")} — a failure that ALSO`,
283
+ " fired one of these (an email sent, a live system mutated) is MATERIALLY WORSE",
284
+ " than a read-only failure; weigh that in your critique.",
285
+ ];
286
+ }
287
+
164
288
  /**
165
289
  * Render the 4-component per-criterion judge prompt for the in-house/export
166
290
  * substrate. The AUTHORITATIVE 4-component / binary / critique-before-verdict
@@ -227,6 +351,11 @@ export async function runJudge(
227
351
  subjectTrace: EvalTrace,
228
352
  judge: JudgeInvoke,
229
353
  pin: JudgePin,
354
+ // EV-5 CUT WIRE 1 — the M1 subject profile the judge reads in its preamble (tools +
355
+ // reversibility). Pre-fix runJudge called buildJudgePrompt WITHOUT it, so the export
356
+ // path always hit the "profile not supplied — reconstruct" branch and never saw the
357
+ // reversibility flags. The pipeline now builds it once (buildSubjectProfile) and passes it.
358
+ subjectProfile?: SubjectProfile,
230
359
  ): Promise<CriterionVerdict> {
231
360
  if (typeof pin.modelId !== "string" || pin.modelId.length === 0) {
232
361
  throw new Error(
@@ -240,7 +369,7 @@ export async function runJudge(
240
369
  "MODEL INTENT IS SACRED: reruns must be byte-identical (C-PIN); refusing.",
241
370
  );
242
371
  }
243
- const { system, user } = buildJudgePrompt(spec, subjectTrace);
372
+ const { system, user } = buildJudgePrompt(spec, subjectTrace, subjectProfile);
244
373
  const raw = await judge(system, user);
245
374
  const verdict = parseCritiqueVerdict(raw);
246
375
  return {
@@ -1,24 +1,27 @@
1
1
  /**
2
- * scripts/materialize-dataset.ts — F8: MATERIALIZE real dataset items from the
3
- * agentspec (Type A — PURE). Seed → ACTUAL items, not just definitions.
2
+ * scripts/materialize-dataset.ts — F8: PROJECT the agentspec's real dataset items
3
+ * into runnable evaluator DatasetCases (Type A — PURE).
4
4
  * ---------------------------------------------------------------------------
5
- * The ADL `*build` stage emits an agentspec whose `definition.evals` carries the
6
- * dataset DEFINITION (dataset_categories[] × edge_cases[]) + scenarios[]. The old
7
- * `*build-dataset` only had the dimensions/definitions; F8 requires it to
8
- * MATERIALIZE real, runnable DatasetCases ≥1 per category, plus one per declared
9
- * edge_case BEFORE the dataset-builder agent expands them synthetically.
5
+ * AgentSpec 0.3.0 (D18/D19) moved the dataset from a DEFINITION (0.2's
6
+ * `dataset_categories[] × edge_cases[]`, which the evaluator had to synthesize
7
+ * base+edge queries from) to `spec.evaluation.datasets[].items[]` REAL,
8
+ * already-materialized rows, each carrying `category` / `scenarioRef` / a `case`
9
+ * assignment over the dataset's `caseDimensions`, plus kind-specific `input` /
10
+ * `expected` payloads.
10
11
  *
11
- * The materialized cases are the SEED layer (source: "seed"):
12
- * - one BASE item per category from a category-tagged scenario when present,
13
- * else synthesized from the category description ("seed, don't duplicate").
14
- * - one EDGE item per declared edge_case query = the edge_case phrase in the
15
- * category's context; flagged `edge_case: "true"` in its tuple.
12
+ * So materialization is now a faithful PROJECTION, not a synthesis:
13
+ * - one DatasetCase per spec item (the seed layer, source: "seed").
14
+ * - the case TUPLE = the item's `category` + its `case` assignment (the axes the
15
+ * dataset varies oversee `dimensionsFromAgentspec`).
16
+ * - the case QUERY = the item's opaque `input` payload rendered deterministically
17
+ * (raw string when `input` is a string; a stable key-sorted serialization
18
+ * otherwise), so a re-projection is byte-identical.
16
19
  *
17
- * These are REAL queries (non-empty NL), not category definitions. The
18
- * dataset-builder agent then expands them; build-dataset.ts's deterministic
19
- * id/dedup/merge dedups any overlap. Subject-agnostic. DETERMINISTIC — stable
20
- * content-derived ids, scenarios/categories/edge_cases consumed in given order;
21
- * no clock / random / network → re-materializing is byte-identical (C-PIN).
20
+ * The dataset-builder agent then EXPANDS these seeds synthetically; build-dataset.ts's
21
+ * deterministic id/dedup/merge dedups any overlap. Subject-agnostic (EV-002): axes
22
+ * and values come from the agentspec, never hard-coded. DETERMINISTIC — items and
23
+ * axes consumed in given order; no clock / random / network → re-projecting is
24
+ * byte-identical (C-PIN).
22
25
  */
23
26
  import {
24
27
  type Dataset,
@@ -26,88 +29,147 @@ import {
26
29
  type DatasetTuple,
27
30
  type Dimension,
28
31
  } from "./contracts/dataset.ts";
29
- import type { AgentspecEvals, AgentspecScenario } from "./contracts/agentspec-evals.ts";
32
+ import type {
33
+ SpecEvaluation,
34
+ SpecEvalDatasetItem,
35
+ } from "./contracts/agentspec-evals.ts";
30
36
  import { buildCase, appendToDataset } from "./build-dataset.ts";
31
37
  import { CaseSource } from "./contracts/dataset.ts";
32
38
 
33
- /** The literal tuple values for the boolean edge_case dimension (DatasetTuple is string-valued). */
34
- const EDGE_TRUE = "true";
35
- const EDGE_FALSE = "false";
39
+ /** The tuple key under which an item's dataset-local category is recorded. */
40
+ const CATEGORY_DIM = "category";
36
41
 
37
42
  /**
38
- * The dimensions the materialized dataset varies over: `category` (values = the
39
- * category ids) + `edge_case` (boolean). Subject-agnostic derived from the
40
- * agentspec, never hard-coded. PURE.
43
+ * Deterministic, key-sorted serialization a stable rendering of an opaque item
44
+ * payload so two projections of the same payload are byte-identical regardless of
45
+ * key insertion order. PURE.
41
46
  */
42
- export function dimensionsFromAgentspec(evals: AgentspecEvals): Dimension[] {
43
- const categories = evals.dataset_categories.map((c) => c.id);
47
+ function stableStringify(value: unknown): string {
48
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
49
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
50
+ const record = value as Record<string, unknown>;
51
+ const body = Object.keys(record)
52
+ .sort()
53
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`)
54
+ .join(",");
55
+ return `{${body}}`;
56
+ }
57
+
58
+ /**
59
+ * The dimensions the projected dataset varies over: `category` (values = the
60
+ * dataset-local category ids, first-seen order) + one dimension per distinct
61
+ * `caseDimensions` axis (values merged across datasets, first-seen order). Both are
62
+ * derived from the agentspec, never hard-coded. Subject-agnostic. PURE.
63
+ */
64
+ export function dimensionsFromAgentspec(evaluation: SpecEvaluation): Dimension[] {
44
65
  const dims: Dimension[] = [];
45
- if (categories.length > 0) {
46
- dims.push({ name: "category", description: "the dataset-category slice", values: categories });
66
+
67
+ // category dimension the union of dataset-local category ids (first-seen, deduped).
68
+ const categoryIds: string[] = [];
69
+ const seenCategory = new Set<string>();
70
+ for (const dataset of evaluation.datasets) {
71
+ for (const category of dataset.categories ?? []) {
72
+ if (seenCategory.has(category.id)) continue;
73
+ seenCategory.add(category.id);
74
+ categoryIds.push(category.id);
75
+ }
76
+ }
77
+ if (categoryIds.length > 0) {
78
+ dims.push({ name: CATEGORY_DIM, description: "the dataset-local category slice", values: categoryIds });
47
79
  }
48
- dims.push({ name: "edge_case", description: "is this an edge/adversarial case", values: [EDGE_FALSE, EDGE_TRUE] });
49
- return dims;
50
- }
51
80
 
52
- /** The first scenario tagged into `categoryId` (given order), if any. PURE. */
53
- function baseScenarioFor(evals: AgentspecEvals, categoryId: string): AgentspecScenario | undefined {
54
- return evals.scenarios.find((s) => s.category === categoryId && s.edge_case !== true);
81
+ // caseDimensions each named axis, values merged across datasets (first-seen, deduped).
82
+ const axisOrder: string[] = [];
83
+ const axes = new Map<string, { description?: string; values: string[]; seen: Set<string> }>();
84
+ for (const dataset of evaluation.datasets) {
85
+ for (const [name, axis] of Object.entries(dataset.caseDimensions ?? {})) {
86
+ let entry = axes.get(name);
87
+ if (entry === undefined) {
88
+ entry = { values: [], seen: new Set<string>() };
89
+ if (axis.description !== undefined) entry.description = axis.description;
90
+ axes.set(name, entry);
91
+ axisOrder.push(name);
92
+ }
93
+ for (const value of axis.values) {
94
+ if (entry.seen.has(value)) continue;
95
+ entry.seen.add(value);
96
+ entry.values.push(value);
97
+ }
98
+ }
99
+ }
100
+ for (const name of axisOrder) {
101
+ const entry = axes.get(name);
102
+ if (entry === undefined) continue;
103
+ const dim: Dimension = { name, values: entry.values };
104
+ if (entry.description !== undefined) dim.description = entry.description;
105
+ dims.push(dim);
106
+ }
107
+
108
+ return dims;
55
109
  }
56
110
 
57
- /** The base query for a category — the scenario text when tagged, else the description. PURE. */
58
- function baseQueryFor(evals: AgentspecEvals, categoryId: string, description: string): string {
59
- const scenario = baseScenarioFor(evals, categoryId);
60
- return scenario !== undefined ? scenario.description : description;
111
+ /**
112
+ * The variation tuple an item realizes — its dataset-local `category` (when present)
113
+ * plus its `case` axis assignments. Falls back to the item id when an item declares
114
+ * neither (a DatasetTuple must have ≥1 assignment). PURE.
115
+ */
116
+ function tupleFromItem(item: SpecEvalDatasetItem): DatasetTuple {
117
+ const tuple: DatasetTuple = {};
118
+ if (item.category !== undefined && item.category.length > 0) tuple[CATEGORY_DIM] = item.category;
119
+ for (const [axis, value] of Object.entries(item.case ?? {})) tuple[axis] = value;
120
+ if (Object.keys(tuple).length === 0) tuple.item = item.id;
121
+ return tuple;
61
122
  }
62
123
 
63
- /** The edge query for an edge_case phrase in a category context. PURE. */
64
- function edgeQueryFor(categoryId: string, edgePhrase: string): string {
65
- // A real NL query expressing the edge condition in the category's context.
66
- return `[${categoryId}] handle the edge case: ${edgePhrase}`;
124
+ /**
125
+ * The natural-language query an item realizes — its opaque `input` payload rendered
126
+ * deterministically. A string input is used verbatim; any other payload is
127
+ * stable-serialized; a missing payload falls back to a deterministic descriptor so
128
+ * the query is always non-empty (DatasetCase requires it). PURE.
129
+ */
130
+ function queryFromItem(item: SpecEvalDatasetItem): string {
131
+ const input = item.input;
132
+ if (typeof input === "string" && input.length > 0) return input;
133
+ if (input !== undefined) {
134
+ const rendered = stableStringify(input);
135
+ if (rendered.length > 0) return rendered;
136
+ }
137
+ const context = item.category ?? item.scenarioRef ?? item.id;
138
+ return `[${context}] ${item.id}`;
67
139
  }
68
140
 
69
141
  /**
70
- * MATERIALIZE real DatasetCases from the agentspec.evals seed (F8). For EACH
71
- * category: one base item (≥1 per category the success gate) + one item per
72
- * declared edge_case (flagged edge_case). DETERMINISTIC, deduped by content id.
73
- * Returns [] for an empty evals block (no categories → no items). PURE.
142
+ * PROJECT the agentspec's real dataset items (0.3.0 `spec.evaluation.datasets[].items[]`)
143
+ * into runnable DatasetCases (F8), one seed case per item. DETERMINISTIC, deduped by
144
+ * content id (a re-projection collides → drop). Returns [] when no dataset carries
145
+ * items. PURE.
74
146
  */
75
- export function materializeFromAgentspec(evals: AgentspecEvals): DatasetCase[] {
147
+ export function materializeFromAgentspec(evaluation: SpecEvaluation): DatasetCase[] {
76
148
  const out: DatasetCase[] = [];
77
149
  const seen = new Set<string>();
78
- const push = (tuple: DatasetTuple, query: string): void => {
79
- const c = buildCase(tuple, query, CaseSource.Seed);
80
- if (seen.has(c.id)) return; // content-id dedup (re-materialize collides → drop)
81
- seen.add(c.id);
82
- out.push(c);
83
- };
84
- for (const cat of evals.dataset_categories) {
85
- // base item — one real query per category.
86
- push(
87
- { category: cat.id, edge_case: EDGE_FALSE },
88
- baseQueryFor(evals, cat.id, cat.description),
89
- );
90
- // edge items — one real query per declared edge_case.
91
- for (const edge of cat.edge_cases) {
92
- if (edge.length === 0) continue;
93
- push({ category: cat.id, edge_case: EDGE_TRUE }, edgeQueryFor(cat.id, edge));
150
+ for (const dataset of evaluation.datasets) {
151
+ for (const item of dataset.items ?? []) {
152
+ const c = buildCase(tupleFromItem(item), queryFromItem(item), CaseSource.Seed);
153
+ if (seen.has(c.id)) continue; // content-id dedup (re-project collides → drop)
154
+ seen.add(c.id);
155
+ out.push(c);
94
156
  }
95
157
  }
96
158
  return out;
97
159
  }
98
160
 
99
161
  /**
100
- * Assemble (or extend) a Dataset seeded with the materialized real items. When
101
- * `existing` is supplied, the materialized cases are merged MONOTONICALLY (no
162
+ * Assemble (or extend) a Dataset seeded with the projected real items. When
163
+ * `existing` is supplied, the projected cases are merged MONOTONICALLY (no
102
164
  * duplicates, version bumps). Subject-agnostic. DETERMINISTIC. PURE.
103
165
  */
104
166
  export function materializeToDataset(
105
167
  subject: string,
106
- evals: AgentspecEvals,
168
+ evaluation: SpecEvaluation,
107
169
  existing?: Dataset,
108
170
  ): Dataset {
109
- const dimensions = dimensionsFromAgentspec(evals);
110
- const cases = materializeFromAgentspec(evals);
171
+ const dimensions = dimensionsFromAgentspec(evaluation);
172
+ const cases = materializeFromAgentspec(evaluation);
111
173
  const base: Dataset = existing ?? { subject, dimensions, cases: [], version: 0 };
112
174
  return appendToDataset(base, cases);
113
175
  }