@axlsdk/eval 0.17.8 → 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,
@@ -125,7 +196,18 @@ ${JSON.stringify(annotations, null, 2)}` : "",
125
196
  { role: "system", content: config.system },
126
197
  { role: "user", content: prompt }
127
198
  ],
128
- { model, temperature: config.temperature ?? 0.2, responseFormat: { type: "json_object" } }
199
+ {
200
+ model,
201
+ temperature: config.temperature ?? 0.2,
202
+ maxTokens: config.maxTokens,
203
+ effort: config.effort,
204
+ thinkingBudget: config.thinkingBudget,
205
+ includeThoughts: config.includeThoughts,
206
+ stop: config.stop,
207
+ providerOptions: config.providerOptions,
208
+ responseFormat: { type: "json_object" },
209
+ signal: context.signal
210
+ }
129
211
  );
130
212
  const responseCost = response.cost;
131
213
  let validated;
@@ -133,16 +215,14 @@ ${JSON.stringify(annotations, null, 2)}` : "",
133
215
  const parsed = JSON.parse((0, import_axl.extractJson)(response.content));
134
216
  validated = schema.parse(parsed);
135
217
  } catch (err) {
136
- if (err && typeof err === "object") {
137
- err.cost = responseCost;
138
- }
218
+ attachScorerErrorCost(err, responseCost);
139
219
  if (err && typeof err === "object" && "issues" in err && Array.isArray(err.issues)) {
140
220
  const issues = err.issues;
141
221
  const messages = issues.map((i) => `${i.path.length ? `${i.path.join(".")}: ` : ""}${i.message}`).join("; ");
142
222
  const error = new Error(
143
223
  `LLM scorer "${config.name}" returned an invalid response: ${messages}`
144
224
  );
145
- error.cost = responseCost;
225
+ attachScorerErrorCost(error, responseCost);
146
226
  throw error;
147
227
  }
148
228
  throw err;
@@ -176,6 +256,92 @@ function computeStats(scores) {
176
256
  function round(n) {
177
257
  return Math.round(n * 1e3) / 1e3;
178
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
+ }
179
345
 
180
346
  // src/runner.ts
181
347
  var import_node_crypto = require("crypto");
@@ -217,7 +383,9 @@ async function runEval(config, executeWorkflow, runtime, options) {
217
383
  const startTime = Date.now();
218
384
  const id = (0, import_node_crypto.randomUUID)();
219
385
  const items = await config.dataset.getItems();
386
+ const droppedAnnotationKeys = [...config.dataset.droppedAnnotationKeys ?? []];
220
387
  const concurrency = config.concurrency ?? 5;
388
+ const scorerConcurrency = config.scorerConcurrency ?? 5;
221
389
  const budgetLimit = config.budget ? parseCost(config.budget) : void 0;
222
390
  const scorerContext = {
223
391
  resolveProvider: (uri) => {
@@ -227,7 +395,8 @@ async function runEval(config, executeWorkflow, runtime, options) {
227
395
  );
228
396
  }
229
397
  return runtime.resolveProvider(uri);
230
- }
398
+ },
399
+ signal: options?.signal
231
400
  };
232
401
  const evalItems = new Array(items.length);
233
402
  let totalCost = 0;
@@ -305,89 +474,53 @@ async function runEval(config, executeWorkflow, runtime, options) {
305
474
  if (budgetLimit != null && totalCost > budgetLimit) {
306
475
  budgetExceeded = true;
307
476
  }
308
- evalItem.scoreDetails = {};
309
- let itemScorerCost = 0;
310
- for (const scorer2 of config.scorers) {
311
- if (options?.signal?.aborted) break;
312
- const scorerStart = Date.now();
313
- try {
314
- const raw = await scorer2.score(
315
- evalItem.output,
316
- item.input,
317
- item.annotations,
318
- scorerContext
319
- );
320
- const scorerResult = normalizeScorerResult(raw);
321
- if (scorerResult.cost != null) {
322
- itemScorerCost += scorerResult.cost;
323
- totalCost += scorerResult.cost;
324
- }
325
- const scorerDuration = Date.now() - scorerStart;
326
- if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
327
- if (!evalItem.scorerErrors) evalItem.scorerErrors = [];
328
- evalItem.scorerErrors.push(
329
- `Scorer "${scorer2.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`
330
- );
331
- evalItem.scores[scorer2.name] = null;
332
- evalItem.scoreDetails[scorer2.name] = {
333
- score: null,
334
- metadata: scorerResult.metadata,
335
- duration: scorerDuration,
336
- cost: scorerResult.cost
337
- };
338
- } else {
339
- evalItem.scores[scorer2.name] = round(scorerResult.score);
340
- evalItem.scoreDetails[scorer2.name] = {
341
- score: round(scorerResult.score),
342
- metadata: scorerResult.metadata,
343
- duration: scorerDuration,
344
- cost: scorerResult.cost
345
- };
346
- }
347
- } catch (err) {
348
- const errCost = typeof err?.cost === "number" ? err.cost : void 0;
349
- if (errCost != null) {
350
- itemScorerCost += errCost;
351
- totalCost += errCost;
352
- }
353
- if (!evalItem.scorerErrors) evalItem.scorerErrors = [];
354
- evalItem.scorerErrors.push(
355
- `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`
356
- );
357
- evalItem.scores[scorer2.name] = null;
358
- evalItem.scoreDetails[scorer2.name] = {
359
- score: null,
360
- duration: Date.now() - scorerStart,
361
- cost: errCost
362
- };
363
- }
364
- }
365
- 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;
366
485
  evalItems[itemIndex] = evalItem;
367
486
  options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
368
487
  }
