@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.
- package/README.md +8 -6
- package/dist/BatchRunner.d.ts +20 -0
- package/dist/BatchRunner.js +28 -2
- package/dist/BatchRunner.js.map +1 -1
- package/dist/blindExport.d.ts +88 -0
- package/dist/blindExport.js +129 -0
- package/dist/blindExport.js.map +1 -0
- package/dist/classification.d.ts +52 -0
- package/dist/classification.js +140 -0
- package/dist/classification.js.map +1 -0
- package/dist/classificationRender.d.ts +24 -0
- package/dist/classificationRender.js +96 -0
- package/dist/classificationRender.js.map +1 -0
- package/dist/classificationReport.d.ts +11 -0
- package/dist/classificationReport.js +60 -0
- package/dist/classificationReport.js.map +1 -0
- package/dist/classificationTypes.d.ts +311 -0
- package/dist/classificationTypes.js +40 -0
- package/dist/classificationTypes.js.map +1 -0
- package/dist/evalCompare.d.ts +108 -0
- package/dist/evalCompare.js +246 -0
- package/dist/evalCompare.js.map +1 -0
- package/dist/evalRunner.d.ts +34 -3
- package/dist/evalRunner.js +259 -9
- package/dist/evalRunner.js.map +1 -1
- package/dist/evalSuite.d.ts +12 -2
- package/dist/evalSuite.js +534 -8
- package/dist/evalSuite.js.map +1 -1
- package/dist/evalTypes.d.ts +358 -5
- package/dist/evalTypes.js +98 -0
- package/dist/evalTypes.js.map +1 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/metrics.d.ts +50 -0
- package/dist/metrics.js +433 -0
- package/dist/metrics.js.map +1 -0
- package/dist/pipelineCli.js +1 -1
- package/dist/pipelineCli.js.map +1 -1
- package/dist/raterTarget.d.ts +94 -0
- package/dist/raterTarget.js +328 -0
- package/dist/raterTarget.js.map +1 -0
- package/dist/reporters/reporterTypes.d.ts +9 -0
- package/dist/reporters/textReporter.js +21 -0
- package/dist/reporters/textReporter.js.map +1 -1
- package/dist/types.d.ts +14 -2
- package/dist/types.js +14 -2
- package/dist/types.js.map +1 -1
- package/dist/workflow/runWorkflow.d.ts +1 -1
- package/dist/workflow/runWorkflow.js +2 -2
- package/dist/workflow/runWorkflow.js.map +1 -1
- 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 {
|
|
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"`,
|
|
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),
|
|
282
|
-
'CLI/HTTP targets are future
|
|
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
|
-
|
|
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
|