@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/index.cjs CHANGED
@@ -34,6 +34,8 @@ __export(index_exports, {
34
34
  dataset: () => dataset,
35
35
  defineEval: () => defineEval,
36
36
  evalCompare: () => evalCompare,
37
+ evaluateScorerErrorRateGate: () => evaluateScorerErrorRateGate,
38
+ evaluateScorerTolerance: () => evaluateScorerTolerance,
37
39
  llmScorer: () => llmScorer,
38
40
  normalizeScorerResult: () => normalizeScorerResult,
39
41
  pairedBootstrapCI: () => pairedBootstrapCI,
@@ -46,11 +48,71 @@ module.exports = __toCommonJS(index_exports);
46
48
  // src/dataset.ts
47
49
  var import_promises = require("fs/promises");
48
50
  var path = __toESM(require("path"), 1);
51
+ function isPlainObject(value) {
52
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
53
+ const proto = Object.getPrototypeOf(value);
54
+ return proto === Object.prototype || proto === null;
55
+ }
56
+ function isObjectSchema(schema) {
57
+ const shape = schema.shape;
58
+ return typeof shape === "object" && shape !== null;
59
+ }
60
+ function collectDroppedKeys(raw, parsed, prefix = "") {
61
+ if (Array.isArray(raw) && Array.isArray(parsed)) {
62
+ const dropped = [];
63
+ const n = Math.min(raw.length, parsed.length);
64
+ for (let i = 0; i < n; i++) {
65
+ dropped.push(...collectDroppedKeys(raw[i], parsed[i], `${prefix}[${i}]`));
66
+ }
67
+ return dropped;
68
+ }
69
+ if (isPlainObject(raw) && isPlainObject(parsed)) {
70
+ const dropped = [];
71
+ for (const key of Object.keys(raw)) {
72
+ const path2 = prefix ? `${prefix}.${key}` : key;
73
+ if (!(key in parsed)) {
74
+ dropped.push(path2);
75
+ } else {
76
+ dropped.push(...collectDroppedKeys(raw[key], parsed[key], path2));
77
+ }
78
+ }
79
+ return dropped;
80
+ }
81
+ return [];
82
+ }
49
83
  function dataset(config) {
84
+ const policy = config.onExtraAnnotationKeys ?? "warn";
85
+ function parseItem(item, droppedAcc) {
86
+ const input = config.schema.parse(item.input);
87
+ if (item.annotations == null || !config.annotations) {
88
+ return { input, annotations: item.annotations };
89
+ }
90
+ const annotations = config.annotations.parse(item.annotations);
91
+ if (policy !== "ignore" && isObjectSchema(config.annotations)) {
92
+ for (const p of collectDroppedKeys(item.annotations, annotations)) droppedAcc.add(p);
93
+ }
94
+ return { input, annotations };
95
+ }
96
+ function reportDropped(dropped) {
97
+ if (dropped.size === 0) return;
98
+ const keys = [...dropped].join(", ");
99
+ if (policy === "error") {
100
+ throw new Error(
101
+ `Dataset "${config.name}": annotation key(s) dropped by schema: ${keys}. These keys are not declared in the annotations schema, so they were stripped and will never reach scorers. Add them to the schema, or set onExtraAnnotationKeys: 'ignore' to allow stripping.`
102
+ );
103
+ }
104
+ console.warn(
105
+ `[axl-eval] Dataset "${config.name}": annotation key(s) dropped by schema: ${keys}. These keys are not declared in the annotations schema, so they were stripped and will not reach scorers (a common cause of no-op scorers). Add them to the schema, set onExtraAnnotationKeys: 'error' to fail, or 'ignore' to silence this warning.`
106
+ );
107
+ }
108
+ let lastDroppedAnnotationKeys = [];
50
109
  return {
51
110
  name: config.name,
52
111
  schema: config.schema,
53
112
  annotationsSchema: config.annotations,
113
+ get droppedAnnotationKeys() {
114
+ return [...lastDroppedAnnotationKeys];
115
+ },
54
116
  async getItems() {
55
117
  if (config.items && config.file) {
56
118
  throw new Error('Dataset config error: "items" and "file" are mutually exclusive');
@@ -58,19 +120,19 @@ function dataset(config) {
58
120
  if (!config.items && !config.file) {
59
121
  throw new Error('Dataset config error: either "items" or "file" must be provided');
60
122
  }
123
+ let rawItems;
61
124
  if (config.items) {
62
- return config.items.map((item) => ({
63
- input: config.schema.parse(item.input),
64
- annotations: item.annotations && config.annotations ? config.annotations.parse(item.annotations) : item.annotations
65
- }));
125
+ rawItems = config.items;
126
+ } else {
127
+ const resolvedPath = path.resolve(config.basePath ?? process.cwd(), config.file);
128
+ const content = await (0, import_promises.readFile)(resolvedPath, "utf-8");
129
+ rawItems = JSON.parse(content);
66
130
  }
67
- const resolvedPath = path.resolve(config.basePath ?? process.cwd(), config.file);
68
- const content = await (0, import_promises.readFile)(resolvedPath, "utf-8");
69
- const items = JSON.parse(content);
70
- return items.map((item) => ({
71
- input: config.schema.parse(item.input),
72
- annotations: item.annotations && config.annotations ? config.annotations.parse(item.annotations) : item.annotations
73
- }));
131
+ const dropped = /* @__PURE__ */ new Set();
132
+ const parsed = rawItems.map((item) => parseItem(item, dropped));
133
+ lastDroppedAnnotationKeys = [...dropped];
134
+ reportDropped(dropped);
135
+ return parsed;
74
136
  }
75
137
  };
76
138
  }
@@ -79,6 +141,15 @@ function dataset(config) {
79
141
  function normalizeScorerResult(result) {
80
142
  return typeof result === "number" ? { score: result } : result;
81
143
  }
144
+ function attachScorerErrorCost(err, cost) {
145
+ if (err && typeof err === "object" && typeof cost === "number" && Number.isFinite(cost) && cost >= 0) {
146
+ err.cost = cost;
147
+ }
148
+ }
149
+ function extractScorerErrorCost(err) {
150
+ const v = err && typeof err === "object" ? err.cost : void 0;
151
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
152
+ }
82
153
  function scorer(config) {
83
154
  return {
84
155
  name: config.name,
@@ -144,16 +215,14 @@ ${JSON.stringify(annotations, null, 2)}` : "",
144
215
  const parsed = JSON.parse((0, import_axl.extractJson)(response.content));
145
216
  validated = schema.parse(parsed);
146
217
  } catch (err) {
147
- if (err && typeof err === "object") {
148
- err.cost = responseCost;
149
- }
218
+ attachScorerErrorCost(err, responseCost);
150
219
  if (err && typeof err === "object" && "issues" in err && Array.isArray(err.issues)) {
151
220
  const issues = err.issues;
152
221
  const messages = issues.map((i) => `${i.path.length ? `${i.path.join(".")}: ` : ""}${i.message}`).join("; ");
153
222
  const error = new Error(
154
223
  `LLM scorer "${config.name}" returned an invalid response: ${messages}`
155
224
  );
156
- error.cost = responseCost;
225
+ attachScorerErrorCost(error, responseCost);
157
226
  throw error;
158
227
  }
159
228
  throw err;
@@ -187,6 +256,92 @@ function computeStats(scores) {
187
256
  function round(n) {
188
257
  return Math.round(n * 1e3) / 1e3;
189
258
  }
259
+ function scorerCounts(items, name) {
260
+ let scored = 0;
261
+ let failed = 0;
262
+ for (const i of items) {
263
+ if (i.error) continue;
264
+ if (i.scores[name] != null) scored++;
265
+ else if (i.scoreDetails?.[name]?.duration != null) failed++;
266
+ }
267
+ return { scored, failed };
268
+ }
269
+ function evaluateScorerTolerance(scored, failed, type, limit) {
270
+ const attempted = scored + failed;
271
+ const rate = attempted === 0 ? 0 : failed / attempted;
272
+ const exceeds = attempted > 0 && (type === "deterministic" ? failed > 0 : rate > limit);
273
+ return { attempted, rate, exceeds, zeroSample: attempted === 0 };
274
+ }
275
+ async function mapWithConcurrency(items, concurrency, task) {
276
+ const results = new Array(items.length);
277
+ const requested = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;
278
+ const workerCount = items.length === 0 ? 0 : Math.min(Math.max(1, requested), items.length);
279
+ let next = 0;
280
+ async function runNext() {
281
+ while (next < items.length) {
282
+ const i = next++;
283
+ results[i] = await task(items[i], i);
284
+ }
285
+ }
286
+ await Promise.all(Array.from({ length: workerCount }, () => runNext()));
287
+ return results;
288
+ }
289
+
290
+ // src/score-item.ts
291
+ async function scoreItem(item, scorers, scorerConcurrency, scorerContext, signal) {
292
+ const scoreDetails = item.scoreDetails ??= {};
293
+ for (const s of scorers) {
294
+ item.scores[s.name] = null;
295
+ scoreDetails[s.name] = { score: null };
296
+ }
297
+ let itemScorerCost = 0;
298
+ const scorerErrorsByName = {};
299
+ await mapWithConcurrency(scorers, scorerConcurrency, async (scorer2) => {
300
+ if (signal?.aborted) return;
301
+ const scorerStart = Date.now();
302
+ try {
303
+ const raw = await scorer2.score(item.output, item.input, item.annotations, scorerContext);
304
+ const scorerResult = normalizeScorerResult(raw);
305
+ if (scorerResult.cost != null) {
306
+ itemScorerCost += scorerResult.cost;
307
+ }
308
+ const scorerDuration = Date.now() - scorerStart;
309
+ if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
310
+ scorerErrorsByName[scorer2.name] = `Scorer "${scorer2.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`;
311
+ item.scores[scorer2.name] = null;
312
+ scoreDetails[scorer2.name] = {
313
+ score: null,
314
+ metadata: scorerResult.metadata,
315
+ duration: scorerDuration,
316
+ cost: scorerResult.cost
317
+ };
318
+ } else {
319
+ item.scores[scorer2.name] = round(scorerResult.score);
320
+ scoreDetails[scorer2.name] = {
321
+ score: round(scorerResult.score),
322
+ metadata: scorerResult.metadata,
323
+ duration: scorerDuration,
324
+ cost: scorerResult.cost
325
+ };
326
+ }
327
+ } catch (err) {
328
+ if (err?.name === "AbortError") return;
329
+ const errCost = extractScorerErrorCost(err);
330
+ if (errCost != null) itemScorerCost += errCost;
331
+ scorerErrorsByName[scorer2.name] = `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`;
332
+ item.scores[scorer2.name] = null;
333
+ scoreDetails[scorer2.name] = {
334
+ score: null,
335
+ duration: Date.now() - scorerStart,
336
+ cost: errCost
337
+ };
338
+ }
339
+ });
340
+ const orderedErrors = scorers.map((s) => scorerErrorsByName[s.name]).filter((e) => e != null);
341
+ if (orderedErrors.length > 0) item.scorerErrors = orderedErrors;
342
+ item.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
343
+ return itemScorerCost;
344
+ }
190
345
 
191
346
  // src/runner.ts
192
347
  var import_node_crypto = require("crypto");
@@ -228,7 +383,9 @@ async function runEval(config, executeWorkflow, runtime, options) {
228
383
  const startTime = Date.now();
229
384
  const id = (0, import_node_crypto.randomUUID)();
230
385
  const items = await config.dataset.getItems();
386
+ const droppedAnnotationKeys = [...config.dataset.droppedAnnotationKeys ?? []];
231
387
  const concurrency = config.concurrency ?? 5;
388
+ const scorerConcurrency = config.scorerConcurrency ?? 5;
232
389
  const budgetLimit = config.budget ? parseCost(config.budget) : void 0;
233
390
  const scorerContext = {
234
391
  resolveProvider: (uri) => {
@@ -317,89 +474,53 @@ async function runEval(config, executeWorkflow, runtime, options) {
317
474
  if (budgetLimit != null && totalCost > budgetLimit) {
318
475
  budgetExceeded = true;
319
476
  }
320
- evalItem.scoreDetails = {};
321
- let itemScorerCost = 0;
322
- for (const scorer2 of config.scorers) {
323
- if (options?.signal?.aborted) break;
324
- const scorerStart = Date.now();
325
- try {
326
- const raw = await scorer2.score(
327
- evalItem.output,
328
- item.input,
329
- item.annotations,
330
- scorerContext
331
- );
332
- const scorerResult = normalizeScorerResult(raw);
333
- if (scorerResult.cost != null) {
334
- itemScorerCost += scorerResult.cost;
335
- totalCost += scorerResult.cost;
336
- }
337
- const scorerDuration = Date.now() - scorerStart;
338
- if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
339
- if (!evalItem.scorerErrors) evalItem.scorerErrors = [];
340
- evalItem.scorerErrors.push(
341
- `Scorer "${scorer2.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`
342
- );
343
- evalItem.scores[scorer2.name] = null;
344
- evalItem.scoreDetails[scorer2.name] = {
345
- score: null,
346
- metadata: scorerResult.metadata,
347
- duration: scorerDuration,
348
- cost: scorerResult.cost
349
- };
350
- } else {
351
- evalItem.scores[scorer2.name] = round(scorerResult.score);
352
- evalItem.scoreDetails[scorer2.name] = {
353
- score: round(scorerResult.score),
354
- metadata: scorerResult.metadata,
355
- duration: scorerDuration,
356
- cost: scorerResult.cost
357
- };
358
- }
359
- } catch (err) {
360
- const errCost = typeof err?.cost === "number" ? err.cost : void 0;
361
- if (errCost != null) {
362
- itemScorerCost += errCost;
363
- totalCost += errCost;
364
- }
365
- if (!evalItem.scorerErrors) evalItem.scorerErrors = [];
366
- evalItem.scorerErrors.push(
367
- `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`
368
- );
369
- evalItem.scores[scorer2.name] = null;
370
- evalItem.scoreDetails[scorer2.name] = {
371
- score: null,
372
- duration: Date.now() - scorerStart,
373
- cost: errCost
374
- };
375
- }
376
- }
377
- evalItem.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
477
+ const itemScorerCost = await scoreItem(
478
+ evalItem,
479
+ config.scorers,
480
+ scorerConcurrency,
481
+ scorerContext,
482
+ options?.signal
483
+ );
484
+ totalCost += itemScorerCost;
378
485
  evalItems[itemIndex] = evalItem;
379
486
  options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
380
487
  }
381
- let index = 0;
382
- async function runNext() {
383
- while (index < items.length) {
384
- const currentIndex = index++;
385
- await processItem(items[currentIndex], currentIndex);
386
- }
387
- }
388
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => runNext());
389
- await Promise.all(workers);
488
+ await mapWithConcurrency(items, concurrency, (item, i) => processItem(item, i));
390
489
  const failures = evalItems.filter((i) => i.error).length;
391
490
  const scorerNames = config.scorers.map((s) => s.name);
392
491
  const scorerStats = {};
393
492
  for (const name of scorerNames) {
394
493
  const scores = evalItems.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
395
- scorerStats[name] = computeStats(scores);
494
+ const { scored, failed } = scorerCounts(evalItems, name);
495
+ scorerStats[name] = { ...computeStats(scores), scored, failed };
396
496
  }
397
- const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
398
- const timing = durations.length > 0 ? computeStats(durations) : void 0;
399
497
  const scorerTypes = {};
400
498
  for (const s of config.scorers) {
401
499
  scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
402
500
  }
501
+ let degraded;
502
+ const rawLimit = config.failOnScorerErrorRate;
503
+ if (rawLimit != null) {
504
+ if (!Number.isFinite(rawLimit) || rawLimit < 0 || rawLimit > 1) {
505
+ console.warn(
506
+ `[axl-eval] Ignoring invalid failOnScorerErrorRate (${rawLimit}); expected a number in [0, 1].`
507
+ );
508
+ } else {
509
+ for (const name of scorerNames) {
510
+ const stats = scorerStats[name];
511
+ const scored = stats.scored ?? 0;
512
+ const failed = stats.failed ?? 0;
513
+ const type = scorerTypes[name] === "llm" ? "llm" : "deterministic";
514
+ const limit = type === "deterministic" ? 0 : rawLimit;
515
+ const verdict = evaluateScorerTolerance(scored, failed, type, limit);
516
+ if (verdict.exceeds) {
517
+ (degraded ??= []).push({ scorer: name, rate: verdict.rate, limit, type, scored, failed });
518
+ }
519
+ }
520
+ }
521
+ }
522
+ const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
523
+ const timing = durations.length > 0 ? computeStats(durations) : void 0;
403
524
  const totalModelCalls = /* @__PURE__ */ new Map();
404
525
  for (const item of evalItems) {
405
526
  const itemCounts = item.metadata?.modelCallCounts;
@@ -458,13 +579,20 @@ async function runEval(config, executeWorkflow, runtime, options) {
458
579
  ...config.metadata,
459
580
  scorerTypes,
460
581
  ...modelsMeta,
461
- ...workflowsMeta
582
+ ...workflowsMeta,
583
+ ...droppedAnnotationKeys.length > 0 ? { droppedAnnotationKeys } : {}
462
584
  },
463
585
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
464
586
  totalCost,
465
587
  duration: Date.now() - startTime,
466
588
  items: evalItems,
467
- summary: { count: items.length, failures, scorers: scorerStats, timing }
589
+ summary: {
590
+ count: items.length,
591
+ failures,
592
+ scorers: scorerStats,
593
+ timing,
594
+ ...degraded ? { degraded } : {}
595
+ }
468
596
  };
469
597
  }
470
598
 
@@ -535,9 +663,6 @@ function xorshift32(seed) {
535
663
  }
536
664
 
537
665
  // src/compare.ts
538
- function round3(n) {
539
- return Math.round(n * 1e3) / 1e3;
540
- }
541
666
  var DEFAULT_LLM_THRESHOLD = 0.05;
542
667
  var DEFAULT_DETERMINISTIC_THRESHOLD = 0;
543
668
  var LEGACY_THRESHOLD = 0.1;
@@ -606,16 +731,34 @@ function evalCompare(baseline, candidate, options) {
606
731
  const bMeanRaw = baselineMeans[name];
607
732
  const cMeanRaw = candidateMeans[name];
608
733
  const deltaRaw = cMeanRaw - bMeanRaw;
609
- const bMean = round3(bMeanRaw);
610
- const cMean = round3(cMeanRaw);
611
- const delta = round3(deltaRaw);
612
- const deltaPercent = bMeanRaw > 0 ? round3(deltaRaw / bMeanRaw * 100) : 0;
734
+ const bMean = round(bMeanRaw);
735
+ const cMean = round(cMeanRaw);
736
+ const delta = round(deltaRaw);
737
+ const deltaPercent = bMeanRaw > 0 ? round(deltaRaw / bMeanRaw * 100) : 0;
613
738
  const threshold = resolveThreshold(name, options, baselineRef.metadata);
739
+ let baselineScored = 0;
740
+ let baselineFailed = 0;
741
+ for (const run of baselineRuns) {
742
+ const c = scorerCounts(run.items, name);
743
+ baselineScored += c.scored;
744
+ baselineFailed += c.failed;
745
+ }
746
+ let candidateScored = 0;
747
+ let candidateFailed = 0;
748
+ for (const run of candidateRuns) {
749
+ const c = scorerCounts(run.items, name);
750
+ candidateScored += c.scored;
751
+ candidateFailed += c.failed;
752
+ }
614
753
  const entry = {
615
754
  baselineMean: bMean,
616
755
  candidateMean: cMean,
617
756
  delta,
618
- deltaPercent
757
+ deltaPercent,
758
+ baselineScored,
759
+ baselineFailed,
760
+ candidateScored,
761
+ candidateFailed
619
762
  };
620
763
  const diffs = pairedDiffs[name];
621
764
  if (diffs.length >= 2) {
@@ -645,15 +788,15 @@ function evalCompare(baseline, candidate, options) {
645
788
  if (bScores.length === 0 || cScores.length === 0) continue;
646
789
  const bAvg = bScores.reduce((a, b) => a + b, 0) / bScores.length;
647
790
  const cAvg = cScores.reduce((a, b) => a + b, 0) / cScores.length;
648
- const delta = round3(cAvg - bAvg);
791
+ const delta = round(cAvg - bAvg);
649
792
  const threshold = resolveThreshold(name, options, baselineRef.metadata);
650
793
  if (delta < -threshold)
651
794
  regressions.push({
652
795
  itemIndex: i,
653
796
  input: baselineRef.items[i]?.input,
654
797
  scorer: name,
655
- baselineScore: round3(bAvg),
656
- candidateScore: round3(cAvg),
798
+ baselineScore: round(bAvg),
799
+ candidateScore: round(cAvg),
657
800
  delta
658
801
  });
659
802
  else if (delta > threshold)
@@ -661,8 +804,8 @@ function evalCompare(baseline, candidate, options) {
661
804
  itemIndex: i,
662
805
  input: baselineRef.items[i]?.input,
663
806
  scorer: name,
664
- baselineScore: round3(bAvg),
665
- candidateScore: round3(cAvg),
807
+ baselineScore: round(bAvg),
808
+ candidateScore: round(cAvg),
666
809
  delta
667
810
  });
668
811
  }
@@ -673,20 +816,20 @@ function evalCompare(baseline, candidate, options) {
673
816
  if (baselineTimings.length > 0 && candidateTimings.length > 0) {
674
817
  const bMean = baselineTimings.reduce((a, b) => a + b, 0) / baselineTimings.length;
675
818
  const cMean = candidateTimings.reduce((a, b) => a + b, 0) / candidateTimings.length;
676
- const delta = round3(cMean - bMean);
677
- const deltaPercent = bMean > 0 ? round3((cMean - bMean) / bMean * 100) : 0;
678
- timing = { baselineMean: round3(bMean), candidateMean: round3(cMean), delta, deltaPercent };
819
+ const delta = round(cMean - bMean);
820
+ const deltaPercent = bMean > 0 ? round((cMean - bMean) / bMean * 100) : 0;
821
+ timing = { baselineMean: round(bMean), candidateMean: round(cMean), delta, deltaPercent };
679
822
  }
680
823
  let cost;
681
824
  const baselineAvgCost = baselineRuns.reduce((sum, r) => sum + r.totalCost, 0) / baselineRuns.length;
682
825
  const candidateAvgCost = candidateRuns.reduce((sum, r) => sum + r.totalCost, 0) / candidateRuns.length;
683
826
  if (baselineAvgCost > 0 || candidateAvgCost > 0) {
684
827
  const deltaRaw = candidateAvgCost - baselineAvgCost;
685
- const delta = round3(deltaRaw);
686
- const deltaPercent = baselineAvgCost > 0 ? round3(deltaRaw / baselineAvgCost * 100) : 0;
828
+ const delta = round(deltaRaw);
829
+ const deltaPercent = baselineAvgCost > 0 ? round(deltaRaw / baselineAvgCost * 100) : 0;
687
830
  cost = {
688
- baselineTotal: round3(baselineAvgCost),
689
- candidateTotal: round3(candidateAvgCost),
831
+ baselineTotal: round(baselineAvgCost),
832
+ candidateTotal: round(candidateAvgCost),
690
833
  delta,
691
834
  deltaPercent
692
835
  };
@@ -751,12 +894,34 @@ function detectPartial(runs) {
751
894
  if (runs.length >= attempted) return void 0;
752
895
  return { completed: runs.length, attempted };
753
896
  }
897
+ function evaluateScorerErrorRateGate(comparison, maxScorerErrorRate) {
898
+ const scorerTypes = comparison.baseline.metadata?.scorerTypes ?? {};
899
+ for (const name of Object.keys(comparison.scorers)) {
900
+ const s = comparison.scorers[name];
901
+ const type = scorerTypes[name] === "deterministic" ? "deterministic" : "llm";
902
+ const limit = type === "deterministic" ? 0 : maxScorerErrorRate;
903
+ for (const side of ["baseline", "candidate"]) {
904
+ const scored = (side === "baseline" ? s.baselineScored : s.candidateScored) ?? 0;
905
+ const failed = (side === "baseline" ? s.baselineFailed : s.candidateFailed) ?? 0;
906
+ const verdict = evaluateScorerTolerance(scored, failed, type, limit);
907
+ if (verdict.zeroSample) {
908
+ return `scorer "${name}" has no scored items on ${side} (its items errored or were skipped) \u2014 cannot certify a mean over an empty sample.`;
909
+ }
910
+ if (verdict.exceeds) {
911
+ const limitStr = type === "deterministic" ? "deterministic zero-tolerance" : `${(limit * 100).toFixed(1)}%`;
912
+ return `scorer "${name}" failed ${(verdict.rate * 100).toFixed(1)}% on ${side} (${failed}/${verdict.attempted}), over the ${limitStr} limit.`;
913
+ }
914
+ }
915
+ }
916
+ return null;
917
+ }
754
918
 
755
919
  // src/rescore.ts
756
920
  var import_node_crypto2 = require("crypto");
757
921
  async function rescore(result, scorers, runtime, options) {
758
922
  const startTime = Date.now();
759
923
  const concurrency = options?.concurrency ?? 5;
924
+ const scorerConcurrency = options?.scorerConcurrency ?? 5;
760
925
  const scorerContext = {
761
926
  resolveProvider: (uri) => {
762
927
  if (typeof runtime.resolveProvider !== "function") {
@@ -800,77 +965,24 @@ async function rescore(result, scorers, runtime, options) {
800
965
  scores: {},
801
966
  scoreDetails: {}
802
967
  };
803
- let itemScorerCost = 0;
804
- for (const scorer2 of scorers) {
805
- const scorerStart = Date.now();
806
- try {
807
- const raw = await scorer2.score(item.output, item.input, item.annotations, scorerContext);
808
- const scorerResult = normalizeScorerResult(raw);
809
- if (scorerResult.cost != null) {
810
- itemScorerCost += scorerResult.cost;
811
- totalCost += scorerResult.cost;
812
- }
813
- const scorerDuration = Date.now() - scorerStart;
814
- if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
815
- if (!item.scorerErrors) item.scorerErrors = [];
816
- item.scorerErrors.push(
817
- `Scorer "${scorer2.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`
818
- );
819
- item.scores[scorer2.name] = null;
820
- item.scoreDetails[scorer2.name] = {
821
- score: null,
822
- metadata: scorerResult.metadata,
823
- duration: scorerDuration,
824
- cost: scorerResult.cost
825
- };
826
- } else {
827
- item.scores[scorer2.name] = round(scorerResult.score);
828
- item.scoreDetails[scorer2.name] = {
829
- score: round(scorerResult.score),
830
- metadata: scorerResult.metadata,
831
- duration: scorerDuration,
832
- cost: scorerResult.cost
833
- };
834
- }
835
- } catch (err) {
836
- const errCost = typeof err?.cost === "number" ? err.cost : void 0;
837
- if (errCost != null) {
838
- itemScorerCost += errCost;
839
- totalCost += errCost;
840
- }
841
- if (!item.scorerErrors) item.scorerErrors = [];
842
- item.scorerErrors.push(
843
- `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`
844
- );
845
- item.scores[scorer2.name] = null;
846
- item.scoreDetails[scorer2.name] = {
847
- score: null,
848
- duration: Date.now() - scorerStart,
849
- cost: errCost
850
- };
851
- }
852
- }
853
- item.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
968
+ const itemScorerCost = await scoreItem(
969
+ item,
970
+ scorers,
971
+ scorerConcurrency,
972
+ scorerContext,
973
+ options?.signal
974
+ );
975
+ totalCost += itemScorerCost;
854
976
  rescored[itemIndex] = item;
855
977
  }
856
- let index = 0;
857
- async function runNext() {
858
- while (index < result.items.length) {
859
- const currentIndex = index++;
860
- await rescoreItem(result.items[currentIndex], currentIndex);
861
- }
862
- }
863
- const workers = Array.from(
864
- { length: Math.min(concurrency, result.items.length) },
865
- () => runNext()
866
- );
867
- await Promise.all(workers);
978
+ await mapWithConcurrency(result.items, concurrency, (item, i) => rescoreItem(item, i));
868
979
  const failures = rescored.filter((i) => i.error).length;
869
980
  const scorerNames = scorers.map((s) => s.name);
870
981
  const scorerStats = {};
871
982
  for (const name of scorerNames) {
872
983
  const scores = rescored.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
873
- scorerStats[name] = computeStats(scores);
984
+ const { scored, failed } = scorerCounts(rescored, name);
985
+ scorerStats[name] = { ...computeStats(scores), scored, failed };
874
986
  }
875
987
  const scorerTypes = {};
876
988
  for (const s of scorers) {
@@ -880,7 +992,11 @@ async function rescore(result, scorers, runtime, options) {
880
992
  id: (0, import_node_crypto2.randomUUID)(),
881
993
  dataset: result.dataset,
882
994
  metadata: (() => {
883
- const { runGroupId: _, runIndex: __, ...rest } = result.metadata;
995
+ const rest = { ...result.metadata };
996
+ delete rest.runGroupId;
997
+ delete rest.runIndex;
998
+ delete rest.scorerFiltered;
999
+ delete rest.scorersRun;
884
1000
  const merged = {
885
1001
  ...rest,
886
1002
  rescored: true,
@@ -933,15 +1049,24 @@ function aggregateRuns(runs) {
933
1049
  const scorerNames = Object.keys(runs[0].summary.scorers);
934
1050
  const scorers = {};
935
1051
  for (const name of scorerNames) {
936
- const means = runs.map((r) => r.summary.scorers[name]?.mean ?? 0);
1052
+ const contributing = runs.filter((r) => {
1053
+ const sc = r.summary.scorers[name]?.scored;
1054
+ return sc == null || sc > 0;
1055
+ });
1056
+ const meanSource = contributing.length > 0 ? contributing : runs;
1057
+ const means = meanSource.map((r) => r.summary.scorers[name]?.mean ?? 0);
937
1058
  const meanOfMeans = means.reduce((a, b) => a + b, 0) / means.length;
938
1059
  const minMean = Math.min(...means);
939
1060
  const maxMean = Math.max(...means);
1061
+ const scored = runs.reduce((sum, r) => sum + (r.summary.scorers[name]?.scored ?? 0), 0);
1062
+ const failed = runs.reduce((sum, r) => sum + (r.summary.scorers[name]?.failed ?? 0), 0);
940
1063
  scorers[name] = {
941
1064
  mean: round(meanOfMeans),
942
1065
  std: round(std(means, meanOfMeans)),
943
1066
  min: round(minMean),
944
- max: round(maxMean)
1067
+ max: round(maxMean),
1068
+ scored,
1069
+ failed
945
1070
  };
946
1071
  }
947
1072
  let timing;
@@ -970,6 +1095,8 @@ function aggregateRuns(runs) {
970
1095
  dataset,
971
1096
  defineEval,
972
1097
  evalCompare,
1098
+ evaluateScorerErrorRateGate,
1099
+ evaluateScorerTolerance,
973
1100
  llmScorer,
974
1101
  normalizeScorerResult,
975
1102
  pairedBootstrapCI,