@axlsdk/eval 0.23.3 → 0.24.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
@@ -271,6 +271,9 @@ npx axl-eval ./evals/ --config ./axl.config.ts # use a specific runtime con
271
271
  npx axl-eval ./evals/ --conditions development # add Node.js import conditions (monorepo source exports)
272
272
  npx axl-eval ./evals/qa.eval.ts --concurrency 10 # override item concurrency for this run
273
273
  npx axl-eval ./evals/qa.eval.ts --scorers accuracy # run only named scorer(s) (single file)
274
+ npx axl-eval ./evals/qa.eval.ts --capture-requests # also record the requests Axl submitted
275
+ npx axl-eval ./evals/qa.eval.ts --budget 2.50 # stop admitting paid work once known spend reaches $2.50
276
+ npx axl-eval ./evals/qa.eval.ts --max-item-error-rate 0.1 # allow up to 10% of items to fail (default 5%; 1 disables)
274
277
  ```
275
278
 
276
279
  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).
@@ -308,9 +311,31 @@ npx axl-eval compare base.json cand.json --fail-on-regression --max-scorer-error
308
311
 
309
312
  Building a custom CI gate? Both decisions are exported as pure functions — `evaluateScorerErrorRateGate` and `evaluateScorerTolerance` (see the [API reference](../../docs/api-reference.md)).
310
313
 
314
+ #### Item error rate — on by default
315
+
316
+ A run that loses items in the *workflow* (a rate-limit storm, an incident, a tool bug) is scored over the survivors, so its means can look healthy while most of the dataset never ran. `axl-eval` therefore fails a run by default when **more than 5%** of its attempted items failed:
317
+
318
+ ```
319
+ Items: 89 completed, 250 failed — item error rate 73.7% (limit 5%)
320
+ Failure causes: 250 × 429 (openai)
321
+ [axl-eval] ITEM ERROR RATE EXCEEDED: qa.eval.ts — 250 of 339 attempted item(s) failed in the workflow (item error rate 73.7%), over the 5% limit; …
322
+ ```
323
+
324
+ The rate is `failed / (count − cancelled − budget_skipped − budget_interrupted)`: a cancelled or budget-stopped item is reported by its own gate and never counts here, and the gate fires on strictly `>`. Under `--runs N` every run is gated on its own, and the failing run is named. The artifact is still written.
325
+
326
+ | Knob | Where | Effect |
327
+ |------|-------|--------|
328
+ | `failOnItemErrorRate` (config field, default `0.05`) | **Source-side** — `runEval` | Sets the limit; `runEval` records `summary.itemErrorRate` whenever an item failed. An invalid value throws before the dataset loads. |
329
+ | `--max-item-error-rate <0..1>` (run flag) | CLI | Overrides the config for this invocation. `1` disables the gate (the rate is still printed). |
330
+ | `--max-item-error-rate <0..1>` (compare flag, default `0.05`) | **Gate-side** — `axl-eval compare` | Refuses to certify a side (any compared run) whose item error rate is over the limit, including legacy artifacts (rate derived from their items). Warns about a side that lost items within the limit. |
331
+
332
+ Each failed item records **why** on `item.failure` — `{ name, provider, status, retryable, requestId }` taken from the first `ProviderError` on the thrown value or its `cause` chain, or just the thrown `name` otherwise — and the table groups them on the `Failure causes:` line (`network` is status `0`, `other` is a non-provider failure), so a rate-limit storm reads differently from a bug. `failure` never records `ProviderError.body`; `item.error` keeps the error message as before, which for some providers can include error-response text.
333
+
334
+ `rescore` does not apply the gate — its failed items belong to the source run — and rejects the flag; `compare` re-applies the floor to a rescored artifact. Building your own gate? `evaluateItemErrorRateGate(baseline, candidate, limit?)` is exported.
335
+
311
336
  #### Total-workflow-wipeout guard
312
337
 
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.
338
+ Separate from the gates 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 still applies with the item gate disabled (`--max-item-error-rate 1`). It is distinct from `failOnScorerErrorRate` (which is about a flaky *scorer* and deliberately ignores a run with no scored items).
314
339
 
315
340
  ### Programmatic
316
341
 
@@ -381,7 +406,12 @@ const results = await runEval(
381
406
  );
382
407
  ```
383
408
 
