@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/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,12 +141,22 @@ 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,
85
156
  description: config.description,
86
157
  isLlm: false,
87
- score: config.score
158
+ score: config.score,
159
+ applies: config.applies
88
160
  };
89
161
  }
90
162
 
@@ -98,6 +170,7 @@ function llmScorer(config) {
98
170
  name: config.name,
99
171
  description: config.description,
100
172
  isLlm: true,
173
+ applies: config.applies,
101
174
  async score(output, input, annotations, context) {
102
175
  if (!context?.resolveProvider) {
103
176
  throw new Error(
@@ -144,16 +217,14 @@ ${JSON.stringify(annotations, null, 2)}` : "",
144
217
  const parsed = JSON.parse((0, import_axl.extractJson)(response.content));
145
218
  validated = schema.parse(parsed);
146
219
  } catch (err) {
147
- if (err && typeof err === "object") {
148
- err.cost = responseCost;
149
- }
220
+ attachScorerErrorCost(err, responseCost);
150
221
  if (err && typeof err === "object" && "issues" in err && Array.isArray(err.issues)) {
151
222
  const issues = err.issues;
152
223
  const messages = issues.map((i) => `${i.path.length ? `${i.path.join(".")}: ` : ""}${i.message}`).join("; ");
153
224
  const error = new Error(
154
225
  `LLM scorer "${config.name}" returned an invalid response: ${messages}`
155
226
  );
156
- error.cost = responseCost;
227
+ attachScorerErrorCost(error, responseCost);
157
228
  throw error;
158
229
  }
159
230
  throw err;
@@ -187,6 +258,99 @@ function computeStats(scores) {
187
258
  function round(n) {
188
259
  return Math.round(n * 1e3) / 1e3;
189
260
  }
261
+ function scorerCounts(items, name) {
262
+ let scored = 0;
263
+ let failed = 0;
264
+ let skipped = 0;
265
+ for (const i of items) {
266
+ if (i.error) continue;
267
+ if (i.scores[name] != null) scored++;
268
+ else if (i.scoreDetails?.[name]?.skipped === true) skipped++;
269
+ else if (i.scoreDetails?.[name]?.duration != null) failed++;
270
+ }
271
+ return { scored, failed, skipped };
272
+ }
273
+ function evaluateScorerTolerance(scored, failed, type, limit) {
274
+ const attempted = scored + failed;
275
+ const rate = attempted === 0 ? 0 : failed / attempted;
276
+ const exceeds = attempted > 0 && (type === "deterministic" ? failed > 0 : rate > limit);
277
+ return { attempted, rate, exceeds, zeroSample: attempted === 0 };
278
+ }
279
+ async function mapWithConcurrency(items, concurrency, task) {
280
+ const results = new Array(items.length);
281
+ const requested = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;
282
+ const workerCount = items.length === 0 ? 0 : Math.min(Math.max(1, requested), items.length);
283
+ let next = 0;
284
+ async function runNext() {
285
+ while (next < items.length) {
286
+ const i = next++;
287
+ results[i] = await task(items[i], i);
288
+ }
289
+ }
290
+ await Promise.all(Array.from({ length: workerCount }, () => runNext()));
291
+ return results;
292
+ }
293
+
294
+ // src/score-item.ts
295
+ async function scoreItem(item, scorers, scorerConcurrency, scorerContext, signal) {
296
+ const scoreDetails = item.scoreDetails ??= {};
297
+ for (const s of scorers) {
298
+ item.scores[s.name] = null;
299
+ scoreDetails[s.name] = { score: null };
300
+ }
301
+ let itemScorerCost = 0;
302
+ const scorerErrorsByName = {};
303
+ await mapWithConcurrency(scorers, scorerConcurrency, async (scorer2) => {
304
+ if (signal?.aborted) return;
305
+ const scorerStart = Date.now();
306
+ try {
307
+ if (scorer2.applies && !scorer2.applies(item.output, item.input, item.annotations)) {
308
+ item.scores[scorer2.name] = null;
309
+ scoreDetails[scorer2.name] = { score: null, skipped: true };
310
+ return;
311
+ }
312
+ const raw = await scorer2.score(item.output, item.input, item.annotations, scorerContext);
313
+ const scorerResult = normalizeScorerResult(raw);
314
+ if (scorerResult.cost != null) {
315
+ itemScorerCost += scorerResult.cost;
316
+ }
317
+ const scorerDuration = Date.now() - scorerStart;
318
+ if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
319
+ scorerErrorsByName[scorer2.name] = `Scorer "${scorer2.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`;
320
+ item.scores[scorer2.name] = null;
321
+ scoreDetails[scorer2.name] = {
322
+ score: null,
323
+ metadata: scorerResult.metadata,
324
+ duration: scorerDuration,
325
+ cost: scorerResult.cost
326
+ };
327
+ } else {
328
+ item.scores[scorer2.name] = round(scorerResult.score);
329
+ scoreDetails[scorer2.name] = {
330
+ score: round(scorerResult.score),
331
+ metadata: scorerResult.metadata,
332
+ duration: scorerDuration,
333
+ cost: scorerResult.cost
334
+ };
335
+ }
336
+ } catch (err) {
337
+ if (err?.name === "AbortError") return;
338
+ const errCost = extractScorerErrorCost(err);
339
+ if (errCost != null) itemScorerCost += errCost;
340
+ scorerErrorsByName[scorer2.name] = `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`;
341
+ item.scores[scorer2.name] = null;
342
+ scoreDetails[scorer2.name] = {
343
+ score: null,
344
+ duration: Date.now() - scorerStart,
345
+ cost: errCost
346
+ };
347
+ }
348
+ });
349
+ const orderedErrors = scorers.map((s) => scorerErrorsByName[s.name]).filter((e) => e != null);
350
+ if (orderedErrors.length > 0) item.scorerErrors = orderedErrors;
351
+ item.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
352
+ return itemScorerCost;
353
+ }
190
354
 
