@axlsdk/eval 0.17.9 → 0.18.0

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.cjs CHANGED
@@ -94,10 +94,52 @@ function xorshift32(seed) {
94
94
  };
95
95
  }
96
96
 
97
- // src/compare.ts
97
+ // src/utils.ts
98
+ function computeStats(scores) {
99
+ if (scores.length === 0) return { mean: 0, min: 0, max: 0, p50: 0, p95: 0 };
100
+ const sorted = [...scores].sort((a, b) => a - b);
101
+ const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
102
+ const min = sorted[0];
103
+ const max = sorted[sorted.length - 1];
104
+ const p50 = sorted[Math.floor(sorted.length * 0.5)];
105
+ const p95 = sorted[Math.floor(sorted.length * 0.95)];
106
+ return { mean: round2(mean), min: round2(min), max: round2(max), p50: round2(p50), p95: round2(p95) };
107
+ }
98
108
  function round2(n) {
99
109
  return Math.round(n * 1e3) / 1e3;
100
110
  }
111
+ function scorerCounts(items, name) {
112
+ let scored = 0;
113
+ let failed = 0;
114
+ for (const i of items) {
115
+ if (i.error) continue;
116
+ if (i.scores[name] != null) scored++;
117
+ else if (i.scoreDetails?.[name]?.duration != null) failed++;
118
+ }
119
+ return { scored, failed };
120
+ }
121
+ function evaluateScorerTolerance(scored, failed, type, limit) {
122
+ const attempted = scored + failed;
123
+ const rate = attempted === 0 ? 0 : failed / attempted;
124
+ const exceeds = attempted > 0 && (type === "deterministic" ? failed > 0 : rate > limit);
125
+ return { attempted, rate, exceeds, zeroSample: attempted === 0 };
126
+ }
127
+ async function mapWithConcurrency(items, concurrency, task) {
128
+ const results = new Array(items.length);
129
+ const requested = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;
130
+ const workerCount = items.length === 0 ? 0 : Math.min(Math.max(1, requested), items.length);
131
+ let next = 0;
132
+ async function runNext() {
133
+ while (next < items.length) {
134
+ const i = next++;
135
+ results[i] = await task(items[i], i);
136
+ }
137
+ }
138
+ await Promise.all(Array.from({ length: workerCount }, () => runNext()));
139
+ return results;
140
+ }
141
+
142
+ // src/compare.ts
101
143
  var DEFAULT_LLM_THRESHOLD = 0.05;
102
144
  var DEFAULT_DETERMINISTIC_THRESHOLD = 0;
103
145
  var LEGACY_THRESHOLD = 0.1;