369
- let index = 0;
370
- async function runNext() {
371
- while (index < items.length) {
372
- const currentIndex = index++;
373
- await processItem(items[currentIndex], currentIndex);
374
- }
375
- }
376
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => runNext());
377
- await Promise.all(workers);
488
+ await mapWithConcurrency(items, concurrency, (item, i) => processItem(item, i));
378
489
  const failures = evalItems.filter((i) => i.error).length;
379
490
  const scorerNames = config.scorers.map((s) => s.name);
380
491
  const scorerStats = {};
381
492
  for (const name of scorerNames) {
382
493
  const scores = evalItems.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
383
- scorerStats[name] = computeStats(scores);
494
+ const { scored, failed } = scorerCounts(evalItems, name);
495
+ scorerStats[name] = { ...computeStats(scores), scored, failed };
384
496
  }
385
- const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
386
- const timing = durations.length > 0 ? computeStats(durations) : void 0;
387
497
  const scorerTypes = {};
388
498
  for (const s of config.scorers) {
389
499
  scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
390
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;
391
524
  const totalModelCalls = /* @__PURE__ */ new Map();
392
525
  for (const item of evalItems) {
393
526
  const itemCounts = item.metadata?.modelCallCounts;
@@ -446,13 +579,20 @@ async function runEval(config, executeWorkflow, runtime, options) {
446
579
  ...config.metadata,
447
580
  scorerTypes,
448
581
  ...modelsMeta,
449
- ...workflowsMeta
582
+ ...workflowsMeta,
583
+ ...droppedAnnotationKeys.length > 0 ? { droppedAnnotationKeys } : {}
450
584
  },
451
585
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
452
586
  totalCost,
453
587
  duration: Date.now() - startTime,
454
588
  items: evalItems,
455
- 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
+ }
456
596
  };
457
597
  }
458
598
 
@@ -523,9 +663,6 @@ function xorshift32(seed) {
523
663
  }
524
664
 
525
665
  // src/compare.ts
526
- function round3(n) {
527
- return Math.round(n * 1e3) / 1e3;
528
- }
529
666
  var DEFAULT_LLM_THRESHOLD = 0.05;
530
667
  var DEFAULT_DETERMINISTIC_THRESHOLD = 0;
531
668
  var LEGACY_THRESHOLD = 0.1;
@@ -594,16 +731,34 @@ function evalCompare(baseline, candidate, options) {
594
731
  const bMeanRaw = baselineMeans[name];
595
732
  const cMeanRaw = candidateMeans[name];
596
733
  const deltaRaw = cMeanRaw - bMeanRaw;
597
- const bMean = round3(bMeanRaw);
598
- const cMean = round3(cMeanRaw);
599
- const delta = round3(deltaRaw);
600
- 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;
601
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
+ }
602
753
  const entry = {
603
754
  baselineMean: bMean,
604
755
  candidateMean: cMean,
605
756
  delta,
606
- deltaPercent
757
+ deltaPercent,
758
+ baselineScored,
759
+ baselineFailed,
760
+ candidateScored,
761
+ candidateFailed
607
762
  };
608
763
  const diffs = pairedDiffs[name];
