@gaunt-sloth/batch 2.0.0-alpha.24 → 2.0.0-alpha.26

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 (52) hide show
  1. package/README.md +8 -6
  2. package/dist/BatchRunner.d.ts +20 -0
  3. package/dist/BatchRunner.js +28 -2
  4. package/dist/BatchRunner.js.map +1 -1
  5. package/dist/blindExport.d.ts +88 -0
  6. package/dist/blindExport.js +129 -0
  7. package/dist/blindExport.js.map +1 -0
  8. package/dist/classification.d.ts +52 -0
  9. package/dist/classification.js +140 -0
  10. package/dist/classification.js.map +1 -0
  11. package/dist/classificationRender.d.ts +24 -0
  12. package/dist/classificationRender.js +96 -0
  13. package/dist/classificationRender.js.map +1 -0
  14. package/dist/classificationReport.d.ts +11 -0
  15. package/dist/classificationReport.js +60 -0
  16. package/dist/classificationReport.js.map +1 -0
  17. package/dist/classificationTypes.d.ts +311 -0
  18. package/dist/classificationTypes.js +40 -0
  19. package/dist/classificationTypes.js.map +1 -0
  20. package/dist/evalCompare.d.ts +108 -0
  21. package/dist/evalCompare.js +246 -0
  22. package/dist/evalCompare.js.map +1 -0
  23. package/dist/evalRunner.d.ts +34 -3
  24. package/dist/evalRunner.js +259 -9
  25. package/dist/evalRunner.js.map +1 -1
  26. package/dist/evalSuite.d.ts +12 -2
  27. package/dist/evalSuite.js +534 -8
  28. package/dist/evalSuite.js.map +1 -1
  29. package/dist/evalTypes.d.ts +358 -5
  30. package/dist/evalTypes.js +98 -0
  31. package/dist/evalTypes.js.map +1 -1
  32. package/dist/index.d.ts +15 -1
  33. package/dist/index.js +14 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/metrics.d.ts +50 -0
  36. package/dist/metrics.js +433 -0
  37. package/dist/metrics.js.map +1 -0
  38. package/dist/pipelineCli.js +1 -1
  39. package/dist/pipelineCli.js.map +1 -1
  40. package/dist/raterTarget.d.ts +94 -0
  41. package/dist/raterTarget.js +328 -0
  42. package/dist/raterTarget.js.map +1 -0
  43. package/dist/reporters/reporterTypes.d.ts +9 -0
  44. package/dist/reporters/textReporter.js +21 -0
  45. package/dist/reporters/textReporter.js.map +1 -1
  46. package/dist/types.d.ts +14 -2
  47. package/dist/types.js +14 -2
  48. package/dist/types.js.map +1 -1
  49. package/dist/workflow/runWorkflow.d.ts +1 -1
  50. package/dist/workflow/runWorkflow.js +2 -2
  51. package/dist/workflow/runWorkflow.js.map +1 -1
  52. package/package.json +3 -3
package/dist/evalSuite.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { parse as parseYaml } from 'yaml';
2
2
  import * as z from 'zod';