384
- **Trust-boundary validation on workflow returns.** When your `executeWorkflow` callback returns `{ output, cost, metadata }`, the runner validates the untrusted fields before trusting them: `cost` must be a non-negative finite number, `metadata` must be a plain object (`Date`, `Map`, `Set`, class instances are rejected). Invalid values trigger a `console.warn` and fall back to trace-derived values from `runtime.trackExecution()`. A buggy workflow returning `{ cost: 'free' }` no longer silently NaN-poisons `totalCost`.
409
+ **A returned `cost` is a claim, not a total.** Spend is measured by the runtime, so a
410
+ `cost` your `executeWorkflow` callback returns never sets `item.cost` and never feeds
411
+ `totalCost`. It is preserved verbatim on `item.callerReport.cost` (and summarized on
412
+ `accounting.callerReported`) so you can still inspect it or compare it against what was
413
+ actually measured. The value is still validated — a non-negative finite number, or it is
414
+ dropped with a `console.warn` — so `{ cost: 'free' }` can poison nothing.
385
415
 
386
416
  **Additive item metadata.** Returning `{ output, metadata: { category: 'billing' } }`
387
417
  preserves tracked models, tokens, agent calls, and workflow attribution, with or without
@@ -435,8 +465,12 @@ console.log(results.summary.scorers['quality'].skipped); // 0 (items the applie
435
465
  console.log(results.summary.count); // 50 items
436
466
  console.log(results.summary.failures); // 2 workflow errors
437
467
  console.log(results.summary.timing); // { mean, min, max, p50, p95 } in ms — WORKFLOW wall clock
438
- console.log(results.summary.modelTiming); // per model: { calls, wireMs, queuedMs, retryMs, firstTokenMs? } — PROVIDER latency, per-CALL distributions
439
- console.log(results.totalCost); // 0.42 (workflow + scorer LLM costs)
468
+ console.log(results.summary.modelTiming); // per model: { calls, wireMs, queuedMs, retryMs, firstTokenMs?, rateLimitRetries? } — PROVIDER latency, per-CALL distributions
469
+ console.log(results.totalCost); // 0.42 — MEASURED spend (workflow + judges)
470
+ console.log(results.unpriced); // true when that total is only a lower bound
471
+ console.log(results.accounting.completeness); // 'complete' | 'incomplete' | 'unverified'
472
+ console.log(results.accounting.breakdown); // { generation, judging, external }
473
+ console.log(results.summary.coverage.items); // { completed, failed, cancelled, budget_skipped, budget_interrupted }
440
474
  console.log(results.metadata.models); // ["openai:gpt-4o"] (sorted by usage)
441
475
  console.log(results.metadata.modelCounts); // { "openai:gpt-4o": 48, "openai:gpt-4o-mini": 2 } (total LLM calls per model)
442
476
 
@@ -447,7 +481,7 @@ for (const item of results.items) {
447
481
  // Timing and cost
448
482
  console.log(item.duration); // workflow execution ms (tools, gates, queue and all)
449
483
  console.log(item.timing); // per model: { calls, queuedMs, retryMs, wireMs, firstTokenMs?, firstTokenCalls? } sums — absent if nothing was timed
450
- console.log(item.cost); // workflow LLM cost
484
+ console.log(item.cost); // measured generation spend (kept even if the case later threw)
451
485
  console.log(item.scorerCost); // total scorer cost for this item
452
486
 
453
487
  // Execution metadata (models, tokens, agent calls — captured by AxlRuntime)
@@ -546,6 +580,7 @@ Compare two runs to detect regressions and improvements. Runs must use the same
546
580
  npx axl-eval compare ./results/v1.json ./results/v2.json
547
581
  npx axl-eval compare v1.json v2.json --fail-on-regression # exit 1 if significant regressions
548
582
  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)
583
+ npx axl-eval compare v1.json v2.json --max-item-error-rate 0.2 # accept a side that lost up to 20% of its items (default 5%, always on; 1 disables)
549
584
  ```
550
585
 
551
586
  ```