609
764
  if (diffs.length >= 2) {
@@ -633,15 +788,15 @@ function evalCompare(baseline, candidate, options) {
633
788
  if (bScores.length === 0 || cScores.length === 0) continue;
634
789
  const bAvg = bScores.reduce((a, b) => a + b, 0) / bScores.length;
635
790
  const cAvg = cScores.reduce((a, b) => a + b, 0) / cScores.length;
636
- const delta = round3(cAvg - bAvg);
791
+ const delta = round(cAvg - bAvg);
637
792
  const threshold = resolveThreshold(name, options, baselineRef.metadata);
638
793
  if (delta < -threshold)
639
794
  regressions.push({
640
795
  itemIndex: i,
641
796
  input: baselineRef.items[i]?.input,
642
797
  scorer: name,
643
- baselineScore: round3(bAvg),
644
- candidateScore: round3(cAvg),
798
+ baselineScore: round(bAvg),
799
+ candidateScore: round(cAvg),
645
800
  delta
646
801
  });
647
802
  else if (delta > threshold)
@@ -649,8 +804,8 @@ function evalCompare(baseline, candidate, options) {
649
804
  itemIndex: i,
650
805
  input: baselineRef.items[i]?.input,
651
806
  scorer: name,
652
- baselineScore: round3(bAvg),
653
- candidateScore: round3(cAvg),
807
+ baselineScore: round(bAvg),
808
+ candidateScore: round(cAvg),
654
809
  delta
655
810
  });
656
811
  }
@@ -661,20 +816,20 @@ function evalCompare(baseline, candidate, options) {
661
816
  if (baselineTimings.length > 0 && candidateTimings.length > 0) {
662
817
  const bMean = baselineTimings.reduce((a, b) => a + b, 0) / baselineTimings.length;
663
818
  const cMean = candidateTimings.reduce((a, b) => a + b, 0) / candidateTimings.length;
664
- const delta = round3(cMean - bMean);
665
- const deltaPercent = bMean > 0 ? round3((cMean - bMean) / bMean * 100) : 0;
666
- 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 };
667
822
  }
668
823
  let cost;
669
824
  const baselineAvgCost = baselineRuns.reduce((sum, r) => sum + r.totalCost, 0) / baselineRuns.length;
670
825
  const candidateAvgCost = candidateRuns.reduce((sum, r) => sum + r.totalCost, 0) / candidateRuns.length;