3
- import { DEFAULT_EVAL_PASS_THRESHOLD } from '#src/evalTypes.js';
3
+ import { APPROVAL_RUNGS, isApprovalRung } from '@gaunt-sloth/core/config/shell-policy.js';
4
+ import { DEFAULT_EVAL_PASS_THRESHOLD, FORCED_BY_ASSERTIONS, FORCED_BY_MECHANISMS, } from '#src/evalTypes.js';
5
+ import { parseMetricPredicate } from '#src/metrics.js';
4
6
  /**
5
7
  * Raw suite-file shape (snake_case, as authored). BATCH-12 adds the identity matrix on top of the
6
8
  * BATCH-10 assertion set: a suite-level `identities` list, and a per-case `expect:` array of
@@ -84,6 +86,15 @@ const RawAssertionsSchema = z.object({
84
86
  // BATCH-21 tool-RESULT assertions (gth-agent target only, enforced after normalization below).
85
87
  must_error: z.array(z.string()).optional(),
86
88
  tool_result_json_path: z.array(RawToolResultJsonPathCheckSchema).optional(),
89
+ // BATCH-25 CLASSIFICATION assertions. They sit in the assertion bundle — not on the case — so
90
+ // they inherit identity scoping AND per-turn scoping for free; a multi-round negotiation case
91
+ // needs a different expected action per round, which a case-level field could not express.
92
+ // Validated against the suite's `classification` enums after normalization.
93
+ expect_label: z.string().optional(),
94
+ expect_action: z.string().optional(),
95
+ // BATCH-25 Half B — assert WHICH deterministic mechanism of the approvals gate decided this
96
+ // round. `rater` target only; desugars to a `must_contain` on that mechanism's rationale marker.
97
+ forced_by: z.string().optional(),
87
98
  judge: z.string().optional(),
88
99
  });
89
100
  const RawExpectationSchema = RawAssertionsSchema.extend({
@@ -114,6 +125,63 @@ const RawCaseSchema = RawAssertionsSchema.extend({
114
125
  // each turn for a multi-turn case). Enforced in code.
115
126
  turns: z.array(RawTurnSchema).optional(),
116
127
  pass_threshold: z.number().min(0).max(10).optional(),
128
+ // BATCH-25: the case's family tags. Unlike the assertion keys these ARE case-level — a family is a
129
+ // property of the case, identical across identities and rounds — so they are legal on a multi-turn
130
+ // case too.
131
+ tags: z.array(z.string()).optional(),
132
+ // BATCH-25: this case must be decided with zero model calls (the hardline-floor / ambiguity
133
+ // families). Requires a target that can classify deterministically; rejected otherwise.
134
+ model_free: z.boolean().optional(),
135
+ });
136
+ /** BATCH-25 — how a classification value is read from an answer: the bare string `answer` (the
137
+ * trimmed answer, matched against the enum) or `{ json_path: "…" }` (the same minimal path resolver
138
+ * `json_path` assertions use). No fuzzy/substring mode exists, deliberately. */
139
+ const RawExtractorSchema = z.union([
140
+ z.literal('answer'),
141
+ z.object({ json_path: z.string().min(1, 'json_path extractor needs a non-empty path') }),
142
+ ]);
143
+ /** BATCH-25 — the suite's `classification:` block: the enum that gives the confusion matrix its
144
+ * axes, plus how to read a value out of the SUT's answer. */
145
+ const RawClassificationSchema = z.object({
146
+ labels: z.array(z.string()).min(1, 'classification.labels must declare at least one label'),
147
+ actions: z.array(z.string()).optional(),
148
+ label_from: RawExtractorSchema.optional(),
149
+ action_from: RawExtractorSchema.optional(),
150
+ });
151
+ /** A predicate list, written as one string or a list of strings. Entries are ANDed. */
152
+ const RawPredicateListSchema = z.union([z.string(), z.array(z.string())]);
153
+ /** BATCH-25 — one declared metric. `over` (the denominator) is OPTIONAL and its absence means the
154
+ * WHOLE scored corpus; that default is the point, not a convenience. */
155
+ const RawMetricSchema = z.object({
156
+ name: z.string().min(1, 'metric name must be a non-empty string'),
157
+ description: z.string().optional(),
158
+ where: RawPredicateListSchema,
159
+ over: RawPredicateListSchema.optional(),
160
+ // FRACTION thresholds (0..1).
161
+ max: z.number().optional(),
162
+ min: z.number().optional(),
163
+ // COUNT thresholds — absolute case counts, invariant to corpus size. Mutually exclusive with the
164
+ // fraction form on a single metric (enforced in code: two thresholds on one gate would have no
165
+ // defined precedence).
166
+ max_count: z.number().optional(),
167
+ min_count: z.number().optional(),
168
+ gate: z.enum(['fail', 'report']).optional(),
169
+ });
170
+ /** BATCH-25 — one sweep axis value. `model` reaches the config through BATCH-1's supported
171
+ * `initConfig({ model })` seam (a genuinely fresh `.llm`); `config` is a deep merge of plain data. */
172
+ const RawSweepValueSchema = z.object({
173
+ name: z.string().min(1, 'sweep value name must be a non-empty string'),
174
+ model: z.string().optional(),
175
+ config: z.record(z.string(), z.unknown()).optional(),
176
+ });
177
+ /** BATCH-25 — the sweep: named axes whose cartesian product is the set of runs (`rung × model`). */
178
+ const RawSweepSchema = z.object({
179
+ axes: z
180
+ .array(z.object({
181
+ name: z.string().min(1, 'sweep axis name must be a non-empty string'),
182
+ values: z.array(RawSweepValueSchema).min(1, 'a sweep axis must declare at least one value'),
183
+ }))
184
+ .min(1, 'sweep must declare at least one axis'),
117
185
  });