@@ -675,14 +710,57 @@ export async function executeWorkflow(input: { raw: string }) {
675
710
 
676
711
  ### Cost tracking
677
712
 
678
- Cost is tracked automatically — the runner wraps each item with `runtime.trackCost()`. LLM scorer costs are also included in `totalCost` and count toward the `budget` limit.
713
+ Cost is measured automatically: the runner opens an accounting scope per run, per item,
714
+ and per scorer, so `totalCost` is what the runtime observed rather than a sum over trace
715
+ events. Judge costs are included and count toward the `budget` limit. Spend the runtime
716
+ could not price is reported as unknown (`unpriced`, with `accounting.reasons`) rather
717
+ than as zero — and because unknown spend cannot be enforced against, it does not consume
718
+ a budget either.
719
+
720
+ ### Captured requests (opt-in)
721
+
722
+ When a score looks wrong, the next question is what the model was actually
723
+ asked. `captureRequests` records the request Axl submitted for every model call
724
+ in the run — case turns, tool continuations, nested asks and LLM judges — beside
725
+ the result rather than inside it:
726
+
727
+ ```typescript
728
+ const runtime = new AxlRuntime({
729
+ diagnostics: { artifacts: { root: '.axl/artifacts' } },
730
+ });
731
+
732
+ const results = await runEval(config, executeWorkflow, runtime, {
733
+ captureRequests: true,
734
+ });
735
+
736
+ results.diagnostics; // { status, records, bytes, redaction, … }
737
+ results.items[0].diagnostics?.operations; // the generation calls behind this item
738
+ results.items[0].scoreDetails?.quality.diagnostics; // the judge's own calls
739
+ ```
679
740
 
680
- To override (e.g., exclude setup calls), return cost explicitly:
741
+ ```bash
742
+ npx axl-eval ./evals/qa.eval.ts --capture-requests --output ./results/v1.json
743
+ # also writes ./results/v1.requests.jsonl
744
+ ```
745
+
746
+ Capture is **off** by default, requires a configured artifact store (a run that
747
+ asks for it without one fails before the dataset loads), is bounded per record /
748
+ per run / per pending queue, and never delays a provider call. It is on a
749
+ different rail from accounting: a failing, truncated or redacted capture leaves
750
+ the numbers byte-identical and reports the loss on `diagnostics.status`. See
751
+ [observability.md](../../docs/observability.md#captured-requests-opt-in).
752
+
753
+ A returned cost can no longer override that measurement — it would let a workflow
754
+ understate what it spent. Report one anyway if it is useful to compare against:
681
755
 
682
756
  ```typescript
757
+ // Recorded on item.callerReport.cost; item.cost stays the measured figure.
683
758
  return { output, cost: ctx.totalCost };
684
759
  ```
685
760
 
761
+ To genuinely exclude work from a run's spend, do it outside the eval — for example warm
762
+ up a cache before calling `runEval` — rather than by subtracting after the fact.
763
+
686
764
  ### Common patterns
687
765
 
688
766
  **Concurrency** — process items in parallel (default: 5), and fan out each item's scorers (default: 5):
@@ -698,7 +776,17 @@ export default defineEval({
698
776
  });
699
777
  ```
700
778
 
701
- `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`.
779
+ `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).
780
+
781
+ **`budget` is a threshold, not a reservation.** Admission closes once known spend reaches
782
+ the limit: later cases become `budget_skipped`, later LLM scorers are skipped,
783
+ deterministic scorers still run, and a case whose next call is refused becomes
784
+ `budget_interrupted` while keeping the charge it already incurred. Calls already in
785
+ flight are allowed to settle, so the run can end slightly over —
786
+ `accounting.budget.knownOvershoot` says by how much, and the higher your `concurrency ×
787
+ scorerConcurrency`, the larger that can be. An invalid limit throws before the dataset is
788
+ even loaded. The CLI prints a distinct `[axl-eval] BUDGET STOPPED …` line and exits
789
+ non-zero without counting the stop as a model failure.
702
790
 
703
791
  **Per-item budget** — cap cost for a single workflow execution:
704
792
 
@@ -755,7 +843,9 @@ const rescored = await rescore(originalResult, [updatedScorer, newScorer], runti
755
843
 
756
844
  console.log(rescored.metadata.rescored); // true
757
845
  console.log(rescored.metadata.originalId); // original result ID
758
- console.log(rescored.totalCost); // scorer cost only
846
+ console.log(rescored.totalCost); // judging only — the original generation is not re-counted
847
+ console.log(rescored.accounting.scope); // 'rescore'
848
+ console.log(rescored.accounting.source); // { runId, generation } — the run this was scored from
759
849
  ```
760
850
 
761
851
  ## Multi-Run
@@ -837,6 +927,10 @@ const comparison = evalCompare(baselineRuns, candidateRuns);
837
927
  | Type | Description |
838
928
  |------|-------------|
839
929
  | `EvalConfig` | Eval definition (workflow, dataset, scorers, concurrency, scorerConcurrency, budget) |
930
+ | `readAccounting(result)` | A result's accounting, or an `unverified` record synthesized from a pre-0.24 artifact's `totalCost` |
931
+ | `aggregateAccounting(inputs)` | Fold several accounting records conservatively — sums known spend, unions reasons, worst completeness wins |
932
+ | `EvalAccounting` / `EvalCoverage` | Run accounting (scope, budget, source, caller claims) and the item / scorer outcome counts |
933
+ | `EvalItemOutcome` / `ScorerOutcome` | `completed \| failed \| cancelled \| budget_skipped \| budget_interrupted`, and the scorer equivalent with `scored` / `skipped` |
840
934
  | `EvalResult` | Full eval output (items, summary, cost, duration) |
841
935
  | `EvalItem` | Per-item result (input, output, scores, scoreDetails, metadata, traces?, `timing?`) |
842
936
  | `EvalSummary` | Aggregate statistics (count, failures, per-scorer stats incl. `scored`/`failed`/`skipped`, wall-clock `timing`, per-model `modelTiming?`) |