@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/README.md CHANGED
@@ -101,6 +101,8 @@ const ds = dataset({
101
101
 
102
102
  You can also load from a file: `dataset({ name: 'large', schema, file: './data.json' })`.
103
103
 
104
+ > **Annotation keys must be in the schema.** Zod strips keys it doesn't declare, so an annotation field missing from the `annotations` schema is dropped before it reaches your scorers — and a scorer reading `annotations?.thatField` then silently sees `undefined` and becomes a no-op. By default `dataset()` warns once per dataset listing any dropped keys (`annotation key(s) dropped by schema: ...`). Set `onExtraAnnotationKeys: 'error'` to fail instead (recommended in CI), or `'ignore'` to silence. This is the only guard for `file:` datasets, which aren't type-checked.
105
+
104
106
  ## Scorers
105
107
 
106
108
  Scorers rate each output on a 0-1 scale. You can mix deterministic and LLM scorers in the same eval.
@@ -223,10 +225,42 @@ npx axl-eval ./evals/ # run all *.eval.* files in
223
225
  npx axl-eval ./evals/ --output ./results/v1.json # save results to JSON
224
226
  npx axl-eval ./evals/ --config ./axl.config.ts # use a specific runtime config
225
227
  npx axl-eval ./evals/ --conditions development # add Node.js import conditions (monorepo source exports)
228
+ npx axl-eval ./evals/qa.eval.ts --concurrency 10 # override item concurrency for this run
229
+ npx axl-eval ./evals/qa.eval.ts --scorers accuracy # run only named scorer(s) (single file)
226
230
  ```
227
231
 
228
232
  The CLI resolves a runtime automatically: `--config <path>` > auto-detect `axl.config.*` > bare `new AxlRuntime()` (providers from env vars). Use `--conditions` when your eval file imports from monorepo packages that use conditional exports (e.g., `"development"` condition for source TypeScript instead of compiled dist).
229
233
 
234
+ **`--concurrency <n>` / `AXL_EVAL_CONCURRENCY`** override item concurrency per-invocation (precedence: flag > env > `defineEval` value > default 5). Concurrency is pure scheduling — it never changes results. Note the per-eval `scorerConcurrency` (default 5) is independent, so the worst-case number of simultaneous scorer calls is `concurrency × scorerConcurrency`; lower `--concurrency` if a rate-limited judge model needs a tighter ceiling.
235
+
236
+ **`--scorers <a,b>`** runs only the named scorers (single eval file only) for a fast iteration loop. The result is stamped `metadata.scorerFiltered: true` so `axl-eval compare` warns — and, under `--fail-on-regression`, refuses — to gate on a subset run mistaken for a full baseline.
237
+
238
+ #### Scorer failure rate — two complementary gates
239
+
240
+ Concurrent scorers (`concurrency × scorerConcurrency`, up to 25 in-flight by default) can overrun a rate-limited judge model. When a judge call exhausts the provider's retry backoff it throws, the runner records that item's score as `null`, and the mean is computed over the **survivors** — so a thinned sample can still look green. The runner now always reports per-scorer `scored`/`failed` counts (visible in the table as `(18/20 scored, 2 failed)` and in Studio as an amber badge), and two opt-in gates turn that signal into a non-zero exit:
241
+
242
+ | Gate | Where | When it fires |
243
+ |------|-------|---------------|
244
+ | `failOnScorerErrorRate` (config field) | **Source-side** — at run/produce time | `runEval` flags `summary.degraded` (and the CLI exits non-zero) when a scorer exceeds tolerance, *before* the thinned result is saved. `runEval` never throws — it returns the result with the flag set, preserving multi-run partials and auto-save. |
245
+ | `--max-scorer-error-rate <0..1>` (compare flag) | **Gate-side** — at consume/gate time | `axl-eval compare` refuses (exit 1) to certify a baseline/candidate whose failure rate is over the limit. Always *warns* when either side rests on a thinned sample, even without the flag. |
246
+
247
+ Both are **type-aware**: deterministic scorers tolerate **zero** failures (a deterministic scorer that throws is a bug, not noise); LLM judges use the configured rate. They're complementary — set `failOnScorerErrorRate` in the eval file to fail fast at run time, and pass `--max-scorer-error-rate` in CI to refuse to gate on an artifact that was already thinned (e.g. an imported result).
248
+
249
+ ```ts
250
+ // eval file — fail this run if the judge errored on >10% of items
251
+ export default { workflow: 'qa', dataset: ds, scorers: [judge], failOnScorerErrorRate: 0.1 };
252
+ ```
253
+
254
+ ```bash
255
+ npx axl-eval compare base.json cand.json --fail-on-regression --max-scorer-error-rate 0.05
256
+ ```
257
+
258
+ Building a custom CI gate? Both decisions are exported as pure functions — `evaluateScorerErrorRateGate` and `evaluateScorerTolerance` (see the [API reference](../../docs/api-reference.md)).
259
+
260
+ #### Total-workflow-wipeout guard
261
+
262
+ Separate from the scorer signal above: if **every** item errored in the *workflow* (0 succeeded), the eval produced no scorable output, so `axl-eval` always exits non-zero with `FAILED: … all N item(s) errored in the workflow` — a fully-broken eval (bad provider URI, an exception in every run) can never go green in CI. This is non-configurable (a 0%-success eval is unambiguously broken) and distinct from `failOnScorerErrorRate` (which is about a flaky *scorer* and deliberately ignores a run with no scored items). Partial workflow-failure rates stay visible (`Failures: N/M` in the table) but non-gating.
263
+
230
264
  ### Programmatic
231
265
 
232
266
  **`runtime.eval()`** — when you have a workflow registered on the runtime. The `workflow` field must match the registered name:
@@ -430,6 +464,7 @@ Compare two runs to detect regressions and improvements. Runs must use the same
430
464
  ```bash
431
465
  npx axl-eval compare ./results/v1.json ./results/v2.json
432
466
  npx axl-eval compare v1.json v2.json --fail-on-regression # exit 1 if significant regressions
467
+ npx axl-eval compare v1.json v2.json --max-scorer-error-rate 0.05 # exit 1 if a scorer failed on >5% of the items it ran against (deterministic scorers: zero tolerance)
433
468
  ```
434
469
 
435
470
  ```
@@ -569,18 +604,21 @@ return { output, cost: ctx.totalCost };
569
604
 
570
605
  ### Common patterns
571
606
 
572
- **Concurrency** — process items in parallel (default: 5):
607
+ **Concurrency** — process items in parallel (default: 5), and fan out each item's scorers (default: 5):
573
608
 
574
609
  ```typescript
575
610
  export default defineEval({
576
611
  workflow: 'qa-eval',
577
612
  dataset: ds,
578
- scorers: [qualityJudge],
579
- concurrency: 10, // run 10 items in parallel
613
+ scorers: [qualityJudge, toneJudge, safetyJudge],
614
+ concurrency: 10, // run 10 items in parallel
615
+ scorerConcurrency: 3, // run up to 3 of an item's scorers at once
580
616
  budget: '$5.00', // stop if total cost exceeds $5
581
617
  });
582
618
  ```
583
619
 
620
+ `scorerConcurrency` parallelizes the per-item judge phase — the dominant cost for evals with several `llmScorer`s. The worst-case number of simultaneous scorer calls is `concurrency × scorerConcurrency`, so a rate-limited judge model may need a lower value (set `scorerConcurrency: 1` for the old serial behavior). Because the per-item `budget` check runs once before an item's scorers, scorer-cost overshoot is bounded by `concurrency × scorerConcurrency × max-scorer-cost`.
621
+
584
622
  **Per-item budget** — cap cost for a single workflow execution:
585
623
 
586
624
  ```typescript
@@ -620,7 +658,9 @@ npx axl-eval rescore ./results/v1.json ./evals/qa.eval.ts
620
658
  npx axl-eval rescore ./results/v1.json ./evals/qa.eval.ts --output ./results/v1-rescored.json
621
659
  ```
622
660
 
623
- The rescored result gets a new `id` and has `metadata.rescored: true` and `metadata.originalId` pointing to the source run. `runGroupId` and `runIndex` are stripped from inherited metadata (rescored results are independent evaluations). Only scorer cost is tracked (workflow cost is zero).
661
+ The rescored result gets a new `id` and has `metadata.rescored: true` and `metadata.originalId` pointing to the source run. `runGroupId`, `runIndex`, and any `scorerFiltered`/`scorersRun` stamp are stripped from inherited metadata (rescored results are independent evaluations, and a rescore always runs the eval file's full scorer set). Only scorer cost is tracked (workflow cost is zero).
662
+
663
+ `--concurrency`/`AXL_EVAL_CONCURRENCY` are honored by `rescore` too (item-level; `scorerConcurrency` keeps its default 5). `--scorers` is **not** supported with `rescore` — it's a run-command-only filter; rescore re-runs whatever scorers the eval file exports (pass a subset there instead).
624
664
 
625
665
  Programmatically:
626
666
 
@@ -628,7 +668,8 @@ Programmatically:
628
668
  import { rescore } from '@axlsdk/eval';
629
669
 
630
670
  const rescored = await rescore(originalResult, [updatedScorer, newScorer], runtime, {
631
- concurrency: 10,
671
+ concurrency: 10, // items rescored in parallel
672
+ scorerConcurrency: 3, // scorers per item run in parallel (default 5)
632
673
  });
633
674
 
634
675
  console.log(rescored.metadata.rescored); // true
@@ -714,7 +755,7 @@ const comparison = evalCompare(baselineRuns, candidateRuns);
714
755
 
715
756
  | Type | Description |
716
757
  |------|-------------|
717
- | `EvalConfig` | Eval definition (workflow, dataset, scorers, concurrency, budget) |
758
+ | `EvalConfig` | Eval definition (workflow, dataset, scorers, concurrency, scorerConcurrency, budget) |
718
759
  | `EvalResult` | Full eval output (items, summary, cost, duration) |
719
760
  | `EvalItem` | Per-item result (input, output, scores, scoreDetails, metadata, traces?) |
720
761
  | `EvalSummary` | Aggregate statistics (count, failures, scorer stats, timing) |
@@ -724,7 +765,7 @@ const comparison = evalCompare(baselineRuns, candidateRuns);
724
765
  | `EvalRegression` / `EvalImprovement` | Per-item change record (itemIndex, scorer, scores, delta) |
725
766
  | `ScorerDetail` | Per-scorer detail (score, metadata, duration, cost) |
726
767
  | `ScorerResult` | Scorer return type (`{ score, metadata?, cost? }`) |
727
- | `RescoreOptions` | Options for `rescore()` (`concurrency`) |
768
+ | `RescoreOptions` | Options for `rescore()` (`concurrency`, `scorerConcurrency`, `signal`) |
728
769
  | `MultiRunSummary` | Aggregated multi-run output (per-scorer mean/std/min/max) |
729
770
  | `BootstrapCIResult` | CI result (`{ lower, upper, mean, pRegression, pImprovement }`) |
730
771
  | `RunEvalOptions` | Options for `runEval()` (`onProgress`, `signal`, `captureTraces`) |
@@ -64,10 +64,52 @@ function xorshift32(seed) {
64
64
  };
65
65
  }
66
66
 
67
- // src/compare.ts
67
+ // src/utils.ts
68
+ function computeStats(scores) {
69
+ if (scores.length === 0) return { mean: 0, min: 0, max: 0, p50: 0, p95: 0 };
70
+ const sorted = [...scores].sort((a, b) => a - b);
71
+ const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
72
+ const min = sorted[0];
73
+ const max = sorted[sorted.length - 1];
74
+ const p50 = sorted[Math.floor(sorted.length * 0.5)];
75
+ const p95 = sorted[Math.floor(sorted.length * 0.95)];
76
+ return { mean: round2(mean), min: round2(min), max: round2(max), p50: round2(p50), p95: round2(p95) };
77
+ }
68
78
  function round2(n) {
69
79
  return Math.round(n * 1e3) / 1e3;
70
80
  }
81
+ function scorerCounts(items, name) {
82
+ let scored = 0;
83
+ let failed = 0;
84
+ for (const i of items) {
85
+ if (i.error) continue;
86
+ if (i.scores[name] != null) scored++;
87
+ else if (i.scoreDetails?.[name]?.duration != null) failed++;
88
+ }
89
+ return { scored, failed };
90
+ }
91
+ function evaluateScorerTolerance(scored, failed, type, limit) {
92
+ const attempted = scored + failed;
93
+ const rate = attempted === 0 ? 0 : failed / attempted;
94
+ const exceeds = attempted > 0 && (type === "deterministic" ? failed > 0 : rate > limit);
95
+ return { attempted, rate, exceeds, zeroSample: attempted === 0 };
96
+ }
97
+ async function mapWithConcurrency(items, concurrency, task) {
98
+ const results = new Array(items.length);
99
+ const requested = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;
100
+ const workerCount = items.length === 0 ? 0 : Math.min(Math.max(1, requested), items.length);
101
+ let next = 0;
102
+ async function runNext() {
103
+ while (next < items.length) {
104
+ const i = next++;
105
+ results[i] = await task(items[i], i);
106
+ }
107
+ }
108
+ await Promise.all(Array.from({ length: workerCount }, () => runNext()));
109
+ return results;
110
+ }
111
+
112
+ // src/compare.ts
71
113
  var DEFAULT_LLM_THRESHOLD = 0.05;
72
114
  var DEFAULT_DETERMINISTIC_THRESHOLD = 0;
73
115
  var LEGACY_THRESHOLD = 0.1;
@@ -141,11 +183,29 @@ function evalCompare(baseline, candidate, options) {
141
183
  const delta = round2(deltaRaw);
142
184
  const deltaPercent = bMeanRaw > 0 ? round2(deltaRaw / bMeanRaw * 100) : 0;
143
185
  const threshold = resolveThreshold(name, options, baselineRef.metadata);
186
+ let baselineScored = 0;
187
+ let baselineFailed = 0;
188
+ for (const run of baselineRuns) {
189
+ const c = scorerCounts(run.items, name);
190
+ baselineScored += c.scored;
191
+ baselineFailed += c.failed;
192
+ }
193
+ let candidateScored = 0;
194
+ let candidateFailed = 0;
195
+ for (const run of candidateRuns) {
196
+ const c = scorerCounts(run.items, name);
197
+ candidateScored += c.scored;
198
+ candidateFailed += c.failed;
199
+ }
144
200
  const entry = {
145
201
  baselineMean: bMean,
146
202
  candidateMean: cMean,
147
203
  delta,
148
- deltaPercent
204
+ deltaPercent,
205
+ baselineScored,
206
+ baselineFailed,
207
+ candidateScored,
208
+ candidateFailed
149
209
  };
150
210
  const diffs = pairedDiffs[name];
151
211
  if (diffs.length >= 2) {
@@ -281,11 +341,41 @@ function detectPartial(runs) {
281
341
  if (runs.length >= attempted) return void 0;
282
342
  return { completed: runs.length, attempted };
283
343
  }
344
+ function evaluateScorerErrorRateGate(comparison, maxScorerErrorRate) {
345
+ const scorerTypes = comparison.baseline.metadata?.scorerTypes ?? {};
346
+ for (const name of Object.keys(comparison.scorers)) {
347
+ const s = comparison.scorers[name];
348
+ const type = scorerTypes[name] === "deterministic" ? "deterministic" : "llm";
349
+ const limit = type === "deterministic" ? 0 : maxScorerErrorRate;
350
+ for (const side of ["baseline", "candidate"]) {
351
+ const scored = (side === "baseline" ? s.baselineScored : s.candidateScored) ?? 0;
352
+ const failed = (side === "baseline" ? s.baselineFailed : s.candidateFailed) ?? 0;
353
+ const verdict = evaluateScorerTolerance(scored, failed, type, limit);
354
+ if (verdict.zeroSample) {
355
+ return `scorer "${name}" has no scored items on ${side} (its items errored or were skipped) \u2014 cannot certify a mean over an empty sample.`;
356
+ }
357
+ if (verdict.exceeds) {
358
+ const limitStr = type === "deterministic" ? "deterministic zero-tolerance" : `${(limit * 100).toFixed(1)}%`;
359
+ return `scorer "${name}" failed ${(verdict.rate * 100).toFixed(1)}% on ${side} (${failed}/${verdict.attempted}), over the ${limitStr} limit.`;
360
+ }
361
+ }
362
+ }
363
+ return null;
364
+ }
284
365
 
285
366
  // src/scorer.ts
286
367
  function normalizeScorerResult(result) {
287
368
  return typeof result === "number" ? { score: result } : result;
288
369
  }
370
+ function attachScorerErrorCost(err, cost) {
371
+ if (err && typeof err === "object" && typeof cost === "number" && Number.isFinite(cost) && cost >= 0) {
372
+ err.cost = cost;
373
+ }
374
+ }
375
+ function extractScorerErrorCost(err) {
376
+ const v = err && typeof err === "object" ? err.cost : void 0;
377
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
378
+ }
289
379
  function scorer(config) {
290
380
  return {
291
381
  name: config.name,
@@ -295,19 +385,60 @@ function scorer(config) {
295
385
  };
296
386
  }
297
387
 
298
- // src/utils.ts
299
- function computeStats(scores) {
300
- if (scores.length === 0) return { mean: 0, min: 0, max: 0, p50: 0, p95: 0 };
301
- const sorted = [...scores].sort((a, b) => a - b);
302
- const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
303
- const min = sorted[0];
304
- const max = sorted[sorted.length - 1];
305
- const p50 = sorted[Math.floor(sorted.length * 0.5)];
306
- const p95 = sorted[Math.floor(sorted.length * 0.95)];
307
- return { mean: round3(mean), min: round3(min), max: round3(max), p50: round3(p50), p95: round3(p95) };
308
- }
309
- function round3(n) {
310
- return Math.round(n * 1e3) / 1e3;
388
+ // src/score-item.ts
389
+ async function scoreItem(item, scorers, scorerConcurrency, scorerContext, signal) {
390
+ const scoreDetails = item.scoreDetails ??= {};
391
+ for (const s of scorers) {
392
+ item.scores[s.name] = null;
393
+ scoreDetails[s.name] = { score: null };
394
+ }
395
+ let itemScorerCost = 0;
396
+ const scorerErrorsByName = {};
397
+ await mapWithConcurrency(scorers, scorerConcurrency, async (scorer2) => {
398
+ if (signal?.aborted) return;
399
+ const scorerStart = Date.now();
400
+ try {
401
+ const raw = await scorer2.score(item.output, item.input, item.annotations, scorerContext);
402
+ const scorerResult = normalizeScorerResult(raw);
403
+ if (scorerResult.cost != null) {
404
+ itemScorerCost += scorerResult.cost;
405
+ }
406
+ const scorerDuration = Date.now() - scorerStart;
407
+ if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
408
+ scorerErrorsByName[scorer2.name] = `Scorer "${scorer2.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`;
409
+ item.scores[scorer2.name] = null;
410
+ scoreDetails[scorer2.name] = {
411
+ score: null,
412
+ metadata: scorerResult.metadata,
413
+ duration: scorerDuration,
414
+ cost: scorerResult.cost
415
+ };
416
+ } else {
417
+ item.scores[scorer2.name] = round2(scorerResult.score);
418
+ scoreDetails[scorer2.name] = {
419
+ score: round2(scorerResult.score),
420
+ metadata: scorerResult.metadata,
421
+ duration: scorerDuration,
422
+ cost: scorerResult.cost
423
+ };
424
+ }
425
+ } catch (err) {
426
+ if (err?.name === "AbortError") return;
427
+ const errCost = extractScorerErrorCost(err);
428
+ if (errCost != null) itemScorerCost += errCost;
429
+ scorerErrorsByName[scorer2.name] = `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`;
430
+ item.scores[scorer2.name] = null;
431
+ scoreDetails[scorer2.name] = {
432
+ score: null,
433
+ duration: Date.now() - scorerStart,
434
+ cost: errCost
435
+ };
436
+ }
437
+ });
438
+ const orderedErrors = scorers.map((s) => scorerErrorsByName[s.name]).filter((e) => e != null);
439
+ if (orderedErrors.length > 0) item.scorerErrors = orderedErrors;
440
+ item.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
441
+ return itemScorerCost;
311
442
  }
312
443
 
313
444
  // src/runner.ts
@@ -350,7 +481,9 @@ async function runEval(config, executeWorkflow, runtime, options) {
350
481
  const startTime = Date.now();
351
482
  const id = randomUUID();
352
483
  const items = await config.dataset.getItems();
484
+ const droppedAnnotationKeys = [...config.dataset.droppedAnnotationKeys ?? []];
353
485
  const concurrency = config.concurrency ?? 5;
486
+ const scorerConcurrency = config.scorerConcurrency ?? 5;
354
487
  const budgetLimit = config.budget ? parseCost(config.budget) : void 0;
355
488
  const scorerContext = {
356
489
  resolveProvider: (uri) => {
@@ -439,89 +572,53 @@ async function runEval(config, executeWorkflow, runtime, options) {
439
572
  if (budgetLimit != null && totalCost > budgetLimit) {
440
573
  budgetExceeded = true;
441
574
  }
442
- evalItem.scoreDetails = {};
443
- let itemScorerCost = 0;
444
- for (const scorer2 of config.scorers) {
445
- if (options?.signal?.aborted) break;
446
- const scorerStart = Date.now();
447
- try {
448
- const raw = await scorer2.score(
449
- evalItem.output,
450
- item.input,
451
- item.annotations,
452
- scorerContext
453
- );
454
- const scorerResult = normalizeScorerResult(raw);
455
- if (scorerResult.cost != null) {
456
- itemScorerCost += scorerResult.cost;
457
- totalCost += scorerResult.cost;
458
- }
459
- const scorerDuration = Date.now() - scorerStart;
460
- if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
461
- if (!evalItem.scorerErrors) evalItem.scorerErrors = [];
462
- evalItem.scorerErrors.push(
463
- `Scorer "${scorer2.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`
464
- );
465
- evalItem.scores[scorer2.name] = null;
466
- evalItem.scoreDetails[scorer2.name] = {
467
- score: null,
468
- metadata: scorerResult.metadata,
469
- duration: scorerDuration,
470
- cost: scorerResult.cost
471
- };
472
- } else {
473
- evalItem.scores[scorer2.name] = round3(scorerResult.score);
474
- evalItem.scoreDetails[scorer2.name] = {
475
- score: round3(scorerResult.score),
476
- metadata: scorerResult.metadata,
477
- duration: scorerDuration,
478
- cost: scorerResult.cost
479
- };
480
- }
481
- } catch (err) {
482
- const errCost = typeof err?.cost === "number" ? err.cost : void 0;
483
- if (errCost != null) {
484
- itemScorerCost += errCost;
485
- totalCost += errCost;
486
- }
487
- if (!evalItem.scorerErrors) evalItem.scorerErrors = [];
488
- evalItem.scorerErrors.push(
489
- `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`
490
- );
491
- evalItem.scores[scorer2.name] = null;
492
- evalItem.scoreDetails[scorer2.name] = {
493
- score: null,
494
- duration: Date.now() - scorerStart,
495
- cost: errCost
496
- };
497
- }
498
- }
499
- evalItem.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
575
+ const itemScorerCost = await scoreItem(
576
+ evalItem,
577
+ config.scorers,
578
+ scorerConcurrency,
579
+ scorerContext,
580
+ options?.signal
581
+ );
582
+ totalCost += itemScorerCost;
500
583
  evalItems[itemIndex] = evalItem;
501
584
  options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
502
585
  }
503
- let index = 0;
504
- async function runNext() {
505
- while (index < items.length) {
506
- const currentIndex = index++;
507
- await processItem(items[currentIndex], currentIndex);
508
- }
509
- }
510
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => runNext());
511
- await Promise.all(workers);
586
+ await mapWithConcurrency(items, concurrency, (item, i) => processItem(item, i));
512
587
  const failures = evalItems.filter((i) => i.error).length;
513
588
  const scorerNames = config.scorers.map((s) => s.name);
514
589
  const scorerStats = {};
515
590
  for (const name of scorerNames) {
516
591
  const scores = evalItems.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
517
- scorerStats[name] = computeStats(scores);
592
+ const { scored, failed } = scorerCounts(evalItems, name);
593
+ scorerStats[name] = { ...computeStats(scores), scored, failed };
518
594
  }
519
- const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
520
- const timing = durations.length > 0 ? computeStats(durations) : void 0;
521
595
  const scorerTypes = {};
522
596
  for (const s of config.scorers) {
523
597
  scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
524
598
  }
599
+ let degraded;
600
+ const rawLimit = config.failOnScorerErrorRate;
601
+ if (rawLimit != null) {
602
+ if (!Number.isFinite(rawLimit) || rawLimit < 0 || rawLimit > 1) {
603
+ console.warn(
604
+ `[axl-eval] Ignoring invalid failOnScorerErrorRate (${rawLimit}); expected a number in [0, 1].`
605
+ );
606
+ } else {
607
+ for (const name of scorerNames) {
608
+ const stats = scorerStats[name];
609
+ const scored = stats.scored ?? 0;
610
+ const failed = stats.failed ?? 0;
611
+ const type = scorerTypes[name] === "llm" ? "llm" : "deterministic";
612
+ const limit = type === "deterministic" ? 0 : rawLimit;
613
+ const verdict = evaluateScorerTolerance(scored, failed, type, limit);
614
+ if (verdict.exceeds) {
615
+ (degraded ??= []).push({ scorer: name, rate: verdict.rate, limit, type, scored, failed });
616
+ }
617
+ }
618
+ }
619
+ }
620
+ const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
621
+ const timing = durations.length > 0 ? computeStats(durations) : void 0;
525
622
  const totalModelCalls = /* @__PURE__ */ new Map();
526
623
  for (const item of evalItems) {
527
624
  const itemCounts = item.metadata?.modelCallCounts;
@@ -580,13 +677,20 @@ async function runEval(config, executeWorkflow, runtime, options) {
580
677
  ...config.metadata,
581
678
  scorerTypes,
582
679
  ...modelsMeta,
583
- ...workflowsMeta
680
+ ...workflowsMeta,
681
+ ...droppedAnnotationKeys.length > 0 ? { droppedAnnotationKeys } : {}
584
682
  },
585
683
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
586
684
  totalCost,
587
685
  duration: Date.now() - startTime,
588
686
  items: evalItems,
589
- summary: { count: items.length, failures, scorers: scorerStats, timing }
687
+ summary: {
688
+ count: items.length,
689
+ failures,
690
+ scorers: scorerStats,
691
+ timing,
692
+ ...degraded ? { degraded } : {}
693
+ }
590
694
  };
591
695
  }
592
696
 
@@ -595,6 +699,7 @@ import { randomUUID as randomUUID2 } from "crypto";
595
699
  async function rescore(result, scorers, runtime, options) {
596
700
  const startTime = Date.now();
597
701
  const concurrency = options?.concurrency ?? 5;
702
+ const scorerConcurrency = options?.scorerConcurrency ?? 5;
598
703
  const scorerContext = {
599
704
  resolveProvider: (uri) => {
600
705
  if (typeof runtime.resolveProvider !== "function") {
@@ -638,77 +743,24 @@ async function rescore(result, scorers, runtime, options) {
638
743
  scores: {},
639
744
  scoreDetails: {}
640
745
  };
641
- let itemScorerCost = 0;
642
- for (const scorer2 of scorers) {
643
- const scorerStart = Date.now();
644
- try {
645
- const raw = await scorer2.score(item.output, item.input, item.annotations, scorerContext);
646
- const scorerResult = normalizeScorerResult(raw);
647
- if (scorerResult.cost != null) {
648
- itemScorerCost += scorerResult.cost;
649
- totalCost += scorerResult.cost;
650
- }
651
- const scorerDuration = Date.now() - scorerStart;
652
- if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
653
- if (!item.scorerErrors) item.scorerErrors = [];
654
- item.scorerErrors.push(
655
- `Scorer "${scorer2.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`
656
- );
657
- item.scores[scorer2.name] = null;
658
- item.scoreDetails[scorer2.name] = {
659
- score: null,
660
- metadata: scorerResult.metadata,
661
- duration: scorerDuration,
662
- cost: scorerResult.cost
663
- };
664
- } else {
665
- item.scores[scorer2.name] = round3(scorerResult.score);
666
- item.scoreDetails[scorer2.name] = {
667
- score: round3(scorerResult.score),
668
- metadata: scorerResult.metadata,
669
- duration: scorerDuration,
670
- cost: scorerResult.cost
671
- };
672
- }
673
- } catch (err) {
674
- const errCost = typeof err?.cost === "number" ? err.cost : void 0;
675
- if (errCost != null) {
676
- itemScorerCost += errCost;
677
- totalCost += errCost;
678
- }
679
- if (!item.scorerErrors) item.scorerErrors = [];
680
- item.scorerErrors.push(
681
- `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`
682
- );
683
- item.scores[scorer2.name] = null;
684
- item.scoreDetails[scorer2.name] = {
685
- score: null,
686
- duration: Date.now() - scorerStart,
687
- cost: errCost
688
- };
689
- }
690
- }
691
- item.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
746
+ const itemScorerCost = await scoreItem(
747
+ item,
748
+ scorers,
749
+ scorerConcurrency,
750
+ scorerContext,
751
+ options?.signal
752
+ );
753
+ totalCost += itemScorerCost;
692
754
  rescored[itemIndex] = item;
693
755
  }
694
- let index = 0;
695
- async function runNext() {
696
- while (index < result.items.length) {
697
- const currentIndex = index++;
698
- await rescoreItem(result.items[currentIndex], currentIndex);
699
- }
700
- }
701
- const workers = Array.from(
702
- { length: Math.min(concurrency, result.items.length) },
703
- () => runNext()
704
- );
705
- await Promise.all(workers);
756
+ await mapWithConcurrency(result.items, concurrency, (item, i) => rescoreItem(item, i));
706
757
  const failures = rescored.filter((i) => i.error).length;
707
758
  const scorerNames = scorers.map((s) => s.name);
708
759
  const scorerStats = {};
709
760
  for (const name of scorerNames) {
710
761
  const scores = rescored.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
711
- scorerStats[name] = computeStats(scores);
762
+ const { scored, failed } = scorerCounts(rescored, name);
763
+ scorerStats[name] = { ...computeStats(scores), scored, failed };
712
764
  }
713
765
  const scorerTypes = {};
714
766
  for (const s of scorers) {
@@ -718,7 +770,11 @@ async function rescore(result, scorers, runtime, options) {
718
770
  id: randomUUID2(),
719
771
  dataset: result.dataset,
720
772
  metadata: (() => {
721
- const { runGroupId: _, runIndex: __, ...rest } = result.metadata;
773
+ const rest = { ...result.metadata };
774
+ delete rest.runGroupId;
775
+ delete rest.runIndex;
776
+ delete rest.scorerFiltered;
777
+ delete rest.scorersRun;
722
778
  const merged = {
723
779
  ...rest,
724
780
  rescored: true,
@@ -771,15 +827,24 @@ function aggregateRuns(runs) {
771
827
  const scorerNames = Object.keys(runs[0].summary.scorers);
772
828
  const scorers = {};
773
829
  for (const name of scorerNames) {
774
- const means = runs.map((r) => r.summary.scorers[name]?.mean ?? 0);
830
+ const contributing = runs.filter((r) => {
831
+ const sc = r.summary.scorers[name]?.scored;
832
+ return sc == null || sc > 0;
833
+ });
834
+ const meanSource = contributing.length > 0 ? contributing : runs;
835
+ const means = meanSource.map((r) => r.summary.scorers[name]?.mean ?? 0);
775
836
  const meanOfMeans = means.reduce((a, b) => a + b, 0) / means.length;
776
837
  const minMean = Math.min(...means);
777
838
  const maxMean = Math.max(...means);
839
+ const scored = runs.reduce((sum, r) => sum + (r.summary.scorers[name]?.scored ?? 0), 0);
840
+ const failed = runs.reduce((sum, r) => sum + (r.summary.scorers[name]?.failed ?? 0), 0);
778
841
  scorers[name] = {
779
- mean: round3(meanOfMeans),
780
- std: round3(std(means, meanOfMeans)),
781
- min: round3(minMean),
782
- max: round3(maxMean)
842
+ mean: round2(meanOfMeans),
843
+ std: round2(std(means, meanOfMeans)),
844
+ min: round2(minMean),
845
+ max: round2(maxMean),
846
+ scored,
847
+ failed
783
848
  };
784
849
  }
785
850
  let timing;
@@ -787,8 +852,8 @@ function aggregateRuns(runs) {
787
852
  if (timingMeans.length === runs.length && timingMeans.length > 0) {
788
853
  const meanTiming = timingMeans.reduce((a, b) => a + b, 0) / timingMeans.length;
789
854
  timing = {
790
- mean: round3(meanTiming),
791
- std: round3(std(timingMeans, meanTiming))
855
+ mean: round2(meanTiming),
856
+ std: round2(std(timingMeans, meanTiming))
792
857
  };
793
858
  }
794
859
  return {
@@ -796,8 +861,8 @@ function aggregateRuns(runs) {
796
861
  runCount: runs.length,
797
862
  workflows,
798
863
  dataset,
799
- totalCost: round3(totalCost),
800
- totalDuration: round3(totalDuration),
864
+ totalCost: round2(totalCost),
865
+ totalDuration: round2(totalDuration),
801
866
  scorers,
802
867
  timing
803
868
  };
@@ -805,11 +870,15 @@ function aggregateRuns(runs) {
805
870
 
806
871
  export {
807
872
  pairedBootstrapCI,
873
+ scorerCounts,
874
+ evaluateScorerTolerance,
808
875
  evalCompare,
876
+ evaluateScorerErrorRateGate,
809
877
  normalizeScorerResult,
878
+ attachScorerErrorCost,
810
879
  scorer,
811
880
  runEval,
812
881
  rescore,
813
882
  aggregateRuns
814
883
  };
815
- //# sourceMappingURL=chunk-NETD4AUB.js.map
884
+ //# sourceMappingURL=chunk-BWBX5YVQ.js.map