@axlsdk/eval 0.17.9 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,9 +2,11 @@
2
2
  import {
3
3
  aggregateRuns,
4
4
  evalCompare,
5
+ evaluateScorerErrorRateGate,
5
6
  rescore,
6
- runEval
7
- } from "./chunk-NETD4AUB.js";
7
+ runEval,
8
+ scorerCounts
9
+ } from "./chunk-66EYSYQS.js";
8
10
 
9
11
  // src/cli.ts
10
12
  import { readdirSync, statSync } from "fs";
@@ -57,10 +59,22 @@ function validateEvalConfig(cfg) {
57
59
  if (badScorerIdx !== -1) {
58
60
  return `exports a scorers array with a non-scorer entry at index ${badScorerIdx} \u2014 each scorer must have a score() method (use the scorer() factory).`;
59
61
  }
62
+ for (const key of ["concurrency", "scorerConcurrency"]) {
63
+ const v = c[key];
64
+ if (v !== void 0 && (typeof v !== "number" || !Number.isInteger(v) || v < 1)) {
65
+ return `exports an invalid ${key} (${typeof v === "number" ? v : typeof v}) \u2014 must be a positive integer.`;
66
+ }
67
+ }
68
+ {
69
+ const v = c.failOnScorerErrorRate;
70
+ if (v !== void 0 && (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1)) {
71
+ return `exports an invalid failOnScorerErrorRate (${typeof v === "number" ? v : typeof v}) \u2014 must be a number between 0 and 1.`;
72
+ }
73
+ }
60
74
  return void 0;
61
75
  }
62
76
 