671
826
  if (baselineAvgCost > 0 || candidateAvgCost > 0) {
672
827
  const deltaRaw = candidateAvgCost - baselineAvgCost;
673
- const delta = round3(deltaRaw);
674
- const deltaPercent = baselineAvgCost > 0 ? round3(deltaRaw / baselineAvgCost * 100) : 0;
828
+ const delta = round(deltaRaw);
829
+ const deltaPercent = baselineAvgCost > 0 ? round(deltaRaw / baselineAvgCost * 100) : 0;
675
830
  cost = {
676
- baselineTotal: round3(baselineAvgCost),
677
- candidateTotal: round3(candidateAvgCost),
831
+ baselineTotal: round(baselineAvgCost),
832
+ candidateTotal: round(candidateAvgCost),
678
833
  delta,
679
834
  deltaPercent
680
835
  };
@@ -739,12 +894,34 @@ function detectPartial(runs) {
739
894
  if (runs.length >= attempted) return void 0;
740
895
  return { completed: runs.length, attempted };
741
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
+ }
742
918
 
743
919
  // src/rescore.ts
744
920
  var import_node_crypto2 = require("crypto");
745
921
  async function rescore(result, scorers, runtime, options) {
746
922
  const startTime = Date.now();
747
923
  const concurrency = options?.concurrency ?? 5;
924
+ const scorerConcurrency = options?.scorerConcurrency ?? 5;
748
925
  const scorerContext = {
749
926
  resolveProvider: (uri) => {
750
927
  if (typeof runtime.resolveProvider !== "function") {
@@ -753,11 +930,22 @@ async function rescore(result, scorers, runtime, options) {
753
930
  );
754
931
  }
755
932
  return runtime.resolveProvider(uri);
756
- }
933
+ },
934
+ signal: options?.signal
757
935
  };
758
936
  const rescored = new Array(result.items.length);
759
937
  let totalCost = 0;
760
938
  async function rescoreItem(original, itemIndex) {
939
+ if (options?.signal?.aborted) {
940
+ rescored[itemIndex] = {
941
+ input: original.input,
942
+ annotations: original.annotations,
943
+ output: original.output,
944
+ error: "cancelled",
945
+ scores: {}
946
+ };
947
+ return;
948
+ }
761
949
  if (original.error) {
762
950
  rescored[itemIndex] = {
763
951
  input: original.input,
@@ -777,77 +965,24 @@ async function rescore(result, scorers, runtime, options) {
777
965
  scores: {},
778
966
  scoreDetails: {}
779
967
  };
780
- let itemScorerCost = 0;
781
- for (const scorer2 of scorers) {
782
- const scorerStart = Date.now();
783
- try {
784
- const raw = await scorer2.score(item.output, item.input, item.annotations, scorerContext);
785
- const scorerResult = normalizeScorerResult(raw);
786
- if (scorerResult.cost != null) {
787
- itemScorerCost += scorerResult.cost;
788
- totalCost += scorerResult.cost;
789
- }
790
- const scorerDuration = Date.now() - scorerStart;
791
- if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
792
- if (!item.scorerErrors) item.scorerErrors = [];
793
- item.scorerErrors.push(
794
- `Scorer "${scorer2.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`
795
- );
796
- item.scores[scorer2.name] = null;
797
- item.scoreDetails[scorer2.name] = {
798
- score: null,
799
- metadata: scorerResult.metadata,
800
- duration: scorerDuration,
801
- cost: scorerResult.cost
802
- };
803
- } else {
804
- item.scores[scorer2.name] = round(scorerResult.score);
805
- item.scoreDetails[scorer2.name] = {
806
- score: round(scorerResult.score),
807
- metadata: scorerResult.metadata,
808
- duration: scorerDuration,
809
- cost: scorerResult.cost
810
- };
811
- }
812
- } catch (err) {
813
- const errCost = typeof err?.cost === "number" ? err.cost : void 0;
814
- if (errCost != null) {
815
- itemScorerCost += errCost;
816
- totalCost += errCost;
817
- }
818
- if (!item.scorerErrors) item.scorerErrors = [];
819
- item.scorerErrors.push(
820
- `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`
821
- );
822
- item.scores[scorer2.name] = null;
823
- item.scoreDetails[scorer2.name] = {
824
- score: null,
825
- duration: Date.now() - scorerStart,
826
- cost: errCost
827
- };
828
- }
829
- }
830
- 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;
831
976
  rescored[itemIndex] = item;
832
977
  }
833
- let index = 0;
834
- async function runNext() {
835
- while (index < result.items.length) {
836
- const currentIndex = index++;
837
- await rescoreItem(result.items[currentIndex], currentIndex);
838
- }
839
- }
840
- const workers = Array.from(
841
- { length: Math.min(concurrency, result.items.length) },
842
- () => runNext()
843
- );
844
- await Promise.all(workers);
978
+ await mapWithConcurrency(result.items, concurrency, (item, i) => rescoreItem(item, i));
845
979
  const failures = rescored.filter((i) => i.error).length;
846
980
  const scorerNames = scorers.map((s) => s.name);
847
981
  const scorerStats = {};
848
982
  for (const name of scorerNames) {
849
983
  const scores = rescored.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
850
- scorerStats[name] = computeStats(scores);
984
+ const { scored, failed } = scorerCounts(rescored, name);
985
+ scorerStats[name] = { ...computeStats(scores), scored, failed };
851
986
  }
852
987
  const scorerTypes = {};
853
988
  for (const s of scorers) {
@@ -857,7 +992,11 @@ async function rescore(result, scorers, runtime, options) {
857
992
  id: (0, import_node_crypto2.randomUUID)(),
858
993
  dataset: result.dataset,
859
994
  metadata: (() => {
860
- 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;
861
1000
  const merged = {
862
1001
  ...rest,
863
1002
  rescored: true,
@@ -910,15 +1049,24 @@ function aggregateRuns(runs) {
910
1049
  const scorerNames = Object.keys(runs[0].summary.scorers);
911
1050
  const scorers = {};
912
1051
  for (const name of scorerNames) {
913
- 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);
914
1058
  const meanOfMeans = means.reduce((a, b) => a + b, 0) / means.length;
915
1059
  const minMean = Math.min(...means);
916
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);
917
1063
  scorers[name] = {
918
1064
  mean: round(meanOfMeans),
919
1065
  std: round(std(means, meanOfMeans)),
920
1066
  min: round(minMean),
921
- max: round(maxMean)
1067
+ max: round(maxMean),
1068
+ scored,
1069
+ failed
922
1070
  };
923
1071
  }
924
1072
  let timing;
@@ -947,6 +1095,8 @@ function aggregateRuns(runs) {
947
1095
  dataset,
948
1096
  defineEval,
949
1097
  evalCompare,
1098
+ evaluateScorerErrorRateGate,
1099
+ evaluateScorerTolerance,
950
1100
  llmScorer,
951
1101
  normalizeScorerResult,
952
1102
  pairedBootstrapCI,