118
186
  const RawSuiteSchema = z.object({
119
187
  target: z.object({
@@ -124,6 +192,9 @@ const RawSuiteSchema = z.object({
124
192
  // optional debug label. Both are ignored for a `gth-agent` target.
125
193
  url: z.string().optional(),
126
194
  agent_id: z.string().optional(),
195
+ // BATCH-25 Half B: the approvals rung a `rater` target rates at (required for that target,
196
+ // validated below against the declared ladder; ignored by every other target).
197
+ rung: z.string().optional(),
127
198
  }),
128
199
  // BATCH-10 Task 2: optional identity profile whose model judges the cases. A top-level sibling of
129
200
  // `target`/`defaults`/`cases`, and distinct from `target.profile` (which selects the SUT and is
@@ -140,6 +211,11 @@ const RawSuiteSchema = z.object({
140
211
  pass_threshold: z.number().min(0).max(10).optional(),
141
212
  })
142
213
  .optional(),
214
+ // BATCH-25: the classifier layer. All three are optional and inert when absent, so every #405-era
215
+ // suite parses to exactly the same `EvalSuite` it did before.
216
+ classification: RawClassificationSchema.optional(),
217
+ metrics: z.array(RawMetricSchema).optional(),
218
+ sweep: RawSweepSchema.optional(),
143
219
  cases: z.array(RawCaseSchema).min(1, 'suite must declare at least one case'),
144
220
  });
145
221
  /** The BATCH-10 assertion keys as authored on a flat case (used to detect "declared both flat
@@ -156,6 +232,12 @@ const FLAT_ASSERTION_KEYS = [
156
232
  'json_path',
157
233
  'must_error',
158
234
  'tool_result_json_path',
235
+ // BATCH-25 — the classification assertions MUST be listed here. This array drives BOTH the
236
+ // flat-vs-`expect:` exclusivity check and the multi-turn "case-level assertions are rejected"
237
+ // check; omitting them would let a suite declare both surfaces and have one silently ignored.
238
+ 'expect_label',
239
+ 'expect_action',
240
+ 'forced_by',
159
241
  'judge',
160
242
  ];
161
243
  /** A plain profile-name pattern — same as a case id: alphanumerics, dashes, underscores, dots. This
@@ -172,8 +254,8 @@ const IDENTITY_NAME_RE = /^[\w.-]+$/;
172
254
  * Rejects, with a clear message, at parse time (never silently no-ops or defers to run time):
173
255
  * - Malformed YAML.
174
256
  * - A suite shape that doesn't match {@link RawSuiteSchema} (missing/wrong-typed fields).
175
- * - `target.type` other than `"gth-agent"`, `"adk-agent"`, or `"ag-ui"` — other pluggable CLI/HTTP
176
- * targets are out of scope.
257
+ * - `target.type` other than `"gth-agent"`, `"adk-agent"`, `"ag-ui"`, or `"rater"` — other pluggable
258
+ * CLI/HTTP targets are out of scope.
177
259
  * - `target.profile` set to anything other than `"default"`/absent — a single suite-wide profile
178
260
  * switch is the `--identities` direction, replaced by the suite-level `identities` list.
179
261
  * - A `"adk-agent"` (BATCH-14) target missing its `url`; an `adk-agent` suite that ALSO uses the
@@ -204,6 +286,16 @@ const IDENTITY_NAME_RE = /^[\w.-]+$/;
204
286
  * - A tool-RESULT assertion (`must_error` / `tool_result_json_path`, BATCH-21) against an
205
287
  * `adk-agent` OR `ag-ui` target — tool results exist only on the in-process `gth-agent` target
206
288
  * (the AG-UI wire streams call names but no result payloads; A2A exposes no tool trace at all).
289
+ * - A `"rater"` (BATCH-25 Half B) target missing its `rung`, naming one that is not on the approvals
290
+ * ladder, or carrying a `profile`; a `rater` suite with no `classification:` block (the classifier
291
+ * layer would be inert and every case would silently run through the ordinary agent); a `rater`
292
+ * suite using the `identities` matrix (the classification seam is per-case, not per-identity); or
293
+ * a `rater` suite using ANY tool assertion (no agent runs, so there is no trace to grade).
294
+ * - `model_free: true` on a case of any target EXCEPT `rater` — running a case through an agent IS
295
+ * a model call, so the flag could never bite; and `model_free: true` on a case that ALSO declares
296
+ * a `judge:` rubric, since the judge is a second model call the target's `modelCalls` cannot see.
297
+ * - `forced_by` on any target except `rater`, or naming something that is not a mechanism of the
298
+ * approvals gate.
207
299
  * - A `judge_profile` containing a path separator or `..`.
208
300
  *
209
301
  * @param yamlText Raw suite file content.
@@ -275,11 +367,33 @@ export function parseEvalSuite(yamlText, sourcePath) {
275
367
  }
276
368
  target = { type: 'ag-ui', url, agentId };
277
369
  }
370
+ else if (data.target.type === 'rater') {
371
+ // BATCH-25 Half B: gth's own approvals rater, graded as a classifier. The `rung` is REQUIRED —
372
+ // the same label produces a different ACTION per rung, so a rater suite that did not say which
373
+ // rung it rates at would report action numbers that mean nothing. Validated against core's
374
+ // ladder here (not by the zod enum) so the message can list the rungs that exist.
375
+ const rung = data.target.rung?.trim();
376
+ if (!rung) {
377
+ throw new Error(`Invalid eval suite${suffix}: a "rater" target requires a \`rung\` — the approvals rung the ` +
378
+ 'corpus is rated at (e.g. `target: { type: rater, rung: auto-safe }`), because the same ' +
379
+ `outcome maps to a different action per rung. One of: ${APPROVAL_RUNGS.join(', ')}.`);
380
+ }
381
+ if (!isApprovalRung(rung)) {
382
+ throw new Error(`Invalid eval suite${suffix}: unsupported target.rung "${rung}" — the approvals ladder is ` +
383
+ `${APPROVAL_RUNGS.join(', ')}.`);
384
+ }
385
+ if (data.target.profile !== undefined) {
386
+ throw new Error(`Invalid eval suite${suffix}: a "rater" target does not take a \`profile\` — the model that ` +
387
+ "rates is the run's own (or the one `approvals.rater` names); omit `target.profile`.");
388
+ }
389
+ target = { type: 'rater', rung };
390
+ }
278
391
  else {
279
392
  throw new Error(`Invalid eval suite${suffix}: unsupported target.type "${data.target.type}" — this version ` +
280
393
  'of `gth eval` supports "gth-agent" (in-process), "adk-agent" (an external Google ADK ' +
281
- 'agent over A2A), and "ag-ui" (an external agent over the AG-UI protocol); other pluggable ' +
282
- 'CLI/HTTP targets are future scope.');
394
+ 'agent over A2A), "ag-ui" (an external agent over the AG-UI protocol), and "rater" (gth\'s ' +
395
+ 'own approvals rater, graded as a classifier); other pluggable CLI/HTTP targets are future ' +
396
+ 'scope.');
283
397
  }
284
398
  // Suite-level identity matrix (BATCH-12). Validate the names here (plain, path-safe, unique) so
285
399
  // later stages — output filenames, the `expect:` identity-membership check, the command's
@@ -327,6 +441,45 @@ export function parseEvalSuite(yamlText, sourcePath) {
327
441
  'target — identity profiles select per-identity gth configs, which do not apply to an ' +
328
442
  'external AG-UI agent. Remove `identities`, or use a `gth-agent` target.');
329
443
  }
444
+ // BATCH-25 Half B — the same honest boundary for the `rater` target. The `identities` matrix runs
445
+ // each case once per identity, and the classifier seam (`RunClassifyFn`) takes no identity: one
446
+ // classifier is built from ONE config, so a matrix would run every identity through the same
447
+ // rater and report N identical columns as if they were evidence. The rater axis that IS
448
+ // meaningful — a different rating model — is the `sweep`, which this target supports.
449
+ if (target.type === 'rater' && identities !== undefined) {
450
+ throw new Error(`Invalid eval suite${suffix}: the \`identities\` matrix is not supported for a "rater" ` +
451
+ 'target — the classification seam is per-case, not per-identity, so every identity would ' +
452
+ 'be rated by the same model. Use a `sweep` to vary the rating model/rung instead.');
453
+ }
454
+ // BATCH-25 — the classifier layer. Parsed BEFORE the cases, because every `expect_label` /
455
+ // `expect_action` and every metric literal is validated against these enums: a typo'd value is a
456
+ // suite error here rather than an assertion that can never pass (or, worse, a metric that reports
457
+ // a permanent, trusted zero).
458
+ const classification = buildClassificationSpec(data.classification, suffix, target.type !== 'rater');
459
+ const metrics = buildMetricSpecs(data.metrics, classification, suffix);
460
+ const sweep = buildSweep(data.sweep, suffix);
461
+ // BATCH-25 Half B — a `rater` suite MUST declare `classification:`. Without it the whole
462
+ // classifier layer is inert by design (`runEvalSuite` only consults an injected classifier for a
463
+ // suite that declares one), so the target would be built, ignored, and every case would fall
464
+ // through to the ordinary agent — sending shell commands to a chat model and grading its prose.
465
+ // That is the one failure mode this target must never have, and it is statically detectable.
466
+ if (target.type === 'rater' && classification === undefined) {
467
+ throw new Error(`Invalid eval suite${suffix}: a "rater" target requires a \`classification:\` block — it ` +
468
+ "declares the label/action enum the rater's verdicts are graded against. Without it the " +
469
+ 'classifier layer is inert and the cases would be run through the ordinary agent instead.');
470
+ }
471
+ // BATCH-25 — a config sweep overrides the gth config the SUT is built from. An `adk-agent` /
472
+ // `ag-ui` target runs OUT OF PROCESS with its own model, tools and auth, so there is no config to
473
+ // sweep: the overrides would silently apply to the judge alone and the comparison table would
474
+ // report cells that differ in nothing. Reject it, the same way the `identities` matrix is
475
+ // rejected for those targets and for the same reason — a false-scope suite is a bug, not
476
+ // something to run half of.
477
+ if (sweep !== undefined && (target.type === 'adk-agent' || target.type === 'ag-ui')) {
478
+ throw new Error(`Invalid eval suite${suffix}: a \`sweep\` is not supported for a "${target.type}" target — ` +
479
+ 'the agent runs out-of-process with its own config, so config overrides would change ' +
480
+ 'nothing about the SUT. Sweep a `gth-agent` target, or vary the external agent yourself ' +
481
+ 'and run the suite once per variant.');
482
+ }
330
483
  const suiteDefaultThreshold = data.defaults?.pass_threshold ?? DEFAULT_EVAL_PASS_THRESHOLD;
331
484
  const seenIds = new Set();
332
485
  const cases = data.cases.map((rawCase, index) => {
@@ -371,6 +524,8 @@ export function parseEvalSuite(yamlText, sourcePath) {
371
524
  turnIndex,
372
525
  declaredIdentities,
373
526
  identities,
527
+ classification,
528
+ targetType: target.type,
374
529
  }),
375
530
  };
376
531
  });
@@ -392,14 +547,62 @@ export function parseEvalSuite(yamlText, sourcePath) {
392
547
  turnIndex: undefined,
393
548
  declaredIdentities,
394
549
  identities,
550
+ classification,
551
+ targetType: target.type,
395
552
  }),
396
553
  },
397
554
  ];
398
555
  }
556
+ // BATCH-25 — family tags. De-duplicated, blanks dropped, order preserved: they are the axis of
557
+ // every per-tag sub-score, and a duplicate would double-count a case within its own family.
558
+ const seenTags = new Set();
559
+ const tags = [];
560
+ for (const rawTag of rawCase.tags ?? []) {
561
+ const tag = rawTag.trim();
562
+ if (tag.length === 0) {
563
+ throw new Error(`Invalid eval suite${suffix}: case "${rawCase.id}" (index ${index}) has a blank tag — ` +
564
+ 'a tag names a case family and is a per-tag sub-score axis, so it must be non-empty.');
565
+ }
566
+ if (seenTags.has(tag))
567
+ continue;
568
+ seenTags.add(tag);
569
+ tags.push(tag);
570
+ }
571
+ // BATCH-25 — `model_free` requires a target that classifies DETERMINISTICALLY and reports its
572
+ // own model-call count. Only the `rater` target does (Half B): a gth-agent / adk-agent / ag-ui
573
+ // case IS a model call by construction. Reject it elsewhere rather than accept a flag that
574
+ // would silently mean nothing — an assertion that cannot bite is worse than an absent one.
575
+ const modelFree = rawCase.model_free === true;
576
+ if (modelFree && target.type !== 'rater') {
577
+ throw new Error(`Invalid eval suite${suffix}: case "${rawCase.id}" (index ${index}) declares ` +
578
+ `\`model_free: true\`, which the "${target.type}" target cannot honour — running a case ` +
579
+ 'through an agent IS a model call. `model_free` needs a classification target that ' +
580
+ "decides deterministically and reports its own model-call count (BATCH-25 Half B's " +
581
+ '`rater` target). Remove the flag, or use `target: { type: rater, rung: … }`.');
582
+ }
583
+ // BATCH-25 Half B — `model_free` must mean NO model call from ANY path, not just from the
584
+ // classify path. The runner enforces it against the TARGET's reported `modelCalls`, and the
585
+ // judge is a second, independent model call: a case declaring both `model_free: true` and a
586
+ // `judge:` rubric would bill an LLM call and still report `modelCalls: 0` and PASS. A contract
587
+ // that exists to make a claim checkable must not have a hole in it.
588
+ if (modelFree) {
589
+ for (const turn of turns) {
590
+ for (const expectation of turn.expectations) {
591
+ if (expectation.judgeRubric !== undefined) {
592
+ throw new Error(`Invalid eval suite${suffix}: case "${rawCase.id}" (index ${index}) declares ` +
593
+ '`model_free: true` AND a `judge:` rubric — the judge is a model call, so the case ' +
594
+ "would not be free and its reported model-call count (the target's, which the " +
595
+ 'judge is not part of) would say it was. Drop the rubric, or drop `model_free`.');
596
+ }
597
+ }
598
+ }
599
+ }
399
600
  return {
400
601
  id: rawCase.id,
401
602
  turns,
402
603
  passThreshold: rawCase.pass_threshold ?? suiteDefaultThreshold,
604
+ tags,
605
+ modelFree,
403
606
  };
404
607
  });
405
608
  // BATCH-14 (design point 4) — the HONEST tool-call boundary for the ADK target. A2A's wire content
@@ -445,6 +648,30 @@ export function parseEvalSuite(yamlText, sourcePath) {
445
648
  }
446
649
  }
447
650
  }
651
+ // BATCH-25 Half B — the honest boundary for the `rater` target: it runs NO agent and calls NO
652
+ // tool, so there is no tool trace and there are no tool results. `must_not_call` would pass
653
+ // vacuously against the empty trace (the silent false green an eval must never produce) and
654
+ // `must_call` would fail for a reason that has nothing to do with the rater. Reject all four,
655
+ // naming the case, the same way the external targets reject what their wire cannot carry.
656
+ if (target.type === 'rater') {
657
+ for (const evalCase of cases) {
658
+ for (const turn of evalCase.turns) {
659
+ for (const expectation of turn.expectations) {
660
+ if (expectation.mustCall.length > 0 ||
661
+ expectation.mustNotCall.length > 0 ||
662
+ expectation.mustError.length > 0 ||
663
+ expectation.toolResultJsonPath.length > 0) {
664
+ throw new Error(`Invalid eval suite${suffix}: case "${evalCase.id}" uses a tool assertion ` +
665
+ '(`must_call` / `must_not_call` / `must_error` / `tool_result_json_path`), which a ' +
666
+ '"rater" target cannot carry — it rates a command string and never runs an agent, ' +
667
+ 'so there is no tool trace to grade (and a vacuous pass is worse than no ' +
668
+ 'assertion). Grade the rating with `expect_label` / `expect_action`, or the ' +
669
+ "rater's own explanation with the content assertions.");
670
+ }
671
+ }
672
+ }
673
+ }
674
+ }
448
675
  // Normalize a blank/whitespace-only judge_profile to undefined (= no separate judge) so the CLI's
449
676
  // resolution treats it the same as absent.
450
677
  const judgeProfile = data.judge_profile?.trim() || undefined;
@@ -459,9 +686,220 @@ export function parseEvalSuite(yamlText, sourcePath) {
459
686
  target,
460
687
  judgeProfile,
461
688
  identities,
689
+ classification,
690
+ metrics,
691
+ sweep,
462
692
  cases,
463
693
  };
464
694
  }
695
+ /**
696
+ * BATCH-25 — normalize the suite's `classification:` block.
697
+ *
698
+ * `label_from` defaults to `answer` (the trimmed answer matched case-insensitively against the
699
+ * declared enum), which suits a suite whose prompt says "reply with exactly one of: …". An
700
+ * `action_from` has NO default: without it the suite has no action dimension, and `expect_action` is
701
+ * then a parse error rather than an assertion that could never be graded.
702
+ *
703
+ * BATCH-25 Half B — `extractsFromAnswer` is false for a target that CLASSIFIES (the `rater`), which
704
+ * reports its label and action directly instead of leaving them to be read out of an answer. The
705
+ * extractors are then irrelevant, so requiring an `action_from` alongside `actions:` would force
706
+ * every rater suite to write a line that is never consulted — and a config line that does nothing
707
+ * is one a reader will eventually believe.
708
+ */
709
+ function buildClassificationSpec(raw, suffix, extractsFromAnswer) {
710
+ if (raw === undefined)
711
+ return undefined;
712
+ const labels = normalizeEnum(raw.labels, 'classification.labels', suffix);
713
+ const actions = normalizeEnum(raw.actions ?? [], 'classification.actions', suffix);
714
+ const actionFrom = raw.action_from ? normalizeExtractor(raw.action_from) : undefined;
715
+ if (actions.length === 0 && actionFrom !== undefined) {
716
+ throw new Error(`Invalid eval suite${suffix}: \`classification.action_from\` is declared but ` +
717
+ '`classification.actions` is empty — an extractor with no enum to match against can only ' +
718
+ 'ever produce "(unrecognized)".');
719
+ }
720
+ if (actions.length > 0 && actionFrom === undefined && extractsFromAnswer) {
721
+ throw new Error(`Invalid eval suite${suffix}: \`classification.actions\` is declared but ` +
722
+ '`classification.action_from` is not — declare how an action is read out of the answer ' +
723
+ '(e.g. `action_from: { json_path: "$.action" }`), or drop `actions`.');
724
+ }
725
+ return {
726
+ labels,
727
+ actions,
728
+ labelFrom: raw.label_from ? normalizeExtractor(raw.label_from) : { kind: 'answer' },
729
+ actionFrom,
730
+ };
731
+ }
732
+ /** Validate one declared enum: non-blank, unique, and free of the parenthesized synthetic buckets
733
+ * (`(unrecognized)` / `(none)`), which the matrix reserves for "no declared value matched" and "no
734
+ * expectation". A declared value colliding with one of those would make the two indistinguishable. */
735
+ function normalizeEnum(values, field, suffix) {
736
+ const seen = new Set();
737
+ const out = [];
738
+ for (const rawValue of values) {
739
+ const value = rawValue.trim();
740
+ if (value.length === 0) {
741
+ throw new Error(`Invalid eval suite${suffix}: \`${field}\` contains a blank value.`);
742
+ }
743
+ if (!/^[\w.-]+$/.test(value)) {
744
+ throw new Error(`Invalid eval suite${suffix}: \`${field}\` value "${value}" must be a plain token ` +
745
+ '(alphanumeric, dashes, underscores, dots) — enum values are matrix axis labels and ' +
746
+ 'metric-predicate literals.');
747
+ }
748
+ if (seen.has(value)) {
749
+ throw new Error(`Invalid eval suite${suffix}: duplicate \`${field}\` value "${value}".`);
750
+ }
751
+ seen.add(value);
752
+ out.push(value);
753
+ }
754
+ return out;
755
+ }
756
+ /** `"answer"` | `{ json_path }` → the normalized extractor. */
757
+ function normalizeExtractor(raw) {
758
+ return raw === 'answer' ? { kind: 'answer' } : { kind: 'json_path', path: raw.json_path };
759
+ }
760
+ /** Accept a predicate list written as one string or a list of strings; entries are ANDed. */
761
+ function toPredicateList(raw) {
762
+ if (raw === undefined)
763
+ return [];
764
+ return Array.isArray(raw) ? raw : [raw];
765
+ }
766
+ /**
767
+ * BATCH-25 — normalize the suite's `metrics:` list.
768
+ *
769
+ * Two rules here are load-bearing rather than defensive:
770
+ * - a metric requires a `classification:` block (there is nothing to read otherwise);
771
+ * - every predicate literal is checked against the declared enums by
772
+ * {@link ../metrics.js parseMetricPredicate}, because a typo'd value produces an unsatisfiable
773
+ * predicate — a metric that reports a permanent, and trusted, zero.
774
+ *
775
+ * `gate` defaults to `fail` when a threshold is declared: a threshold nobody acts on is a comment.
776
+ * An author who wants a reported-but-not-gating number writes `gate: report` (the corpus plan's
777
+ * `over_escalation`).
778
+ */
779
+ function buildMetricSpecs(raw, classification, suffix) {
780
+ if (raw === undefined || raw.length === 0)
781
+ return [];
782
+ if (!classification) {
783
+ throw new Error(`Invalid eval suite${suffix}: \`metrics\` requires a \`classification:\` block — a metric ` +
784
+ 'reads labels/actions, so the suite must declare which ones exist.');
785
+ }
786
+ const seen = new Set();
787
+ return raw.map((rawMetric, index) => {
788
+ const name = rawMetric.name.trim();
789
+ if (seen.has(name)) {
790
+ throw new Error(`Invalid eval suite${suffix}: duplicate metric name "${name}".`);
791
+ }
792
+ seen.add(name);
793
+ const where = toPredicateList(rawMetric.where);
794
+ if (where.length === 0) {
795
+ throw new Error(`Invalid eval suite${suffix}: metric "${name}" (index ${index}) has an empty \`where\` — ` +
796
+ 'a metric with no numerator predicate counts every case in its denominator, which is a ' +
797
+ 'coverage report, not a metric.');
798
+ }
799
+ const rawOver = toPredicateList(rawMetric.over);
800
+ const at = `Invalid eval suite${suffix}: metric "${name}" (index ${index})`;
801
+ const wherePredicates = where.map((predicate) => parseMetricPredicate(predicate, classification, `${at} \`where\``));
802
+ const overPredicates = rawMetric.over === undefined
803
+ ? undefined
804
+ : rawOver.map((predicate) => parseMetricPredicate(predicate, classification, `${at} \`over\``));
805
+ if (overPredicates !== undefined && overPredicates.length === 0) {
806
+ throw new Error(`${at} has an empty \`over\` — omit the key entirely to score the WHOLE corpus (the ` +
807
+ 'default, and the one that cannot go blind).');
808
+ }
809
+ // A metric declares its thresholds in ONE unit. Mixing them would put two thresholds on one
810
+ // gate with no defined precedence, and would leave every downstream reader — the console, the
811
+ // comparison table, results.json — guessing whether `2` meant two cases or 200%.
812
+ const hasFraction = rawMetric.max !== undefined || rawMetric.min !== undefined;
813
+ const hasCount = rawMetric.max_count !== undefined || rawMetric.min_count !== undefined;
814
+ if (hasFraction && hasCount) {
815
+ throw new Error(`${at} declares BOTH fraction thresholds (\`max\`/\`min\`) and count thresholds ` +
816
+ '(`max_count`/`min_count`) — a metric gates in one unit. Use counts when the target is ' +
817
+ '"at most N cases" (invariant to corpus size), fractions when it is "at most X% of the ' +
818
+ 'denominator".');
819
+ }
820
+ for (const [field, value] of [
821
+ ['max', rawMetric.max],
822
+ ['min', rawMetric.min],
823
+ ]) {
824
+ if (value !== undefined && (value < 0 || value > 1)) {
825
+ throw new Error(`${at} has \`${field}: ${value}\` — \`${field}\` is a FRACTION of the denominator ` +
826
+ `(0..1), so a zero-tolerance gate is \`max: 0\` and a full-recall gate is \`min: 1\`. ` +
827
+ `For an absolute target like "at most ${value} case(s)", use \`${field}_count: ` +
828
+ `${value}\` — it does not drift as the corpus grows.`);
829
+ }
830
+ }
831
+ for (const [field, value] of [
832
+ ['max_count', rawMetric.max_count],
833
+ ['min_count', rawMetric.min_count],
834
+ ]) {
835
+ if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
836
+ throw new Error(`${at} has \`${field}: ${value}\` — a count threshold is a whole, non-negative number ` +
837
+ 'of cases. For a fractional target use `max`/`min` (0..1).');
838
+ }
839
+ }
840
+ return {
841
+ name,
842
+ description: rawMetric.description?.trim() || undefined,
843
+ where: wherePredicates,
844
+ over: overPredicates,
845
+ max: rawMetric.max,
846
+ min: rawMetric.min,
847
+ maxCount: rawMetric.max_count,
848
+ minCount: rawMetric.min_count,
849
+ gate: rawMetric.gate ?? 'fail',
850
+ };
851
+ });
852
+ }
853
+ /** BATCH-25 — normalize the `sweep:` block. Axis and value names double as output-dir components,
854
+ * so they get the same path-safe validation case ids and identity names get. */
855
+ function buildSweep(raw, suffix) {
856
+ if (raw === undefined)
857
+ return undefined;
858
+ const seenAxes = new Set();
859
+ const axes = raw.axes.map((rawAxis) => {
860
+ const axisName = rawAxis.name.trim();
861
+ assertPathSafeToken(axisName, `sweep axis name`, suffix);
862
+ if (seenAxes.has(axisName)) {
863
+ throw new Error(`Invalid eval suite${suffix}: duplicate sweep axis "${axisName}".`);
864
+ }
865
+ seenAxes.add(axisName);
866
+ const seenValues = new Set();
867
+ const values = rawAxis.values.map((rawValue) => {
868
+ const valueName = rawValue.name.trim();
869
+ assertPathSafeToken(valueName, `sweep value name (axis "${axisName}")`, suffix);
870
+ if (seenValues.has(valueName)) {
871
+ throw new Error(`Invalid eval suite${suffix}: duplicate sweep value "${valueName}" on axis "${axisName}".`);
872
+ }
873
+ seenValues.add(valueName);
874
+ if (rawValue.config && 'llm' in rawValue.config) {
875
+ throw new Error(`Invalid eval suite${suffix}: sweep value "${axisName}=${valueName}" sets \`config.llm\`. ` +
876
+ '`llm` holds a CONSTRUCTED model instance, not data — merging into it produces a ' +
877
+ 'half-built object. Use the sibling `model:` key, which rebuilds the model through the ' +
878
+ 'provider (a genuinely fresh instance).');
879
+ }
880
+ if (rawValue.model === undefined && rawValue.config === undefined) {
881
+ throw new Error(`Invalid eval suite${suffix}: sweep value "${axisName}=${valueName}" declares neither ` +
882
+ '`model:` nor `config:` — a cell that overrides nothing is an unnamed duplicate run.');
883
+ }
884
+ return {
885
+ name: valueName,
886
+ model: rawValue.model?.trim() || undefined,
887
+ config: rawValue.config,
888
+ };
889
+ });
890
+ return { name: axisName, values };
891
+ });
892
+ return { axes };
893
+ }
894
+ /** A plain, path-safe token — the same rule case ids and identity names follow, for the same reason
895
+ * (these names become output-directory components). */
896
+ function assertPathSafeToken(value, what, suffix) {
897
+ if (!/^[\w.-]+$/.test(value) || value.includes('..')) {
898
+ throw new Error(`Invalid eval suite${suffix}: ${what} "${value}" must be a plain token (alphanumeric, ` +
899
+ 'dashes, underscores, dots) — it becomes an output-directory component, so path ' +
900
+ 'separators and ".." are rejected.');
901
+ }
902
+ }
465
903
  /**
466
904
  * Normalize ONE turn's raw assertion surface — a single-`prompt` case's case-level fields, or one
467
905
  * `turns:` entry — into its {@link EvalExpectation} blocks. Shared by the single-turn and multi-turn
@@ -495,6 +933,8 @@ function buildTurnExpectations(raw, ctx) {
495
933
  turnIndex: ctx.turnIndex,
496
934
  blockIndex,
497
935
  declaredIdentities: ctx.declaredIdentities,
936
+ classification: ctx.classification,
937
+ targetType: ctx.targetType,
498
938
  }));
499
939
  }
500
940
  else {
@@ -507,6 +947,8 @@ function buildTurnExpectations(raw, ctx) {
507
947
  turnIndex: ctx.turnIndex,
508
948
  blockIndex: undefined,
509
949
  declaredIdentities: ctx.declaredIdentities,
950
+ classification: ctx.classification,
951
+ targetType: ctx.targetType,
510
952
  }),
511
953
  ];
512
954
  }
@@ -526,6 +968,42 @@ function buildTurnExpectations(raw, ctx) {
526
968
  }
527
969
  return expectations;
528
970
  }
971
+ /**
972
+ * BATCH-25 Half B — desugar `forced_by: <mechanism>` into the content assertion that grades it.
973
+ *
974
+ * ## Why a case needs this at all
975
+ *
976
+ * A `model_free` case cannot be graded on its `action`. Without a verdict, core's decision mapping
977
+ * substitutes its fail-closed one and returns the SAME action for every command at a rated rung, so
978
+ * `expect_action: escalate` passes for `ls -la` exactly as it does for `rm -rf $(echo /)` — and
979
+ * would still pass with the hardline floor and both preflights deleted. A model-free assertion is
980
+ * only a regression gate if a DIFFERENT command would get it wrong, and the one signal that
981
+ * satisfies that is WHICH deterministic mechanism decided the command. The `rater` target reports
982
+ * it in the rationale; this turns the corpus's own `forced_by` / `floor_refuses` vocabulary into the
983
+ * `must_contain` that reads it, so transcribing a corpus case stays a copy.
984
+ *
985
+ * `rater`-only: no other target has a deterministic layer to attribute a decision to, and an
986
+ * assertion that can never be satisfied is as bad as one that always is.
987
+ */
988
+ function parseForcedBy(raw, ctx) {
989
+ const mechanism = raw?.trim();
990
+ if (!mechanism)
991
+ return undefined;
992
+ const turnPart = ctx.turnIndex === undefined ? '' : ` turn ${ctx.turnIndex}`;
993
+ const where = ctx.blockIndex === undefined
994
+ ? `case "${ctx.caseId}" (index ${ctx.caseIndex})${turnPart}`
995
+ : `case "${ctx.caseId}" (index ${ctx.caseIndex})${turnPart} expect block ${ctx.blockIndex}`;
996
+ if (ctx.targetType !== 'rater') {
997
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} uses \`forced_by\`, which only the "rater" target ` +
998
+ `can grade — it names a deterministic mechanism of the approvals gate (${FORCED_BY_MECHANISMS.join(', ')}), and a "${ctx.targetType}" target has none to report. Remove it, or use ` +
999
+ '`target: { type: rater, rung: … }`.');
1000
+ }
1001
+ if (!FORCED_BY_MECHANISMS.includes(mechanism)) {
1002
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} declares \`forced_by: ${mechanism}\`, which is not ` +
1003
+ `a mechanism of the approvals gate. One of: ${FORCED_BY_MECHANISMS.join(', ')}.`);
1004
+ }
1005
+ return mechanism;
1006
+ }
529
1007
  /**
530
1008
  * Normalize one raw assertion bundle (a flat case's case-level fields, or one `expect:` block) into
531
1009
  * an {@link EvalExpectation}: default arrays to `[]`, compile regexes at parse time, validate
@@ -554,7 +1032,16 @@ function buildExpectation(raw, blockIdentities, ctx) {
554
1032
  }
555
1033
  identities = blockIdentities;
556
1034
  }
557
- const mustContain = raw.must_contain ?? [];
1035
+ // `forced_by` is BOTH graded and load-bearing for how the round is driven, so the mechanism is
1036
+ // kept as well as desugared: the marker below is what the grader reads, and `forcedBy` on the
1037
+ // returned expectation is what lets the target hand the gate a rating to override (see
1038
+ // `mechanismNeedsPermissiveRating`). Deriving one from the other later would mean matching marker
1039
+ // TEXT back to a mechanism.
1040
+ const forcedBy = parseForcedBy(raw.forced_by, ctx);
1041
+ const mustContain = [
1042
+ ...(raw.must_contain ?? []),
1043
+ ...(forcedBy === undefined ? [] : [FORCED_BY_ASSERTIONS[forcedBy]]),
1044
+ ];
558
1045
  const mustNotContain = raw.must_not_contain ?? [];
559
1046
  const shouldContainAny = raw.should_contain_any ?? [];
560
1047
  const mustCall = raw.must_call ?? [];
@@ -605,6 +1092,37 @@ function buildExpectation(raw, blockIdentities, ctx) {
605
1092
  return { tool: entry.tool, path: entry.path, equals: entry.equals };
606
1093
  return { tool: entry.tool, path: entry.path };
607
1094
  });
1095
+ // BATCH-25 classification assertions. Validated against the suite's declared enums HERE (not
1096
+ // later) so a typo'd label is a suite error rather than a case that can never pass.
1097
+ const expectLabel = raw.expect_label?.trim() || undefined;
1098
+ const expectAction = raw.expect_action?.trim() || undefined;
1099
+ if (expectLabel !== undefined) {
1100
+ if (!ctx.classification) {
1101
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} uses \`expect_label\` but the suite declares no ` +
1102
+ '`classification:` block — add one naming the label enum (e.g. `classification: { ' +
1103
+ 'labels: [safe, destructive] }`), which is also what gives the confusion matrix its axes.');
1104
+ }
1105
+ if (!ctx.classification.labels.includes(expectLabel)) {
1106
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} expects label "${expectLabel}", which is not in ` +
1107
+ `the suite's \`classification.labels\` (${ctx.classification.labels.join(', ')}).`);
1108
+ }
1109
+ }
1110
+ if (expectAction !== undefined) {
1111
+ if (!ctx.classification) {
1112
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} uses \`expect_action\` but the suite declares ` +
1113
+ 'no `classification:` block — add one declaring `actions:` and `action_from:`.');
1114
+ }
1115
+ if (ctx.classification.actions.length === 0) {
1116
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} uses \`expect_action\` but the suite's ` +
1117
+ '`classification:` block declares no `actions:` enum — an action assertion with no ' +
1118
+ 'action dimension could never be graded, and an ungradeable assertion that silently ' +
1119
+ 'passes is the worst outcome for an eval tool.');
1120
+ }
1121
+ if (!ctx.classification.actions.includes(expectAction)) {
1122
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} expects action "${expectAction}", which is not ` +
1123
+ `in the suite's \`classification.actions\` (${ctx.classification.actions.join(', ')}).`);
1124
+ }
1125
+ }
608
1126
  const hasChecks = mustContain.length > 0 ||
609
1127
  mustNotContain.length > 0 ||
610
1128
  shouldContainAny.length > 0 ||
@@ -614,14 +1132,19 @@ function buildExpectation(raw, blockIdentities, ctx) {
614
1132
  mustNotMatch.length > 0 ||
615
1133
  jsonPath.length > 0 ||
616
1134
  mustError.length > 0 ||
617
- toolResultJsonPath.length > 0;
1135
+ toolResultJsonPath.length > 0 ||
1136
+ // BATCH-25 — a classification case whose ONLY assertion is `expect_label`/`expect_action` is the
1137
+ // primary shape of a classifier suite; without these two clauses it would be rejected here as
1138
+ // "no checks and no judge rubric".
1139
+ expectLabel !== undefined ||
1140
+ expectAction !== undefined;
618
1141
  const judgeRubric = raw.judge?.trim();
619
1142
  const hasJudge = !!judgeRubric;
620
1143
  if (!hasChecks && !hasJudge) {
621
1144
  throw new Error(`Invalid eval suite${ctx.suffix}: ${where} has no checks and no judge rubric — it must ` +
622
1145
  'declare at least one of must_contain / must_not_contain / should_contain_any / must_call ' +
623
1146
  '/ must_not_call / must_match / must_not_match / json_path / must_error / ' +
624
- 'tool_result_json_path, or a judge rubric.');
1147
+ 'tool_result_json_path / expect_label / expect_action / forced_by, or a judge rubric.');
625
1148
  }
626
1149
  return {
627
1150
  identities,
@@ -635,7 +1158,10 @@ function buildExpectation(raw, blockIdentities, ctx) {
635
1158
  jsonPath,
636
1159
  mustError,
637
1160
  toolResultJsonPath,
1161
+ expectLabel,
1162
+ expectAction,
638
1163
  judgeRubric: hasJudge ? judgeRubric : undefined,
1164
+ forcedBy,
639
1165
  };
640
1166
  }
641
1167
  //# sourceMappingURL=evalSuite.js.map