@@ -171,11 +213,29 @@ function evalCompare(baseline, candidate, options) {
171
213
  const delta = round2(deltaRaw);
172
214
  const deltaPercent = bMeanRaw > 0 ? round2(deltaRaw / bMeanRaw * 100) : 0;
173
215
  const threshold = resolveThreshold(name, options, baselineRef.metadata);
216
+ let baselineScored = 0;
217
+ let baselineFailed = 0;
218
+ for (const run of baselineRuns) {
219
+ const c = scorerCounts(run.items, name);
220
+ baselineScored += c.scored;
221
+ baselineFailed += c.failed;
222
+ }
223
+ let candidateScored = 0;
224
+ let candidateFailed = 0;
225
+ for (const run of candidateRuns) {
226
+ const c = scorerCounts(run.items, name);
227
+ candidateScored += c.scored;
228
+ candidateFailed += c.failed;
229
+ }
174
230
  const entry = {
175
231
  baselineMean: bMean,
176
232
  candidateMean: cMean,
177
233
  delta,
178
- deltaPercent
234
+ deltaPercent,
235
+ baselineScored,
236
+ baselineFailed,
237
+ candidateScored,
238
+ candidateFailed
179
239
  };
180
240
  const diffs = pairedDiffs[name];
181
241
  if (diffs.length >= 2) {
@@ -311,25 +371,91 @@ function detectPartial(runs) {
311
371
  if (runs.length >= attempted) return void 0;
312
372
  return { completed: runs.length, attempted };
313
373
  }
374
+ function evaluateScorerErrorRateGate(comparison, maxScorerErrorRate) {
375
+ const scorerTypes = comparison.baseline.metadata?.scorerTypes ?? {};
376
+ for (const name of Object.keys(comparison.scorers)) {
377
+ const s = comparison.scorers[name];
378
+ const type = scorerTypes[name] === "deterministic" ? "deterministic" : "llm";
379
+ const limit = type === "deterministic" ? 0 : maxScorerErrorRate;
380
+ for (const side of ["baseline", "candidate"]) {
381
+ const scored = (side === "baseline" ? s.baselineScored : s.candidateScored) ?? 0;
382
+ const failed = (side === "baseline" ? s.baselineFailed : s.candidateFailed) ?? 0;
383
+ const verdict = evaluateScorerTolerance(scored, failed, type, limit);
384
+ if (verdict.zeroSample) {
385
+ return `scorer "${name}" has no scored items on ${side} (its items errored or were skipped) \u2014 cannot certify a mean over an empty sample.`;
386
+ }
387
+ if (verdict.exceeds) {
388
+ const limitStr = type === "deterministic" ? "deterministic zero-tolerance" : `${(limit * 100).toFixed(1)}%`;
389
+ return `scorer "${name}" failed ${(verdict.rate * 100).toFixed(1)}% on ${side} (${failed}/${verdict.attempted}), over the ${limitStr} limit.`;
390
+ }
391
+ }
392
+ }
393
+ return null;
394
+ }
314
395
 
315
396
  // src/scorer.ts
316
397
  function normalizeScorerResult(result) {
317
398
  return typeof result === "number" ? { score: result } : result;
318
399
  }
319
-
320
- // src/utils.ts
321
- function computeStats(scores) {
322
- if (scores.length === 0) return { mean: 0, min: 0, max: 0, p50: 0, p95: 0 };
323
- const sorted = [...scores].sort((a, b) => a - b);
324
- const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
325
- const min = sorted[0];
326
- const max = sorted[sorted.length - 1];
327
- const p50 = sorted[Math.floor(sorted.length * 0.5)];
328
- const p95 = sorted[Math.floor(sorted.length * 0.95)];
329
- return { mean: round3(mean), min: round3(min), max: round3(max), p50: round3(p50), p95: round3(p95) };
400
+ function extractScorerErrorCost(err) {
401
+ const v = err && typeof err === "object" ? err.cost : void 0;
402
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
330
403
  }
331
- function round3(n) {
332
- return Math.round(n * 1e3) / 1e3;
404
+
405
+ // src/score-item.ts
406
+ async function scoreItem(item, scorers, scorerConcurrency, scorerContext, signal) {
407
+ const scoreDetails = item.scoreDetails ??= {};
408
+ for (const s of scorers) {
409
+ item.scores[s.name] = null;
410
+ scoreDetails[s.name] = { score: null };
411
+ }
412
+ let itemScorerCost = 0;
413
+ const scorerErrorsByName = {};
414
+ await mapWithConcurrency(scorers, scorerConcurrency, async (scorer) => {
415
+ if (signal?.aborted) return;
416
+ const scorerStart = Date.now();
417
+ try {
418
+ const raw = await scorer.score(item.output, item.input, item.annotations, scorerContext);
419
+ const scorerResult = normalizeScorerResult(raw);
420
+ if (scorerResult.cost != null) {
421
+ itemScorerCost += scorerResult.cost;
422
+ }
423
+ const scorerDuration = Date.now() - scorerStart;
424
+ if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
425
+ scorerErrorsByName[scorer.name] = `Scorer "${scorer.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`;
426
+ item.scores[scorer.name] = null;
427
+ scoreDetails[scorer.name] = {
428
+ score: null,
429
+ metadata: scorerResult.metadata,
430
+ duration: scorerDuration,
431
+ cost: scorerResult.cost
432
+ };
433
+ } else {
434
+ item.scores[scorer.name] = round2(scorerResult.score);
435
+ scoreDetails[scorer.name] = {
436
+ score: round2(scorerResult.score),
437
+ metadata: scorerResult.metadata,
438
+ duration: scorerDuration,
439
+ cost: scorerResult.cost
440
+ };
441
+ }
442
+ } catch (err) {
443
+ if (err?.name === "AbortError") return;
444
+ const errCost = extractScorerErrorCost(err);
445
+ if (errCost != null) itemScorerCost += errCost;
446
+ scorerErrorsByName[scorer.name] = `Scorer "${scorer.name}" threw: ${err instanceof Error ? err.message : String(err)}`;
447
+ item.scores[scorer.name] = null;
448
+ scoreDetails[scorer.name] = {
449
+ score: null,
450
+ duration: Date.now() - scorerStart,
451
+ cost: errCost
452
+ };
453
+ }
454
+ });
455
+ const orderedErrors = scorers.map((s) => scorerErrorsByName[s.name]).filter((e) => e != null);
456
+ if (orderedErrors.length > 0) item.scorerErrors = orderedErrors;
457
+ item.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
458
+ return itemScorerCost;
333
459
  }
334
460
 
335
461
  // src/runner.ts
@@ -372,7 +498,9 @@ async function runEval(config, executeWorkflow, runtime, options) {
372
498
  const startTime = Date.now();
373
499
  const id = (0, import_node_crypto.randomUUID)();
374
500
  const items = await config.dataset.getItems();
501
+ const droppedAnnotationKeys = [...config.dataset.droppedAnnotationKeys ?? []];
375
502
  const concurrency = config.concurrency ?? 5;
503
+ const scorerConcurrency = config.scorerConcurrency ?? 5;
376
504
  const budgetLimit = config.budget ? parseCost(config.budget) : void 0;
377
505
  const scorerContext = {
378
506
  resolveProvider: (uri) => {
@@ -461,89 +589,53 @@ async function runEval(config, executeWorkflow, runtime, options) {
461
589
  if (budgetLimit != null && totalCost > budgetLimit) {
462
590
  budgetExceeded = true;
463
591
  }
464
- evalItem.scoreDetails = {};
465
- let itemScorerCost = 0;
466
- for (const scorer of config.scorers) {
467
- if (options?.signal?.aborted) break;
468
- const scorerStart = Date.now();
469
- try {
470
- const raw = await scorer.score(
471
- evalItem.output,
472
- item.input,
473
- item.annotations,
474
- scorerContext
475
- );
476
- const scorerResult = normalizeScorerResult(raw);
477
- if (scorerResult.cost != null) {
478
- itemScorerCost += scorerResult.cost;
479
- totalCost += scorerResult.cost;
480
- }
481
- const scorerDuration = Date.now() - scorerStart;
482
- if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
483
- if (!evalItem.scorerErrors) evalItem.scorerErrors = [];
484
- evalItem.scorerErrors.push(
485
- `Scorer "${scorer.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`
486
- );
487
- evalItem.scores[scorer.name] = null;
488
- evalItem.scoreDetails[scorer.name] = {
489
- score: null,
490
- metadata: scorerResult.metadata,
491
- duration: scorerDuration,
492
- cost: scorerResult.cost
493
- };
494
- } else {
495
- evalItem.scores[scorer.name] = round3(scorerResult.score);
496
- evalItem.scoreDetails[scorer.name] = {
497
- score: round3(scorerResult.score),
498
- metadata: scorerResult.metadata,
499
- duration: scorerDuration,
500
- cost: scorerResult.cost
501
- };
502
- }
503
- } catch (err) {
504
- const errCost = typeof err?.cost === "number" ? err.cost : void 0;
505
- if (errCost != null) {
506
- itemScorerCost += errCost;
507
- totalCost += errCost;
508
- }
509
- if (!evalItem.scorerErrors) evalItem.scorerErrors = [];
510
- evalItem.scorerErrors.push(
511
- `Scorer "${scorer.name}" threw: ${err instanceof Error ? err.message : String(err)}`
512
- );
513
- evalItem.scores[scorer.name] = null;
514
- evalItem.scoreDetails[scorer.name] = {
515
- score: null,
516
- duration: Date.now() - scorerStart,
517
- cost: errCost
518
- };
519
- }
520
- }
521
- evalItem.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
592
+ const itemScorerCost = await scoreItem(
593
+ evalItem,
594
+ config.scorers,
595
+ scorerConcurrency,
596
+ scorerContext,
597
+ options?.signal
598
+ );
599
+ totalCost += itemScorerCost;
522
600
  evalItems[itemIndex] = evalItem;
523
601
  options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
524
602
  }
525
- let index = 0;
526
- async function runNext() {
527
- while (index < items.length) {
528
- const currentIndex = index++;
529
- await processItem(items[currentIndex], currentIndex);
530
- }
531
- }
532
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => runNext());
533
- await Promise.all(workers);
603
+ await mapWithConcurrency(items, concurrency, (item, i) => processItem(item, i));
534
604
  const failures = evalItems.filter((i) => i.error).length;
535
605
  const scorerNames = config.scorers.map((s) => s.name);
536
606
  const scorerStats = {};
537
607
  for (const name of scorerNames) {
538
608
  const scores = evalItems.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
539
- scorerStats[name] = computeStats(scores);
609
+ const { scored, failed } = scorerCounts(evalItems, name);
610
+ scorerStats[name] = { ...computeStats(scores), scored, failed };
540
611
  }
541
- const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
542
- const timing = durations.length > 0 ? computeStats(durations) : void 0;
543
612
  const scorerTypes = {};
544
613
  for (const s of config.scorers) {
545
614
  scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
546
615
  }
616
+ let degraded;
617
+ const rawLimit = config.failOnScorerErrorRate;
618
+ if (rawLimit != null) {
619
+ if (!Number.isFinite(rawLimit) || rawLimit < 0 || rawLimit > 1) {
620
+ console.warn(
621
+ `[axl-eval] Ignoring invalid failOnScorerErrorRate (${rawLimit}); expected a number in [0, 1].`
622
+ );
623
+ } else {
624
+ for (const name of scorerNames) {
625
+ const stats = scorerStats[name];
626
+ const scored = stats.scored ?? 0;
627
+ const failed = stats.failed ?? 0;
628
+ const type = scorerTypes[name] === "llm" ? "llm" : "deterministic";
629
+ const limit = type === "deterministic" ? 0 : rawLimit;
630
+ const verdict = evaluateScorerTolerance(scored, failed, type, limit);
631
+ if (verdict.exceeds) {
632
+ (degraded ??= []).push({ scorer: name, rate: verdict.rate, limit, type, scored, failed });
633
+ }
634
+ }
635
+ }
636
+ }
637
+ const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
638
+ const timing = durations.length > 0 ? computeStats(durations) : void 0;
547
639
  const totalModelCalls = /* @__PURE__ */ new Map();
548
640
  for (const item of evalItems) {
549
641
  const itemCounts = item.metadata?.modelCallCounts;
@@ -602,13 +694,20 @@ async function runEval(config, executeWorkflow, runtime, options) {
602
694
  ...config.metadata,
603
695
  scorerTypes,
604
696
  ...modelsMeta,
605
- ...workflowsMeta
697
+ ...workflowsMeta,
698
+ ...droppedAnnotationKeys.length > 0 ? { droppedAnnotationKeys } : {}
606
699
  },
607
700
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
608
701
  totalCost,
609
702
  duration: Date.now() - startTime,
610
703
  items: evalItems,
611
- summary: { count: items.length, failures, scorers: scorerStats, timing }
704
+ summary: {
705
+ count: items.length,
706
+ failures,
707
+ scorers: scorerStats,
708
+ timing,
709
+ ...degraded ? { degraded } : {}
710
+ }
612
711
  };
613
712
  }
614
713
 
@@ -617,6 +716,7 @@ var import_node_crypto2 = require("crypto");
617
716
  async function rescore(result, scorers, runtime, options) {
618
717
  const startTime = Date.now();
619
718
  const concurrency = options?.concurrency ?? 5;
719
+ const scorerConcurrency = options?.scorerConcurrency ?? 5;
620
720
  const scorerContext = {
621
721
  resolveProvider: (uri) => {
622
722
  if (typeof runtime.resolveProvider !== "function") {
@@ -660,77 +760,24 @@ async function rescore(result, scorers, runtime, options) {
660
760
  scores: {},
661
761
  scoreDetails: {}
662
762
  };
663
- let itemScorerCost = 0;
664
- for (const scorer of scorers) {
665
- const scorerStart = Date.now();
666
- try {
667
- const raw = await scorer.score(item.output, item.input, item.annotations, scorerContext);
668
- const scorerResult = normalizeScorerResult(raw);
669
- if (scorerResult.cost != null) {
670
- itemScorerCost += scorerResult.cost;
671
- totalCost += scorerResult.cost;
672
- }
673
- const scorerDuration = Date.now() - scorerStart;
674
- if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
675
- if (!item.scorerErrors) item.scorerErrors = [];
676
- item.scorerErrors.push(
677
- `Scorer "${scorer.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`
678
- );
679
- item.scores[scorer.name] = null;
680
- item.scoreDetails[scorer.name] = {
681
- score: null,
682
- metadata: scorerResult.metadata,
683
- duration: scorerDuration,
684
- cost: scorerResult.cost
685
- };
686
- } else {
687
- item.scores[scorer.name] = round3(scorerResult.score);
688
- item.scoreDetails[scorer.name] = {
689
- score: round3(scorerResult.score),
690
- metadata: scorerResult.metadata,
691
- duration: scorerDuration,
692
- cost: scorerResult.cost
693
- };
694
- }
695
- } catch (err) {
696
- const errCost = typeof err?.cost === "number" ? err.cost : void 0;
697
- if (errCost != null) {
698
- itemScorerCost += errCost;
699
- totalCost += errCost;
700
- }
701
- if (!item.scorerErrors) item.scorerErrors = [];
702
- item.scorerErrors.push(
703
- `Scorer "${scorer.name}" threw: ${err instanceof Error ? err.message : String(err)}`
704
- );
705
- item.scores[scorer.name] = null;
706
- item.scoreDetails[scorer.name] = {
707
- score: null,
708
- duration: Date.now() - scorerStart,
709
- cost: errCost
710
- };
711
- }
712
- }
713
- item.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
763
+ const itemScorerCost = await scoreItem(
764
+ item,
765
+ scorers,
766
+ scorerConcurrency,
767
+ scorerContext,
768
+ options?.signal
769
+ );
770
+ totalCost += itemScorerCost;
714
771
  rescored[itemIndex] = item;
715
772
  }
716
- let index = 0;
717
- async function runNext() {
718
- while (index < result.items.length) {
719
- const currentIndex = index++;
720
- await rescoreItem(result.items[currentIndex], currentIndex);
721
- }
722
- }
723
- const workers = Array.from(
724
- { length: Math.min(concurrency, result.items.length) },
725
- () => runNext()
726
- );
727
- await Promise.all(workers);
773
+ await mapWithConcurrency(result.items, concurrency, (item, i) => rescoreItem(item, i));
728
774
  const failures = rescored.filter((i) => i.error).length;
729
775
  const scorerNames = scorers.map((s) => s.name);
730
776
  const scorerStats = {};
731
777
  for (const name of scorerNames) {
732
778
  const scores = rescored.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
733
- scorerStats[name] = computeStats(scores);
779
+ const { scored, failed } = scorerCounts(rescored, name);
780
+ scorerStats[name] = { ...computeStats(scores), scored, failed };
734
781
  }
735
782
  const scorerTypes = {};
736
783
  for (const s of scorers) {
@@ -740,7 +787,11 @@ async function rescore(result, scorers, runtime, options) {
740
787
  id: (0, import_node_crypto2.randomUUID)(),
741
788
  dataset: result.dataset,
742
789
  metadata: (() => {
743
- const { runGroupId: _, runIndex: __, ...rest } = result.metadata;
790
+ const rest = { ...result.metadata };
791
+ delete rest.runGroupId;
792
+ delete rest.runIndex;
793
+ delete rest.scorerFiltered;
794
+ delete rest.scorersRun;
744
795
  const merged = {
745
796
  ...rest,
746
797
  rescored: true,
@@ -793,15 +844,24 @@ function aggregateRuns(runs) {
793
844
  const scorerNames = Object.keys(runs[0].summary.scorers);
794
845
  const scorers = {};
795
846
  for (const name of scorerNames) {
796
- const means = runs.map((r) => r.summary.scorers[name]?.mean ?? 0);
847
+ const contributing = runs.filter((r) => {
848
+ const sc = r.summary.scorers[name]?.scored;
849
+ return sc == null || sc > 0;
850
+ });
851
+ const meanSource = contributing.length > 0 ? contributing : runs;
852
+ const means = meanSource.map((r) => r.summary.scorers[name]?.mean ?? 0);
797
853
  const meanOfMeans = means.reduce((a, b) => a + b, 0) / means.length;
798
854
  const minMean = Math.min(...means);
799
855
  const maxMean = Math.max(...means);
856
+ const scored = runs.reduce((sum, r) => sum + (r.summary.scorers[name]?.scored ?? 0), 0);
857
+ const failed = runs.reduce((sum, r) => sum + (r.summary.scorers[name]?.failed ?? 0), 0);
800
858
  scorers[name] = {
801
- mean: round3(meanOfMeans),
802
- std: round3(std(means, meanOfMeans)),
803
- min: round3(minMean),
804
- max: round3(maxMean)
859
+ mean: round2(meanOfMeans),
860
+ std: round2(std(means, meanOfMeans)),
861
+ min: round2(minMean),
862
+ max: round2(maxMean),
863
+ scored,
864
+ failed
805
865
  };
806
866
  }
807
867
  let timing;
@@ -809,8 +869,8 @@ function aggregateRuns(runs) {
809
869
  if (timingMeans.length === runs.length && timingMeans.length > 0) {
810
870
  const meanTiming = timingMeans.reduce((a, b) => a + b, 0) / timingMeans.length;
811
871
  timing = {
812
- mean: round3(meanTiming),
813
- std: round3(std(timingMeans, meanTiming))
872
+ mean: round2(meanTiming),
873
+ std: round2(std(timingMeans, meanTiming))
814
874
  };
815
875
  }
816
876
  return {
@@ -818,8 +878,8 @@ function aggregateRuns(runs) {
818
878
  runCount: runs.length,
819
879
  workflows,
820
880
  dataset,
821
- totalCost: round3(totalCost),
822
- totalDuration: round3(totalDuration),
881
+ totalCost: round2(totalCost),
882
+ totalDuration: round2(totalDuration),
823
883
  scorers,
824
884
  timing
825
885
  };
@@ -861,11 +921,22 @@ function validateEvalConfig(cfg) {
861
921
  if (badScorerIdx !== -1) {
862
922
  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).`;
863
923
  }
924
+ for (const key of ["concurrency", "scorerConcurrency"]) {
925
+ const v = c[key];
926
+ if (v !== void 0 && (typeof v !== "number" || !Number.isInteger(v) || v < 1)) {
927
+ return `exports an invalid ${key} (${typeof v === "number" ? v : typeof v}) \u2014 must be a positive integer.`;
928
+ }
929
+ }
930
+ {
931
+ const v = c.failOnScorerErrorRate;
932
+ if (v !== void 0 && (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1)) {
933
+ return `exports an invalid failOnScorerErrorRate (${typeof v === "number" ? v : typeof v}) \u2014 must be a number between 0 and 1.`;
934
+ }
935
+ }
864
936
  return void 0;
865
937
  }
866
938
 
867
- // src/cli.ts
868
- var import_meta = {};
939
+ // src/cli-args.ts
869
940
  var KNOWN_FLAGS = /* @__PURE__ */ new Set([
870
941
  "--output",
871
942
  "--config",
@@ -873,8 +944,98 @@ var KNOWN_FLAGS = /* @__PURE__ */ new Set([
873
944
  "--fail-on-regression",
874
945
  "--threshold",
875
946
  "--runs",
876
- "--capture-traces"
947
+ "--capture-traces",
948
+ "--concurrency",
949
+ "--scorers"
950
+ ]);
951
+ var VALUE_FLAGS = /* @__PURE__ */ new Set([
952
+ "--output",
953
+ "--config",
954
+ "--conditions",
955
+ "--runs",
956
+ "--concurrency",
957
+ "--scorers"
877
958
  ]);
959
+ function envInt(name) {
960
+ const raw = process.env[name];
961
+ if (raw == null || raw === "") return void 0;
962
+ const n = parseInt(raw, 10);
963
+ if (!Number.isFinite(n) || n <= 0) return void 0;
964
+ return n;
965
+ }
966
+ function parseEvalArgs(args) {
967
+ let outputPath;
968
+ let configArg;
969
+ let conditions = [];
970
+ let runs = 1;
971
+ let captureTraces = false;
972
+ let concurrency;
973
+ let scorerNames;
974
+ const paths = [];
975
+ for (let i = 0; i < args.length; i++) {
976
+ const arg = args[i];
977
+ if (VALUE_FLAGS.has(arg)) {
978
+ if (i + 1 >= args.length) {
979
+ console.error(`Error: ${arg} requires a value`);
980
+ process.exit(1);
981
+ }
982
+ const value = args[++i];
983
+ if (arg === "--output") outputPath = value;
984
+ else if (arg === "--config") configArg = value;
985
+ else if (arg === "--runs") runs = Math.max(1, parseInt(value, 10) || 1);
986
+ else if (arg === "--concurrency") {
987
+ const n = parseInt(value, 10);
988
+ if (!Number.isFinite(n) || n <= 0) {
989
+ console.error(`[axl-eval] Ignoring invalid --concurrency "${value}"; using 1.`);
990
+ concurrency = 1;
991
+ } else {
992
+ concurrency = n;
993
+ }
994
+ } else if (arg === "--scorers") {
995
+ scorerNames = [
996
+ ...new Set(
997
+ value.split(",").map((s) => s.trim()).filter(Boolean)
998
+ )
999
+ ];
1000
+ } else {
1001
+ conditions = value.split(",").map((c) => c.trim()).filter(Boolean);
1002
+ }
1003
+ } else if (arg === "--capture-traces") {
1004
+ captureTraces = true;
1005
+ } else if (arg.startsWith("--")) {
1006
+ if (!KNOWN_FLAGS.has(arg)) {
1007
+ console.error(`Unknown flag: ${arg}`);
1008
+ process.exit(1);
1009
+ }
1010
+ } else {
1011
+ paths.push(arg);
1012
+ }
1013
+ }
1014
+ return {
1015
+ outputPath,
1016
+ configArg,
1017
+ conditions,
1018
+ runs,
1019
+ captureTraces,
1020
+ concurrency,
1021
+ scorerNames,
1022
+ paths
1023
+ };
1024
+ }
1025
+
1026
+ // src/cli.ts
1027
+ var import_meta = {};
1028
+ function refuseToGate(reason) {
1029
+ console.error(`[axl-eval] Refusing to gate: ${reason}`);
1030
+ process.exit(1);
1031
+ }
1032
+ function scorerSampleCounts(entry, name, items) {
1033
+ if (entry.scored != null && entry.failed != null) {
1034
+ return { scored: entry.scored, failed: entry.failed };
1035
+ }
1036
+ if (items) return scorerCounts(items, name);
1037
+ return { scored: entry.scored ?? 0, failed: entry.failed ?? 0 };
1038
+ }
878
1039
  function installAbortHandler(controller) {
879
1040
  let signalled = false;
880
1041
  const onSignal = (sig) => {
@@ -901,6 +1062,15 @@ Usage:
901
1062
  axl-eval <path> --output <file> Save results to JSON
902
1063
  axl-eval <path> --config <file> Use config file for runtime
903
1064
  axl-eval <path> --conditions <list> Node.js import conditions (comma-separated)
1065
+ axl-eval <path> --concurrency <n> Override item concurrency (flag > env
1066
+ AXL_EVAL_CONCURRENCY > config > 5).
1067
+ Max concurrent scorer calls is
1068
+ concurrency x scorerConcurrency (the
1069
+ per-eval scorerConcurrency defaults to 5).
1070
+ axl-eval <path> --scorers <a,b> Run only the named scorers (single eval
1071
+ file only). Stamps the result as a
1072
+ subset so compare won't treat it as a
1073
+ full baseline.
904
1074
  axl-eval <path> --capture-traces Populate EvalItem.traces on every item
905
1075
  (success + failure). Adds memory
906
1076
  overhead proportional to dataset size
@@ -909,6 +1079,12 @@ Usage:
909
1079
  axl-eval compare <a> <b> Compare two eval result files
910
1080
  axl-eval compare <a> <b> --threshold <v> Set regression threshold (global or per-scorer)
911
1081
  axl-eval compare <a> <b> --fail-on-regression Exit 1 if regressions
1082
+ axl-eval compare <a> <b> --max-scorer-error-rate <0..1> Exit 1 if a scorer's
1083
+ failure rate exceeds the limit on
1084
+ either side (deterministic scorers
1085
+ tolerate zero failures). Always warns
1086
+ when either side rests on a thinned
1087
+ sample, even without this flag.
912
1088
 
913
1089
  Config auto-detection (when --config is not specified):
914
1090
  ${import_axl.CONFIG_CANDIDATES.join(" -> ")}
@@ -943,20 +1119,61 @@ function parseThresholdArg(args) {
943
1119
  }
944
1120
  return Object.keys(map).length > 0 ? map : void 0;
945
1121
  }
1122
+ function parseMaxScorerErrorRate(args) {
1123
+ const idx = args.indexOf("--max-scorer-error-rate");
1124
+ if (idx === -1) return void 0;
1125
+ if (idx + 1 >= args.length) {
1126
+ console.error("Error: --max-scorer-error-rate requires a value in [0, 1]");
1127
+ process.exit(1);
1128
+ }
1129
+ const raw = args[idx + 1];
1130
+ const n = /^\d*\.?\d+$/.test(raw.trim()) ? Number(raw) : NaN;
1131
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
1132
+ console.error(`Error: --max-scorer-error-rate must be a number in [0, 1], got "${raw}"`);
1133
+ process.exit(1);
1134
+ }
1135
+ return n;
1136
+ }
1137
+ function scorerFilteredScorers(r) {
1138
+ const first = Array.isArray(r) ? r[0] : r;
1139
+ const meta = first?.metadata;
1140
+ if (!meta || meta.scorerFiltered !== true) return void 0;
1141
+ return Array.isArray(meta.scorersRun) ? meta.scorersRun : [];
1142
+ }
946
1143
  async function runCompare(args) {
947
1144
  const failOnRegression = args.includes("--fail-on-regression");
948
1145
  const thresholds = parseThresholdArg(args);
1146
+ const maxScorerErrorRate = parseMaxScorerErrorRate(args);
1147
+ const valueFlags = /* @__PURE__ */ new Set(["--threshold", "--max-scorer-error-rate"]);
949
1148
  const files = args.filter(
950
- (a, i) => !a.startsWith("--") && !(i > 0 && args[i - 1] === "--threshold")
1149
+ (a, i) => !a.startsWith("--") && !(i > 0 && valueFlags.has(args[i - 1]))
951
1150
  );
952
1151
  if (files.length !== 2) {
953
- console.error("Usage: axl-eval compare <baseline.json> <candidate.json> [--threshold <value>]");
1152
+ console.error(
1153
+ "Usage: axl-eval compare <baseline.json> <candidate.json> [--threshold <value>] [--fail-on-regression] [--max-scorer-error-rate <0..1>]"
1154
+ );
954
1155
  process.exit(1);
955
1156
  }
956
1157
  const baselineRaw = JSON.parse(await (0, import_promises.readFile)(files[0], "utf-8"));
957
1158
  const candidateRaw = JSON.parse(await (0, import_promises.readFile)(files[1], "utf-8"));
958
1159
  const baseline = baselineRaw;
959
1160
  const candidate = candidateRaw;
1161
+ const filteredSides = [
1162
+ ["baseline", baseline],
1163
+ ["candidate", candidate]
1164
+ ].filter(([, r]) => scorerFilteredScorers(r) != null);
1165
+ for (const [side, r] of filteredSides) {
1166
+ const ran = scorerFilteredScorers(r);
1167
+ const ranStr = ran && ran.length ? ` (ran: ${ran.join(", ")})` : "";
1168
+ console.error(
1169
+ `[axl-eval] WARNING: ${side} was run with --scorers \u2014 it tested only a subset${ranStr}, so this comparison is incomplete.`
1170
+ );
1171
+ }
1172
+ if (failOnRegression && filteredSides.length > 0) {
1173
+ refuseToGate(
1174
+ `${filteredSides.map(([s]) => s).join(", ")} was run with --scorers (a scorer subset); re-run without --scorers for a complete baseline.`
1175
+ );
1176
+ }
960
1177
  const compareOptions = thresholds != null ? { thresholds } : void 0;
961
1178
  const comparison = evalCompare(baseline, candidate, compareOptions);
962
1179
  console.log(
@@ -1012,6 +1229,29 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
1012
1229
  Regressions: ${comparison.regressions.length} | Improvements: ${comparison.improvements.length} | Stable: ${stable}
1013
1230
  `
1014
1231
  );
1232
+ let sawThinnedSample = false;
1233
+ for (const name of scorerNames) {
1234
+ const s = comparison.scorers[name];
1235
+ const bFailed = s.baselineFailed ?? 0;
1236
+ const cFailed = s.candidateFailed ?? 0;
1237
+ if (bFailed > 0 || cFailed > 0) {
1238
+ sawThinnedSample = true;
1239
+ const bAtt = (s.baselineScored ?? 0) + bFailed;
1240
+ const cAtt = (s.candidateScored ?? 0) + cFailed;
1241
+ console.error(
1242
+ `[axl-eval] WARNING: scorer "${name}" failed on some items (baseline ${bFailed}/${bAtt}, candidate ${cFailed}/${cAtt}); the means above are computed over the surviving sample.`
1243
+ );
1244
+ }
1245
+ }
1246
+ if (sawThinnedSample && maxScorerErrorRate == null) {
1247
+ console.error(
1248
+ `[axl-eval] (advisory \u2014 pass --max-scorer-error-rate <0..1> to fail CI on a thinned sample)`
1249
+ );
1250
+ }
1251
+ if (maxScorerErrorRate != null) {
1252
+ const reason = evaluateScorerErrorRateGate(comparison, maxScorerErrorRate);
1253
+ if (reason) refuseToGate(reason);
1254
+ }
1015
1255
  if (failOnRegression && comparison.regressions.length > 0) {
1016
1256
  const hasSignificance = scorerNames.some((n) => comparison.scorers[n].significant != null);
1017
1257
  if (hasSignificance) {
@@ -1025,11 +1265,18 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
1025
1265
  }
1026
1266
  }
1027
1267
  async function runRescore(args, signal) {
1028
- const { outputPath, configArg, conditions, paths } = parseEvalArgs(args);
1268
+ const { outputPath, configArg, conditions, concurrency, scorerNames, paths } = parseEvalArgs(args);
1029
1269
  if (paths.length < 2) {
1030
1270
  console.error("Usage: axl-eval rescore <results.json> <eval-file> [--output <file>]");
1031
1271
  process.exit(1);
1032
1272
  }
1273
+ if (scorerNames?.length) {
1274
+ console.error(
1275
+ "Error: --scorers is not supported with rescore; pass a scorer subset in the eval file instead."
1276
+ );
1277
+ process.exit(1);
1278
+ }
1279
+ const itemConcurrency = concurrency ?? envInt("AXL_EVAL_CONCURRENCY");
1033
1280
  const [resultsPath, evalFilePath] = paths;
1034
1281
  const raw = JSON.parse(await (0, import_promises.readFile)(resultsPath, "utf-8"));
1035
1282
  const results = Array.isArray(raw) ? raw : [raw];
@@ -1050,7 +1297,12 @@ async function runRescore(args, signal) {
1050
1297
  const rescored = [];
1051
1298
  for (const resultData of results) {
1052
1299
  if (signal.aborted) break;
1053
- rescored.push(await rescore(resultData, evalConfig.scorers, runtime, { signal }));
1300
+ rescored.push(
1301
+ await rescore(resultData, evalConfig.scorers, runtime, {
1302
+ signal,
1303
+ ...itemConcurrency != null ? { concurrency: itemConcurrency } : {}
1304
+ })
1305
+ );
1054
1306
  }
1055
1307
  for (const r of rescored) {
1056
1308
  console.log("\n" + formatTable(r) + "\n");
@@ -1100,14 +1352,15 @@ function formatTable(result) {
1100
1352
  lines.push(" " + "\u2500".repeat(ruleLen));
1101
1353
  for (const name of scorerNames) {
1102
1354
  const s = result.summary.scorers[name];
1103
- const validScoreCount = result.items.filter((i) => !i.error && i.scores[name] != null).length;
1104
- if (validScoreCount === 0) {
1355
+ const { scored, failed } = scorerSampleCounts(s, name, result.items);
1356
+ const annotation = failed > 0 ? ` (${scored}/${scored + failed} scored, ${failed} failed)` : "";
1357
+ if (scored === 0) {
1105
1358
  lines.push(
1106
- ` ${name.padEnd(maxNameLen)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)}`
1359
+ ` ${name.padEnd(maxNameLen)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)} ${"--".padStart(colWidth)}${annotation}`
1107
1360
  );
1108
1361
  } else {
1109
1362
  lines.push(
1110
- ` ${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)}`
1363
+ ` ${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}`
1111
1364
  );
1112
1365
  }
1113
1366
  }
@@ -1155,8 +1408,10 @@ function formatMultiRunTable(summary) {
1155
1408
  for (const name of scorerNames) {
1156
1409
  const s = summary.scorers[name];
1157
1410
  const meanStd = `${s.mean.toFixed(3)} \xB1 ${s.std.toFixed(3)}`;
1411
+ const { scored, failed } = scorerSampleCounts(s, name);
1412
+ const annotation = failed > 0 ? ` (${scored}/${scored + failed} scored, ${failed} failed)` : "";
1158
1413
  lines.push(
1159
- ` ${name.padEnd(maxNameLen)} ${meanStd.padStart(colWidth)} ${s.min.toFixed(3).padStart(8)} ${s.max.toFixed(3).padStart(8)}`
1414
+ ` ${name.padEnd(maxNameLen)} ${meanStd.padStart(colWidth)} ${s.min.toFixed(3).padStart(8)} ${s.max.toFixed(3).padStart(8)}${annotation}`
1160
1415
  );
1161
1416
  }
1162
1417
  if (summary.timing) {
@@ -1170,6 +1425,26 @@ function formatMultiRunTable(summary) {
1170
1425
  lines.push(` Total Cost: ${costStr} | Total Duration: ${durationStr}s`);
1171
1426
  return lines.join("\n");
1172
1427
  }
1428
+ function reportDegraded(result, label) {
1429
+ const d = result.summary.degraded;
1430
+ if (!d || d.length === 0) return false;
1431
+ for (const x of d) {
1432
+ const attempted = x.scored + x.failed;
1433
+ const limitStr = x.type === "deterministic" ? "deterministic zero-tolerance" : `${(x.limit * 100).toFixed(1)}% limit`;
1434
+ console.error(
1435
+ `[axl-eval] DEGRADED: ${label} scorer "${x.scorer}" (${x.type}) failed ${(x.rate * 100).toFixed(1)}% (${x.failed}/${attempted}), over the ${limitStr}.`
1436
+ );
1437
+ }
1438
+ return true;
1439
+ }
1440
+ function reportTotalWipeout(result, label) {
1441
+ const { count, failures } = result.summary;
1442
+ if (count === 0 || failures < count) return false;
1443
+ console.error(
1444
+ `[axl-eval] FAILED: ${label} \u2014 all ${count} item(s) errored in the workflow (0 succeeded); the eval produced no scorable output.`
1445
+ );
1446
+ return true;
1447
+ }
1173
1448
  async function resolveRuntimeFromConfig(configPath) {
1174
1449
  try {
1175
1450
  const mod = await (0, import_axl.importModule)(configPath, import_meta.url);
@@ -1230,41 +1505,17 @@ async function getRuntime(configArg, conditions) {
1230
1505
  const { AxlRuntime } = await import("@axlsdk/axl");
1231
1506
  return new AxlRuntime();
1232
1507
  }
1233
- function parseEvalArgs(args) {
1234
- let outputPath;
1235
- let configArg;
1236
- let conditions = [];
1237
- let runs = 1;
1238
- let captureTraces = false;
1239
- const paths = [];
1240
- for (let i = 0; i < args.length; i++) {
1241
- const arg = args[i];
1242
- if (arg === "--output" || arg === "--config" || arg === "--conditions" || arg === "--runs") {
1243
- if (i + 1 >= args.length) {
1244
- console.error(`Error: ${arg} requires a value`);
1245
- process.exit(1);
1246
- }
1247
- const value = args[++i];
1248
- if (arg === "--output") outputPath = value;
1249
- else if (arg === "--config") configArg = value;
1250
- else if (arg === "--runs") runs = Math.max(1, parseInt(value, 10) || 1);
1251
- else
1252
- conditions = value.split(",").map((c) => c.trim()).filter(Boolean);
1253
- } else if (arg === "--capture-traces") {
1254
- captureTraces = true;
1255
- } else if (arg.startsWith("--")) {
1256
- if (!KNOWN_FLAGS.has(arg)) {
1257
- console.error(`Unknown flag: ${arg}`);
1258
- process.exit(1);
1259
- }
1260
- } else {
1261
- paths.push(arg);
1262
- }
1263
- }
1264
- return { outputPath, configArg, conditions, runs, captureTraces, paths };
1265
- }
1266
1508
  async function runEvalCommand(args, signal) {
1267
- const { outputPath, configArg, conditions, runs, captureTraces, paths } = parseEvalArgs(args);
1509
+ const {
1510
+ outputPath,
1511
+ configArg,
1512
+ conditions,
1513
+ runs,
1514
+ captureTraces,
1515
+ concurrency,
1516
+ scorerNames,
1517
+ paths
1518
+ } = parseEvalArgs(args);
1268
1519
  if (paths.length === 0) {
1269
1520
  console.error("Error: No eval file path provided");
1270
1521
  process.exit(1);
@@ -1277,6 +1528,10 @@ async function runEvalCommand(args, signal) {
1277
1528
  console.error("Error: No eval files found");
1278
1529
  process.exit(1);
1279
1530
  }
1531
+ if (scorerNames?.length && evalFiles.length > 1) {
1532
+ console.error(`Error: --scorers requires a single eval file (matched ${evalFiles.length}).`);
1533
+ process.exit(1);
1534
+ }
1280
1535
  const runtime = await getRuntime(configArg, conditions);
1281
1536
  const results = [];
1282
1537
  let failedFiles = 0;
@@ -1292,6 +1547,22 @@ async function runEvalCommand(args, signal) {
1292
1547
  failedFiles++;
1293
1548
  continue;
1294
1549
  }
1550
+ evalConfig.concurrency = concurrency ?? envInt("AXL_EVAL_CONCURRENCY") ?? evalConfig.concurrency ?? 5;
1551
+ if (scorerNames?.length) {
1552
+ const available = evalConfig.scorers.map((s) => s.name);
1553
+ const unknown = scorerNames.filter((n) => !available.includes(n));
1554
+ if (unknown.length) {
1555
+ console.error(
1556
+ `Error: --scorers: unknown scorer(s): ${unknown.join(", ")}. Available: ${available.join(", ")}`
1557
+ );
1558
+ process.exit(1);
1559
+ }
1560
+ evalConfig.scorers = evalConfig.scorers.filter((s) => scorerNames.includes(s.name));
1561
+ if (evalConfig.scorers.length === 0) {
1562
+ console.error("Error: --scorers matched no scorers.");
1563
+ process.exit(1);
1564
+ }
1565
+ }
1295
1566
  const exported = (0, import_axl.pickExport)(mod, "executeWorkflow");
1296
1567
  let customExecute;
1297
1568
  if (typeof exported === "function") {
@@ -1348,6 +1619,13 @@ async function runEvalCommand(args, signal) {
1348
1619
  continue;
1349
1620
  }
1350
1621
  const runOptions = { signal, ...captureTraces ? { captureTraces: true } : {} };
1622
+ const scorersRun = scorerNames?.length ? evalConfig.scorers.map((s) => s.name) : void 0;
1623
+ const stampFiltered = (result) => {
1624
+ if (scorersRun) {
1625
+ result.metadata.scorerFiltered = true;
1626
+ result.metadata.scorersRun = scorersRun;
1627
+ }
1628
+ };
1351
1629
  if (runs > 1) {
1352
1630
  const { randomUUID: randomUUID4 } = await import("crypto");
1353
1631
  const runGroupId = randomUUID4();
@@ -1360,6 +1638,7 @@ async function runEvalCommand(args, signal) {
1360
1638
  const result = await runEval(evalConfig, executeWorkflow, runtime, runOptions);
1361
1639
  result.metadata.runGroupId = runGroupId;
1362
1640
  result.metadata.runIndex = r;
1641
+ stampFiltered(result);
1363
1642
  runResults.push(result);
1364
1643
  } catch (err) {
1365
1644
  runFailure = err instanceof Error ? err : new Error(String(err));
@@ -1391,10 +1670,21 @@ async function runEvalCommand(args, signal) {
1391
1670
  const summary = aggregateRuns(runResults);
1392
1671
  console.log("\n" + formatMultiRunTable(summary) + "\n");
1393
1672
  for (const r of runResults) results.push(r);
1673
+ let anyFailing = false;
1674
+ for (const r of runResults) {
1675
+ const wipeout = reportTotalWipeout(r, filePath);
1676
+ const degraded = reportDegraded(r, filePath);
1677
+ if (wipeout || degraded) anyFailing = true;
1678
+ }
1679
+ if (anyFailing && !partial) failedFiles++;
1394
1680
  } else {
1395
1681
  const result = await runEval(evalConfig, executeWorkflow, runtime, runOptions);
1682
+ stampFiltered(result);
1396
1683
  results.push(result);
1397
1684
  console.log("\n" + formatTable(result) + "\n");
1685
+ const wipeout = reportTotalWipeout(result, filePath);
1686
+ const degraded = reportDegraded(result, filePath);
1687
+ if (wipeout || degraded) failedFiles++;
1398
1688
  }
1399
1689
  } catch (err) {
1400
1690
  console.error(