@axlsdk/eval 0.17.9 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -5,6 +5,27 @@ type DatasetItem<TInput, TAnnotations = undefined> = {
5
5
  input: TInput;
6
6
  annotations?: TAnnotations;
7
7
  };
8
+ /**
9
+ * What to do when a dataset item's `annotations` contain keys that are absent
10
+ * from the `annotations` schema.
11
+ *
12
+ * Zod object schemas strip unknown keys by default, so an annotation field
13
+ * that isn't declared in the schema is silently dropped before it ever reaches
14
+ * a scorer. A scorer that reads such a field sees `undefined` and quietly
15
+ * degrades to a no-op — the eval still "passes", so the bug is invisible. This
16
+ * is especially easy to hit with file-based datasets, where the JSON is never
17
+ * type-checked.
18
+ *
19
+ * - `'warn'` (default) — log a single `console.warn` per dataset listing the
20
+ * dropped key paths. Non-breaking; surfaces the contract violation.
21
+ * - `'error'` — throw, listing the dropped key paths. The strict path for CI.
22
+ * - `'ignore'` — preserve the silent-strip behavior (skips detection entirely).
23
+ *
24
+ * Only `annotations` are checked, not `input`: stripped input fields flow to
25
+ * the workflow where they surface, whereas stripped annotations vanish into a
26
+ * silent scorer.
27
+ */
28
+ type ExtraKeyPolicy = 'warn' | 'error' | 'ignore';
8
29
  type DatasetConfig<TInput extends z.ZodType, TAnnotations extends z.ZodType = z.ZodType> = {
9
30
  name: string;
10
31
  schema: TInput;
@@ -13,11 +34,28 @@ type DatasetConfig<TInput extends z.ZodType, TAnnotations extends z.ZodType = z.
13
34
  file?: string;
14
35
  /** Base directory for resolving relative file paths. Defaults to cwd. */
15
36
  basePath?: string;
37
+ /**
38
+ * How to handle annotation keys not present in the `annotations` schema
39
+ * (Zod strips them by default). Defaults to `'warn'`. See {@link ExtraKeyPolicy}.
40
+ */
41
+ onExtraAnnotationKeys?: ExtraKeyPolicy;
16
42
  };
17
43
  type Dataset<TInput = unknown, TAnnotations = unknown> = {
18
44
  readonly name: string;
19
45
  readonly schema: z.ZodType;
20
46
  readonly annotationsSchema?: z.ZodType;
47
+ /**
48
+ * Annotation key paths the schema dropped during the most recent
49
+ * `getItems()` call (empty when none were dropped, or when
50
+ * `onExtraAnnotationKeys: 'ignore'`). Populated as a side effect of
51
+ * `getItems()` — read it synchronously right after awaiting, on the same
52
+ * instance; a later `getItems()` overwrites it (calling `getItems()`
53
+ * concurrently on one shared dataset is unsupported). The eval runner folds
54
+ * this into `EvalResult.metadata.droppedAnnotationKeys` so any consumer (the
55
+ * CLI logs it; UIs can render it) sees the same signal. Optional so
56
+ * hand-rolled `Dataset`-shaped objects (test harnesses) remain valid.
57
+ */
58
+ readonly droppedAnnotationKeys?: readonly string[];
21
59
  getItems(): Promise<DatasetItem<TInput, TAnnotations>[]>;
22
60
  };
23
61
  declare function dataset<TInput extends z.ZodType, TAnnotations extends z.ZodType>(config: DatasetConfig<TInput, TAnnotations>): Dataset<z.infer<TInput>, z.infer<TAnnotations>>;
@@ -41,16 +79,51 @@ type ScorerContext = {
41
79
  signal?: AbortSignal;
42
80
  };
43
81
  type ScorerFn<TOutput, TInput, TAnnotations> = (output: TOutput, input: TInput, annotations?: TAnnotations) => number | ScorerResult;
82
+ /**
83
+ * Applicability predicate for a conditional scorer. Returns `true` if this
84
+ * scorer should run for the given item, `false` to skip it.
85
+ *
86
+ * The eval runner runs every scorer against every item, so a scorer that only
87
+ * applies to a subset (a refusal judge for refusal-expected items, a constraint
88
+ * judge for constrained items, …) declares its scope here. A skipped item is
89
+ * counted as NEITHER `scored` NOR `failed` — it's excluded from the mean AND
90
+ * from the failure-rate denominator, so a conditional scorer stays honest to
91
+ * both. The predicate runs BEFORE the scorer body, so an inapplicable
92
+ * `llmScorer` never makes its provider call.
93
+ *
94
+ * Signature mirrors {@link ScorerFn} (`output, input, annotations`). Applicability
95
+ * usually keys off `input`/`annotations` (e.g. a flag or exercise type), but
96
+ * `output` is available too (e.g. "only score outputs that refused"). A
97
+ * predicate that THROWS is a bug, not a skip — it's recorded as a scorer
98
+ * failure, never silently swallowed.
99
+ *
100
+ * The contract is `=> boolean`: return `true` to run, `false` to skip. The
101
+ * runtime is defensively lenient — it coerces with `!verdict`, so a stray falsy
102
+ * value (a predicate that forgot to `return`) skips rather than crashes — but
103
+ * authors should return a real boolean. When deriving from a truthy value,
104
+ * coerce it yourself: `(o, i, a) => !!a.constraints?.length`, not
105
+ * `=> a.constraints?.length` (which is `number | undefined`, a type error). A
106
+ * predicate that accidentally never returns therefore skips EVERY item — but
107
+ * that surfaces conspicuously as a non-zero `skipped` count (the Studio "N/A"
108
+ * chip), not as a silently green mean.
109
+ */
110
+ type ScorerApplies<TOutput, TInput, TAnnotations> = (output: TOutput, input: TInput, annotations?: TAnnotations) => boolean;
44
111
  type ScorerConfig<TOutput = unknown, TInput = unknown, TAnnotations = unknown> = {
45
112
  name: string;
46
113
  description: string;
47
114
  score: ScorerFn<TOutput, TInput, TAnnotations>;
115
+ /** Optional applicability predicate — see {@link ScorerApplies}. When it
116
+ * returns `false`, the scorer is skipped for that item (counted as neither
117
+ * scored nor failed). Omit to run on every item (the default). */
118
+ applies?: ScorerApplies<TOutput, TInput, TAnnotations>;
48
119
  };
49
120
  type Scorer<TOutput = unknown, TInput = unknown, TAnnotations = unknown> = {
50
121
  readonly name: string;
51
122
  readonly description: string;
52
123
  readonly isLlm: boolean;
53
124
  score(output: TOutput, input: TInput, annotations?: TAnnotations, context?: ScorerContext): number | ScorerResult | Promise<number | ScorerResult>;
125
+ /** See {@link ScorerApplies}. Evaluated by the runner before {@link score}. */
126
+ applies?: ScorerApplies<TOutput, TInput, TAnnotations>;
54
127
  };