63
- // src/cli.ts
77
+ // src/cli-args.ts
64
78
  var KNOWN_FLAGS = /* @__PURE__ */ new Set([
65
79
  "--output",
66
80
  "--config",
@@ -68,8 +82,98 @@ var KNOWN_FLAGS = /* @__PURE__ */ new Set([
68
82
  "--fail-on-regression",
69
83
  "--threshold",
70
84
  "--runs",
71
- "--capture-traces"
85
+ "--capture-traces",
86
+ "--concurrency",
87
+ "--scorers"
88
+ ]);
89
+ var VALUE_FLAGS = /* @__PURE__ */ new Set([
90
+ "--output",
91
+ "--config",
92
+ "--conditions",
93
+ "--runs",
94
+ "--concurrency",
95
+ "--scorers"
72
96
  ]);
97
+ function envInt(name) {
98
+ const raw = process.env[name];
99
+ if (raw == null || raw === "") return void 0;
100
+ const n = parseInt(raw, 10);
101
+ if (!Number.isFinite(n) || n <= 0) return void 0;
102
+ return n;
103
+ }
104
+ function parseEvalArgs(args) {
105
+ let outputPath;
106
+ let configArg;
107
+ let conditions = [];
108
+ let runs = 1;
109
+ let captureTraces = false;
110
+ let concurrency;
111
+ let scorerNames;
112
+ const paths = [];
113
+ for (let i = 0; i < args.length; i++) {
114
+ const arg = args[i];
115
+ if (VALUE_FLAGS.has(arg)) {
116
+ if (i + 1 >= args.length) {
117
+ console.error(`Error: ${arg} requires a value`);
118
+ process.exit(1);
119
+ }
120
+ const value = args[++i];
121
+ if (arg === "--output") outputPath = value;
122
+ else if (arg === "--config") configArg = value;
123
+ else if (arg === "--runs") runs = Math.max(1, parseInt(value, 10) || 1);
124
+ else if (arg === "--concurrency") {
125
+ const n = parseInt(value, 10);
126
+ if (!Number.isFinite(n) || n <= 0) {
127
+ console.error(`[axl-eval] Ignoring invalid --concurrency "${value}"; using 1.`);
128
+ concurrency = 1;
129
+ } else {
130
+ concurrency = n;
131
+ }
132
+ } else if (arg === "--scorers") {
133
+ scorerNames = [
134
+ ...new Set(
135
+ value.split(",").map((s) => s.trim()).filter(Boolean)
136
+ )
137
+ ];
138
+ } else {
139
+ conditions = value.split(",").map((c) => c.trim()).filter(Boolean);
140
+ }
141
+ } else if (arg === "--capture-traces") {
142
+ captureTraces = true;
143
+ } else if (arg.startsWith("--")) {
144
+ if (!KNOWN_FLAGS.has(arg)) {
145
+ console.error(`Unknown flag: ${arg}`);
146
+ process.exit(1);
147
+ }
148
+ } else {
149
+ paths.push(arg);
150
+ }
151
+ }
152
+ return {
153
+ outputPath,
154
+ configArg,
155
+ conditions,
156
+ runs,
157
+ captureTraces,
158
+ concurrency,
159
+ scorerNames,
160
+ paths
161
+ };
162
+ }
163
+
164
+ // src/cli.ts
165
+ function refuseToGate(reason) {
166
+ console.error(`[axl-eval] Refusing to gate: ${reason}`);
167
+ process.exit(1);
168
+ }
169
+ function scorerSampleCounts(entry, name, items) {
170
+ if (entry.scored != null && entry.failed != null) {
171
+ const skipped = entry.skipped ?? (items ? scorerCounts(items, name).skipped : 0);
172
+ return { scored: entry.scored, failed: entry.failed, skipped };
173
+ }
174
+ if (items) return scorerCounts(items, name);
175
+ return { scored: entry.scored ?? 0, failed: entry.failed ?? 0, skipped: entry.skipped ?? 0 };
176
+ }
73
177
  function installAbortHandler(controller) {
74
178
  let signalled = false;
75
179
  const onSignal = (sig) => {
@@ -96,6 +200,15 @@ Usage:
96
200
  axl-eval <path> --output <file> Save results to JSON
97
201
  axl-eval <path> --config <file> Use config file for runtime
98
202
  axl-eval <path> --conditions <list> Node.js import conditions (comma-separated)
203
+ axl-eval <path> --concurrency <n> Override item concurrency (flag > env
204
+ AXL_EVAL_CONCURRENCY > config > 5).
205
+ Max concurrent scorer calls is
206
+ concurrency x scorerConcurrency (the
207
+ per-eval scorerConcurrency defaults to 5).
208
+ axl-eval <path> --scorers <a,b> Run only the named scorers (single eval
209
+ file only). Stamps the result as a
210
+ subset so compare won't treat it as a
211
+ full baseline.
99
212
  axl-eval <path> --capture-traces Populate EvalItem.traces on every item
100
213
  (success + failure). Adds memory
101
214
  overhead proportional to dataset size
@@ -104,6 +217,12 @@ Usage:
104
217
  axl-eval compare <a> <b> Compare two eval result files
105
218
  axl-eval compare <a> <b> --threshold <v> Set regression threshold (global or per-scorer)
106
219
  axl-eval compare <a> <b> --fail-on-regression Exit 1 if regressions
220
+ axl-eval compare <a> <b> --max-scorer-error-rate <0..1> Exit 1 if a scorer's
221
+ failure rate exceeds the limit on
222
+ either side (deterministic scorers
223
+ tolerate zero failures). Always warns
224
+ when either side rests on a thinned
225
+ sample, even without this flag.
107
226
 
108
227
  Config auto-detection (when --config is not specified):
109
228
  ${CONFIG_CANDIDATES.join(" -> ")}
@@ -138,20 +257,61 @@ function parseThresholdArg(args) {
138
257
  }
139
258
  return Object.keys(map).length > 0 ? map : void 0;
140
259
  }
260
+ function parseMaxScorerErrorRate(args) {
261
+ const idx = args.indexOf("--max-scorer-error-rate");
262
+ if (idx === -1) return void 0;
263
+ if (idx + 1 >= args.length) {
264
+ console.error("Error: --max-scorer-error-rate requires a value in [0, 1]");
265
+ process.exit(1);
266
+ }
267
+ const raw = args[idx + 1];
268
+ const n = /^\d*\.?\d+$/.test(raw.trim()) ? Number(raw) : NaN;
269
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
270
+ console.error(`Error: --max-scorer-error-rate must be a number in [0, 1], got "${raw}"`);
271
+ process.exit(1);
272
+ }
273
+ return n;
274
+ }
275
+ function scorerFilteredScorers(r) {
276
+ const first = Array.isArray(r) ? r[0] : r;
277
+ const meta = first?.metadata;
278
+ if (!meta || meta.scorerFiltered !== true) return void 0;
279
+ return Array.isArray(meta.scorersRun) ? meta.scorersRun : [];
280
+ }
141
281
  async function runCompare(args) {
142
282
  const failOnRegression = args.includes("--fail-on-regression");
143
283
  const thresholds = parseThresholdArg(args);
284
+ const maxScorerErrorRate = parseMaxScorerErrorRate(args);
285
+ const valueFlags = /* @__PURE__ */ new Set(["--threshold", "--max-scorer-error-rate"]);
144
286
  const files = args.filter(
145
- (a, i) => !a.startsWith("--") && !(i > 0 && args[i - 1] === "--threshold")
287
+ (a, i) => !a.startsWith("--") && !(i > 0 && valueFlags.has(args[i - 1]))
146
288
  );
147
289
  if (files.length !== 2) {
148
- console.error("Usage: axl-eval compare <baseline.json> <candidate.json> [--threshold <value>]");
290
+ console.error(
291
+ "Usage: axl-eval compare <baseline.json> <candidate.json> [--threshold <value>] [--fail-on-regression] [--max-scorer-error-rate <0..1>]"
292
+ );
149
293
  process.exit(1);
150
294
  }
151
295
  const baselineRaw = JSON.parse(await readFileAsync(files[0], "utf-8"));
152
296
  const candidateRaw = JSON.parse(await readFileAsync(files[1], "utf-8"));
153
297
  const baseline = baselineRaw;
154
298
  const candidate = candidateRaw;
299
+ const filteredSides = [
300
+ ["baseline", baseline],
301
+ ["candidate", candidate]
302
+ ].filter(([, r]) => scorerFilteredScorers(r) != null);
303
+ for (const [side, r] of filteredSides) {
304
+ const ran = scorerFilteredScorers(r);
305
+ const ranStr = ran && ran.length ? ` (ran: ${ran.join(", ")})` : "";
306
+ console.error(
307
+ `[axl-eval] WARNING: ${side} was run with --scorers \u2014 it tested only a subset${ranStr}, so this comparison is incomplete.`
308
+ );
309
+ }
310
+ if (failOnRegression && filteredSides.length > 0) {
311
+ refuseToGate(
312
+ `${filteredSides.map(([s]) => s).join(", ")} was run with --scorers (a scorer subset); re-run without --scorers for a complete baseline.`
313
+ );
314
+ }
155
315
  const compareOptions = thresholds != null ? { thresholds } : void 0;
156
316
  const comparison = evalCompare(baseline, candidate, compareOptions);
157
317
  console.log(
@@ -207,6 +367,39 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
207
367
  Regressions: ${comparison.regressions.length} | Improvements: ${comparison.improvements.length} | Stable: ${stable}
208
368
  `
209
369
  );
370
+ let sawThinnedSample = false;
371
+ for (const name of scorerNames) {
372
+ const s = comparison.scorers[name];
373
+ const bFailed = s.baselineFailed ?? 0;
374
+ const cFailed = s.candidateFailed ?? 0;
375
+ if (bFailed > 0 || cFailed > 0) {
376
+ sawThinnedSample = true;
377
+ const bAtt = (s.baselineScored ?? 0) + bFailed;
378
+ const cAtt = (s.candidateScored ?? 0) + cFailed;
379
+ console.error(
380
+ `[axl-eval] WARNING: scorer "${name}" failed on some items (baseline ${bFailed}/${bAtt}, candidate ${cFailed}/${cAtt}); the means above are computed over the surviving sample.`
381
+ );
382
+ }
383
+ const bSkipped = s.baselineSkipped ?? 0;
384
+ const cSkipped = s.candidateSkipped ?? 0;
385
+ const pairedN = s.n ?? 0;
386
+ const minScored = Math.min(s.baselineScored ?? 0, s.candidateScored ?? 0);
387
+ const skipsDiverge = (bSkipped > 0 || cSkipped > 0) && (bSkipped !== cSkipped || pairedN < minScored);
388
+ if (skipsDiverge) {
389
+ console.error(
390
+ `[axl-eval] NOTE: scorer "${name}" applies to different subsets per side (baseline ${bSkipped} N/A, candidate ${cSkipped} N/A; ${pairedN} item(s) scored on both); the two means cover different applicable subsets, so compare the delta with care.`
391
+ );
392
+ }
393
+ }
394
+ if (sawThinnedSample && maxScorerErrorRate == null) {
395
+ console.error(
396
+ `[axl-eval] (advisory \u2014 pass --max-scorer-error-rate <0..1> to fail CI on a thinned sample)`
397
+ );
398
+ }
399
+ if (maxScorerErrorRate != null) {
400
+ const reason = evaluateScorerErrorRateGate(comparison, maxScorerErrorRate);
401
+ if (reason) refuseToGate(reason);
402
+ }
210
403
  if (failOnRegression && comparison.regressions.length > 0) {
211
404
  const hasSignificance = scorerNames.some((n) => comparison.scorers[n].significant != null);
212
405
  if (hasSignificance) {
@@ -220,11 +413,18 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
220
413
  }
221
414
  }
222
415
  async function runRescore(args, signal) {
223
- const { outputPath, configArg, conditions, paths } = parseEvalArgs(args);
416
+ const { outputPath, configArg, conditions, concurrency, scorerNames, paths } = parseEvalArgs(args);
224
417
  if (paths.length < 2) {
225
418
  console.error("Usage: axl-eval rescore <results.json> <eval-file> [--output <file>]");
226
419
  process.exit(1);
227
420
  }
421
+ if (scorerNames?.length) {
422
+ console.error(
423
+ "Error: --scorers is not supported with rescore; pass a scorer subset in the eval file instead."
424
+ );
425
+ process.exit(1);
426
+ }
427
+ const itemConcurrency = concurrency ?? envInt("AXL_EVAL_CONCURRENCY");
228
428
  const [resultsPath, evalFilePath] = paths;
229
429
  const raw = JSON.parse(await readFileAsync(resultsPath, "utf-8"));
230
430
  const results = Array.isArray(raw) ? raw : [raw];
@@ -245,7 +445,12 @@ async function runRescore(args, signal) {
245
445
  const rescored = [];
246
446
  for (const resultData of results) {
247
447
  if (signal.aborted) break;
248
- rescored.push(await rescore(resultData, evalConfig.scorers, runtime, { signal }));
448
+ rescored.push(
449
+ await rescore(resultData, evalConfig.scorers, runtime, {
450
+ signal,
451
+ ...itemConcurrency != null ? { concurrency: itemConcurrency } : {}
452
+ })
453
+ );
249
454
  }
250
455
  for (const r of rescored) {
251
456
  console.log("\n" + formatTable(r) + "\n");
@@ -295,14 +500,18 @@ function formatTable(result) {
295
500
  lines.push(" " + "\u2500".repeat(ruleLen));
296
501
  for (const name of scorerNames) {
297
502
  const s = result.summary.scorers[name];
298
- const validScoreCount = result.items.filter((i) => !i.error && i.scores[name] != null).length;
299
- if (validScoreCount === 0) {
503
+ const { scored, failed, skipped } = scorerSampleCounts(s, name, result.items);
504
+ const annotationParts = [];
505
+ if (failed > 0) annotationParts.push(`${scored}/${scored + failed} scored, ${failed} failed`);
506
+ if (skipped > 0) annotationParts.push(`${skipped} N/A`);
507
+ const annotation = annotationParts.length > 0 ? ` (${annotationParts.join(", ")})` : "";
508
+ if (scored === 0) {
300
509
  lines.push(
301
- ` ${name.padEnd(maxNameLen)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)}`
510
+ ` ${name.padEnd(maxNameLen)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)}${annotation}`
302
511
  );
303
512
  } else {
304
513
  lines.push(
305
- ` ${name.padEnd(maxNameLen)} ${s.mean.toFixed(2).padStart(colWidth)} ${s.min.toFixed(2).padStart(colWidth)} ${s.max.toFixed(2).padStart(colWidth)} ${s.p50.toFixed(2).padStart(colWidth)} ${s.p95.toFixed(2).padStart(colWidth)}`
514
+ ` ${name.padEnd(maxNameLen)} ${s.mean.toFixed(2).padStart(colWidth)} ${s.min.toFixed(2).padStart(colWidth)} ${s.max.toFixed(2).padStart(colWidth)} ${s.p50.toFixed(2).padStart(colWidth)} ${s.p95.toFixed(2).padStart(colWidth)}${annotation}`
306
515
  );
307
516
  }
308
517
  }
@@ -350,8 +559,10 @@ function formatMultiRunTable(summary) {
350
559
  for (const name of scorerNames) {
351
560
  const s = summary.scorers[name];
352
561
  const meanStd = `${s.mean.toFixed(3)} \xB1 ${s.std.toFixed(3)}`;
562
+ const { scored, failed } = scorerSampleCounts(s, name);
563
+ const annotation = failed > 0 ? ` (${scored}/${scored + failed} scored, ${failed} failed)` : "";
353
564
  lines.push(
354
- ` ${name.padEnd(maxNameLen)} ${meanStd.padStart(colWidth)} ${s.min.toFixed(3).padStart(8)} ${s.max.toFixed(3).padStart(8)}`
565
+ ` ${name.padEnd(maxNameLen)} ${meanStd.padStart(colWidth)} ${s.min.toFixed(3).padStart(8)} ${s.max.toFixed(3).padStart(8)}${annotation}`
355
566
  );
356
567
  }
357
568
  if (summary.timing) {
@@ -365,6 +576,36 @@ function formatMultiRunTable(summary) {
365
576
  lines.push(` Total Cost: ${costStr} | Total Duration: ${durationStr}s`);
366
577
  return lines.join("\n");
367
578
  }
579
+ function reportDegraded(result, label) {
580
+ const d = result.summary.degraded;
581
+ if (!d || d.length === 0) return false;
582
+ for (const x of d) {
583
+ const attempted = x.scored + x.failed;
584
+ const limitStr = x.type === "deterministic" ? "deterministic zero-tolerance" : `${(x.limit * 100).toFixed(1)}% limit`;
585
+ console.error(
586
+ `[axl-eval] DEGRADED: ${label} scorer "${x.scorer}" (${x.type}) failed ${(x.rate * 100).toFixed(1)}% (${x.failed}/${attempted}), over the ${limitStr}.`
587
+ );
588
+ }
589
+ return true;
590
+ }
591
+ function reportFullySkippedScorers(result, label) {
592
+ for (const [name, entry] of Object.entries(result.summary.scorers)) {
593
+ const { scored, failed, skipped } = scorerSampleCounts(entry, name, result.items);
594
+ if (skipped > 0 && scored + failed === 0) {
595
+ console.error(
596
+ `[axl-eval] NOTE: ${label} scorer "${name}" was not applicable to any item (${skipped} N/A, 0 scored) \u2014 its mean is not meaningful. Check the scorer's \`applies\` predicate and dataset if you expected it to run.`
597
+ );
598
+ }
599
+ }
600
+ }
601
+ function reportTotalWipeout(result, label) {
602
+ const { count, failures } = result.summary;
603
+ if (count === 0 || failures < count) return false;
604
+ console.error(
605
+ `[axl-eval] FAILED: ${label} \u2014 all ${count} item(s) errored in the workflow (0 succeeded); the eval produced no scorable output.`
606
+ );
607
+ return true;
608
+ }
368
609
  async function resolveRuntimeFromConfig(configPath) {
369
610
  try {
370
611
  const mod = await importModule(configPath, import.meta.url);
@@ -425,41 +666,17 @@ async function getRuntime(configArg, conditions) {
425
666
  const { AxlRuntime } = await import("@axlsdk/axl");
426
667
  return new AxlRuntime();
427
668
  }
428
- function parseEvalArgs(args) {
429
- let outputPath;
430
- let configArg;
431
- let conditions = [];
432
- let runs = 1;
433
- let captureTraces = false;
434
- const paths = [];
435
- for (let i = 0; i < args.length; i++) {
436
- const arg = args[i];
437
- if (arg === "--output" || arg === "--config" || arg === "--conditions" || arg === "--runs") {
438
- if (i + 1 >= args.length) {
439
- console.error(`Error: ${arg} requires a value`);
440
- process.exit(1);
441
- }
442
- const value = args[++i];
443
- if (arg === "--output") outputPath = value;
444
- else if (arg === "--config") configArg = value;
445
- else if (arg === "--runs") runs = Math.max(1, parseInt(value, 10) || 1);
446
- else
447
- conditions = value.split(",").map((c) => c.trim()).filter(Boolean);
448
- } else if (arg === "--capture-traces") {
449
- captureTraces = true;
450
- } else if (arg.startsWith("--")) {
451
- if (!KNOWN_FLAGS.has(arg)) {
452
- console.error(`Unknown flag: ${arg}`);
453
- process.exit(1);
454
- }
455
- } else {
456
- paths.push(arg);
457
- }
458
- }
459
- return { outputPath, configArg, conditions, runs, captureTraces, paths };
460
- }
461
669
  async function runEvalCommand(args, signal) {
462
- const { outputPath, configArg, conditions, runs, captureTraces, paths } = parseEvalArgs(args);
670
+ const {
671
+ outputPath,
672
+ configArg,
673
+ conditions,
674
+ runs,
675
+ captureTraces,
676
+ concurrency,
677
+ scorerNames,
678
+ paths
679
+ } = parseEvalArgs(args);
463
680
  if (paths.length === 0) {
464
681
  console.error("Error: No eval file path provided");
465
682
  process.exit(1);
@@ -472,6 +689,10 @@ async function runEvalCommand(args, signal) {
472
689
  console.error("Error: No eval files found");
473
690
  process.exit(1);
474
691
  }
692
+ if (scorerNames?.length && evalFiles.length > 1) {
693
+ console.error(`Error: --scorers requires a single eval file (matched ${evalFiles.length}).`);
694
+ process.exit(1);
695
+ }
475
696
  const runtime = await getRuntime(configArg, conditions);
476
697
  const results = [];
477
698
  let failedFiles = 0;
@@ -487,6 +708,22 @@ async function runEvalCommand(args, signal) {
487
708
  failedFiles++;
488
709
  continue;
489
710
  }
711
+ evalConfig.concurrency = concurrency ?? envInt("AXL_EVAL_CONCURRENCY") ?? evalConfig.concurrency ?? 5;
712
+ if (scorerNames?.length) {
713
+ const available = evalConfig.scorers.map((s) => s.name);
714
+ const unknown = scorerNames.filter((n) => !available.includes(n));
715
+ if (unknown.length) {
716
+ console.error(
717
+ `Error: --scorers: unknown scorer(s): ${unknown.join(", ")}. Available: ${available.join(", ")}`
718
+ );
719
+ process.exit(1);
720
+ }
721
+ evalConfig.scorers = evalConfig.scorers.filter((s) => scorerNames.includes(s.name));
722
+ if (evalConfig.scorers.length === 0) {
723
+ console.error("Error: --scorers matched no scorers.");
724
+ process.exit(1);
725
+ }
726
+ }
490
727
  const exported = pickExport(mod, "executeWorkflow");
491
728
  let customExecute;
492
729
  if (typeof exported === "function") {
@@ -543,6 +780,13 @@ async function runEvalCommand(args, signal) {
543
780
  continue;
544
781
  }
545
782
  const runOptions = { signal, ...captureTraces ? { captureTraces: true } : {} };
783
+ const scorersRun = scorerNames?.length ? evalConfig.scorers.map((s) => s.name) : void 0;
784
+ const stampFiltered = (result) => {
785
+ if (scorersRun) {
786
+ result.metadata.scorerFiltered = true;
787
+ result.metadata.scorersRun = scorersRun;
788
+ }
789
+ };
546
790
  if (runs > 1) {
547
791
  const { randomUUID } = await import("crypto");
548
792
  const runGroupId = randomUUID();
@@ -555,6 +799,7 @@ async function runEvalCommand(args, signal) {
555
799
  const result = await runEval(evalConfig, executeWorkflow, runtime, runOptions);
556
800
  result.metadata.runGroupId = runGroupId;
557
801
  result.metadata.runIndex = r;
802
+ stampFiltered(result);
558
803
  runResults.push(result);
559
804
  } catch (err) {
560
805
  runFailure = err instanceof Error ? err : new Error(String(err));
@@ -586,10 +831,23 @@ async function runEvalCommand(args, signal) {
586
831
  const summary = aggregateRuns(runResults);
587
832
  console.log("\n" + formatMultiRunTable(summary) + "\n");
588
833
  for (const r of runResults) results.push(r);
834
+ let anyFailing = false;
835
+ for (const r of runResults) {
836
+ const wipeout = reportTotalWipeout(r, filePath);
837
+ const degraded = reportDegraded(r, filePath);
838
+ reportFullySkippedScorers(r, filePath);
839
+ if (wipeout || degraded) anyFailing = true;
840
+ }
841
+ if (anyFailing && !partial) failedFiles++;
589
842
  } else {
590
843
  const result = await runEval(evalConfig, executeWorkflow, runtime, runOptions);
844
+ stampFiltered(result);
591
845
  results.push(result);
592
846
  console.log("\n" + formatTable(result) + "\n");
847
+ const wipeout = reportTotalWipeout(result, filePath);
848
+ const degraded = reportDegraded(result, filePath);
849
+ reportFullySkippedScorers(result, filePath);
850
+ if (wipeout || degraded) failedFiles++;
593
851
  }
594
852
  } catch (err) {
595
853
  console.error(