191
355
  // src/runner.ts
192
356
  var import_node_crypto = require("crypto");
@@ -228,7 +392,9 @@ async function runEval(config, executeWorkflow, runtime, options) {
228
392
  const startTime = Date.now();
229
393
  const id = (0, import_node_crypto.randomUUID)();
230
394
  const items = await config.dataset.getItems();
395
+ const droppedAnnotationKeys = [...config.dataset.droppedAnnotationKeys ?? []];
231
396
  const concurrency = config.concurrency ?? 5;
397
+ const scorerConcurrency = config.scorerConcurrency ?? 5;
232
398
  const budgetLimit = config.budget ? parseCost(config.budget) : void 0;
233
399
  const scorerContext = {
234
400
  resolveProvider: (uri) => {
@@ -317,89 +483,53 @@ async function runEval(config, executeWorkflow, runtime, options) {
317
483
  if (budgetLimit != null && totalCost > budgetLimit) {
318
484
  budgetExceeded = true;
319
485
  }
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;
486
+ const itemScorerCost = await scoreItem(
487
+ evalItem,
488
+ config.scorers,
489
+ scorerConcurrency,
490
+ scorerContext,
491
+ options?.signal
492
+ );
493
+ totalCost += itemScorerCost;
378
494
  evalItems[itemIndex] = evalItem;
379
495
  options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
380
496
  }
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);
497
+ await mapWithConcurrency(items, concurrency, (item, i) => processItem(item, i));
390
498
  const failures = evalItems.filter((i) => i.error).length;
391
499
  const scorerNames = config.scorers.map((s) => s.name);
392
500
  const scorerStats = {};
393
501
  for (const name of scorerNames) {
394
502
  const scores = evalItems.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
395
- scorerStats[name] = computeStats(scores);
503
+ const { scored, failed, skipped } = scorerCounts(evalItems, name);
504
+ scorerStats[name] = { ...computeStats(scores), scored, failed, skipped };
396
505
  }
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
506
  const scorerTypes = {};
400
507
  for (const s of config.scorers) {
401
508
  scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
402
509
  }
510
+ let degraded;
511
+ const rawLimit = config.failOnScorerErrorRate;
512
+ if (rawLimit != null) {
513
+ if (!Number.isFinite(rawLimit) || rawLimit < 0 || rawLimit > 1) {
514
+ console.warn(
515
+ `[axl-eval] Ignoring invalid failOnScorerErrorRate (${rawLimit}); expected a number in [0, 1].`
516
+ );
517
+ } else {
518
+ for (const name of scorerNames) {
519
+ const stats = scorerStats[name];
520
+ const scored = stats.scored ?? 0;
521
+ const failed = stats.failed ?? 0;
522
+ const type = scorerTypes[name] === "llm" ? "llm" : "deterministic";
523
+ const limit = type === "deterministic" ? 0 : rawLimit;
524
+ const verdict = evaluateScorerTolerance(scored, failed, type, limit);
525
+ if (verdict.exceeds) {
526
+ (degraded ??= []).push({ scorer: name, rate: verdict.rate, limit, type, scored, failed });
527
+ }
528
+ }
529
+ }
530
+ }
531
+ const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
532
+ const timing = durations.length > 0 ? computeStats(durations) : void 0;
403
533
  const totalModelCalls = /* @__PURE__ */ new Map();
404
534
  for (const item of evalItems) {
405
535
  const itemCounts = item.metadata?.modelCallCounts;
@@ -458,13 +588,20 @@ async function runEval(config, executeWorkflow, runtime, options) {
458
588
  ...config.metadata,
459
589
  scorerTypes,
460
590
  ...modelsMeta,
461
- ...workflowsMeta
591
+ ...workflowsMeta,
592
+ ...droppedAnnotationKeys.length > 0 ? { droppedAnnotationKeys } : {}
462
593
  },
463
594
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
464
595
  totalCost,
465
596
  duration: Date.now() - startTime,
466
597
  items: evalItems,
467
- summary: { count: items.length, failures, scorers: scorerStats, timing }
598
+ summary: {
599
+ count: items.length,
600
+ failures,
601
+ scorers: scorerStats,
602
+ timing,
603
+ ...degraded ? { degraded } : {}
604
+ }
468
605
  };
469
606
  }
470
607
 
@@ -535,9 +672,6 @@ function xorshift32(seed) {
535
672
  }
536
673
 
537
674
  // src/compare.ts
538
- function round3(n) {
539
- return Math.round(n * 1e3) / 1e3;
540
- }
541
675
  var DEFAULT_LLM_THRESHOLD = 0.05;
542
676
  var DEFAULT_DETERMINISTIC_THRESHOLD = 0;
543
677
  var LEGACY_THRESHOLD = 0.1;
@@ -606,24 +740,48 @@ function evalCompare(baseline, candidate, options) {
606
740
  const bMeanRaw = baselineMeans[name];
607
741
  const cMeanRaw = candidateMeans[name];
608
742
  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;
743
+ const bMean = round(bMeanRaw);
744
+ const cMean = round(cMeanRaw);
745
+ const delta = round(deltaRaw);
746
+ const deltaPercent = bMeanRaw > 0 ? round(deltaRaw / bMeanRaw * 100) : 0;
613
747
  const threshold = resolveThreshold(name, options, baselineRef.metadata);
748
+ let baselineScored = 0;
749
+ let baselineFailed = 0;
750
+ let baselineSkipped = 0;
751
+ for (const run of baselineRuns) {
752
+ const c = scorerCounts(run.items, name);
753
+ baselineScored += c.scored;
754
+ baselineFailed += c.failed;
755
+ baselineSkipped += c.skipped;
756
+ }
757
+ let candidateScored = 0;
758
+ let candidateFailed = 0;
759
+ let candidateSkipped = 0;
760
+ for (const run of candidateRuns) {
761
+ const c = scorerCounts(run.items, name);
762
+ candidateScored += c.scored;
763
+ candidateFailed += c.failed;
764
+ candidateSkipped += c.skipped;
765
+ }
614
766
  const entry = {
615
767
  baselineMean: bMean,
616
768
  candidateMean: cMean,
617
769
  delta,
618
- deltaPercent
770
+ deltaPercent,
771
+ baselineScored,
772
+ baselineFailed,
773
+ candidateScored,
774
+ candidateFailed,
775
+ baselineSkipped,
776
+ candidateSkipped
619
777
  };
620
778
  const diffs = pairedDiffs[name];
779
+ entry.n = diffs.length;
621
780
  if (diffs.length >= 2) {
622
781
  const ci = pairedBootstrapCI(diffs);
623
782
  entry.ci = { lower: ci.lower, upper: ci.upper };
624
783
  entry.pRegression = ci.pRegression;
625
784
  entry.pImprovement = ci.pImprovement;
626
- entry.n = diffs.length;
627
785
  const ciExcludesZero = ci.lower > 0 || ci.upper < 0;
628
786
  entry.significant = ciExcludesZero && Math.abs(ci.mean) >= threshold;
629
787
  }
@@ -645,15 +803,15 @@ function evalCompare(baseline, candidate, options) {
645
803
  if (bScores.length === 0 || cScores.length === 0) continue;
646
804
  const bAvg = bScores.reduce((a, b) => a + b, 0) / bScores.length;
647
805
  const cAvg = cScores.reduce((a, b) => a + b, 0) / cScores.length;
648
- const delta = round3(cAvg - bAvg);
806
+ const delta = round(cAvg - bAvg);
649
807
  const threshold = resolveThreshold(name, options, baselineRef.metadata);
650
808
  if (delta < -threshold)
651
809
  regressions.push({
652
810
  itemIndex: i,
653
811
  input: baselineRef.items[i]?.input,
654
812
  scorer: name,
655
- baselineScore: round3(bAvg),
656
- candidateScore: round3(cAvg),
813
+ baselineScore: round(bAvg),
814
+ candidateScore: round(cAvg),
657
815
  delta
658
816
  });
659
817
  else if (delta > threshold)
@@ -661,8 +819,8 @@ function evalCompare(baseline, candidate, options) {
661
819
  itemIndex: i,
662
820
  input: baselineRef.items[i]?.input,
663
821
  scorer: name,
664
- baselineScore: round3(bAvg),
665
- candidateScore: round3(cAvg),
822
+ baselineScore: round(bAvg),
823
+ candidateScore: round(cAvg),
666
824
  delta
667
825
  });
668
826
  }
@@ -673,20 +831,20 @@ function evalCompare(baseline, candidate, options) {
673
831
  if (baselineTimings.length > 0 && candidateTimings.length > 0) {
674
832
  const bMean = baselineTimings.reduce((a, b) => a + b, 0) / baselineTimings.length;
675
833
  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 };
834
+ const delta = round(cMean - bMean);
835
+ const deltaPercent = bMean > 0 ? round((cMean - bMean) / bMean * 100) : 0;
836
+ timing = { baselineMean: round(bMean), candidateMean: round(cMean), delta, deltaPercent };
679
837
  }
680
838
  let cost;
681
839
  const baselineAvgCost = baselineRuns.reduce((sum, r) => sum + r.totalCost, 0) / baselineRuns.length;
682
840
  const candidateAvgCost = candidateRuns.reduce((sum, r) => sum + r.totalCost, 0) / candidateRuns.length;
683
841
  if (baselineAvgCost > 0 || candidateAvgCost > 0) {
684
842
  const deltaRaw = candidateAvgCost - baselineAvgCost;
685
- const delta = round3(deltaRaw);
686
- const deltaPercent = baselineAvgCost > 0 ? round3(deltaRaw / baselineAvgCost * 100) : 0;
843
+ const delta = round(deltaRaw);
844
+ const deltaPercent = baselineAvgCost > 0 ? round(deltaRaw / baselineAvgCost * 100) : 0;
687
845
  cost = {
688
- baselineTotal: round3(baselineAvgCost),
689
- candidateTotal: round3(candidateAvgCost),
846
+ baselineTotal: round(baselineAvgCost),
847
+ candidateTotal: round(candidateAvgCost),
690
848
  delta,
691
849
  deltaPercent
692
850
  };
@@ -751,12 +909,36 @@ function detectPartial(runs) {
751
909
  if (runs.length >= attempted) return void 0;
752
910
  return { completed: runs.length, attempted };
753
911
  }
912
+ function evaluateScorerErrorRateGate(comparison, maxScorerErrorRate) {
913
+ const scorerTypes = comparison.baseline.metadata?.scorerTypes ?? {};
914
+ for (const name of Object.keys(comparison.scorers)) {
915
+ const s = comparison.scorers[name];
916
+ const type = scorerTypes[name] === "deterministic" ? "deterministic" : "llm";
917
+ const limit = type === "deterministic" ? 0 : maxScorerErrorRate;
918
+ for (const side of ["baseline", "candidate"]) {
919
+ const scored = (side === "baseline" ? s.baselineScored : s.candidateScored) ?? 0;
920
+ const failed = (side === "baseline" ? s.baselineFailed : s.candidateFailed) ?? 0;
921
+ const skipped = (side === "baseline" ? s.baselineSkipped : s.candidateSkipped) ?? 0;
922
+ const verdict = evaluateScorerTolerance(scored, failed, type, limit);
923
+ if (verdict.zeroSample) {
924
+ const cause = skipped > 0 ? `every one of its items was N/A (the \`applies\` predicate skipped all ${skipped})` : `its items errored or produced no score`;
925
+ return `scorer "${name}" has no scored items on ${side} (${cause}) \u2014 cannot certify a mean over an empty sample.`;
926
+ }
927
+ if (verdict.exceeds) {
928
+ const limitStr = type === "deterministic" ? "deterministic zero-tolerance" : `${(limit * 100).toFixed(1)}%`;
929
+ return `scorer "${name}" failed ${(verdict.rate * 100).toFixed(1)}% on ${side} (${failed}/${verdict.attempted}), over the ${limitStr} limit.`;
930
+ }
931
+ }
932
+ }
933
+ return null;
934
+ }
754
935
 
755
936
  // src/rescore.ts
756
937
  var import_node_crypto2 = require("crypto");
757
938
  async function rescore(result, scorers, runtime, options) {
758
939
  const startTime = Date.now();
759
940
  const concurrency = options?.concurrency ?? 5;
941
+ const scorerConcurrency = options?.scorerConcurrency ?? 5;
760
942
  const scorerContext = {
761
943
  resolveProvider: (uri) => {
762
944
  if (typeof runtime.resolveProvider !== "function") {
@@ -800,77 +982,24 @@ async function rescore(result, scorers, runtime, options) {
800
982
  scores: {},
801
983
  scoreDetails: {}
802
984
  };
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;
985
+ const itemScorerCost = await scoreItem(
986
+ item,
987
+ scorers,
988
+ scorerConcurrency,
989
+ scorerContext,
990
+ options?.signal
991
+ );
992
+ totalCost += itemScorerCost;
854
993
  rescored[itemIndex] = item;
855
994
  }
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);
995
+ await mapWithConcurrency(result.items, concurrency, (item, i) => rescoreItem(item, i));
868
996
  const failures = rescored.filter((i) => i.error).length;
869
997
  const scorerNames = scorers.map((s) => s.name);
870
998
  const scorerStats = {};
871
999
  for (const name of scorerNames) {
872
1000
  const scores = rescored.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
873
- scorerStats[name] = computeStats(scores);
1001
+ const { scored, failed, skipped } = scorerCounts(rescored, name);
1002
+ scorerStats[name] = { ...computeStats(scores), scored, failed, skipped };
874
1003
  }
875
1004
  const scorerTypes = {};
876
1005
  for (const s of scorers) {
@@ -880,7 +1009,11 @@ async function rescore(result, scorers, runtime, options) {
880
1009
  id: (0, import_node_crypto2.randomUUID)(),
881
1010
  dataset: result.dataset,
882
1011
  metadata: (() => {
883
- const { runGroupId: _, runIndex: __, ...rest } = result.metadata;
1012
+ const rest = { ...result.metadata };
1013
+ delete rest.runGroupId;
1014
+ delete rest.runIndex;
1015
+ delete rest.scorerFiltered;
1016
+ delete rest.scorersRun;
884
1017
  const merged = {
885
1018
  ...rest,
886
1019
  rescored: true,
@@ -933,15 +1066,26 @@ function aggregateRuns(runs) {
933
1066
  const scorerNames = Object.keys(runs[0].summary.scorers);
934
1067
  const scorers = {};
935
1068
  for (const name of scorerNames) {
936
- const means = runs.map((r) => r.summary.scorers[name]?.mean ?? 0);
1069
+ const contributing = runs.filter((r) => {
1070
+ const sc = r.summary.scorers[name]?.scored;
1071
+ return sc == null || sc > 0;
1072
+ });
1073
+ const meanSource = contributing.length > 0 ? contributing : runs;
1074
+ const means = meanSource.map((r) => r.summary.scorers[name]?.mean ?? 0);
937
1075
  const meanOfMeans = means.reduce((a, b) => a + b, 0) / means.length;
938
1076
  const minMean = Math.min(...means);
939
1077
  const maxMean = Math.max(...means);
1078
+ const scored = runs.reduce((sum, r) => sum + (r.summary.scorers[name]?.scored ?? 0), 0);
1079
+ const failed = runs.reduce((sum, r) => sum + (r.summary.scorers[name]?.failed ?? 0), 0);
1080
+ const skipped = runs.reduce((sum, r) => sum + (r.summary.scorers[name]?.skipped ?? 0), 0);
940
1081
  scorers[name] = {
941
1082
  mean: round(meanOfMeans),
942
1083
  std: round(std(means, meanOfMeans)),
943
1084
  min: round(minMean),
944
- max: round(maxMean)
1085
+ max: round(maxMean),
1086
+ scored,
1087
+ failed,
1088
+ skipped
945
1089
  };
946
1090
  }
947
1091
  let timing;
@@ -970,6 +1114,8 @@ function aggregateRuns(runs) {
970
1114
  dataset,
971
1115
  defineEval,
972
1116
  evalCompare,
1117
+ evaluateScorerErrorRateGate,
1118
+ evaluateScorerTolerance,
973
1119
  llmScorer,
974
1120
  normalizeScorerResult,
975
1121
  pairedBootstrapCI,