55
128
  declare function scorer<TOutput = unknown, TInput = unknown, TAnnotations = unknown>(config: ScorerConfig<TOutput, TInput, TAnnotations>): Scorer<TOutput, TInput, TAnnotations>;
56
129
 
@@ -79,6 +152,11 @@ type LlmScorerConfig = {
79
152
  stop?: string[];
80
153
  /** Provider-specific escape hatch — merged last into the raw API request. */
81
154
  providerOptions?: Record<string, unknown>;
155
+ /** Optional applicability predicate — see {@link ScorerApplies}. When it
156
+ * returns `false`, the judge is skipped for that item and NO provider call is
157
+ * made (the conditional-LLM-judge cost/rate-limit win). The item counts as
158
+ * neither scored nor failed. */
159
+ applies?: ScorerApplies<unknown, unknown, unknown>;
82
160
  };
83
161
  declare function llmScorer(config: LlmScorerConfig): Scorer;
84
162
 
@@ -86,10 +164,53 @@ type EvalConfig = {
86
164
  workflow: string;
87
165
  dataset: Dataset<unknown, unknown>;
88
166
  scorers: Scorer<unknown, unknown, unknown>[];
167
+ /** Item-level worker-pool size (how many dataset items run in parallel). Default 5. */
89
168
  concurrency?: number;
169
+ /**
170
+ * Per-item scorer fan-out — how many of an item's scorers run concurrently.
171
+ * Default 5 (matches `concurrency`; scorers are parallel-by-default). The
172
+ * worst-case number of concurrent scorer calls is `concurrency × scorerConcurrency`,
173
+ * so lower `concurrency` if a rate-limited judge model needs a tighter ceiling.
174
+ */
175
+ scorerConcurrency?: number;
90
176
  budget?: string;
177
+ /**
178
+ * Opt-in source-side trust gate. When set (0–1), `runEval` flags the run as
179
+ * `summary.degraded` if any scorer's failure rate exceeds tolerance, and the
180
+ * CLI exits non-zero. Enforcement is **type-aware**: deterministic scorers
181
+ * tolerate ZERO failures (a deterministic scorer that throws is a bug, not
182
+ * noise), while LLM scorers use this rate (a flaky judge / rate-limit storm).
183
+ *
184
+ * Failure rate = `failed / (scored + failed)` per scorer, where `scored` is
185
+ * the number of items that produced a valid numeric score and `failed` is the
186
+ * number whose scorer ran and threw / returned out-of-range. `0` means "any
187
+ * LLM failure degrades the run". Invalid values (non-finite, <0, >1) are
188
+ * ignored with a `console.warn`.
189
+ *
190
+ * Distinct from the gate-side `--max-scorer-error-rate` compare flag: this one
191
+ * fires at run/produce time (catch a thinned sample before it's saved), the
192
+ * other at consume/gate time (refuse to certify a thinned baseline/candidate).
193
+ */
194
+ failOnScorerErrorRate?: number;
91
195
  metadata?: Record<string, unknown>;
92
196
  };
