@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/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.
@@ -132,6 +134,48 @@ const lengthScore = scorer({
132
134
 
133
135
  Scorers that return a non-finite value (`NaN` / `Infinity`) or a score outside `[0, 1]` are recorded as `null` with an entry in `item.scorerErrors` — they don't abort the run.
134
136
 
137
+ ### Conditional scorers (`applies`)
138
+
139
+ By default every scorer runs against every item. When a scorer only applies to a subset — a refusal judge for refusal-expected items, a constraint judge for constrained items, an asymmetry scorer for asymmetric exercises — declare its scope with an `applies` predicate. It mirrors the `score` signature (`output, input, annotations?`) and returns `true` to run or `false` to skip:
140
+
141
+ ```typescript
142
+ import { scorer } from '@axlsdk/eval';
143
+
144
+ // Deterministic: only score items the dataset flagged as "constrained"
145
+ const constraintAdherence = scorer({
146
+ name: 'constraint-adherence',
147
+ description: 'Output respects the stated constraint',
148
+ applies: (_output, _input, annotations) => annotations?.constrained === true,
149
+ score: (output, _input, annotations) =>
150
+ String(output).includes(String(annotations?.constraint)) ? 1 : 0,
151
+ });
152
+ ```
153
+
154
+ When `applies` returns `false`, the scorer is **skipped** for that item:
155
+
156
+ - The scorer body never runs — for an `llmScorer`, **no provider call is made**, saving real cost and rate-limit budget (a skipped judge can't 429 you).
157
+ - The item counts as **neither `scored` nor `failed`** — it's excluded from the `mean` *and* from the failure-rate denominator (`failOnScorerErrorRate` and `compare --max-scorer-error-rate`), so a conditional scorer stays honest to both the mean and the trust gate.
158
+ - The skip is visible: `item.scoreDetails[name].skipped === true`, and the run summary carries a per-scorer `skipped` count (alongside `scored` / `failed`). Studio's Eval Runner shows an "N/A" chip.
159
+
160
+ Applicability usually keys off `input` / `annotations` (a flag or exercise type), but `output` is available too (e.g. "only score outputs that actually refused"). The predicate runs **before** the scorer body, so an inapplicable `llmScorer` never makes its provider call:
161
+
162
+ ```typescript
163
+ import { llmScorer } from '@axlsdk/eval';
164
+
165
+ // LLM judge: only invoke the (expensive) judge for items where a refusal was expected
166
+ const refusalQuality = llmScorer({
167
+ name: 'refusal-quality',
168
+ description: 'Did the model refuse appropriately and explain why?',
169
+ model: 'openai:gpt-4o',
170
+ system: 'Rate whether the output is a well-justified refusal.',
171
+ applies: (_output, _input, annotations) => annotations?.expectRefusal === true,
172
+ });
173
+ ```
174
+
175
+ A predicate that **throws** is treated as a scorer failure (it's a bug), not a skip — the item lands in the `failed` bucket with an entry in `item.scorerErrors`, never silently swallowed.
176
+
177
+ > **Deprecated: the `NaN`-skip workaround.** Before `applies`, the only way to express "not applicable" was to return a `NaN` / out-of-range sentinel for skipped items. The failure-rate gate (correctly) treats a non-finite score as a real failure, so a deterministic conditional scorer that returned `NaN` for inapplicable items showed a ~90% failure rate and permanently tripped the zero-tolerance deterministic gate. Use `applies` instead. The gate's strictness about `NaN` is intentional and unchanged.
178
+
135
179
  ### LLM scorers
136
180
 
137
181
  Use an LLM as a judge. The scorer constructs a prompt from the input, output, and annotations, calls the LLM, and validates the response:
@@ -185,6 +229,8 @@ const qualityJudge = llmScorer({ name: 'quality', model: 'openai:gpt-4o', ... })
185
229
  const safetyJudge = llmScorer({ name: 'safety', model: 'anthropic:claude-sonnet-4-5-20250514', ... });
186
230
  ```
187
231
 
232
+ LLM scorers also accept the conditional `applies` predicate described in [Conditional scorers](#conditional-scorers-applies) — the most valuable place to use it, since a skipped judge makes no provider call at all.
233
+
188
234
  #### Tuning the judge
189
235
 
190
236
  For reasoning-capable judge models (gpt-5.x, Anthropic Opus/Sonnet 4.5+, Gemini 3.x), `effort` is the highest-leverage knob — judges with reasoning enabled are materially more consistent. The full `ChatOptions` surface is forwarded:
@@ -223,10 +269,49 @@ npx axl-eval ./evals/ # run all *.eval.* files in
223
269
  npx axl-eval ./evals/ --output ./results/v1.json # save results to JSON
224
270
  npx axl-eval ./evals/ --config ./axl.config.ts # use a specific runtime config
225
271
  npx axl-eval ./evals/ --conditions development # add Node.js import conditions (monorepo source exports)
272
+ npx axl-eval ./evals/qa.eval.ts --concurrency 10 # override item concurrency for this run
273
+ npx axl-eval ./evals/qa.eval.ts --scorers accuracy # run only named scorer(s) (single file)
226
274
  ```
227
275
 
228
276
  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
277
 
278
+ **`--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.
279
+
280
+ **`--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.
281
+
282
+ #### Scorer failure rate — two complementary gates
283
+
284
+ 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:
285
+
286
+ | Gate | Where | When it fires |
287
+ |------|-------|---------------|
288
+ | `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. |
289
+ | `--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. |
290
+
291
+ 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).
292
+
293
+ **Skipped items don't count against either gate.** The failure rate is `failed / (scored + failed)` — items a scorer's `applies` predicate skipped land in neither bucket, so they're excluded from the denominator. This is the supported way to scope a conditional scorer (a refusal judge that only runs on refusal-expected items, say) to its applicable subset without polluting the trust signal. **Do not** return `NaN` for inapplicable items: the gate (correctly) treats a non-finite score as a real failure, so a `NaN`-skipping deterministic scorer trips the zero-tolerance deterministic gate on every skipped item. Use [`applies`](#conditional-scorers-applies) instead.
294
+
295
+ Because skips are excluded rather than failed, two failure modes are surfaced as **advisories** (stderr, non-fatal) so a conditional scorer can't silently mislead:
296
+
297
+ - **Fully N/A scorer** — if a scorer ends up applicable to *zero* items (an over-strict predicate, a dataset missing the flagged items, or a predicate that forgot to `return`), its mean is a meaningless empty-sample `0` and the failure-rate gate can't assess it. `axl-eval` prints a `NOTE` (the run still exits 0 — skips are legitimately conditional).
298
+ - **Asymmetric skips in `compare`** — if the baseline and candidate applied a scorer to *different* item subsets (different N/A counts, or the same count of *different* items — detected as fewer items scored on both sides than either scored alone), their per-side means cover different subsets, so a delta may be a sample mismatch rather than a real change. `axl-eval compare` prints a `NOTE` alongside the usual thinned-sample warning. (Studio's compare view shows the same as a "paired n" note.)
299
+
300
+ ```ts
301
+ // eval file — fail this run if the judge errored on >10% of the items it ran against
302
+ export default { workflow: 'qa', dataset: ds, scorers: [judge], failOnScorerErrorRate: 0.1 };
303
+ ```
304
+
305
+ ```bash
306
+ npx axl-eval compare base.json cand.json --fail-on-regression --max-scorer-error-rate 0.05
307
+ ```
308
+
309
+ Building a custom CI gate? Both decisions are exported as pure functions — `evaluateScorerErrorRateGate` and `evaluateScorerTolerance` (see the [API reference](../../docs/api-reference.md)).
310
+
311
+ #### Total-workflow-wipeout guard
312
+
313
+ 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.
314
+
230
315
  ### Programmatic
231
316
 
232
317
  **`runtime.eval()`** — when you have a workflow registered on the runtime. The `workflow` field must match the registered name:
@@ -319,8 +404,16 @@ Each eval run returns an `EvalResult` with per-item scores and aggregate statist
319
404
 
320
405
  Each item has two ways to access scores:
321
406
 
322
- - **`item.scores`** — quick numeric lookup: `Record<string, number | null>`. Use this for simple checks and aggregation. `null` means the scorer failed (see `item.scorerErrors`).
323
- - **`item.scoreDetails`** — full context: `Record<string, ScorerDetail>`. Each detail has the numeric score plus `metadata` (e.g., LLM reasoning), per-scorer `duration`, and `cost`. Use this when you need to understand *why* a score is what it is.
407
+ - **`item.scores`** — quick numeric lookup: `Record<string, number | null>`. Use this for simple checks and aggregation. `null` means the scorer failed (see `item.scorerErrors`) **or** was skipped by its `applies` predicate — disambiguate with `scoreDetails[name].skipped`.
408
+ - **`item.scoreDetails`** — full context: `Record<string, ScorerDetail>`. Each detail has the numeric score plus `metadata` (e.g., LLM reasoning), per-scorer `duration`, `cost`, and a `skipped` flag. Use this when you need to understand *why* a score is what it is.
409
+
410
+ A `ScorerDetail` distinguishes three terminal states for a scorer on a given item:
411
+
412
+ | State | `score` | `skipped` | `duration` |
413
+ |-------|---------|-----------|------------|
414
+ | Scored | `number` | absent | set |
415
+ | Ran-and-failed (threw / out-of-range) | `null` | absent | set |
416
+ | Skipped by `applies` | `null` | `true` | absent |
324
417
 
325
418
  Summary statistics (mean, p50, p95, etc.) exclude `null` scores. If all scores for a scorer are `null`, the CLI shows `--` instead of misleading `0.00`.
326
419
 
@@ -329,6 +422,9 @@ const results = await runtime.eval({ ... });
329
422
 
330
423
  // ── Aggregate stats ──────────────────────────────────
331
424
  console.log(results.summary.scorers['quality'].mean); // 0.85
425
+ console.log(results.summary.scorers['quality'].scored); // 48 (items that produced a numeric score)
426
+ console.log(results.summary.scorers['quality'].failed); // 2 (scorer ran and threw / out-of-range)
427
+ console.log(results.summary.scorers['quality'].skipped); // 0 (items the applies predicate skipped)
332
428
  console.log(results.summary.count); // 50 items
333
429
  console.log(results.summary.failures); // 2 workflow errors
334
430
  console.log(results.summary.timing); // { mean, min, max, p50, p95 } in ms
@@ -356,14 +452,19 @@ for (const item of results.items) {
356
452
  // Rich per-scorer detail — reasoning, timing, cost
357
453
  const detail = item.scoreDetails?.['quality'];
358
454
  if (detail) {
359
- console.log(detail.score); // 0.85
360
- console.log(detail.metadata?.reasoning); // "The answer is relevant..."
361
- console.log(detail.duration); // scorer execution ms
362
- console.log(detail.cost); // scorer LLM cost
455
+ if (detail.skipped) {
456
+ // This scorer's `applies` predicate returned false — not run, not counted
457
+ console.log('Scorer skipped (not applicable to this item)');
458
+ } else {
459
+ console.log(detail.score); // 0.85
460
+ console.log(detail.metadata?.reasoning); // "The answer is relevant..."
461
+ console.log(detail.duration); // scorer execution ms
462
+ console.log(detail.cost); // scorer LLM cost
463
+ }
363
464
  }
364
465
 
365
- // Error handling
366
- if (item.scores['quality'] === null) {
466
+ // Error handling — a null score is either a failure OR a skip
467
+ if (item.scores['quality'] === null && !item.scoreDetails?.['quality']?.skipped) {
367
468
  console.log('Scorer failed:', item.scorerErrors); // ["Scorer "quality" threw: ..."]
368
469
  }
369
470
 
@@ -430,6 +531,7 @@ Compare two runs to detect regressions and improvements. Runs must use the same
430
531
  ```bash
431
532
  npx axl-eval compare ./results/v1.json ./results/v2.json
432
533
  npx axl-eval compare v1.json v2.json --fail-on-regression # exit 1 if significant regressions
534
+ 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
535
  ```
434
536
 
435
537
  ```
@@ -569,18 +671,21 @@ return { output, cost: ctx.totalCost };
569
671
 
570
672
  ### Common patterns
571
673
 
572
- **Concurrency** — process items in parallel (default: 5):
674
+ **Concurrency** — process items in parallel (default: 5), and fan out each item's scorers (default: 5):
573
675
 
574
676
  ```typescript
575
677
  export default defineEval({
576
678
  workflow: 'qa-eval',
577
679
  dataset: ds,
578
- scorers: [qualityJudge],
579
- concurrency: 10, // run 10 items in parallel
680
+ scorers: [qualityJudge, toneJudge, safetyJudge],
681
+ concurrency: 10, // run 10 items in parallel
682
+ scorerConcurrency: 3, // run up to 3 of an item's scorers at once
580
683
  budget: '$5.00', // stop if total cost exceeds $5
581
684
  });
582
685
  ```
583
686
 
687
+ `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`.
688
+
584
689
  **Per-item budget** — cap cost for a single workflow execution:
585
690
 
586
691
  ```typescript
@@ -620,7 +725,9 @@ npx axl-eval rescore ./results/v1.json ./evals/qa.eval.ts
620
725
  npx axl-eval rescore ./results/v1.json ./evals/qa.eval.ts --output ./results/v1-rescored.json
621
726
  ```
622
727
 
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).
728
+ 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).
729
+
730
+ `--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
731
 
625
732
  Programmatically:
626
733
 
@@ -628,7 +735,8 @@ Programmatically:
628
735
  import { rescore } from '@axlsdk/eval';
629
736
 
630
737
  const rescored = await rescore(originalResult, [updatedScorer, newScorer], runtime, {
631
- concurrency: 10,
738
+ concurrency: 10, // items rescored in parallel
739
+ scorerConcurrency: 3, // scorers per item run in parallel (default 5)
632
740
  });
633
741
 
634
742
  console.log(rescored.metadata.rescored); // true
@@ -700,8 +808,8 @@ const comparison = evalCompare(baselineRuns, candidateRuns);
700
808
  | Function | Description |
701
809
  |----------|-------------|
702
810
  | `dataset(config)` | Create a dataset from inline items or a JSON file |
703
- | `scorer(config)` | Create a deterministic scorer |
704
- | `llmScorer(config)` | Create an LLM-as-judge scorer |
811
+ | `scorer(config)` | Create a deterministic scorer (optional `applies` predicate for conditional scoring) |
812
+ | `llmScorer(config)` | Create an LLM-as-judge scorer (optional `applies` predicate — a skipped judge makes no provider call) |
705
813
  | `defineEval(config)` | Wrap an eval config for CLI discovery |
706
814
  | `runEval(config, executeFn, runtime, options?)` | Run an eval programmatically. `options: RunEvalOptions` accepts `onProgress` / `signal` / `captureTraces` |
707
815
  | `evalCompare(baseline, candidate, options?)` | Compare eval results with bootstrap CI |
@@ -714,17 +822,17 @@ const comparison = evalCompare(baselineRuns, candidateRuns);
714
822
 
715
823
  | Type | Description |
716
824
  |------|-------------|
717
- | `EvalConfig` | Eval definition (workflow, dataset, scorers, concurrency, budget) |
825
+ | `EvalConfig` | Eval definition (workflow, dataset, scorers, concurrency, scorerConcurrency, budget) |
718
826
  | `EvalResult` | Full eval output (items, summary, cost, duration) |
719
827
  | `EvalItem` | Per-item result (input, output, scores, scoreDetails, metadata, traces?) |
720
- | `EvalSummary` | Aggregate statistics (count, failures, scorer stats, timing) |
828
+ | `EvalSummary` | Aggregate statistics (count, failures, per-scorer stats incl. `scored`/`failed`/`skipped`, timing) |
721
829
  | `EvalComparison` | Comparison output (scorer deltas, CI, pRegression/pImprovement, n, per-side `runCount` / `partial?`, regressions, improvements). Both sides truncate to `min(baseline.length, candidate.length)` so means align with the paired bootstrap CI's sample |
722
830
  | `EvalComparisonPartial` | `{ completed, attempted }` set on a comparison side when the pooled run count is less than the original batch's planned count |
723
831
  | `EvalCompareOptions` | Options for `evalCompare()` (`thresholds`) |
724
832
  | `EvalRegression` / `EvalImprovement` | Per-item change record (itemIndex, scorer, scores, delta) |
725
- | `ScorerDetail` | Per-scorer detail (score, metadata, duration, cost) |
833
+ | `ScorerDetail` | Per-scorer detail (score, metadata, duration, cost, `skipped?`) |
726
834
  | `ScorerResult` | Scorer return type (`{ score, metadata?, cost? }`) |
727
- | `RescoreOptions` | Options for `rescore()` (`concurrency`) |
835
+ | `RescoreOptions` | Options for `rescore()` (`concurrency`, `scorerConcurrency`, `signal`) |
728
836
  | `MultiRunSummary` | Aggregated multi-run output (per-scorer mean/std/min/max) |
729
837
  | `BootstrapCIResult` | CI result (`{ lower, upper, mean, pRegression, pImprovement }`) |
730
838
  | `RunEvalOptions` | Options for `runEval()` (`onProgress`, `signal`, `captureTraces`) |