197
+ /**
198
+ * One scorer whose failure rate tripped `EvalConfig.failOnScorerErrorRate`.
199
+ * Surfaced on `EvalSummary.degraded` so consumers (CLI exit code, Studio
200
+ * banner) can refuse to trust the mean without re-deriving the rate.
201
+ */
202
+ type DegradedScorer = {
203
+ scorer: string;
204
+ /** Observed failure rate `failed / (scored + failed)` (0 when nothing ran). */
205
+ rate: number;
206
+ /** The tolerance that was exceeded (0 for deterministic scorers). */
207
+ limit: number;
208
+ type: 'llm' | 'deterministic';
209
+ /** Items that produced a valid numeric score. */
210
+ scored: number;
211
+ /** Items whose scorer ran and failed (threw / out-of-range). */
212
+ failed: number;
213
+ };
93
214
  type EvalResult = {
94
215
  id: string;
95
216
  /**
@@ -108,6 +229,13 @@ type EvalResult = {
108
229
  * that need a single "primary" workflow should use `workflows[0]`.
109
230
  * - `scorerTypes: Record<string, 'llm' | 'deterministic'>`
110
231
  * - `runGroupId?: string`, `runIndex?: number` for multi-run groups
232
+ * - `droppedAnnotationKeys?: string[]` — annotation key paths the dataset
233
+ * schema stripped (present only when non-empty). Mirrors the dataset's
234
+ * `console.warn`; lets any consumer surface the same signal.
235
+ * - `scorerFiltered?: boolean` + `scorersRun?: string[]` — set when the CLI
236
+ * `--scorers` flag ran a subset of scorers. A guard so a filtered run isn't
237
+ * silently used as a full baseline (`compare` warns / refuses to gate on it).
238
+ * Distinct from the multi-run "partial" concept (see {@link EvalComparisonPartial}).
111
239
  */
112
240
  metadata: Record<string, unknown>;
113
241
  timestamp: string;
@@ -121,6 +249,14 @@ type ScorerDetail = {
121
249
  metadata?: Record<string, unknown>;
122
250
  duration?: number;
123
251
  cost?: number;
252
+ /**
253
+ * `true` when the scorer's `applies` predicate returned `false` for this item,
254
+ * so the scorer was deliberately skipped (NOT run). Distinct from a `null`
255
+ * score WITH a `duration` (ran-and-failed) and from a `null` score with
256
+ * neither field (skipped by cancellation). A skipped item is excluded from the
257
+ * mean AND from the failure-rate denominator — see `scorerCounts`.
258
+ */
259
+ skipped?: boolean;
124
260
  };
125
261
  type EvalItem = {
126
262
  input: unknown;
@@ -150,6 +286,29 @@ type EvalSummary = {
150
286
  max: number;
151
287
  p50: number;
152
288
  p95: number;
289
+ /**
290
+ * Number of items that produced a valid numeric score — the sample size
291
+ * the `mean` actually covers. Optional so pre-existing artifacts (and
292
+ * hand-rolled summaries) stay valid; absent ⇒ recompute from `items`.
293
+ * Named `scored` (NOT `n`) to avoid collision with
294
+ * `EvalComparison.scorers[].n` (paired-diff count).
295
+ */
296
+ scored?: number;
297
+ /**
298
+ * Number of items whose scorer RAN and failed (threw or returned
299
+ * out-of-range). Distinct from items skipped by cancellation, which land
300
+ * in neither bucket — so `scored + failed` is the honest "attempted"
301
+ * denominator and may be `<` the eligible item count. A non-zero `failed`
302
+ * means the `mean` was computed over a thinned sample.
303
+ */
304
+ failed?: number;
305
+ /**
306
+ * Number of items for which this scorer's `applies` predicate returned
307
+ * `false` — deliberately skipped, NOT run. Counted in neither `scored` nor
308
+ * `failed`, so it never inflates the failure rate. Optional so pre-existing
309
+ * artifacts stay valid; absent ⇒ recompute from `items` (0 if no skips).
310
+ */
311
+ skipped?: number;
153
312
  }>;
154
313
  timing?: {
155
314
  mean: number;
@@ -158,6 +317,14 @@ type EvalSummary = {
158
317
  p50: number;
159
318
  p95: number;
160
319
  };
320
+ /**
321
+ * Populated by `runEval` ONLY when `EvalConfig.failOnScorerErrorRate` is set
322
+ * and one or more scorers exceeded tolerance. `runEval` never throws on this
323
+ * — it returns the (still-useful) result with this flag set, and the CLI /
324
+ * consumer decides whether to fail. Absent / empty ⇒ no degradation gate
325
+ * tripped (either not configured or all scorers within tolerance).
326
+ */
327
+ degraded?: DegradedScorer[];
161
328
  };
162
329
  /**
163
330
  * Sample-size context for one side of a comparison.
@@ -214,8 +381,39 @@ type EvalComparison = {
214
381
  significant?: boolean;
215
382
  pRegression?: number;
216
383
  pImprovement?: number;
217
- /** Number of paired item differences used for CI computation. */
384
+ /**
385
+ * Paired sample size — the count of items scored on BOTH sides (the only
386
+ * items a difference exists for). Always set, including `0`/`1`. The CI /
387
+ * `significant` / `pRegression` / `pImprovement` fields are populated only
388
+ * when `n >= 2`. NOTE the asymmetry: `delta` is the difference of the two
389
+ * INDEPENDENT per-side means (each over `{baseline,candidate}Scored`
390
+ * items), whereas the CI is paired over these `n` — so when `n` is much
391
+ * smaller than the per-side scored counts (different skips/failures per
392
+ * side), the delta and the CI rest on different samples.
393
+ */
218
394
  n?: number;
395
+ /**
396
+ * Per-side scorer success/failure counts over the SAME truncated pool the
397
+ * means and CI are computed from (not a separately-recomputed raw set), so
398
+ * a gate reads numbers consistent with what the table displays. Raw counts
399
+ * (consumer divides) keep this truncation-consistent. A non-zero
400
+ * `*Failed` means that side's mean rests on a thinned sample — the gate-side
401
+ * `--max-scorer-error-rate` flag refuses to certify when it's over tolerance.
402
+ */
403
+ baselineScored?: number;
404
+ baselineFailed?: number;
405
+ candidateScored?: number;
406
+ candidateFailed?: number;
407
+ /**
408
+ * Per-side `applies`-skipped (N/A) counts over the same truncated pool.
409
+ * Skips are excluded from both the mean and the failure-rate denominator,
410
+ * so they never move the delta — but a side that skipped items scored a
411
+ * different subset than a side that didn't, so surfacing the count lets a
412
+ * consumer explain a delta that's really a sample mismatch rather than a
413
+ * regression.
414
+ */
415
+ baselineSkipped?: number;
416
+ candidateSkipped?: number;
219
417
  }>;
220
418
  timing?: {
221
419
  baselineMean: number;
@@ -297,6 +495,48 @@ declare function runEval(config: EvalConfig, executeWorkflow: (input: unknown, r
297
495
  }>, runtime: AxlRuntime, options?: RunEvalOptions): Promise<EvalResult>;
298
496
 
299
497
  declare function evalCompare(baseline: EvalResult | EvalResult[], candidate: EvalResult | EvalResult[], options?: EvalCompareOptions): EvalComparison;
498
+ /**
499
+ * Decide whether a comparison should be REFUSED under a gate-side scorer
500
+ * failure-rate limit (`axl-eval compare --max-scorer-error-rate`). Pure and
501
+ * testable — returns a human-readable refusal reason, or `null` to allow.
502
+ *
503
+ * Type-aware via the baseline's `scorerTypes` metadata: a scorer EXPLICITLY
504
+ * marked `'deterministic'` tolerates ZERO failures (a deterministic throw is a
505
+ * bug); everything else — including an unknown/absent type on a pre-`scorerTypes`
506
+ * artifact — uses the LLM `limit`. Defaulting unknown types to LLM (the
507
+ * permissive tier) is deliberate: classifying an old artifact's scorers as
508
+ * deterministic would hard-fail them on the first flake, contradicting the
509
+ * `--max-scorer-error-rate <n>` the user passed. Scorer NAMES are identical
510
+ * across sides (enforced by `evalCompare`), so reading types from the baseline
511
+ * is sufficient.
512
+ *
513
+ * Also refuses a scorer that produced ZERO scored items on a side — a mean over
514
+ * an empty sample can't be certified. Counts come from the same truncated pool
515
+ * `evalCompare` used for the means/CI (`baseline/candidateScored/Failed`).
516
+ */
517
+ declare function evaluateScorerErrorRateGate(comparison: EvalComparison, maxScorerErrorRate: number): string | null;
518
+
519
+ /** A scorer's failure-rate verdict against a tolerance — see {@link evaluateScorerTolerance}. */
520
+ type ScorerToleranceVerdict = {
521
+ /** `scored + failed` — the attempted denominator (excludes cancelled/never-run). */
522
+ attempted: number;
523
+ /** `failed / attempted`, or `0` when nothing was attempted. */
524
+ rate: number;
525
+ /** Over tolerance AND something was actually attempted. */
526
+ exceeds: boolean;
527
+ /** Nothing was attempted (`attempted === 0`) — no basis to certify a rate. */
528
+ zeroSample: boolean;
529
+ };
530
+ /**
531
+ * Single source of truth for the type-aware failure-rate decision, shared by the
532
+ * source-side gate (`runEval` → `summary.degraded`) and the gate-side compare
533
+ * gate (`evalCompare` consumers). Deterministic scorers tolerate ZERO failures
534
+ * (a deterministic throw is a bug, not noise); LLM scorers tolerate up to `limit`
535
+ * (`failed / attempted`). `exceeds` is never true when nothing was attempted —
536
+ * callers decide their own policy for `zeroSample` (the runner skips it; the
537
+ * compare gate refuses to certify a zero-sample scorer).
538
+ */
539
+ declare function evaluateScorerTolerance(scored: number, failed: number, type: 'llm' | 'deterministic', limit: number): ScorerToleranceVerdict;
300
540
 
301
541
  type BootstrapCIResult = {
302
542
  /** Lower bound of the confidence interval. */
@@ -323,7 +563,12 @@ declare function pairedBootstrapCI(differences: number[], options?: {
323
563
  }): BootstrapCIResult;
324
564
 
325
565
  type RescoreOptions = {
566
+ /** Item-level worker-pool size (how many saved items rescore in parallel). Default 5. */
326
567
  concurrency?: number;
568
+ /** Per-item scorer fan-out (how many scorers run concurrently within one item).
569
+ * Default 5 — matches `EvalConfig.scorerConcurrency`. Worst-case concurrent
570
+ * judge calls is `concurrency × scorerConcurrency`. */
571
+ scorerConcurrency?: number;
327
572
  /** Abort signal forwarded into ScorerContext so in-flight LLM scorer calls can
328
573
  * be cancelled mid-flight. Also checked between items to short-circuit
329
574
  * remaining work. Mirrors `RunEvalOptions.signal`. */
@@ -353,6 +598,13 @@ type MultiRunSummary = {
353
598
  std: number;
354
599
  min: number;
355
600
  max: number;
601
+ /** Total valid scores summed across all runs in the group. */
602
+ scored?: number;
603
+ /** Total ran-and-failed scorer invocations summed across all runs. */
604
+ failed?: number;
605
+ /** Total `applies`-skipped (N/A) items summed across all runs. Excluded
606
+ * from both the mean and the failure-rate denominator. */
607
+ skipped?: number;
356
608
  }>;
357
609
  timing?: {
358
610
  mean: number;
@@ -364,4 +616,4 @@ type MultiRunSummary = {
364
616
  */
365
617
  declare function aggregateRuns(runs: EvalResult[]): MultiRunSummary;
366
618
 
367
- export { type BootstrapCIResult, type Dataset, type DatasetConfig, type DatasetItem, type EvalCompareOptions, type EvalComparison, type EvalComparisonPartial, type EvalConfig, type EvalImprovement, type EvalItem, type EvalProgressEvent, type EvalRegression, type EvalResult, type EvalSummary, type LlmScorerConfig, type MultiRunSummary, type RescoreOptions, type RunEvalOptions, type Scorer, type ScorerConfig, type ScorerContext, type ScorerDetail, type ScorerFn, type ScorerResult, aggregateRuns, dataset, defineEval, evalCompare, llmScorer, normalizeScorerResult, pairedBootstrapCI, rescore, runEval, scorer };
619
+ export { type BootstrapCIResult, type Dataset, type DatasetConfig, type DatasetItem, type DegradedScorer, type EvalCompareOptions, type EvalComparison, type EvalComparisonPartial, type EvalConfig, type EvalImprovement, type EvalItem, type EvalProgressEvent, type EvalRegression, type EvalResult, type EvalSummary, type ExtraKeyPolicy, type LlmScorerConfig, type MultiRunSummary, type RescoreOptions, type RunEvalOptions, type Scorer, type ScorerApplies, type ScorerConfig, type ScorerContext, type ScorerDetail, type ScorerFn, type ScorerResult, type ScorerToleranceVerdict, aggregateRuns, dataset, defineEval, evalCompare, evaluateScorerErrorRateGate, evaluateScorerTolerance, llmScorer, normalizeScorerResult, pairedBootstrapCI, rescore, runEval, scorer };
package/dist/index.js CHANGED
@@ -1,21 +1,84 @@
1
1
  import {
2
2
  aggregateRuns,
3
+ attachScorerErrorCost,
3
4
  evalCompare,
5
+ evaluateScorerErrorRateGate,
6
+ evaluateScorerTolerance,
4
7
  normalizeScorerResult,
5
8
  pairedBootstrapCI,
6
9
  rescore,
7
10
  runEval,
8
11
  scorer
9
- } from "./chunk-NETD4AUB.js";
12
+ } from "./chunk-66EYSYQS.js";
10
13
 
11
14
  // src/dataset.ts
12
15
  import { readFile } from "fs/promises";
13
16
  import * as path from "path";
17
+ function isPlainObject(value) {
18
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
19
+ const proto = Object.getPrototypeOf(value);
20
+ return proto === Object.prototype || proto === null;
21
+ }
22
+ function isObjectSchema(schema) {
23
+ const shape = schema.shape;
24
+ return typeof shape === "object" && shape !== null;
25
+ }
26
+ function collectDroppedKeys(raw, parsed, prefix = "") {
27
+ if (Array.isArray(raw) && Array.isArray(parsed)) {
28
+ const dropped = [];
29
+ const n = Math.min(raw.length, parsed.length);
30
+ for (let i = 0; i < n; i++) {
31
+ dropped.push(...collectDroppedKeys(raw[i], parsed[i], `${prefix}[${i}]`));
32
+ }
33
+ return dropped;
34
+ }
35
+ if (isPlainObject(raw) && isPlainObject(parsed)) {
36
+ const dropped = [];
37
+ for (const key of Object.keys(raw)) {
38
+ const path2 = prefix ? `${prefix}.${key}` : key;
39
+ if (!(key in parsed)) {
40
+ dropped.push(path2);
41
+ } else {
42
+ dropped.push(...collectDroppedKeys(raw[key], parsed[key], path2));
43
+ }
44
+ }
45
+ return dropped;
46
+ }
47
+ return [];
48
+ }
14
49
  function dataset(config) {
50
+ const policy = config.onExtraAnnotationKeys ?? "warn";
51
+ function parseItem(item, droppedAcc) {
52
+ const input = config.schema.parse(item.input);
53
+ if (item.annotations == null || !config.annotations) {
54
+ return { input, annotations: item.annotations };
55
+ }
56
+ const annotations = config.annotations.parse(item.annotations);
57
+ if (policy !== "ignore" && isObjectSchema(config.annotations)) {
58
+ for (const p of collectDroppedKeys(item.annotations, annotations)) droppedAcc.add(p);
59
+ }
60
+ return { input, annotations };
61
+ }
62
+ function reportDropped(dropped) {
63
+ if (dropped.size === 0) return;
64
+ const keys = [...dropped].join(", ");
65
+ if (policy === "error") {
66
+ throw new Error(
67
+ `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.`
68
+ );
69
+ }
70
+ console.warn(
71
+ `[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.`
72
+ );
73
+ }
74
+ let lastDroppedAnnotationKeys = [];
15
75
  return {
16
76
  name: config.name,
17
77
  schema: config.schema,
18
78
  annotationsSchema: config.annotations,
79
+ get droppedAnnotationKeys() {
80
+ return [...lastDroppedAnnotationKeys];
81
+ },
19
82
  async getItems() {
20
83
  if (config.items && config.file) {
21
84
  throw new Error('Dataset config error: "items" and "file" are mutually exclusive');
@@ -23,19 +86,19 @@ function dataset(config) {
23
86
  if (!config.items && !config.file) {
24
87
  throw new Error('Dataset config error: either "items" or "file" must be provided');
25
88
  }
89
+ let rawItems;
26
90
  if (config.items) {
27
- return config.items.map((item) => ({
28
- input: config.schema.parse(item.input),
29
- annotations: item.annotations && config.annotations ? config.annotations.parse(item.annotations) : item.annotations
30
- }));
91
+ rawItems = config.items;
92
+ } else {
93
+ const resolvedPath = path.resolve(config.basePath ?? process.cwd(), config.file);
94
+ const content = await readFile(resolvedPath, "utf-8");
95
+ rawItems = JSON.parse(content);
31
96
  }
32
- const resolvedPath = path.resolve(config.basePath ?? process.cwd(), config.file);
33
- const content = await readFile(resolvedPath, "utf-8");
34
- const items = JSON.parse(content);
35
- return items.map((item) => ({
36
- input: config.schema.parse(item.input),
37
- annotations: item.annotations && config.annotations ? config.annotations.parse(item.annotations) : item.annotations
38
- }));
97
+ const dropped = /* @__PURE__ */ new Set();
98
+ const parsed = rawItems.map((item) => parseItem(item, dropped));
99
+ lastDroppedAnnotationKeys = [...dropped];
100
+ reportDropped(dropped);
101
+ return parsed;
39
102
  }
40
103
  };
41
104
  }
@@ -50,6 +113,7 @@ function llmScorer(config) {
50
113
  name: config.name,
51
114
  description: config.description,
52
115
  isLlm: true,
116
+ applies: config.applies,
53
117
  async score(output, input, annotations, context) {
54
118
  if (!context?.resolveProvider) {
55
119
  throw new Error(
@@ -96,16 +160,14 @@ ${JSON.stringify(annotations, null, 2)}` : "",
96
160
  const parsed = JSON.parse(extractJson(response.content));
97
161
  validated = schema.parse(parsed);
98
162
  } catch (err) {
99
- if (err && typeof err === "object") {
100
- err.cost = responseCost;
101
- }
163
+ attachScorerErrorCost(err, responseCost);
102
164
  if (err && typeof err === "object" && "issues" in err && Array.isArray(err.issues)) {
103
165
  const issues = err.issues;
104
166
  const messages = issues.map((i) => `${i.path.length ? `${i.path.join(".")}: ` : ""}${i.message}`).join("; ");
105
167
  const error = new Error(
106
168
  `LLM scorer "${config.name}" returned an invalid response: ${messages}`
107
169
  );
108
- error.cost = responseCost;
170
+ attachScorerErrorCost(error, responseCost);
109
171
  throw error;
110
172
  }
111
173
  throw err;
@@ -129,6 +191,8 @@ export {
129
191
  dataset,
130
192
  defineEval,
131
193
  evalCompare,
194
+ evaluateScorerErrorRateGate,
195
+ evaluateScorerTolerance,
132
196
  llmScorer,
133
197
  normalizeScorerResult,
134
198
  pairedBootstrapCI,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/dataset.ts","../src/llm-scorer.ts","../src/define-eval.ts"],"sourcesContent":["import type { z } from 'zod';\nimport { readFile } from 'node:fs/promises';\nimport * as path from 'node:path';\n\nexport type DatasetItem<TInput, TAnnotations = undefined> = {\n input: TInput;\n annotations?: TAnnotations;\n};\n\nexport type DatasetConfig<TInput extends z.ZodType, TAnnotations extends z.ZodType = z.ZodType> = {\n name: string;\n schema: TInput;\n annotations?: TAnnotations;\n items?: DatasetItem<z.infer<TInput>, z.infer<TAnnotations>>[];\n file?: string;\n /** Base directory for resolving relative file paths. Defaults to cwd. */\n basePath?: string;\n};\n\nexport type Dataset<TInput = unknown, TAnnotations = unknown> = {\n readonly name: string;\n readonly schema: z.ZodType;\n readonly annotationsSchema?: z.ZodType;\n getItems(): Promise<DatasetItem<TInput, TAnnotations>[]>;\n};\n\nexport function dataset<TInput extends z.ZodType, TAnnotations extends z.ZodType>(\n config: DatasetConfig<TInput, TAnnotations>,\n): Dataset<z.infer<TInput>, z.infer<TAnnotations>> {\n return {\n name: config.name,\n schema: config.schema,\n annotationsSchema: config.annotations,\n async getItems() {\n if (config.items && config.file) {\n throw new Error('Dataset config error: \"items\" and \"file\" are mutually exclusive');\n }\n if (!config.items && !config.file) {\n throw new Error('Dataset config error: either \"items\" or \"file\" must be provided');\n }\n if (config.items) {\n return config.items.map((item) => ({\n input: config.schema.parse(item.input),\n annotations:\n item.annotations && config.annotations\n ? config.annotations.parse(item.annotations)\n : item.annotations,\n }));\n }\n const resolvedPath = path.resolve(config.basePath ?? process.cwd(), config.file!);\n const content = await readFile(resolvedPath, 'utf-8');\n const items = JSON.parse(content) as DatasetItem<z.infer<TInput>, z.infer<TAnnotations>>[];\n return items.map((item) => ({\n input: config.schema.parse(item.input),\n annotations:\n item.annotations && config.annotations\n ? config.annotations.parse(item.annotations)\n : item.annotations,\n }));\n },\n };\n}\n","import { z } from 'zod';\nimport type { Scorer, ScorerContext, ScorerResult } from './scorer.js';\nimport { extractJson, zodToJsonSchema } from '@axlsdk/axl';\nimport type { Effort } from '@axlsdk/axl';\n\nexport type LlmScorerConfig = {\n name: string;\n description: string;\n model: string;\n system: string;\n schema?: z.ZodType<{ score: number; [key: string]: unknown }>;\n /** Sampling temperature. Default: 0.2 for deterministic judging. */\n temperature?: number;\n /** Cap on response tokens. Falls back to provider default if unset. */\n maxTokens?: number;\n /** Reasoning effort — `'high'` or `'max'` materially improves judge calibration on\n * reasoning-capable models (gpt-5.x, Opus 4.6+, Gemini 3.x). See `Effort` for\n * provider-specific mapping. */\n effort?: Effort;\n /** Precise thinking-token budget override. See `ChatOptions.thinkingBudget`. */\n thinkingBudget?: number;\n /** Surface reasoning summaries in the judge's response (Gemini + OpenAI Responses). */\n includeThoughts?: boolean;\n /** Stop sequences forwarded to the provider. */\n stop?: string[];\n /** Provider-specific escape hatch — merged last into the raw API request. */\n providerOptions?: Record<string, unknown>;\n};\n\nexport function llmScorer(config: LlmScorerConfig): Scorer {\n // Resolve schema and its JSON representation once at construction time —\n // both are fixed for the lifetime of this scorer instance.\n const schema: z.ZodType<{ score: number; [key: string]: unknown }> =\n config.schema ?? z.object({ score: z.number().min(0).max(1), reasoning: z.string() });\n const schemaJson = JSON.stringify(zodToJsonSchema(schema), null, 2);\n\n return {\n name: config.name,\n description: config.description,\n isLlm: true,\n async score(\n output: unknown,\n input: unknown,\n annotations?: unknown,\n context?: ScorerContext,\n ): Promise<ScorerResult> {\n if (!context?.resolveProvider) {\n throw new Error(\n `LLM scorer \"${config.name}\" has no provider. ` +\n `Ensure you are running via runEval() with a real AxlRuntime instance.`,\n );\n }\n\n const { provider, model } = context.resolveProvider(config.model);\n\n const prompt = [\n `Evaluate the following output.`,\n ``,\n `## Input`,\n `${JSON.stringify(input, null, 2)}`,\n annotations\n ? `\\n## Annotations (Ground Truth)\\n${JSON.stringify(annotations, null, 2)}`\n : '',\n ``,\n `## Output to Evaluate`,\n `${JSON.stringify(output, null, 2)}`,\n ``,\n `Respond with valid JSON matching this schema:`,\n schemaJson,\n ].join('\\n');\n\n // Field-by-field forwarding (deliberately NOT spreading `config`):\n // `LlmScorerConfig` has scorer-specific fields (`name`, `description`,\n // `schema`, `system`, `model` — the URI, not the chat-options `model`\n // which is the post-resolveProvider stripped form) that must not leak\n // into ChatOptions. A spread would silently pass them through to\n // provider.chat and either be ignored or, worse, override our hardcoded\n // `responseFormat`. Keep this explicit.\n const response = await provider.chat(\n [\n { role: 'system', content: config.system },\n { role: 'user', content: prompt },\n ],\n {\n model,\n temperature: config.temperature ?? 0.2,\n maxTokens: config.maxTokens,\n effort: config.effort,\n thinkingBudget: config.thinkingBudget,\n includeThoughts: config.includeThoughts,\n stop: config.stop,\n providerOptions: config.providerOptions,\n responseFormat: { type: 'json_object' },\n signal: context.signal,\n },\n );\n\n const responseCost = response.cost;\n\n let validated: { score: number; [key: string]: unknown };\n try {\n const parsed = JSON.parse(extractJson(response.content));\n validated = schema.parse(parsed) as { score: number; [key: string]: unknown };\n } catch (err) {\n // Attach cost to all errors so the runner can capture it even on failure.\n if (err && typeof err === 'object') {\n (err as any).cost = responseCost;\n }\n // Duck-type check instead of instanceof to handle potential dual-instance\n // scenarios where two copies of zod are present in the dependency tree.\n if (\n err &&\n typeof err === 'object' &&\n 'issues' in err &&\n Array.isArray((err as any).issues)\n ) {\n const issues = (err as any).issues as Array<{\n path: (string | number)[];\n message: string;\n }>;\n const messages = issues\n .map((i) => `${i.path.length ? `${i.path.join('.')}: ` : ''}${i.message}`)\n .join('; ');\n const error = new Error(\n `LLM scorer \"${config.name}\" returned an invalid response: ${messages}`,\n );\n (error as any).cost = responseCost;\n throw error;\n }\n throw err;\n }\n\n const { score, ...rest } = validated;\n return {\n score,\n metadata: Object.keys(rest).length > 0 ? rest : undefined,\n cost: responseCost,\n };\n },\n };\n}\n","import type { EvalConfig } from './types.js';\n\nexport function defineEval(config: EvalConfig): EvalConfig {\n return config;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/dataset.ts","../src/llm-scorer.ts","../src/define-eval.ts"],"sourcesContent":["import type { z } from 'zod';\nimport { readFile } from 'node:fs/promises';\nimport * as path from 'node:path';\n\nexport type DatasetItem<TInput, TAnnotations = undefined> = {\n input: TInput;\n annotations?: TAnnotations;\n};\n\n/**\n * What to do when a dataset item's `annotations` contain keys that are absent\n * from the `annotations` schema.\n *\n * Zod object schemas strip unknown keys by default, so an annotation field\n * that isn't declared in the schema is silently dropped before it ever reaches\n * a scorer. A scorer that reads such a field sees `undefined` and quietly\n * degrades to a no-op — the eval still \"passes\", so the bug is invisible. This\n * is especially easy to hit with file-based datasets, where the JSON is never\n * type-checked.\n *\n * - `'warn'` (default) — log a single `console.warn` per dataset listing the\n * dropped key paths. Non-breaking; surfaces the contract violation.\n * - `'error'` — throw, listing the dropped key paths. The strict path for CI.\n * - `'ignore'` — preserve the silent-strip behavior (skips detection entirely).\n *\n * Only `annotations` are checked, not `input`: stripped input fields flow to\n * the workflow where they surface, whereas stripped annotations vanish into a\n * silent scorer.\n */\nexport type ExtraKeyPolicy = 'warn' | 'error' | 'ignore';\n\nexport type DatasetConfig<TInput extends z.ZodType, TAnnotations extends z.ZodType = z.ZodType> = {\n name: string;\n schema: TInput;\n annotations?: TAnnotations;\n items?: DatasetItem<z.infer<TInput>, z.infer<TAnnotations>>[];\n file?: string;\n /** Base directory for resolving relative file paths. Defaults to cwd. */\n basePath?: string;\n /**\n * How to handle annotation keys not present in the `annotations` schema\n * (Zod strips them by default). Defaults to `'warn'`. See {@link ExtraKeyPolicy}.\n */\n onExtraAnnotationKeys?: ExtraKeyPolicy;\n};\n\nexport type Dataset<TInput = unknown, TAnnotations = unknown> = {\n readonly name: string;\n readonly schema: z.ZodType;\n readonly annotationsSchema?: z.ZodType;\n /**\n * Annotation key paths the schema dropped during the most recent\n * `getItems()` call (empty when none were dropped, or when\n * `onExtraAnnotationKeys: 'ignore'`). Populated as a side effect of\n * `getItems()` — read it synchronously right after awaiting, on the same\n * instance; a later `getItems()` overwrites it (calling `getItems()`\n * concurrently on one shared dataset is unsupported). The eval runner folds\n * this into `EvalResult.metadata.droppedAnnotationKeys` so any consumer (the\n * CLI logs it; UIs can render it) sees the same signal. Optional so\n * hand-rolled `Dataset`-shaped objects (test harnesses) remain valid.\n */\n readonly droppedAnnotationKeys?: readonly string[];\n getItems(): Promise<DatasetItem<TInput, TAnnotations>[]>;\n};\n\n/** True for `{}`-style plain objects only — excludes arrays, null, Date, Map, class instances. */\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;\n const proto = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n}\n\n/**\n * True when `schema` is a `ZodObject` (any unknown-key mode: strip/strict/loose).\n * Detected via the public `.shape` getter, which only `ZodObject` exposes —\n * transforms/pipes/preprocess (`.def.type === 'pipe'`), unions, records, arrays,\n * and wrappers (`.optional()` etc.) all return `undefined`. Uses duck-typing\n * rather than `instanceof z.ZodObject` so we keep a type-only zod import and\n * stay robust against duplicate zod copies in a consumer's tree.\n *\n * Drop-detection (raw-vs-parsed diff) is only sound when `.parse()` preserves\n * the input's object shape minus stripped keys. A `.transform()`/`.preprocess()`\n * that intentionally removes or reshapes keys would otherwise be mis-reported as\n * \"dropped by schema\" — so we gate detection on this predicate at the top level.\n */\nfunction isObjectSchema(schema: z.ZodType): boolean {\n const shape = (schema as { shape?: unknown }).shape;\n return typeof shape === 'object' && shape !== null;\n}\n\n/**\n * Recursively collect key paths present in `raw` but absent from `parsed`\n * (i.e. stripped by the schema). Walks plain objects and arrays; anything else\n * is a leaf. Paths use dot/bracket notation: `persona.tone`, `tags[0].label`.\n *\n * Only called for a top-level `ZodObject` annotations schema (see\n * `isObjectSchema`). Mode-independent within that scope — works whether the\n * object strips (detects drops), is strict (`.parse()` throws before we reach\n * here), or is loose (parsed keeps the keys, so nothing is reported). Known\n * limitation: a `.transform()` nested inside an object field that removes keys\n * can still produce a false positive for that sub-path; nested transforms on\n * annotation data are rare and not worth a full schema walk here.\n */\nfunction collectDroppedKeys(raw: unknown, parsed: unknown, prefix = ''): string[] {\n if (Array.isArray(raw) && Array.isArray(parsed)) {\n const dropped: string[] = [];\n const n = Math.min(raw.length, parsed.length);\n for (let i = 0; i < n; i++) {\n dropped.push(...collectDroppedKeys(raw[i], parsed[i], `${prefix}[${i}]`));\n }\n return dropped;\n }\n if (isPlainObject(raw) && isPlainObject(parsed)) {\n const dropped: string[] = [];\n for (const key of Object.keys(raw)) {\n const path = prefix ? `${prefix}.${key}` : key;\n if (!(key in parsed)) {\n dropped.push(path);\n } else {\n dropped.push(...collectDroppedKeys(raw[key], parsed[key], path));\n }\n }\n return dropped;\n }\n return [];\n}\n\nexport function dataset<TInput extends z.ZodType, TAnnotations extends z.ZodType>(\n config: DatasetConfig<TInput, TAnnotations>,\n): Dataset<z.infer<TInput>, z.infer<TAnnotations>> {\n const policy: ExtraKeyPolicy = config.onExtraAnnotationKeys ?? 'warn';\n\n /**\n * Parse one item's input + annotations, recording any annotation keys the\n * schema dropped into `droppedAcc` (deduped across the whole dataset so we\n * emit a single warning rather than one per item).\n */\n function parseItem(\n item: DatasetItem<z.infer<TInput>, z.infer<TAnnotations>>,\n droppedAcc: Set<string>,\n ): DatasetItem<z.infer<TInput>, z.infer<TAnnotations>> {\n const input = config.schema.parse(item.input);\n if (item.annotations == null || !config.annotations) {\n return { input, annotations: item.annotations };\n }\n const annotations = config.annotations.parse(item.annotations);\n // Only diff for plain object schemas: a transform/preprocess/pipe reshapes\n // output, so a raw-vs-parsed diff would mis-report intentionally-removed\n // keys as \"dropped by schema\" (and hard-fail under 'error').\n if (policy !== 'ignore' && isObjectSchema(config.annotations)) {\n for (const p of collectDroppedKeys(item.annotations, annotations)) droppedAcc.add(p);\n }\n return { input, annotations };\n }\n\n /** Emit the consolidated warning or throw, once per `getItems()` call. */\n function reportDropped(dropped: Set<string>): void {\n if (dropped.size === 0) return;\n const keys = [...dropped].join(', ');\n if (policy === 'error') {\n throw new Error(\n `Dataset \"${config.name}\": annotation key(s) dropped by schema: ${keys}. ` +\n `These keys are not declared in the annotations schema, so they were stripped and ` +\n `will never reach scorers. Add them to the schema, or set ` +\n `onExtraAnnotationKeys: 'ignore' to allow stripping.`,\n );\n }\n console.warn(\n `[axl-eval] Dataset \"${config.name}\": annotation key(s) dropped by schema: ${keys}. ` +\n `These keys are not declared in the annotations schema, so they were stripped and ` +\n `will not reach scorers (a common cause of no-op scorers). Add them to the schema, ` +\n `set onExtraAnnotationKeys: 'error' to fail, or 'ignore' to silence this warning.`,\n );\n }\n\n // Captured from the most recent getItems() so the runner can surface it\n // structurally (in addition to the console.warn) without re-deriving it.\n let lastDroppedAnnotationKeys: string[] = [];\n\n return {\n name: config.name,\n schema: config.schema,\n annotationsSchema: config.annotations,\n get droppedAnnotationKeys() {\n // Copy so the `readonly string[]` contract holds at runtime — a consumer\n // can't mutate internal state, and a held reference can't observe a later\n // getItems() reassignment.\n return [...lastDroppedAnnotationKeys];\n },\n async getItems() {\n if (config.items && config.file) {\n throw new Error('Dataset config error: \"items\" and \"file\" are mutually exclusive');\n }\n if (!config.items && !config.file) {\n throw new Error('Dataset config error: either \"items\" or \"file\" must be provided');\n }\n\n let rawItems: DatasetItem<z.infer<TInput>, z.infer<TAnnotations>>[];\n if (config.items) {\n rawItems = config.items;\n } else {\n const resolvedPath = path.resolve(config.basePath ?? process.cwd(), config.file!);\n const content = await readFile(resolvedPath, 'utf-8');\n rawItems = JSON.parse(content) as DatasetItem<z.infer<TInput>, z.infer<TAnnotations>>[];\n }\n\n const dropped = new Set<string>();\n const parsed = rawItems.map((item) => parseItem(item, dropped));\n // Set before reportDropped: in 'error' mode reportDropped throws, but the\n // runner only reads the getter after a *successful* getItems(), so the\n // value behind a rejected call is never observed.\n lastDroppedAnnotationKeys = [...dropped];\n reportDropped(dropped);\n return parsed;\n },\n };\n}\n","import { z } from 'zod';\nimport type { Scorer, ScorerApplies, ScorerContext, ScorerResult } from './scorer.js';\nimport { attachScorerErrorCost } from './scorer.js';\nimport { extractJson, zodToJsonSchema } from '@axlsdk/axl';\nimport type { Effort } from '@axlsdk/axl';\n\nexport type LlmScorerConfig = {\n name: string;\n description: string;\n model: string;\n system: string;\n schema?: z.ZodType<{ score: number; [key: string]: unknown }>;\n /** Sampling temperature. Default: 0.2 for deterministic judging. */\n temperature?: number;\n /** Cap on response tokens. Falls back to provider default if unset. */\n maxTokens?: number;\n /** Reasoning effort — `'high'` or `'max'` materially improves judge calibration on\n * reasoning-capable models (gpt-5.x, Opus 4.6+, Gemini 3.x). See `Effort` for\n * provider-specific mapping. */\n effort?: Effort;\n /** Precise thinking-token budget override. See `ChatOptions.thinkingBudget`. */\n thinkingBudget?: number;\n /** Surface reasoning summaries in the judge's response (Gemini + OpenAI Responses). */\n includeThoughts?: boolean;\n /** Stop sequences forwarded to the provider. */\n stop?: string[];\n /** Provider-specific escape hatch — merged last into the raw API request. */\n providerOptions?: Record<string, unknown>;\n /** Optional applicability predicate — see {@link ScorerApplies}. When it\n * returns `false`, the judge is skipped for that item and NO provider call is\n * made (the conditional-LLM-judge cost/rate-limit win). The item counts as\n * neither scored nor failed. */\n applies?: ScorerApplies<unknown, unknown, unknown>;\n};\n\nexport function llmScorer(config: LlmScorerConfig): Scorer {\n // Resolve schema and its JSON representation once at construction time —\n // both are fixed for the lifetime of this scorer instance.\n const schema: z.ZodType<{ score: number; [key: string]: unknown }> =\n config.schema ?? z.object({ score: z.number().min(0).max(1), reasoning: z.string() });\n const schemaJson = JSON.stringify(zodToJsonSchema(schema), null, 2);\n\n return {\n name: config.name,\n description: config.description,\n isLlm: true,\n applies: config.applies,\n async score(\n output: unknown,\n input: unknown,\n annotations?: unknown,\n context?: ScorerContext,\n ): Promise<ScorerResult> {\n if (!context?.resolveProvider) {\n throw new Error(\n `LLM scorer \"${config.name}\" has no provider. ` +\n `Ensure you are running via runEval() with a real AxlRuntime instance.`,\n );\n }\n\n const { provider, model } = context.resolveProvider(config.model);\n\n const prompt = [\n `Evaluate the following output.`,\n ``,\n `## Input`,\n `${JSON.stringify(input, null, 2)}`,\n annotations\n ? `\\n## Annotations (Ground Truth)\\n${JSON.stringify(annotations, null, 2)}`\n : '',\n ``,\n `## Output to Evaluate`,\n `${JSON.stringify(output, null, 2)}`,\n ``,\n `Respond with valid JSON matching this schema:`,\n schemaJson,\n ].join('\\n');\n\n // Field-by-field forwarding (deliberately NOT spreading `config`):\n // `LlmScorerConfig` has scorer-specific fields (`name`, `description`,\n // `schema`, `system`, `model` — the URI, not the chat-options `model`\n // which is the post-resolveProvider stripped form) that must not leak\n // into ChatOptions. A spread would silently pass them through to\n // provider.chat and either be ignored or, worse, override our hardcoded\n // `responseFormat`. Keep this explicit.\n const response = await provider.chat(\n [\n { role: 'system', content: config.system },\n { role: 'user', content: prompt },\n ],\n {\n model,\n temperature: config.temperature ?? 0.2,\n maxTokens: config.maxTokens,\n effort: config.effort,\n thinkingBudget: config.thinkingBudget,\n includeThoughts: config.includeThoughts,\n stop: config.stop,\n providerOptions: config.providerOptions,\n responseFormat: { type: 'json_object' },\n signal: context.signal,\n },\n );\n\n const responseCost = response.cost;\n\n let validated: { score: number; [key: string]: unknown };\n try {\n const parsed = JSON.parse(extractJson(response.content));\n validated = schema.parse(parsed) as { score: number; [key: string]: unknown };\n } catch (err) {\n // Attach cost to all errors so the runner can capture it even on failure.\n attachScorerErrorCost(err, responseCost);\n // Duck-type check instead of instanceof to handle potential dual-instance\n // scenarios where two copies of zod are present in the dependency tree.\n if (\n err &&\n typeof err === 'object' &&\n 'issues' in err &&\n Array.isArray((err as any).issues)\n ) {\n const issues = (err as any).issues as Array<{\n path: (string | number)[];\n message: string;\n }>;\n const messages = issues\n .map((i) => `${i.path.length ? `${i.path.join('.')}: ` : ''}${i.message}`)\n .join('; ');\n const error = new Error(\n `LLM scorer \"${config.name}\" returned an invalid response: ${messages}`,\n );\n attachScorerErrorCost(error, responseCost);\n throw error;\n }\n throw err;\n }\n\n const { score, ...rest } = validated;\n return {\n score,\n metadata: Object.keys(rest).length > 0 ? rest : undefined,\n cost: responseCost,\n };\n },\n };\n}\n","import type { EvalConfig } from './types.js';\n\nexport function defineEval(config: EvalConfig): EvalConfig {\n return config;\n}\n"],"mappings":"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","names":["path"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axlsdk/eval",
3
- "version": "0.17.9",
3
+ "version": "0.18.1",
4
4
  "description": "Evaluation framework for Axl agentic workflows",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -25,7 +25,7 @@
25
25
  "dist"
26
26
  ],
27
27
  "dependencies": {
28
- "@axlsdk/axl": "0.17.9"
28
+ "@axlsdk/axl": "0.18.1"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "zod": "^4.0.0",
@@ -71,6 +71,7 @@
71
71
  "typecheck": "tsc --noEmit",
72
72
  "test": "vitest run",
73
73
  "test:watch": "vitest watch",
74
+ "test:integration": "vitest run --config vitest.integration.config.ts",
74
75
  "lint": "tsc --noEmit"
75
76
  }
76
77
  }