@axlsdk/eval 0.23.2 → 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/dist/cli.cjs CHANGED
@@ -108,15 +108,35 @@ function computeStats(scores) {
108
108
  function round2(n) {
109
109
  return Math.round(n * 1e3) / 1e3;
110
110
  }
111
+ function formatPercent(rate) {
112
+ return `${Number((rate * 100).toFixed(1))}%`;
113
+ }
114
+ var DEFAULT_ITEM_ERROR_RATE_LIMIT = 0.05;
115
+ function isErrorRateLimit(value) {
116
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
117
+ }
118
+ function evaluateItemErrorRate(items, count, limit) {
119
+ const failed = items.failed;
120
+ const attempted = count - items.cancelled - items.budget_skipped - items.budget_interrupted;
121
+ const rate = attempted > 0 ? failed / attempted : 0;
122
+ return { failed, attempted, rate, limit, exceeded: attempted > 0 && rate > limit };
123
+ }
111
124
  function scorerCounts(items, name) {
112
125
  let scored = 0;
113
126
  let failed = 0;
114
127
  let skipped = 0;
115
128
  for (const i of items) {
116
129
  if (i.error) continue;
130
+ const detail = i.scoreDetails?.[name];
131
+ if (detail?.outcome !== void 0) {
132
+ if (detail.outcome === "scored") scored++;
133
+ else if (detail.outcome === "failed") failed++;
134
+ else if (detail.outcome === "skipped") skipped++;
135
+ continue;
136
+ }
117
137
  if (i.scores[name] != null) scored++;
118
- else if (i.scoreDetails?.[name]?.skipped === true) skipped++;
119
- else if (i.scoreDetails?.[name]?.duration != null) failed++;
138
+ else if (detail?.skipped === true) skipped++;
139
+ else if (detail?.duration != null) failed++;
120
140
  }
121
141
  return { scored, failed, skipped };
122
142
  }
@@ -137,11 +157,918 @@ async function mapWithConcurrency(items, concurrency, task) {
137
157
  results[i] = await task(items[i], i);
138
158
  }
139
159
  }
140
- await Promise.all(Array.from({ length: workerCount }, () => runNext()));
141
- return results;
160
+ await Promise.all(Array.from({ length: workerCount }, () => runNext()));
161
+ return results;
162
+ }
163
+
164
+ // src/accounting.ts
165
+ var import_axl = require("@axlsdk/axl");
166
+ var USAGE_KEYS = [
167
+ "inputTokens",
168
+ "outputTokens",
169
+ "reasoningTokens",
170
+ "cachedTokens",
171
+ "cacheWriteTokens",
172
+ "audioSeconds"
173
+ ];
174
+ function zeroUsage() {
175
+ return {
176
+ inputTokens: 0,
177
+ outputTokens: 0,
178
+ reasoningTokens: 0,
179
+ cachedTokens: 0,
180
+ cacheWriteTokens: 0,
181
+ audioSeconds: 0
182
+ };
183
+ }
184
+ function emptyAccounting() {
185
+ return {
186
+ version: 1,
187
+ currency: "USD",
188
+ knownCost: 0,
189
+ completeness: "complete",
190
+ reasons: {},
191
+ usage: zeroUsage(),
192
+ operations: { total: 0, settled: 0, unknown: 0, denied: 0, byKind: {} },
193
+ breakdown: { generation: 0, judging: 0, external: 0 },
194
+ provenance: {}
195
+ };
196
+ }
197
+ function uninstrumentedAccounting() {
198
+ return { ...emptyAccounting(), completeness: "incomplete", reasons: { uninstrumented: 1 } };
199
+ }
200
+ function isInstrumented(runtime) {
201
+ return typeof runtime?.trackOutcome === "function";
202
+ }
203
+ async function trackScope(runtime, fn, options) {
204
+ if (!isInstrumented(runtime)) {
205
+ try {
206
+ return {
207
+ status: "fulfilled",
208
+ value: await fn(),
209
+ accounting: uninstrumentedAccounting(),
210
+ instrumented: false
211
+ };
212
+ } catch (error) {
213
+ return {
214
+ status: "rejected",
215
+ error,
216
+ accounting: uninstrumentedAccounting(),
217
+ instrumented: false
218
+ };
219
+ }
220
+ }
221
+ const outcome = await runtime.trackOutcome(fn, options);
222
+ return { ...outcome, instrumented: true };
223
+ }
224
+ function parseBudget(budget) {
225
+ const match = typeof budget === "string" ? budget.match(/^\$?(\d+(?:\.\d+)?)$/) : null;
226
+ if (!match) {
227
+ throw new import_axl.AxlError(
228
+ "INVALID_BUDGET",
229
+ `Invalid budget "${String(budget)}": expected a non-negative USD amount like "$1" or "0.50".`
230
+ );
231
+ }
232
+ const limit = Number(match[1]);
233
+ if (!Number.isFinite(limit)) {
234
+ throw new import_axl.AxlError("INVALID_BUDGET", `Invalid budget "${budget}": not a finite amount.`);
235
+ }
236
+ return limit;
237
+ }
238
+ function usableCost(value) {
239
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
240
+ }
241
+ function isAdmissionDenied(err) {
242
+ return (0, import_axl.isAdmissionDeniedError)(err);
243
+ }
244
+ function readAccounting(result) {
245
+ if (result?.accounting) return result.accounting;
246
+ const rescored = result?.metadata?.rescored === true;
247
+ return {
248
+ ...emptyAccounting(),
249
+ knownCost: usableCost(result?.totalCost),
250
+ completeness: "unverified",
251
+ scope: rescored ? "rescore" : "run"
252
+ };
253
+ }
254
+ function refusedWork(coverage) {
255
+ if (!coverage) return 0;
256
+ return refusedIn(coverage.items) + Object.values(coverage.scorers ?? {}).reduce((n, s) => n + refusedIn(s), 0);
257
+ }
258
+ function refusedIn(counts) {
259
+ return atLeastZero(counts?.budget_skipped) + atLeastZero(counts?.budget_interrupted);
260
+ }
261
+ function atLeastZero(value) {
262
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
263
+ }
264
+ function isBudgetStopped(summary) {
265
+ if (summary.budget?.status !== "closed") return false;
266
+ return refusedWork(summary.coverage) > 0;
267
+ }
268
+ function addUsage(target, source) {
269
+ if (!source) return;
270
+ for (const key of USAGE_KEYS) {
271
+ const value = source[key];
272
+ if (typeof value === "number" && Number.isFinite(value)) target[key] += value;
273
+ }
274
+ }
275
+ function addCounts(target, source) {
276
+ if (!source) return;
277
+ for (const [key, count] of Object.entries(source)) {
278
+ if (typeof count === "number" && Number.isFinite(count)) {
279
+ target[key] = (target[key] ?? 0) + count;
280
+ }
281
+ }
282
+ }
283
+ function aggregateAccounting(inputs) {
284
+ const out = emptyAccounting();
285
+ let anyUnverified = false;
286
+ let anyIncomplete = false;
287
+ for (const input of inputs) {
288
+ if (!input) continue;
289
+ if (input.completeness === "unverified") anyUnverified = true;
290
+ else if (input.completeness === "incomplete") anyIncomplete = true;
291
+ out.knownCost += usableCost(input.knownCost);
292
+ addUsage(out.usage, input.usage);
293
+ addCounts(out.reasons, input.reasons);
294
+ addCounts(out.operations.byKind, input.operations?.byKind);
295
+ addCounts(out.provenance, input.provenance);
296
+ out.operations.total += input.operations?.total ?? 0;
297
+ out.operations.settled += input.operations?.settled ?? 0;
298
+ out.operations.unknown += input.operations?.unknown ?? 0;
299
+ out.operations.denied += input.operations?.denied ?? 0;
300
+ out.breakdown.generation += usableCost(input.breakdown?.generation);
301
+ out.breakdown.judging += usableCost(input.breakdown?.judging);
302
+ out.breakdown.external += usableCost(input.breakdown?.external);
303
+ }
304
+ out.completeness = anyUnverified ? "unverified" : anyIncomplete ? "incomplete" : "complete";
305
+ return out;
306
+ }
307
+
308
+ // src/runner.ts
309
+ var import_axl2 = require("@axlsdk/axl");
310
+
311
+ // src/scorer.ts
312
+ function normalizeScorerResult(result) {
313
+ return typeof result === "number" ? { score: result } : result;
314
+ }
315
+ function extractScorerErrorCost(err) {
316
+ const v = err && typeof err === "object" ? err.cost : void 0;
317
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
318
+ }
319
+
320
+ // src/score-item.ts
321
+ function describeInput(input) {
322
+ let rendered;
323
+ try {
324
+ rendered = JSON.stringify(input) ?? String(input);
325
+ } catch {
326
+ rendered = "[unserializable input]";
327
+ }
328
+ return rendered.length > 200 ? `${rendered.slice(0, 200)}\u2026` : rendered;
329
+ }
330
+ async function scoreItem(item, scorers, context) {
331
+ const scoreDetails = item.scoreDetails ??= {};
332
+ for (const s of scorers) {
333
+ item.scores[s.name] = null;
334
+ scoreDetails[s.name] = { score: null, outcome: "cancelled" };
335
+ }
336
+ const { runtime, scorerContext, signal, admission } = context;
337
+ const scorerErrorsByName = {};
338
+ await mapWithConcurrency(scorers, context.scorerConcurrency, async (scorer) => {
339
+ const record = (detail, outcome2) => {
340
+ scoreDetails[scorer.name] = { ...detail, outcome: outcome2 };
341
+ };
342
+ if (signal?.aborted) {
343
+ record({ score: null }, "cancelled");
344
+ return;
345
+ }
346
+ if (admission?.closed && scorer.isLlm) {
347
+ record({ score: null }, "budget_skipped");
348
+ return;
349
+ }
350
+ const scorerStart = Date.now();
351
+ let applicable = true;
352
+ try {
353
+ applicable = !scorer.applies || scorer.applies(item.output, item.input, item.annotations);
354
+ } catch (err) {
355
+ scorerErrorsByName[scorer.name] = `Scorer "${scorer.name}" threw: ${err instanceof Error ? err.message : String(err)}`;
356
+ record({ score: null, duration: Date.now() - scorerStart }, "failed");
357
+ return;
358
+ }
359
+ if (!applicable) {
360
+ record({ score: null, skipped: true }, "skipped");
361
+ return;
362
+ }
363
+ const outcome = await trackScope(
364
+ runtime,
365
+ async () => scorer.score(item.output, item.input, item.annotations, scorerContext),
366
+ // The scorer stamp is merged OVER the enclosing item's correlation, so a
367
+ // judge's records keep the case index and add the judge's name -- which is
368
+ // what lets `scoreDetails[name].diagnostics` point at this judge's own
369
+ // calls rather than at the whole item's.
370
+ { purpose: "judging", captureCorrelation: { scorer: scorer.name } }
371
+ );
372
+ const duration = Date.now() - scorerStart;
373
+ const { accounting } = outcome;
374
+ const measured = accounting.operations.total > 0 || accounting.operations.denied > 0;
375
+ const withCost = (detail, callerCost) => ({
376
+ ...detail,
377
+ ...measured ? { accounting, cost: accounting.knownCost } : {},
378
+ ...!measured && !outcome.instrumented && callerCost != null ? { cost: callerCost } : {}
379
+ });
380
+ if (outcome.status === "rejected") {
381
+ const err = outcome.error;
382
+ if (err?.name === "AbortError") {
383
+ record(withCost({ score: null, duration }, void 0), "cancelled");
384
+ return;
385
+ }
386
+ if (isAdmissionDenied(err)) {
387
+ context.onClosure?.("operation");
388
+ record(withCost({ score: null, duration }, void 0), "budget_interrupted");
389
+ return;
390
+ }
391
+ scorerErrorsByName[scorer.name] = `Scorer "${scorer.name}" threw: ${err instanceof Error ? err.message : String(err)}`;
392
+ record(withCost({ score: null, duration }, extractScorerErrorCost(err)), "failed");
393
+ if (admission?.closed) context.onClosure?.("scorer");
394
+ return;
395
+ }
396
+ try {
397
+ const scorerResult = normalizeScorerResult(outcome.value);
398
+ const callerCost = typeof scorerResult.cost === "number" && Number.isFinite(scorerResult.cost) && scorerResult.cost >= 0 ? scorerResult.cost : void 0;
399
+ if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
400
+ scorerErrorsByName[scorer.name] = `Scorer "${scorer.name}" returned out-of-range score ${scorerResult.score} for input ${describeInput(item.input)}`;
401
+ record(
402
+ withCost({ score: null, metadata: scorerResult.metadata, duration }, callerCost),
403
+ "failed"
404
+ );
405
+ } else {
406
+ item.scores[scorer.name] = round2(scorerResult.score);
407
+ record(
408
+ withCost(
409
+ {
410
+ score: round2(scorerResult.score),
411
+ metadata: scorerResult.metadata,
412
+ duration
413
+ },
414
+ callerCost
415
+ ),
416
+ "scored"
417
+ );
418
+ }
419
+ } catch (err) {
420
+ scorerErrorsByName[scorer.name] = `Scorer "${scorer.name}" threw: ${err instanceof Error ? err.message : String(err)}`;
421
+ record(withCost({ score: null, duration }, void 0), "failed");
422
+ }
423
+ if (admission?.closed) context.onClosure?.("scorer");
424
+ });
425
+ const orderedErrors = scorers.map((s) => scorerErrorsByName[s.name]).filter((e) => e != null);
426
+ if (orderedErrors.length > 0) item.scorerErrors = orderedErrors;
427
+ }
428
+
429
+ // src/diagnostics.ts
430
+ var DEFAULT_COPY_MAX_BYTES = 16 * 1024 * 1024;
431
+ function resolveCaptureLimits(option) {
432
+ if (option === void 0 || option === false) return void 0;
433
+ if (option === true) return {};
434
+ return {
435
+ ...option.maxRecordBytes !== void 0 ? { maxRecordBytes: option.maxRecordBytes } : {},
436
+ ...option.maxRunBytes !== void 0 ? { maxRunBytes: option.maxRunBytes } : {},
437
+ ...option.maxQueueBytes !== void 0 ? { maxQueueBytes: option.maxQueueBytes } : {}
438
+ };
439
+ }
440
+ function toDiagnosticManifest(manifest) {
441
+ return {
442
+ version: 1,
443
+ artifactId: manifest.artifactId,
444
+ fidelity: "runtime_request",
445
+ status: manifest.status,
446
+ ...manifest.reason !== void 0 ? { reason: manifest.reason } : {},
447
+ records: manifest.records,
448
+ bytes: manifest.bytes,
449
+ redaction: manifest.redaction,
450
+ ...manifest.expiresAt !== void 0 ? { expiresAt: manifest.expiresAt } : {}
451
+ };
452
+ }
453
+ function unavailableManifest(artifactId, reason) {
454
+ return {
455
+ version: 1,
456
+ artifactId,
457
+ fidelity: "runtime_request",
458
+ status: "unavailable",
459
+ reason,
460
+ records: 0,
461
+ bytes: 0,
462
+ redaction: "none"
463
+ };
464
+ }
465
+ var DEFAULT_SIDECAR_MAX_BYTES = 16 * 1024 * 1024;
466
+ var DEFAULT_SIDECAR_MAX_RECORD_BYTES = 4 * 1024 * 1024;
467
+ function serializeRequestRecords(records) {
468
+ return records.map((record) => JSON.stringify(record)).join("\n") + (records.length ? "\n" : "");
469
+ }
470
+
471
+ // src/runner.ts
472
+ var import_node_crypto = require("crypto");
473
+ var RESERVED_METADATA_KEYS = [
474
+ "models",
475
+ "modelCallCounts",
476
+ "workflows",
477
+ "workflowCallCounts",
478
+ "tokens",
479
+ "agentCalls"
480
+ ];
481
+ var ITEM_OUTCOMES = [
482
+ "completed",
483
+ "failed",
484
+ "cancelled",
485
+ "budget_skipped",
486
+ "budget_interrupted"
487
+ ];
488
+ var SCORER_OUTCOMES = [
489
+ "scored",
490
+ "failed",
491
+ "skipped",
492
+ "cancelled",
493
+ "budget_skipped",
494
+ "budget_interrupted"
495
+ ];
496
+ function extractUserCost(result, label = "executeWorkflow") {
497
+ if (result === null || typeof result !== "object") return void 0;
498
+ const raw = result.cost;
499
+ if (raw === void 0) return void 0;
500
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return raw;
501
+ console.warn(
502
+ `[axl-eval] Ignoring invalid \`cost\` from ${label} return: expected non-negative finite number, got ${typeof raw === "number" ? String(raw) : typeof raw}. Measured spend is unaffected.`
503
+ );
504
+ return void 0;
505
+ }
506
+ function extractUserMetadata(result, label = "executeWorkflow") {
507
+ if (result === null || typeof result !== "object") return void 0;
508
+ const meta = result.metadata;
509
+ if (meta === void 0) return void 0;
510
+ if (meta === null || typeof meta !== "object" || Array.isArray(meta)) {
511
+ console.warn(
512
+ `[axl-eval] Ignoring invalid \`metadata\` from ${label} return: expected plain object, got ${meta === null ? "null" : Array.isArray(meta) ? "array" : typeof meta}.`
513
+ );
514
+ return void 0;
515
+ }
516
+ const proto = Object.getPrototypeOf(meta);
517
+ if (proto !== Object.prototype && proto !== null) {
518
+ console.warn(
519
+ `[axl-eval] Ignoring invalid \`metadata\` from ${label} return: expected plain object, got ${proto?.constructor?.name ?? "exotic object"}.`
520
+ );
521
+ return void 0;
522
+ }
523
+ return meta;
524
+ }
525
+ function splitCallerMetadata(user) {
526
+ if (!user) return {};
527
+ let reserved;
528
+ const safe = {};
529
+ for (const [key, value] of Object.entries(user)) {
530
+ if (RESERVED_METADATA_KEYS.includes(key)) {
531
+ (reserved ??= {})[key] = value;
532
+ } else {
533
+ safe[key] = value;
534
+ }
535
+ }
536
+ return { reserved, ...Object.keys(safe).length > 0 ? { safe } : {} };
537
+ }
538
+ function isAbortError(err) {
539
+ return err?.name === "AbortError";
540
+ }
541
+ async function stageCapture(runtime, ownerId, option) {
542
+ const limits = resolveCaptureLimits(option);
543
+ if (!limits) return void 0;
544
+ const staged = await runtime.stageDiagnosticArtifact({ kind: "eval", id: ownerId });
545
+ return {
546
+ artifactId: staged.artifactId,
547
+ channel: new import_axl2.RequestCaptureChannel({
548
+ sink: staged.sink,
549
+ ...limits,
550
+ // Redaction is the RUNTIME's policy, not the caller's: a run cannot opt
551
+ // out of compliance mode by asking for diagnostics.
552
+ redact: runtime.isRedactEnabled()
553
+ })
554
+ };
555
+ }
556
+ async function finishCapture(runtime, capture) {
557
+ const status = await capture.channel.close();
558
+ try {
559
+ return await runtime.finalizeDiagnosticArtifact(
560
+ capture.artifactId,
561
+ status.status,
562
+ status.reason,
563
+ // What the channel ACTUALLY applied. The store sees only scrubbed bytes
564
+ // and cannot tell, so a manifest without this reports every compliance-
565
+ // mode artifact as unredacted.
566
+ status.redaction
567
+ );
568
+ } catch {
569
+ return void 0;
570
+ }
571
+ }
572
+ async function abandonCapture(runtime, capture) {
573
+ if (!capture) return;
574
+ await capture.channel.close().catch(() => void 0);
575
+ await runtime.rollbackDiagnosticArtifact(capture.artifactId).catch(() => void 0);
576
+ }
577
+ function attachOperationRefs(items, channel) {
578
+ const byItem = /* @__PURE__ */ new Map();
579
+ const byScorer = /* @__PURE__ */ new Map();
580
+ for (const entry of channel.operations()) {
581
+ if (entry.caseIndex === void 0) continue;
582
+ const ref = {
583
+ operationId: entry.operationId,
584
+ kind: entry.kind,
585
+ ...entry.turn !== void 0 ? { turn: entry.turn } : {},
586
+ ...entry.attempt !== void 0 ? { attempt: entry.attempt } : {},
587
+ status: entry.status
588
+ };
589
+ if (entry.scorer !== void 0) {
590
+ const forItem = byScorer.get(entry.caseIndex) ?? /* @__PURE__ */ new Map();
591
+ const list = forItem.get(entry.scorer) ?? [];
592
+ list.push(ref);
593
+ forItem.set(entry.scorer, list);
594
+ byScorer.set(entry.caseIndex, forItem);
595
+ } else {
596
+ const list = byItem.get(entry.caseIndex) ?? [];
597
+ list.push(ref);
598
+ byItem.set(entry.caseIndex, list);
599
+ }
600
+ }
601
+ for (const [caseIndex, operations] of byItem) {
602
+ const item = items[caseIndex];
603
+ if (!item) continue;
604
+ item.diagnostics = { operations: [...item.diagnostics?.operations ?? [], ...operations] };
605
+ }
606
+ for (const [caseIndex, forItem] of byScorer) {
607
+ for (const [scorer, operations] of forItem) {
608
+ const detail = items[caseIndex]?.scoreDetails?.[scorer];
609
+ if (detail) detail.diagnostics = { operations };
610
+ }
611
+ }
612
+ }
613
+ var MAX_CAUSE_DEPTH = 8;
614
+ function isProviderErrorLike(value) {
615
+ if (value instanceof import_axl2.ProviderError) return true;
616
+ if (typeof value !== "object" || value === null) return false;
617
+ const candidate = value;
618
+ return candidate.code === "PROVIDER_ERROR" && candidate.name === "ProviderError";
619
+ }
620
+ function describeItemFailure(thrown) {
621
+ let current = thrown;
622
+ for (let depth = 0; depth <= MAX_CAUSE_DEPTH; depth++) {
623
+ if (typeof current !== "object" || current === null) break;
624
+ if (isProviderErrorLike(current)) {
625
+ const { name: name2, provider, status, retryable, requestId } = current;
626
+ return {
627
+ name: typeof name2 === "string" ? name2 : "ProviderError",
628
+ ...typeof provider === "string" ? { provider } : {},
629
+ ...typeof status === "number" && Number.isFinite(status) ? { status } : {},
630
+ ...typeof retryable === "boolean" ? { retryable } : {},
631
+ ...typeof requestId === "string" ? { requestId } : {}
632
+ };
633
+ }
634
+ current = current.cause;
635
+ }
636
+ const name = thrown?.name;
637
+ return typeof thrown === "object" && thrown !== null && typeof name === "string" && name ? { name } : void 0;
638
+ }
639
+ async function runEval(config, executeWorkflow, runtime, options) {
640
+ const startTime = Date.now();
641
+ const id = (0, import_node_crypto.randomUUID)();
642
+ const itemErrorRateLimit = config.failOnItemErrorRate ?? DEFAULT_ITEM_ERROR_RATE_LIMIT;
643
+ if (!isErrorRateLimit(itemErrorRateLimit)) {
644
+ throw new import_axl2.AxlError(
645
+ "INVALID_ITEM_ERROR_RATE",
646
+ `Invalid failOnItemErrorRate (${String(itemErrorRateLimit)}): expected a number in [0, 1]; 1 disables the gate.`
647
+ );
648
+ }
649
+ const admission = config.budget != null ? new import_axl2.AdmissionController({ limit: parseBudget(config.budget) }) : void 0;
650
+ const capture = await stageCapture(runtime, id, options?.captureRequests);
651
+ let items;
652
+ try {
653
+ items = await config.dataset.getItems();
654
+ } catch (error) {
655
+ await abandonCapture(runtime, capture);
656
+ throw error;
657
+ }
658
+ const droppedAnnotationKeys = [...config.dataset.droppedAnnotationKeys ?? []];
659
+ const concurrency = config.concurrency ?? 5;
660
+ const scorerConcurrency = config.scorerConcurrency ?? 5;
661
+ let closedBy;
662
+ const noteClosure = (source) => {
663
+ if (closedBy === void 0 && admission?.closed) closedBy = source;
664
+ };
665
+ const scorerContext = {
666
+ resolveProvider: (uri) => {
667
+ if (typeof runtime.resolveProvider !== "function") {
668
+ throw new Error(
669
+ `LLM scorers require a runtime with resolveProvider(). Ensure you are using a real AxlRuntime instance, not a mock.`
670
+ );
671
+ }
672
+ return runtime.resolveProvider(uri);
673
+ },
674
+ signal: options?.signal
675
+ };
676
+ const evalItems = new Array(items.length);
677
+ const callerReported = { costItems: 0, costTotal: 0, metadataItems: 0 };
678
+ const runCallSamples = /* @__PURE__ */ new Map();
679
+ function absorbTiming(rollup, evalItem) {
680
+ if (!rollup) return;
681
+ const perItem = {};
682
+ for (const [model, bucket] of Object.entries(rollup)) {
683
+ const sums = {
684
+ calls: bucket.calls,
685
+ queuedMs: bucket.queuedMs,
686
+ retryMs: bucket.retryMs,
687
+ wireMs: bucket.wireMs,
688
+ ...bucket.firstTokenMs !== void 0 && bucket.firstTokenCalls !== void 0 ? { firstTokenMs: bucket.firstTokenMs, firstTokenCalls: bucket.firstTokenCalls } : {}
689
+ };
690
+ perItem[model] = sums;
691
+ const samples = bucket.samples;
692
+ if (!samples) continue;
693
+ const pooled = runCallSamples.get(model) ?? [];
694
+ for (const sample of samples) pooled.push(sample);
695
+ runCallSamples.set(model, pooled);
696
+ }
697
+ evalItem.timing = perItem;
698
+ }
699
+ function stub(item, outcome, error) {
700
+ return {
701
+ input: item.input,
702
+ annotations: item.annotations,
703
+ output: null,
704
+ error,
705
+ outcome,
706
+ accounting: emptyAccounting(),
707
+ cost: 0,
708
+ scorerCost: 0,
709
+ scores: {}
710
+ };
711
+ }
712
+ async function processItem(item, itemIndex) {
713
+ if (options?.signal?.aborted) {
714
+ evalItems[itemIndex] = stub(item, "cancelled", "Cancelled");
715
+ options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
716
+ return;
717
+ }
718
+ if (admission?.closed) {
719
+ noteClosure("case");
720
+ evalItems[itemIndex] = stub(item, "budget_skipped", "Budget exceeded");
721
+ options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
722
+ return;
723
+ }
724
+ const evalItem = {
725
+ input: item.input,
726
+ annotations: item.annotations,
727
+ output: null,
728
+ scores: {}
729
+ };
730
+ const itemStart = Date.now();
731
+ const itemOutcome = await trackScope(
732
+ runtime,
733
+ async () => {
734
+ const generation = await trackScope(
735
+ runtime,
736
+ async () => executeWorkflow(item.input, runtime),
737
+ {
738
+ purpose: "generation",
739
+ captureTraces: options?.captureTraces === true,
740
+ captureTimingSamples: true
741
+ }
742
+ );
743
+ evalItem.duration = Date.now() - itemStart;
744
+ absorbTiming(generation.modelTiming, evalItem);
745
+ if (generation.traces && generation.traces.length > 0) {
746
+ evalItem.traces = generation.traces;
747
+ }
748
+ if (generation.status === "rejected") {
749
+ const err = generation.error;
750
+ if (isAdmissionDenied(err)) {
751
+ noteClosure("operation");
752
+ evalItem.outcome = "budget_interrupted";
753
+ evalItem.error = "Budget interrupted";
754
+ } else if (isAbortError(err)) {
755
+ evalItem.outcome = "cancelled";
756
+ evalItem.error = "Cancelled";
757
+ } else {
758
+ evalItem.outcome = "failed";
759
+ const failure = describeItemFailure(err);
760
+ if (failure) evalItem.failure = failure;
761
+ evalItem.error = err instanceof Error ? err.message : String(err);
762
+ if (admission?.closed) noteClosure("case");
763
+ }
764
+ return;
765
+ }
766
+ const result = generation.value;
767
+ evalItem.outcome = "completed";
768
+ evalItem.output = result.output;
769
+ const callerCost = extractUserCost(result);
770
+ if (callerCost !== void 0) {
771
+ (evalItem.callerReport ??= {}).cost = callerCost;
772
+ callerReported.costItems += 1;
773
+ callerReported.costTotal += callerCost;
774
+ }
775
+ const { reserved, safe } = splitCallerMetadata(extractUserMetadata(result));
776
+ if (reserved) {
777
+ (evalItem.callerReport ??= {}).metadata = reserved;
778
+ callerReported.metadataItems += 1;
779
+ }
780
+ if (generation.metadata || safe) {
781
+ evalItem.metadata = { ...generation.metadata, ...safe };
782
+ }
783
+ if (admission?.closed) noteClosure("case");
784
+ await scoreItem(evalItem, config.scorers, {
785
+ runtime,
786
+ scorerContext,
787
+ scorerConcurrency,
788
+ signal: options?.signal,
789
+ admission,
790
+ onClosure: noteClosure
791
+ });
792
+ },
793
+ {
794
+ purpose: "generation",
795
+ // Every record produced anywhere under this item — workflow turns,
796
+ // nested asks, tool continuations, judges — is stamped with the case
797
+ // index, which is how the result can point an item at its own requests.
798
+ ...capture ? { captureCorrelation: { caseIndex: itemIndex } } : {}
799
+ }
800
+ );
801
+ if (itemOutcome.status === "rejected") throw itemOutcome.error;
802
+ evalItem.accounting = itemOutcome.accounting;
803
+ evalItem.cost = itemOutcome.accounting.breakdown.generation;
804
+ evalItem.scorerCost = itemOutcome.accounting.breakdown.judging;
805
+ if (itemOutcome.accounting.completeness !== "complete") evalItem.unpriced = true;
806
+ evalItems[itemIndex] = evalItem;
807
+ options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
808
+ }
809
+ const runOutcome = await trackScope(
810
+ runtime,
811
+ async () => {
812
+ await mapWithConcurrency(items, concurrency, (item, i) => processItem(item, i));
813
+ },
814
+ // The capture channel is declared ONCE, at the run scope: its byte budget
815
+ // and pending queue are per RUN, and every nested item/scorer scope
816
+ // inherits it rather than opening a competing budget of its own.
817
+ { admission, ...capture ? { capture: capture.channel } : {} }
818
+ );
819
+ if (runOutcome.status === "rejected") {
820
+ await abandonCapture(runtime, capture);
821
+ throw runOutcome.error;
822
+ }
823
+ let diagnostics;
824
+ if (capture) {
825
+ try {
826
+ attachOperationRefs(evalItems, capture.channel);
827
+ } catch (error) {
828
+ await abandonCapture(runtime, capture);
829
+ throw error;
830
+ }
831
+ const manifest = await finishCapture(runtime, capture);
832
+ diagnostics = manifest ? toDiagnosticManifest(manifest) : (
833
+ // An artifact that was never sealed is never committed, so publishing
834
+ // its id would point a reader at bytes the sweeper is about to reclaim.
835
+ unavailableManifest("", "the diagnostic artifact could not be finalized")
836
+ );
837
+ }
838
+ const accounting = {
839
+ ...runOutcome.accounting,
840
+ scope: "run",
841
+ ...admission ? {
842
+ budget: {
843
+ ...admission.snapshot(),
844
+ ...closedBy ? { closedBy } : {}
845
+ }
846
+ } : {},
847
+ ...callerReported.costItems > 0 || callerReported.metadataItems > 0 ? { callerReported } : {}
848
+ };
849
+ const failures = evalItems.filter((i) => i.error).length;
850
+ const scorerNames = config.scorers.map((s) => s.name);
851
+ const scorerStats = {};
852
+ for (const name of scorerNames) {
853
+ const scores = evalItems.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
854
+ const { scored, failed, skipped } = scorerCounts(evalItems, name);
855
+ scorerStats[name] = { ...computeStats(scores), scored, failed, skipped };
856
+ }
857
+ const coverage = buildCoverage(evalItems, scorerNames);
858
+ const scorerTypes = {};
859
+ for (const s of config.scorers) {
860
+ scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
861
+ }
862
+ let degraded;
863
+ const rawLimit = config.failOnScorerErrorRate;
864
+ if (rawLimit != null) {
865
+ if (!Number.isFinite(rawLimit) || rawLimit < 0 || rawLimit > 1) {
866
+ console.warn(
867
+ `[axl-eval] Ignoring invalid failOnScorerErrorRate (${rawLimit}); expected a number in [0, 1].`
868
+ );
869
+ } else {
870
+ for (const name of scorerNames) {
871
+ const stats = scorerStats[name];
872
+ const scored = stats.scored ?? 0;
873
+ const failed = stats.failed ?? 0;
874
+ const type = scorerTypes[name] === "llm" ? "llm" : "deterministic";
875
+ const limit = type === "deterministic" ? 0 : rawLimit;
876
+ const verdict = evaluateScorerTolerance(scored, failed, type, limit);
877
+ if (verdict.exceeds) {
878
+ (degraded ??= []).push({ scorer: name, rate: verdict.rate, limit, type, scored, failed });
879
+ }
880
+ }
881
+ }
882
+ }
883
+ const itemErrorRate = evaluateItemErrorRate(coverage.items, items.length, itemErrorRateLimit);
884
+ const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
885
+ const timing = durations.length > 0 ? computeStats(durations) : void 0;
886
+ const modelTiming = buildModelTiming(runCallSamples);
887
+ const totalModelCalls = /* @__PURE__ */ new Map();
888
+ for (const item of evalItems) {
889
+ const itemCounts = item.metadata?.modelCallCounts;
890
+ if (itemCounts && typeof itemCounts === "object") {
891
+ for (const [m, count] of Object.entries(itemCounts)) {
892
+ if (typeof count === "number")
893
+ totalModelCalls.set(m, (totalModelCalls.get(m) ?? 0) + count);
894
+ }
895
+ } else {
896
+ const itemModels = item.metadata?.models;
897
+ if (Array.isArray(itemModels)) {
898
+ for (const m of itemModels) {
899
+ if (typeof m === "string") totalModelCalls.set(m, (totalModelCalls.get(m) ?? 0) + 1);
900
+ }
901
+ }
902
+ }
903
+ }
904
+ const modelsMeta = {};
905
+ if (totalModelCalls.size > 0) {
906
+ const sorted = [...totalModelCalls.entries()].sort((a, b) => b[1] - a[1]);
907
+ modelsMeta.models = sorted.map(([m]) => m);
908
+ modelsMeta.modelCounts = Object.fromEntries(sorted);
909
+ }
910
+ const totalWorkflowCalls = /* @__PURE__ */ new Map();
911
+ for (const item of evalItems) {
912
+ const itemCounts = item.metadata?.workflowCallCounts;
913
+ if (itemCounts && typeof itemCounts === "object") {
914
+ for (const [w, count] of Object.entries(itemCounts)) {
915
+ if (typeof count === "number")
916
+ totalWorkflowCalls.set(w, (totalWorkflowCalls.get(w) ?? 0) + count);
917
+ }
918
+ } else {
919
+ const itemWorkflows = item.metadata?.workflows;
920
+ if (Array.isArray(itemWorkflows)) {
921
+ for (const w of itemWorkflows) {
922
+ if (typeof w === "string")
923
+ totalWorkflowCalls.set(w, (totalWorkflowCalls.get(w) ?? 0) + 1);
924
+ }
925
+ }
926
+ }
927
+ }
928
+ const workflowsMeta = {};
929
+ if (totalWorkflowCalls.size > 0) {
930
+ const sorted = [...totalWorkflowCalls.entries()].sort((a, b) => b[1] - a[1]);
931
+ workflowsMeta.workflows = sorted.map(([w]) => w);
932
+ workflowsMeta.workflowCounts = Object.fromEntries(sorted);
933
+ } else if (config.workflow) {
934
+ workflowsMeta.workflows = [config.workflow];
935
+ workflowsMeta.workflowCounts = { [config.workflow]: items.length };
936
+ }
937
+ options?.onProgress?.({ type: "run_done", totalItems: items.length, failures });
938
+ return {
939
+ id,
940
+ dataset: config.dataset.name,
941
+ metadata: {
942
+ ...config.metadata,
943
+ scorerTypes,
944
+ ...modelsMeta,
945
+ ...workflowsMeta,
946
+ ...droppedAnnotationKeys.length > 0 ? { droppedAnnotationKeys } : {}
947
+ },
948
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
949
+ totalCost: accounting.knownCost,
950
+ ...accounting.completeness !== "complete" ? { unpriced: true } : {},
951
+ accounting,
952
+ ...diagnostics ? { diagnostics } : {},
953
+ duration: Date.now() - startTime,
954
+ items: evalItems,
955
+ summary: {
956
+ count: items.length,
957
+ failures,
958
+ coverage,
959
+ scorers: scorerStats,
960
+ timing,
961
+ ...modelTiming ? { modelTiming } : {},
962
+ ...degraded ? { degraded } : {},
963
+ ...itemErrorRate.failed > 0 ? { itemErrorRate } : {}
964
+ }
965
+ };
966
+ }
967
+ function buildCoverage(items, scorerNames) {
968
+ const coverage = {
969
+ items: Object.fromEntries(ITEM_OUTCOMES.map((o) => [o, 0])),
970
+ scorers: {}
971
+ };
972
+ for (const name of scorerNames) {
973
+ coverage.scorers[name] = Object.fromEntries(SCORER_OUTCOMES.map((o) => [o, 0]));
974
+ }
975
+ for (const item of items) {
976
+ const outcome = item.outcome ?? (item.error ? "failed" : "completed");
977
+ if (coverage.items[outcome] !== void 0) coverage.items[outcome] += 1;
978
+ for (const name of scorerNames) {
979
+ const detail = item.scoreDetails?.[name];
980
+ if (!detail?.outcome) continue;
981
+ const bucket = coverage.scorers[name];
982
+ if (bucket[detail.outcome] !== void 0) bucket[detail.outcome] += 1;
983
+ }
984
+ }
985
+ return coverage;
986
+ }
987
+ function buildModelTiming(runCallSamples) {
988
+ if (runCallSamples.size === 0) return void 0;
989
+ return Object.fromEntries(
990
+ [...runCallSamples.entries()].map(([model, samples]) => {
991
+ const firstToken = samples.map((s) => s.firstTokenMs).filter((v) => v != null);
992
+ return [
993
+ model,
994
+ {
995
+ calls: samples.length,
996
+ wireMs: computeStats(samples.map((s) => s.wireMs)),
997
+ queuedMs: computeStats(samples.map((s) => s.queuedMs)),
998
+ retryMs: computeStats(samples.map((s) => s.retryMs)),
999
+ // Custom providers may omit the optional field; that call adds 0.
1000
+ rateLimitRetries: samples.reduce((sum, s) => sum + (s.rateLimitRetries ?? 0), 0),
1001
+ ...firstToken.length > 0 ? { firstTokenMs: computeStats(firstToken), firstTokenCalls: firstToken.length } : {}
1002
+ }
1003
+ ];
1004
+ })
1005
+ );
1006
+ }
1007
+
1008
+ // src/compare.ts
1009
+ function certifyCost(baselineRuns, candidateRuns, scorerNames) {
1010
+ for (const [side, runs] of [
1011
+ ["baseline", baselineRuns],
1012
+ ["candidate", candidateRuns]
1013
+ ]) {
1014
+ for (const run of runs) {
1015
+ const accounting = readAccounting(run);
1016
+ if (accounting.completeness === "unverified") {
1017
+ return `${side} run ${run.id} carries no accounting (unverified) \u2014 its total is not a measured figure.`;
1018
+ }
1019
+ if (accounting.completeness === "incomplete") {
1020
+ const reasons = Object.keys(accounting.reasons).join(", ") || "unknown spend";
1021
+ return `${side} run ${run.id} has incomplete accounting (${reasons}) \u2014 its total is a lower bound.`;
1022
+ }
1023
+ }
1024
+ }
1025
+ const scopes = /* @__PURE__ */ new Map();
1026
+ for (const [side, runs] of [
1027
+ ["baseline", baselineRuns],
1028
+ ["candidate", candidateRuns]
1029
+ ]) {
1030
+ for (const run of runs) {
1031
+ const scope = readAccounting(run).scope;
1032
+ const seen = scopes.get(side);
1033
+ if (seen !== void 0 && seen !== scope) {
1034
+ return `${side} mixes accounting scopes ("${seen}" and "${scope}") \u2014 a rescore total covers judging only and cannot be averaged with a full run.`;
1035
+ }
1036
+ scopes.set(side, scope);
1037
+ }
1038
+ }
1039
+ const baselineScope = scopes.get("baseline");
1040
+ const candidateScope = scopes.get("candidate");
1041
+ if (baselineScope !== candidateScope) {
1042
+ return `accounting scope differs (baseline "${baselineScope}" vs candidate "${candidateScope}") \u2014 a rescore total covers judging only.`;
1043
+ }
1044
+ const runCount = Math.min(baselineRuns.length, candidateRuns.length);
1045
+ for (let r = 0; r < runCount; r++) {
1046
+ const bRun = baselineRuns[r];
1047
+ const cRun = candidateRuns[r];
1048
+ if (bRun.items.length !== cRun.items.length) {
1049
+ return `item counts differ (baseline ${bRun.items.length} vs candidate ${cRun.items.length}) \u2014 the runs did different amounts of work.`;
1050
+ }
1051
+ for (let i = 0; i < bRun.items.length; i++) {
1052
+ const bItem = bRun.items[i];
1053
+ const cItem = cRun.items[i];
1054
+ const bRan = !bItem.error;
1055
+ const cRan = !cItem.error;
1056
+ if (bRan !== cRan) {
1057
+ const stopped = bRan ? cItem : bItem;
1058
+ return `case coverage differs at item ${i}: ${bRan ? "candidate" : "baseline"} did not complete it (${stopped.outcome ?? stopped.error}) \u2014 the cheaper side did less work.`;
1059
+ }
1060
+ if (!bRan) continue;
1061
+ for (const name of scorerNames) {
1062
+ const bScored = bItem.scores[name] != null;
1063
+ const cScored = cItem.scores[name] != null;
1064
+ if (bScored !== cScored) {
1065
+ return `scorer coverage differs for "${name}" at item ${i}: only ${bScored ? "baseline" : "candidate"} produced a score.`;
1066
+ }
1067
+ }
1068
+ }
1069
+ }
1070
+ return null;
142
1071
  }
143
-
144
- // src/compare.ts
145
1072
  var DEFAULT_LLM_THRESHOLD = 0.05;
146
1073
  var DEFAULT_DETERMINISTIC_THRESHOLD = 0;
147
1074
  var LEGACY_THRESHOLD = 0.1;
@@ -306,17 +1233,21 @@ function evalCompare(baseline, candidate, options) {
306
1233
  timing = { baselineMean: round2(bMean), candidateMean: round2(cMean), delta, deltaPercent };
307
1234
  }
308
1235
  let cost;
309
- const baselineAvgCost = baselineRuns.reduce((sum, r) => sum + r.totalCost, 0) / baselineRuns.length;
310
- const candidateAvgCost = candidateRuns.reduce((sum, r) => sum + r.totalCost, 0) / candidateRuns.length;
311
- if (baselineAvgCost > 0 || candidateAvgCost > 0) {
1236
+ const baselineAvgCost = baselineRuns.reduce((sum, r) => sum + readAccounting(r).knownCost, 0) / baselineRuns.length;
1237
+ const candidateAvgCost = candidateRuns.reduce((sum, r) => sum + readAccounting(r).knownCost, 0) / candidateRuns.length;
1238
+ const hasAccounting = [...baselineRuns, ...candidateRuns].some((r) => r.accounting != null);
1239
+ if (baselineAvgCost > 0 || candidateAvgCost > 0 || hasAccounting) {
312
1240
  const deltaRaw = candidateAvgCost - baselineAvgCost;
313
1241
  const delta = round2(deltaRaw);
314
- const deltaPercent = baselineAvgCost > 0 ? round2(deltaRaw / baselineAvgCost * 100) : 0;
1242
+ const deltaPercent = baselineAvgCost > 0 ? round2(deltaRaw / baselineAvgCost * 100) : null;
1243
+ const verdict = certifyCost(baselineRuns, candidateRuns, baselineScorerNames);
315
1244
  cost = {
316
1245
  baselineTotal: round2(baselineAvgCost),
317
1246
  candidateTotal: round2(candidateAvgCost),
318
1247
  delta,
319
- deltaPercent
1248
+ deltaPercent,
1249
+ certified: verdict === null,
1250
+ ...verdict !== null ? { reason: verdict } : {}
320
1251
  };
321
1252
  }
322
1253
  const parts = [];
@@ -333,469 +1264,219 @@ function evalCompare(baseline, candidate, options) {
333
1264
  );
334
1265
  }
335
1266
  }
336
- if (timing && Math.abs(timing.deltaPercent) > 1) {
337
- const dir = timing.delta > 0 ? "slower" : "faster";
338
- parts.push(`${Math.abs(timing.deltaPercent).toFixed(0)}% ${dir}`);
339
- }
340
- if (cost && Math.abs(cost.deltaPercent) > 1) {
341
- const dir = cost.delta > 0 ? "more expensive" : "cheaper";
342
- parts.push(`${Math.abs(cost.deltaPercent).toFixed(0)}% ${dir}`);
343
- }
344
- const summaryStr = `candidate ${parts.length > 0 ? parts.join(", ") : "no meaningful changes"} with ${regressions.length} regressions and ${improvements.length} improvements`;
345
- const baselinePartial = detectPartial(baselineRunsRaw);
346
- const candidatePartial = detectPartial(candidateRunsRaw);
347
- return {
348
- baseline: {
349
- id: baselineRef.id,
350
- metadata: baselineRef.metadata,
351
- runCount,
352
- ...baselinePartial ? { partial: baselinePartial } : {}
353
- },
354
- candidate: {
355
- id: candidateRef.id,
356
- metadata: candidateRef.metadata,
357
- runCount,
358
- ...candidatePartial ? { partial: candidatePartial } : {}
359
- },
360
- scorers,
361
- timing,
362
- cost,
363
- regressions,
364
- improvements,
365
- summary: summaryStr
366
- };
367
- }
368
- function detectPartial(runs) {
369
- if (runs.length === 0) return void 0;
370
- let attempted;
371
- for (const r of runs) {
372
- const v = r.metadata?.batchAttempted;
373
- if (typeof v === "number" && Number.isFinite(v)) {
374
- attempted = v;
375
- break;
376
- }
377
- }
378
- if (attempted === void 0) return void 0;
379
- if (runs.length >= attempted) return void 0;
380
- return { completed: runs.length, attempted };
381
- }
382
- function evaluateScorerErrorRateGate(comparison, maxScorerErrorRate) {
383
- const scorerTypes = comparison.baseline.metadata?.scorerTypes ?? {};
384
- for (const name of Object.keys(comparison.scorers)) {
385
- const s = comparison.scorers[name];
386
- const type = scorerTypes[name] === "deterministic" ? "deterministic" : "llm";
387
- const limit = type === "deterministic" ? 0 : maxScorerErrorRate;
388
- for (const side of ["baseline", "candidate"]) {
389
- const scored = (side === "baseline" ? s.baselineScored : s.candidateScored) ?? 0;
390
- const failed = (side === "baseline" ? s.baselineFailed : s.candidateFailed) ?? 0;
391
- const skipped = (side === "baseline" ? s.baselineSkipped : s.candidateSkipped) ?? 0;
392
- const verdict = evaluateScorerTolerance(scored, failed, type, limit);
393
- if (verdict.zeroSample) {
394
- const cause = skipped > 0 ? `every one of its items was N/A (the \`applies\` predicate skipped all ${skipped})` : `its items errored or produced no score`;
395
- return `scorer "${name}" has no scored items on ${side} (${cause}) \u2014 cannot certify a mean over an empty sample.`;
396
- }
397
- if (verdict.exceeds) {
398
- const limitStr = type === "deterministic" ? "deterministic zero-tolerance" : `${(limit * 100).toFixed(1)}%`;
399
- return `scorer "${name}" failed ${(verdict.rate * 100).toFixed(1)}% on ${side} (${failed}/${verdict.attempted}), over the ${limitStr} limit.`;
400
- }
401
- }
402
- }
403
- return null;
404
- }
405
-
406
- // src/scorer.ts
407
- function normalizeScorerResult(result) {
408
- return typeof result === "number" ? { score: result } : result;
409
- }
410
- function extractScorerErrorCost(err) {
411
- const v = err && typeof err === "object" ? err.cost : void 0;
412
- return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
413
- }
414
-
415
- // src/score-item.ts
416
- async function scoreItem(item, scorers, scorerConcurrency, scorerContext, signal) {
417
- const scoreDetails = item.scoreDetails ??= {};
418
- for (const s of scorers) {
419
- item.scores[s.name] = null;
420
- scoreDetails[s.name] = { score: null };
421
- }
422
- let itemScorerCost = 0;
423
- const scorerErrorsByName = {};
424
- await mapWithConcurrency(scorers, scorerConcurrency, async (scorer) => {
425
- if (signal?.aborted) return;
426
- const scorerStart = Date.now();
427
- try {
428
- if (scorer.applies && !scorer.applies(item.output, item.input, item.annotations)) {
429
- item.scores[scorer.name] = null;
430
- scoreDetails[scorer.name] = { score: null, skipped: true };
431
- return;
432
- }
433
- const raw = await scorer.score(item.output, item.input, item.annotations, scorerContext);
434
- const scorerResult = normalizeScorerResult(raw);
435
- if (scorerResult.cost != null) {
436
- itemScorerCost += scorerResult.cost;
437
- }
438
- const scorerDuration = Date.now() - scorerStart;
439
- if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
440
- scorerErrorsByName[scorer.name] = `Scorer "${scorer.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`;
441
- item.scores[scorer.name] = null;
442
- scoreDetails[scorer.name] = {
443
- score: null,
444
- metadata: scorerResult.metadata,
445
- duration: scorerDuration,
446
- cost: scorerResult.cost
447
- };
448
- } else {
449
- item.scores[scorer.name] = round2(scorerResult.score);
450
- scoreDetails[scorer.name] = {
451
- score: round2(scorerResult.score),
452
- metadata: scorerResult.metadata,
453
- duration: scorerDuration,
454
- cost: scorerResult.cost
455
- };
456
- }
457
- } catch (err) {
458
- if (err?.name === "AbortError") return;
459
- const errCost = extractScorerErrorCost(err);
460
- if (errCost != null) itemScorerCost += errCost;
461
- scorerErrorsByName[scorer.name] = `Scorer "${scorer.name}" threw: ${err instanceof Error ? err.message : String(err)}`;
462
- item.scores[scorer.name] = null;
463
- scoreDetails[scorer.name] = {
464
- score: null,
465
- duration: Date.now() - scorerStart,
466
- cost: errCost
467
- };
468
- }
469
- });
470
- const orderedErrors = scorers.map((s) => scorerErrorsByName[s.name]).filter((e) => e != null);
471
- if (orderedErrors.length > 0) item.scorerErrors = orderedErrors;
472
- item.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
473
- return itemScorerCost;
474
- }
475
-
476
- // src/runner.ts
477
- var import_node_crypto = require("crypto");
478
- function parseCost(cost) {
479
- const match = cost.match(/^\$?([\d.]+)$/);
480
- if (!match) throw new Error(`Invalid cost format: "${cost}"`);
481
- return parseFloat(match[1]);
482
- }
483
- function extractUserCost(result, label = "executeWorkflow") {
484
- if (result === null || typeof result !== "object") return void 0;
485
- const raw = result.cost;
486
- if (raw === void 0) return void 0;
487
- if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return raw;
488
- console.warn(
489
- `[axl-eval] Ignoring invalid \`cost\` from ${label} return: expected non-negative finite number, got ${typeof raw === "number" ? String(raw) : typeof raw}. Falling back to tracked cost (or undefined).`
490
- );
491
- return void 0;
492
- }
493
- function extractUserMetadata(result, label = "executeWorkflow") {
494
- if (result === null || typeof result !== "object") return void 0;
495
- const meta = result.metadata;
496
- if (meta === void 0) return void 0;
497
- if (meta === null || typeof meta !== "object" || Array.isArray(meta)) {
498
- console.warn(
499
- `[axl-eval] Ignoring invalid \`metadata\` from ${label} return: expected plain object, got ${meta === null ? "null" : Array.isArray(meta) ? "array" : typeof meta}.`
500
- );
501
- return void 0;
502
- }
503
- const proto = Object.getPrototypeOf(meta);
504
- if (proto !== Object.prototype && proto !== null) {
505
- console.warn(
506
- `[axl-eval] Ignoring invalid \`metadata\` from ${label} return: expected plain object, got ${proto?.constructor?.name ?? "exotic object"}.`
507
- );
508
- return void 0;
509
- }
510
- return meta;
511
- }
512
- function mergeMetadata(tracked, user) {
513
- const merged = { ...tracked, ...user };
514
- for (const [list, counts] of [
515
- ["models", "modelCallCounts"],
516
- ["workflows", "workflowCallCounts"]
517
- ]) {
518
- if (user && Object.hasOwn(user, list) && !Object.hasOwn(user, counts)) {
519
- delete merged[counts];
520
- }
521
- }
522
- return merged;
523
- }
524
- async function runEval(config, executeWorkflow, runtime, options) {
525
- const startTime = Date.now();
526
- const id = (0, import_node_crypto.randomUUID)();
527
- const items = await config.dataset.getItems();
528
- const droppedAnnotationKeys = [...config.dataset.droppedAnnotationKeys ?? []];
529
- const concurrency = config.concurrency ?? 5;
530
- const scorerConcurrency = config.scorerConcurrency ?? 5;
531
- const budgetLimit = config.budget ? parseCost(config.budget) : void 0;
532
- const scorerContext = {
533
- resolveProvider: (uri) => {
534
- if (typeof runtime.resolveProvider !== "function") {
535
- throw new Error(
536
- `LLM scorers require a runtime with resolveProvider(). Ensure you are using a real AxlRuntime instance, not a mock.`
537
- );
538
- }
539
- return runtime.resolveProvider(uri);
540
- },
541
- signal: options?.signal
542
- };
543
- const evalItems = new Array(items.length);
544
- let totalCost = 0;
545
- let budgetExceeded = false;
546
- const runCallSamples = /* @__PURE__ */ new Map();
547
- function absorbTiming(rollup, evalItem) {
548
- if (!rollup) return;
549
- const perItem = {};
550
- for (const [model, bucket] of Object.entries(rollup)) {
551
- const sums = {
552
- calls: bucket.calls,
553
- queuedMs: bucket.queuedMs,
554
- retryMs: bucket.retryMs,
555
- wireMs: bucket.wireMs,
556
- ...bucket.firstTokenMs !== void 0 && bucket.firstTokenCalls !== void 0 ? { firstTokenMs: bucket.firstTokenMs, firstTokenCalls: bucket.firstTokenCalls } : {}
557
- };
558
- perItem[model] = sums;
559
- const samples = bucket.samples;
560
- if (!samples) continue;
561
- const pooled = runCallSamples.get(model) ?? [];
562
- for (const sample of samples) pooled.push(sample);
563
- runCallSamples.set(model, pooled);
564
- }
565
- evalItem.timing = perItem;
566
- }
567
- async function processItem(item, itemIndex) {
568
- if (options?.signal?.aborted) {
569
- evalItems[itemIndex] = {
570
- input: item.input,
571
- annotations: item.annotations,
572
- output: null,
573
- error: "Cancelled",
574
- scores: {}
575
- };
576
- options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
577
- return;
578
- }
579
- if (budgetExceeded) {
580
- evalItems[itemIndex] = {
581
- input: item.input,
582
- annotations: item.annotations,
583
- output: null,
584
- error: "Budget exceeded",
585
- scores: {}
586
- };
587
- options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
588
- return;
589
- }
590
- const evalItem = {
591
- input: item.input,
592
- annotations: item.annotations,
593
- output: null,
594
- scores: {}
595
- };
596
- const itemStart = Date.now();
597
- try {
598
- if (options?.captureTraces) {
599
- const tracked = await runtime.trackExecution(
600
- async () => executeWorkflow(item.input, runtime),
601
- { captureTraces: true, captureTimingSamples: true }
602
- );
603
- evalItem.duration = Date.now() - itemStart;
604
- evalItem.output = tracked.result.output;
605
- evalItem.cost = extractUserCost(tracked.result) ?? tracked.cost;
606
- if (tracked.unpriced) evalItem.unpriced = true;
607
- evalItem.metadata = mergeMetadata(tracked.metadata, extractUserMetadata(tracked.result));
608
- absorbTiming(tracked.modelTiming, evalItem);
609
- if (tracked.traces && tracked.traces.length > 0) {
610
- evalItem.traces = tracked.traces;
611
- }
612
- if (evalItem.cost != null) {
613
- totalCost += evalItem.cost;
614
- }
615
- } else {
616
- let trackedTiming;
617
- let trackedMetadata;
618
- let result;
619
- if (typeof runtime.trackExecution === "function") {
620
- const tracked = await runtime.trackExecution(
621
- async () => executeWorkflow(item.input, runtime),
622
- { captureTraces: false, captureTimingSamples: true }
623
- );
624
- result = tracked.result;
625
- trackedTiming = tracked.modelTiming;
626
- trackedMetadata = tracked.metadata;
627
- } else {
628
- result = await executeWorkflow(item.input, runtime);
629
- }
630
- evalItem.duration = Date.now() - itemStart;
631
- evalItem.output = result.output;
632
- evalItem.cost = extractUserCost(result);
633
- const meta = extractUserMetadata(result);
634
- if (trackedMetadata || meta) evalItem.metadata = mergeMetadata(trackedMetadata, meta);
635
- absorbTiming(trackedTiming, evalItem);
636
- if (evalItem.cost != null) {
637
- totalCost += evalItem.cost;
638
- }
639
- }
640
- } catch (err) {
641
- evalItem.duration = Date.now() - itemStart;
642
- evalItem.error = err instanceof Error ? err.message : String(err);
643
- if (options?.captureTraces) {
644
- const captured = err.axlCapturedTraces;
645
- if (Array.isArray(captured) && captured.length > 0) {
646
- evalItem.traces = captured;
647
- }
648
- }
649
- evalItems[itemIndex] = evalItem;
650
- options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
651
- return;
652
- }
653
- if (budgetLimit != null && totalCost > budgetLimit) {
654
- budgetExceeded = true;
655
- }
656
- const itemScorerCost = await scoreItem(
657
- evalItem,
658
- config.scorers,
659
- scorerConcurrency,
660
- scorerContext,
661
- options?.signal
662
- );
663
- totalCost += itemScorerCost;
664
- evalItems[itemIndex] = evalItem;
665
- options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
666
- }
667
- await mapWithConcurrency(items, concurrency, (item, i) => processItem(item, i));
668
- const failures = evalItems.filter((i) => i.error).length;
669
- const scorerNames = config.scorers.map((s) => s.name);
670
- const scorerStats = {};
671
- for (const name of scorerNames) {
672
- const scores = evalItems.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
673
- const { scored, failed, skipped } = scorerCounts(evalItems, name);
674
- scorerStats[name] = { ...computeStats(scores), scored, failed, skipped };
675
- }
676
- const scorerTypes = {};
677
- for (const s of config.scorers) {
678
- scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
679
- }
680
- let degraded;
681
- const rawLimit = config.failOnScorerErrorRate;
682
- if (rawLimit != null) {
683
- if (!Number.isFinite(rawLimit) || rawLimit < 0 || rawLimit > 1) {
684
- console.warn(
685
- `[axl-eval] Ignoring invalid failOnScorerErrorRate (${rawLimit}); expected a number in [0, 1].`
686
- );
687
- } else {
688
- for (const name of scorerNames) {
689
- const stats = scorerStats[name];
690
- const scored = stats.scored ?? 0;
691
- const failed = stats.failed ?? 0;
692
- const type = scorerTypes[name] === "llm" ? "llm" : "deterministic";
693
- const limit = type === "deterministic" ? 0 : rawLimit;
694
- const verdict = evaluateScorerTolerance(scored, failed, type, limit);
695
- if (verdict.exceeds) {
696
- (degraded ??= []).push({ scorer: name, rate: verdict.rate, limit, type, scored, failed });
697
- }
698
- }
699
- }
700
- }
701
- const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
702
- const timing = durations.length > 0 ? computeStats(durations) : void 0;
703
- const modelTiming = runCallSamples.size > 0 ? Object.fromEntries(
704
- [...runCallSamples.entries()].map(([model, samples]) => {
705
- const firstToken = samples.map((s) => s.firstTokenMs).filter((v) => v != null);
706
- const stats = {
707
- calls: samples.length,
708
- wireMs: computeStats(samples.map((s) => s.wireMs)),
709
- queuedMs: computeStats(samples.map((s) => s.queuedMs)),
710
- retryMs: computeStats(samples.map((s) => s.retryMs)),
711
- ...firstToken.length > 0 ? { firstTokenMs: computeStats(firstToken), firstTokenCalls: firstToken.length } : {}
712
- };
713
- return [model, stats];
714
- })
715
- ) : void 0;
716
- const totalModelCalls = /* @__PURE__ */ new Map();
717
- for (const item of evalItems) {
718
- const itemCounts = item.metadata?.modelCallCounts;
719
- if (itemCounts && typeof itemCounts === "object") {
720
- for (const [m, count] of Object.entries(itemCounts)) {
721
- if (typeof count === "number")
722
- totalModelCalls.set(m, (totalModelCalls.get(m) ?? 0) + count);
723
- }
724
- } else {
725
- const itemModels = item.metadata?.models;
726
- if (Array.isArray(itemModels)) {
727
- for (const m of itemModels) {
728
- if (typeof m === "string") totalModelCalls.set(m, (totalModelCalls.get(m) ?? 0) + 1);
729
- }
730
- }
731
- }
1267
+ if (timing && Math.abs(timing.deltaPercent) > 1) {
1268
+ const dir = timing.delta > 0 ? "slower" : "faster";
1269
+ parts.push(`${Math.abs(timing.deltaPercent).toFixed(0)}% ${dir}`);
732
1270
  }
733
- const modelsMeta = {};
734
- if (totalModelCalls.size > 0) {
735
- const sorted = [...totalModelCalls.entries()].sort((a, b) => b[1] - a[1]);
736
- modelsMeta.models = sorted.map(([m]) => m);
737
- modelsMeta.modelCounts = Object.fromEntries(sorted);
1271
+ if (cost?.certified && cost.deltaPercent !== null && Math.abs(cost.deltaPercent) > 1) {
1272
+ const dir = cost.delta > 0 ? "more expensive" : "cheaper";
1273
+ parts.push(`${Math.abs(cost.deltaPercent).toFixed(0)}% ${dir}`);
738
1274
  }
739
- const totalWorkflowCalls = /* @__PURE__ */ new Map();
740
- for (const item of evalItems) {
741
- const itemCounts = item.metadata?.workflowCallCounts;
742
- if (itemCounts && typeof itemCounts === "object") {
743
- for (const [w, count] of Object.entries(itemCounts)) {
744
- if (typeof count === "number")
745
- totalWorkflowCalls.set(w, (totalWorkflowCalls.get(w) ?? 0) + count);
1275
+ const summaryStr = `candidate ${parts.length > 0 ? parts.join(", ") : "no meaningful changes"} with ${regressions.length} regressions and ${improvements.length} improvements`;
1276
+ const baselinePartial = detectPartial(baselineRunsRaw);
1277
+ const candidatePartial = detectPartial(candidateRunsRaw);
1278
+ return {
1279
+ baseline: {
1280
+ id: baselineRef.id,
1281
+ metadata: baselineRef.metadata,
1282
+ runCount,
1283
+ ...baselinePartial ? { partial: baselinePartial } : {}
1284
+ },
1285
+ candidate: {
1286
+ id: candidateRef.id,
1287
+ metadata: candidateRef.metadata,
1288
+ runCount,
1289
+ ...candidatePartial ? { partial: candidatePartial } : {}
1290
+ },
1291
+ scorers,
1292
+ timing,
1293
+ cost,
1294
+ regressions,
1295
+ improvements,
1296
+ summary: summaryStr
1297
+ };
1298
+ }
1299
+ function detectPartial(runs) {
1300
+ if (runs.length === 0) return void 0;
1301
+ let attempted;
1302
+ for (const r of runs) {
1303
+ const v = r.metadata?.batchAttempted;
1304
+ if (typeof v === "number" && Number.isFinite(v)) {
1305
+ attempted = v;
1306
+ break;
1307
+ }
1308
+ }
1309
+ if (attempted === void 0) return void 0;
1310
+ if (runs.length >= attempted) return void 0;
1311
+ return { completed: runs.length, attempted };
1312
+ }
1313
+ function evaluateScorerErrorRateGate(comparison, maxScorerErrorRate) {
1314
+ const scorerTypes = comparison.baseline.metadata?.scorerTypes ?? {};
1315
+ for (const name of Object.keys(comparison.scorers)) {
1316
+ const s = comparison.scorers[name];
1317
+ const type = scorerTypes[name] === "deterministic" ? "deterministic" : "llm";
1318
+ const limit = type === "deterministic" ? 0 : maxScorerErrorRate;
1319
+ for (const side of ["baseline", "candidate"]) {
1320
+ const scored = (side === "baseline" ? s.baselineScored : s.candidateScored) ?? 0;
1321
+ const failed = (side === "baseline" ? s.baselineFailed : s.candidateFailed) ?? 0;
1322
+ const skipped = (side === "baseline" ? s.baselineSkipped : s.candidateSkipped) ?? 0;
1323
+ const verdict = evaluateScorerTolerance(scored, failed, type, limit);
1324
+ if (verdict.zeroSample) {
1325
+ const cause = skipped > 0 ? `every one of its items was N/A (the \`applies\` predicate skipped all ${skipped})` : `its items errored or produced no score`;
1326
+ return `scorer "${name}" has no scored items on ${side} (${cause}) \u2014 cannot certify a mean over an empty sample.`;
746
1327
  }
747
- } else {
748
- const itemWorkflows = item.metadata?.workflows;
749
- if (Array.isArray(itemWorkflows)) {
750
- for (const w of itemWorkflows) {
751
- if (typeof w === "string")
752
- totalWorkflowCalls.set(w, (totalWorkflowCalls.get(w) ?? 0) + 1);
753
- }
1328
+ if (verdict.exceeds) {
1329
+ const limitStr = type === "deterministic" ? "deterministic zero-tolerance" : `${(limit * 100).toFixed(1)}%`;
1330
+ return `scorer "${name}" failed ${(verdict.rate * 100).toFixed(1)}% on ${side} (${failed}/${verdict.attempted}), over the ${limitStr} limit.`;
754
1331
  }
755
1332
  }
756
1333
  }
757
- const workflowsMeta = {};
758
- if (totalWorkflowCalls.size > 0) {
759
- const sorted = [...totalWorkflowCalls.entries()].sort((a, b) => b[1] - a[1]);
760
- workflowsMeta.workflows = sorted.map(([w]) => w);
761
- workflowsMeta.workflowCounts = Object.fromEntries(sorted);
762
- } else if (config.workflow) {
763
- workflowsMeta.workflows = [config.workflow];
764
- workflowsMeta.workflowCounts = { [config.workflow]: items.length };
1334
+ return null;
1335
+ }
1336
+ function compareItemErrorRates(baseline, candidate, maxItemErrorRate = DEFAULT_ITEM_ERROR_RATE_LIMIT) {
1337
+ if (!isErrorRateLimit(maxItemErrorRate)) {
1338
+ throw new Error(
1339
+ `Invalid maxItemErrorRate (${String(maxItemErrorRate)}): expected a number in [0, 1].`
1340
+ );
765
1341
  }
766
- options?.onProgress?.({ type: "run_done", totalItems: items.length, failures });
767
- return {
768
- id,
769
- dataset: config.dataset.name,
770
- metadata: {
771
- ...config.metadata,
772
- scorerTypes,
773
- ...modelsMeta,
774
- ...workflowsMeta,
775
- ...droppedAnnotationKeys.length > 0 ? { droppedAnnotationKeys } : {}
776
- },
777
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
778
- totalCost,
779
- ...evalItems.some((item) => item.unpriced) ? { unpriced: true } : {},
780
- duration: Date.now() - startTime,
781
- items: evalItems,
782
- summary: {
783
- count: items.length,
784
- failures,
785
- scorers: scorerStats,
786
- timing,
787
- ...modelTiming ? { modelTiming } : {},
788
- ...degraded ? { degraded } : {}
1342
+ const baselineRuns = Array.isArray(baseline) ? baseline : [baseline];
1343
+ const candidateRuns = Array.isArray(candidate) ? candidate : [candidate];
1344
+ const runCount = Math.min(baselineRuns.length, candidateRuns.length);
1345
+ const rates = [];
1346
+ for (const [side, runs] of [
1347
+ ["baseline", baselineRuns],
1348
+ ["candidate", candidateRuns]
1349
+ ]) {
1350
+ for (let r = 0; r < runCount; r++) {
1351
+ const run = runs[r];
1352
+ const coverage = run.summary.coverage?.items;
1353
+ const legacy = coverage === void 0;
1354
+ const verdict = legacy ? evaluateItemErrorRate(
1355
+ buildCoverage(run.items, []).items,
1356
+ run.items.length,
1357
+ maxItemErrorRate
1358
+ ) : evaluateItemErrorRate(coverage, run.summary.count, maxItemErrorRate);
1359
+ rates.push({ side, runIndex: r, runId: run.id, legacy, ...verdict });
789
1360
  }
790
- };
1361
+ }
1362
+ return rates;
1363
+ }
1364
+ function describeItemErrorRate(rate, multiRun) {
1365
+ const which = multiRun ? ` run ${rate.runIndex + 1} (${rate.runId})` : ` (${rate.runId})`;
1366
+ const legacy = rate.legacy ? ", derived from the items of a pre-0.24 artifact" : "";
1367
+ return `${rate.side}${which} item error rate ${formatPercent(rate.rate)} (${rate.failed}/${rate.attempted} attempted items failed${legacy})`;
1368
+ }
1369
+ function evaluateItemErrorRateGate(baseline, candidate, maxItemErrorRate = DEFAULT_ITEM_ERROR_RATE_LIMIT) {
1370
+ const rates = compareItemErrorRates(baseline, candidate, maxItemErrorRate);
1371
+ const multiRun = rates.some((r) => r.runIndex > 0);
1372
+ const over = rates.find((r) => r.exceeded);
1373
+ if (!over) return null;
1374
+ return `coverage: ${describeItemErrorRate(over, multiRun)} exceeds the ${formatPercent(maxItemErrorRate)} limit \u2014 its scores cover only the surviving items. Pass --max-item-error-rate <0..1> to accept a thinned side (1 disables the check).`;
791
1375
  }
792
1376
 
793
1377
  // src/rescore.ts
1378
+ var import_axl3 = require("@axlsdk/axl");
794
1379
  var import_node_crypto2 = require("crypto");
1380
+ var COPY_SHARE_OF_RUN_BOUND = 0.75;
1381
+ function describeFailure(error) {
1382
+ return error instanceof Error ? error.message : String(error);
1383
+ }
1384
+ function isDiagnosticsUnavailable(error) {
1385
+ return error?.code === "DIAGNOSTICS_UNAVAILABLE";
1386
+ }
1387
+ async function beginCapture(source, ownerId, runtime, options) {
1388
+ const limits = resolveCaptureLimits(options?.captureRequests);
1389
+ if (!limits) return {};
1390
+ const open = (artifactId, sink, carriedBytes) => ({
1391
+ artifactId,
1392
+ channel: new import_axl3.RequestCaptureChannel({
1393
+ sink,
1394
+ ...limits,
1395
+ carriedBytes,
1396
+ // Redaction is the RUNTIME's policy, not the caller's: a rescore cannot
1397
+ // opt out of compliance mode by asking for diagnostics.
1398
+ redact: runtime.isRedactEnabled()
1399
+ })
1400
+ });
1401
+ const sourceId = source.diagnostics?.artifactId;
1402
+ let stagedId;
1403
+ try {
1404
+ if (!sourceId) {
1405
+ const staged = await runtime.stageDiagnosticArtifact({ kind: "eval", id: ownerId });
1406
+ stagedId = staged.artifactId;
1407
+ return { capture: { ...open(staged.artifactId, staged.sink, 0), copied: false } };
1408
+ }
1409
+ const runBound = limits.maxRunBytes ?? DEFAULT_COPY_MAX_BYTES;
1410
+ const maxBytes = Math.max(1, Math.floor(runBound * COPY_SHARE_OF_RUN_BOUND));
1411
+ const copied = await runtime.copyDiagnosticArtifact(
1412
+ sourceId,
1413
+ { kind: "eval", id: ownerId },
1414
+ { maxBytes }
1415
+ );
1416
+ if (!copied) {
1417
+ return {
1418
+ degraded: unavailableManifest(
1419
+ "",
1420
+ "the source run's captured requests are no longer available"
1421
+ )
1422
+ };
1423
+ }
1424
+ stagedId = copied.artifactId;
1425
+ const capture = {
1426
+ ...open(copied.artifactId, copied.sink, copied.bytes),
1427
+ copied: true,
1428
+ carriedRedaction: copied.redaction
1429
+ };
1430
+ if (copied.truncated) {
1431
+ capture.copyTruncated = `the copied source records stopped at ${maxBytes} bytes, the share of the ${runBound} byte run limit reserved for them`;
1432
+ }
1433
+ return { capture };
1434
+ } catch (error) {
1435
+ if (stagedId !== void 0) {
1436
+ await runtime.rollbackDiagnosticArtifact(stagedId).catch(() => void 0);
1437
+ }
1438
+ if (isDiagnosticsUnavailable(error)) throw error;
1439
+ return {
1440
+ degraded: unavailableManifest(
1441
+ "",
1442
+ `the captured requests could not be carried forward: ${describeFailure(error)}`
1443
+ )
1444
+ };
1445
+ }
1446
+ }
1447
+ async function abandonCapture2(runtime, capture) {
1448
+ if (!capture) return;
1449
+ await capture.channel.close().catch(() => void 0);
1450
+ await runtime.rollbackDiagnosticArtifact(capture.artifactId).catch(() => void 0);
1451
+ }
1452
+ async function finishCapture2(runtime, capture) {
1453
+ const status = await capture.channel.close();
1454
+ const reason = [capture.copyTruncated, status.reason].filter(Boolean).join("; ") || void 0;
1455
+ const truncated = capture.copyTruncated !== void 0 || status.status === "truncated";
1456
+ const finalStatus = status.status === "unavailable" ? "unavailable" : truncated ? "truncated" : status.status;
1457
+ const redaction = status.redaction === "applied" && (capture.carriedRedaction ?? "applied") === "applied" ? "applied" : "none";
1458
+ try {
1459
+ const manifest = await runtime.finalizeDiagnosticArtifact(
1460
+ capture.artifactId,
1461
+ finalStatus,
1462
+ reason,
1463
+ redaction
1464
+ );
1465
+ return toDiagnosticManifest(manifest);
1466
+ } catch {
1467
+ return unavailableManifest("", "the diagnostic artifact could not be finalized");
1468
+ }
1469
+ }
795
1470
  async function rescore(result, scorers, runtime, options) {
796
1471
  const startTime = Date.now();
1472
+ const rescoredId = (0, import_node_crypto2.randomUUID)();
797
1473
  const concurrency = options?.concurrency ?? 5;
798
1474
  const scorerConcurrency = options?.scorerConcurrency ?? 5;
1475
+ const admission = options?.budget != null ? new import_axl3.AdmissionController({ limit: parseBudget(options.budget) }) : void 0;
1476
+ let closedBy;
1477
+ const noteClosure = (source) => {
1478
+ if (closedBy === void 0 && admission?.closed) closedBy = source;
1479
+ };
799
1480
  const scorerContext = {
800
1481
  resolveProvider: (uri) => {
801
1482
  if (typeof runtime.resolveProvider !== "function") {
@@ -808,7 +1489,8 @@ async function rescore(result, scorers, runtime, options) {
808
1489
  signal: options?.signal
809
1490
  };
810
1491
  const rescored = new Array(result.items.length);
811
- let totalCost = 0;
1492
+ const { capture, degraded } = await beginCapture(result, rescoredId, runtime, options);
1493
+ const carryDiagnostics = capture?.copied === true;
812
1494
  async function rescoreItem(original, itemIndex) {
813
1495
  if (options?.signal?.aborted) {
814
1496
  rescored[itemIndex] = {
@@ -816,16 +1498,31 @@ async function rescore(result, scorers, runtime, options) {
816
1498
  annotations: original.annotations,
817
1499
  output: original.output,
818
1500
  error: "cancelled",
1501
+ outcome: "cancelled",
1502
+ accounting: emptyAccounting(),
819
1503
  scores: {}
820
1504
  };
821
1505
  return;
822
1506
  }
823
1507
  if (original.error) {
1508
+ const accounting2 = emptyAccounting();
824
1509
  rescored[itemIndex] = {
825
1510
  input: original.input,
826
1511
  annotations: original.annotations,
827
1512
  output: original.output,
828
1513
  error: original.error,
1514
+ ...original.outcome ? { outcome: original.outcome } : {},
1515
+ // The failure cause is a fact about the source run too.
1516
+ ...original.failure ? { failure: original.failure } : {},
1517
+ accounting: accounting2,
1518
+ // Same rule as a scored item: an item carrying `accounting` always
1519
+ // carries both compat views, so no reader falls through to the legacy
1520
+ // "no accounting → trust the caller's number" branch and reports the
1521
+ // SOURCE run's spend as this rescore's. A passthrough did no work, so
1522
+ // both are a measured $0; the source's spend lives in the run-level
1523
+ // `accounting.source` provenance and nowhere else.
1524
+ cost: accounting2.breakdown.generation,
1525
+ scorerCost: accounting2.breakdown.judging,
829
1526
  scores: {}
830
1527
  };
831
1528
  return;
@@ -836,20 +1533,77 @@ async function rescore(result, scorers, runtime, options) {
836
1533
  output: original.output,
837
1534
  metadata: original.metadata,
838
1535
  traces: original.traces,
1536
+ outcome: "completed",
1537
+ // Carried forward VERBATIM, original operation ids included. The rescore
1538
+ // did not re-run the workflow, so these still describe the calls that
1539
+ // produced this output; reminting the ids would break the only link back
1540
+ // to the run that made them. Only carried when the source records were
1541
+ // actually copied into this rescore's artifact — a dangling reference is
1542
+ // worse than none.
1543
+ ...carryDiagnostics && original.diagnostics ? { diagnostics: original.diagnostics } : {},
839
1544
  scores: {},
840
1545
  scoreDetails: {}
841
1546
  };
842
- const itemScorerCost = await scoreItem(
843
- item,
844
- scorers,
845
- scorerConcurrency,
846
- scorerContext,
847
- options?.signal
1547
+ const itemOutcome = await trackScope(
1548
+ runtime,
1549
+ async () => {
1550
+ await scoreItem(item, scorers, {
1551
+ runtime,
1552
+ scorerContext,
1553
+ scorerConcurrency,
1554
+ signal: options?.signal,
1555
+ admission,
1556
+ onClosure: noteClosure
1557
+ });
1558
+ },
1559
+ {
1560
+ purpose: "judging",
1561
+ // Every record produced under this item is stamped with the case index,
1562
+ // which is how a `ScorerDetail` can point at its own judge calls.
1563
+ ...capture ? { captureCorrelation: { caseIndex: itemIndex } } : {}
1564
+ }
848
1565
  );
849
- totalCost += itemScorerCost;
1566
+ if (itemOutcome.status === "rejected") throw itemOutcome.error;
1567
+ item.accounting = itemOutcome.accounting;
1568
+ item.cost = itemOutcome.accounting.breakdown.generation;
1569
+ item.scorerCost = itemOutcome.accounting.breakdown.judging;
1570
+ if (itemOutcome.accounting.completeness !== "complete") item.unpriced = true;
850
1571
  rescored[itemIndex] = item;
851
1572
  }
852
- await mapWithConcurrency(result.items, concurrency, (item, i) => rescoreItem(item, i));
1573
+ const runOutcome = await trackScope(
1574
+ runtime,
1575
+ async () => {
1576
+ await mapWithConcurrency(result.items, concurrency, (item, i) => rescoreItem(item, i));
1577
+ },
1578
+ // The channel is declared ONCE, at the run scope: its byte budget and
1579
+ // pending queue are per rescore, and every nested item/scorer scope inherits
1580
+ // it rather than opening a competing budget of its own.
1581
+ { purpose: "judging", admission, ...capture ? { capture: capture.channel } : {} }
1582
+ );
1583
+ if (runOutcome.status === "rejected") {
1584
+ await abandonCapture2(runtime, capture);
1585
+ throw runOutcome.error;
1586
+ }
1587
+ const sourceAccounting = result.accounting;
1588
+ const generation = sourceAccounting?.scope === "rescore" ? sourceAccounting.source?.generation ?? null : sourceAccounting ?? null;
1589
+ let diagnostics = degraded;
1590
+ if (capture) {
1591
+ try {
1592
+ attachOperationRefs(rescored, capture.channel);
1593
+ } catch (error) {
1594
+ await abandonCapture2(runtime, capture);
1595
+ throw error;
1596
+ }
1597
+ diagnostics = await finishCapture2(runtime, capture);
1598
+ }
1599
+ const accounting = {
1600
+ ...runOutcome.accounting,
1601
+ scope: "rescore",
1602
+ // `?? null` is load-bearing: a legacy source has no accounting and must read
1603
+ // as "unknown generation", never as a synthesized complete record.
1604
+ source: { runId: result.id, generation },
1605
+ ...admission ? { budget: { ...admission.snapshot(), ...closedBy ? { closedBy } : {} } } : {}
1606
+ };
853
1607
  const failures = rescored.filter((i) => i.error).length;
854
1608
  const scorerNames = scorers.map((s) => s.name);
855
1609
  const scorerStats = {};
@@ -863,7 +1617,7 @@ async function rescore(result, scorers, runtime, options) {
863
1617
  scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
864
1618
  }
865
1619
  return {
866
- id: (0, import_node_crypto2.randomUUID)(),
1620
+ id: rescoredId,
867
1621
  dataset: result.dataset,
868
1622
  metadata: (() => {
869
1623
  const rest = { ...result.metadata };
@@ -887,10 +1641,24 @@ async function rescore(result, scorers, runtime, options) {
887
1641
  return merged;
888
1642
  })(),
889
1643
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
890
- totalCost,
1644
+ totalCost: accounting.knownCost,
1645
+ ...accounting.completeness !== "complete" ? { unpriced: true } : {},
1646
+ accounting,
1647
+ ...diagnostics ? { diagnostics } : {},
891
1648
  duration: Date.now() - startTime,
892
1649
  items: rescored,
893
- summary: { count: result.items.length, failures, scorers: scorerStats }
1650
+ summary: {
1651
+ count: result.items.length,
1652
+ failures,
1653
+ coverage: buildCoverage(rescored, scorerNames),
1654
+ scorers: scorerStats,
1655
+ // A rescore makes no generation calls, so the source run's wall-clock
1656
+ // and provider latency are still the only measurements of these items.
1657
+ // `modelTiming` cannot be rebuilt from `item.timing` (sums, not per-call
1658
+ // samples): carry both forward.
1659
+ ...result.summary.timing ? { timing: { ...result.summary.timing } } : {},
1660
+ ...result.summary.modelTiming ? { modelTiming: structuredClone(result.summary.modelTiming) } : {}
1661
+ }
894
1662
  };
895
1663
  }
896
1664
 
@@ -960,6 +1728,7 @@ function aggregateRuns(runs) {
960
1728
  workflows,
961
1729
  dataset,
962
1730
  totalCost: round2(totalCost),
1731
+ accounting: aggregateAccounting(runs.map((r) => readAccounting(r))),
963
1732
  totalDuration: round2(totalDuration),
964
1733
  scorers,
965
1734
  timing
@@ -967,7 +1736,7 @@ function aggregateRuns(runs) {
967
1736
  }
968
1737
 
969
1738
  // src/cli-utils.ts
970
- var import_axl = require("@axlsdk/axl");
1739
+ var import_axl4 = require("@axlsdk/axl");
971
1740
 
972
1741
  // src/cli-validate.ts
973
1742
  function validateEvalConfig(cfg) {
@@ -1014,6 +1783,12 @@ function validateEvalConfig(cfg) {
1014
1783
  return `exports an invalid failOnScorerErrorRate (${typeof v === "number" ? v : typeof v}) \u2014 must be a number between 0 and 1.`;
1015
1784
  }
1016
1785
  }
1786
+ {
1787
+ const v = c.failOnItemErrorRate;
1788
+ if (v != null && !isErrorRateLimit(v)) {
1789
+ return `exports an invalid failOnItemErrorRate (${typeof v === "number" ? v : typeof v}) \u2014 must be a number between 0 and 1 (1 disables the gate).`;
1790
+ }
1791
+ }
1017
1792
  return void 0;
1018
1793
  }
1019
1794
 
@@ -1026,8 +1801,11 @@ var KNOWN_FLAGS = /* @__PURE__ */ new Set([
1026
1801
  "--threshold",
1027
1802
  "--runs",
1028
1803
  "--capture-traces",
1804
+ "--capture-requests",
1029
1805
  "--concurrency",
1030
- "--scorers"
1806
+ "--scorers",
1807
+ "--budget",
1808
+ "--max-item-error-rate"
1031
1809
  ]);
1032
1810
  var VALUE_FLAGS = /* @__PURE__ */ new Set([
1033
1811
  "--output",
@@ -1035,7 +1813,9 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
1035
1813
  "--conditions",
1036
1814
  "--runs",
1037
1815
  "--concurrency",
1038
- "--scorers"
1816
+ "--scorers",
1817
+ "--budget",
1818
+ "--max-item-error-rate"
1039
1819
  ]);
1040
1820
  function envInt(name) {
1041
1821
  const raw = process.env[name];
@@ -1044,14 +1824,25 @@ function envInt(name) {
1044
1824
  if (!Number.isFinite(n) || n <= 0) return void 0;
1045
1825
  return n;
1046
1826
  }
1827
+ function parseErrorRateFlag(flag, raw) {
1828
+ const n = /^\d*\.?\d+$/.test(raw.trim()) ? Number(raw) : NaN;
1829
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
1830
+ console.error(`Error: ${flag} must be a number in [0, 1], got "${raw}"`);
1831
+ process.exit(1);
1832
+ }
1833
+ return n;
1834
+ }
1047
1835
  function parseEvalArgs(args) {
1048
1836
  let outputPath;
1049
1837
  let configArg;
1050
1838
  let conditions = [];
1051
1839
  let runs = 1;
1052
1840
  let captureTraces = false;
1841
+ let captureRequests = false;
1053
1842
  let concurrency;
1054
1843
  let scorerNames;
1844
+ let budget;
1845
+ let maxItemErrorRate;
1055
1846
  const paths = [];
1056
1847
  for (let i = 0; i < args.length; i++) {
1057
1848
  const arg = args[i];
@@ -1072,6 +1863,10 @@ function parseEvalArgs(args) {
1072
1863
  } else {
1073
1864
  concurrency = n;
1074
1865
  }
1866
+ } else if (arg === "--budget") {
1867
+ budget = value;
1868
+ } else if (arg === "--max-item-error-rate") {
1869
+ maxItemErrorRate = parseErrorRateFlag(arg, value);
1075
1870
  } else if (arg === "--scorers") {
1076
1871
  scorerNames = [
1077
1872
  ...new Set(
@@ -1083,6 +1878,8 @@ function parseEvalArgs(args) {
1083
1878
  }
1084
1879
  } else if (arg === "--capture-traces") {
1085
1880
  captureTraces = true;
1881
+ } else if (arg === "--capture-requests") {
1882
+ captureRequests = true;
1086
1883
  } else if (arg.startsWith("--")) {
1087
1884
  if (!KNOWN_FLAGS.has(arg)) {
1088
1885
  console.error(`Unknown flag: ${arg}`);
@@ -1098,13 +1895,90 @@ function parseEvalArgs(args) {
1098
1895
  conditions,
1099
1896
  runs,
1100
1897
  captureTraces,
1898
+ captureRequests,
1101
1899
  concurrency,
1102
1900
  scorerNames,
1901
+ budget,
1902
+ maxItemErrorRate,
1103
1903
  paths
1104
1904
  };
1105
1905
  }
1106
1906
 
1107
1907
  // src/cli-format.ts
1908
+ function formatKnownSpend(accounting) {
1909
+ const cost = `$${accounting.knownCost.toFixed(2)}`;
1910
+ if (accounting.completeness === "complete") return cost;
1911
+ if (accounting.completeness === "unverified") return `${cost} (unverified)`;
1912
+ const reasons = Object.entries(accounting.reasons).map(([reason, count]) => `${count} ${reason}`).join(", ");
1913
+ return `${cost} (incomplete: ${reasons || "unknown spend"})`;
1914
+ }
1915
+ function formatBudgetLine(accounting) {
1916
+ const budget = accounting.budget;
1917
+ if (!budget) return void 0;
1918
+ const limit = `$${budget.limit.toFixed(2)}`;
1919
+ const spent = `$${budget.knownSpend.toFixed(2)}`;
1920
+ if (budget.status === "open") {
1921
+ return ` Budget: ${spent} of ${limit} (open)`;
1922
+ }
1923
+ const by = budget.closedBy ? `, first observed by ${budget.closedBy}` : "";
1924
+ return ` Budget: STOPPED \u2014 ${spent} known spend against a ${limit} limit, $${budget.knownOvershoot.toFixed(2)} over${by}`;
1925
+ }
1926
+ function formatCoverageLine(result) {
1927
+ const items = result.summary.coverage?.items;
1928
+ if (!items) return void 0;
1929
+ const parts = [
1930
+ ["failed", items.failed],
1931
+ ["cancelled", items.cancelled],
1932
+ ["budget-skipped", items.budget_skipped],
1933
+ ["budget-interrupted", items.budget_interrupted]
1934
+ ].filter(([, n]) => n > 0).map(([label, n]) => `${n} ${label}`);
1935
+ if (parts.length === 0) return void 0;
1936
+ const rate = result.summary.itemErrorRate;
1937
+ const rateSuffix = rate ? ` \u2014 item error rate ${formatPercent(rate.rate)} (limit ${formatPercent(rate.limit)})` : "";
1938
+ const line = ` Items: ${items.completed} completed, ${parts.join(", ")}${rateSuffix}`;
1939
+ const causes = formatFailureCauses(result);
1940
+ return causes ? `${line}
1941
+ ${causes}` : line;
1942
+ }
1943
+ function formatFailureCauses(result) {
1944
+ const counts = /* @__PURE__ */ new Map();
1945
+ for (const item of result.items) {
1946
+ if (item.outcome !== "failed") continue;
1947
+ const f = item.failure;
1948
+ let label = "other";
1949
+ if (f?.status !== void 0) {
1950
+ const status = f.status === 0 ? "network" : String(f.status);
1951
+ label = f.provider ? `${status} (${f.provider})` : status;
1952
+ }
1953
+ counts.set(label, (counts.get(label) ?? 0) + 1);
1954
+ }
1955
+ if (counts.size === 0) return void 0;
1956
+ const groups = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([label, n]) => `${n} \xD7 ${label}`);
1957
+ return ` Failure causes: ${groups.join(", ")}`;
1958
+ }
1959
+ function itemErrorRateMessage(result, label) {
1960
+ const rate = result.summary.itemErrorRate;
1961
+ if (!rate?.exceeded) return null;
1962
+ return `[axl-eval] ITEM ERROR RATE EXCEEDED: ${label} \u2014 ${rate.failed} of ${rate.attempted} attempted item(s) failed in the workflow (item error rate ${formatPercent(rate.rate)}), over the ${formatPercent(rate.limit)} limit; the scores cover only the surviving items. Raise failOnItemErrorRate or pass --max-item-error-rate <0..1> to accept it (1 disables the gate).`;
1963
+ }
1964
+ function budgetStopMessage(result, label) {
1965
+ const budget = readAccounting(result).budget;
1966
+ if (!budget || !isBudgetStopped({ budget, coverage: result.summary.coverage })) return null;
1967
+ const items = result.summary.coverage?.items;
1968
+ const stopped = items ? ` ${items.budget_skipped} case(s) never started, ${items.budget_interrupted} stopped mid-flight, ${items.completed} completed.` : "";
1969
+ return `[axl-eval] BUDGET STOPPED: ${label} \u2014 known spend $${budget.knownSpend.toFixed(2)} reached the $${budget.limit.toFixed(2)} limit (over by $${budget.knownOvershoot.toFixed(2)}).${stopped} The run is incomplete by design; this is NOT a model or scorer failure.`;
1970
+ }
1971
+ function isTotalWipeout(result) {
1972
+ const { count, failures } = result.summary;
1973
+ if (count === 0) return false;
1974
+ const coverage = result.summary.coverage?.items;
1975
+ if (coverage) {
1976
+ const budgetStopped = coverage.budget_skipped + coverage.budget_interrupted;
1977
+ if (coverage.completed > 0 || budgetStopped > 0) return false;
1978
+ return coverage.failed === count;
1979
+ }
1980
+ return failures >= count;
1981
+ }
1108
1982
  function formatModelTimingLines(modelTiming, nameWidth) {
1109
1983
  if (!modelTiming) return [];
1110
1984
  return Object.entries(modelTiming).map(([model, t]) => {
@@ -1114,6 +1988,7 @@ function formatModelTimingLines(modelTiming, nameWidth) {
1114
1988
  }
1115
1989
  parts.push(`queued ${formatMs(t.queuedMs.mean)}`);
1116
1990
  parts.push(`retries ${formatMs(t.retryMs.mean)}`);
1991
+ if (t.rateLimitRetries) parts.push(`rate-limited ${t.rateLimitRetries}\xD7`);
1117
1992
  const calls = `${t.calls} call${t.calls === 1 ? "" : "s"}`;
1118
1993
  return ` ${model.padEnd(Math.max(nameWidth - 2, 0))} ${parts.join(" \xB7 ")} (${calls}, mean/p95 per call)`;
1119
1994
  });
@@ -1175,7 +2050,16 @@ Usage:
1175
2050
  (success + failure). Adds memory
1176
2051
  overhead proportional to dataset size
1177
2052
  x turns x agents; off by default.
2053
+ axl-eval <path> --budget <amount> Stop admitting spend once known cost
2054
+ reaches the limit (e.g. "$1", "0.50").
2055
+ Overrides the eval file's budget and
2056
+ applies PER RUN under --runs. A
2057
+ budget-stopped run exits 1 with a
2058
+ distinct BUDGET STOPPED line.
1178
2059
  axl-eval rescore <results> <eval-file> Re-run scorers on saved outputs
2060
+ axl-eval rescore <results> <eval-file> --budget <amount> Limit the NEW judging
2061
+ spend only; the source run's cost is
2062
+ not counted against it.
1179
2063
  axl-eval compare <a> <b> Compare two eval result files
1180
2064
  axl-eval compare <a> <b> --threshold <v> Set regression threshold (global or per-scorer)
1181
2065
  axl-eval compare <a> <b> --fail-on-regression Exit 1 if regressions
@@ -1187,10 +2071,11 @@ Usage:
1187
2071
  sample, even without this flag.
1188
2072
 
1189
2073
  Config auto-detection (when --config is not specified):
1190
- ${import_axl.CONFIG_CANDIDATES.join(" -> ")}
2074
+ ${import_axl4.CONFIG_CANDIDATES.join(" -> ")}
1191
2075
 
1192
2076
  When a config is found, the exported AxlRuntime is passed to executeWorkflow
1193
- and cost is tracked automatically via runtime.trackCost().
2077
+ and spend is measured by the runner's accounting scope (a "cost" your callback
2078
+ returns is recorded under item.callerReport, never used as the measurement).
1194
2079
  When no config is found, a bare AxlRuntime is created (providers from env vars).
1195
2080
  `);
1196
2081
  process.exit(0);
@@ -1240,17 +2125,27 @@ function scorerFilteredScorers(r) {
1240
2125
  if (!meta || meta.scorerFiltered !== true) return void 0;
1241
2126
  return Array.isArray(meta.scorersRun) ? meta.scorersRun : [];
1242
2127
  }
2128
+ function parseMaxItemErrorRate(args) {
2129
+ const idx = args.indexOf("--max-item-error-rate");
2130
+ if (idx === -1) return void 0;
2131
+ if (idx + 1 >= args.length) {
2132
+ console.error("Error: --max-item-error-rate requires a value in [0, 1]");
2133
+ process.exit(1);
2134
+ }
2135
+ return parseErrorRateFlag("--max-item-error-rate", args[idx + 1]);
2136
+ }
1243
2137
  async function runCompare(args) {
1244
2138
  const failOnRegression = args.includes("--fail-on-regression");
1245
2139
  const thresholds = parseThresholdArg(args);
1246
2140
  const maxScorerErrorRate = parseMaxScorerErrorRate(args);
1247
- const valueFlags = /* @__PURE__ */ new Set(["--threshold", "--max-scorer-error-rate"]);
2141
+ const maxItemErrorRate = parseMaxItemErrorRate(args);
2142
+ const valueFlags = /* @__PURE__ */ new Set(["--threshold", "--max-scorer-error-rate", "--max-item-error-rate"]);
1248
2143
  const files = args.filter(
1249
2144
  (a, i) => !a.startsWith("--") && !(i > 0 && valueFlags.has(args[i - 1]))
1250
2145
  );
1251
2146
  if (files.length !== 2) {
1252
2147
  console.error(
1253
- "Usage: axl-eval compare <baseline.json> <candidate.json> [--threshold <value>] [--fail-on-regression] [--max-scorer-error-rate <0..1>]"
2148
+ "Usage: axl-eval compare <baseline.json> <candidate.json> [--threshold <value>] [--fail-on-regression] [--max-scorer-error-rate <0..1>] [--max-item-error-rate <0..1>]"
1254
2149
  );
1255
2150
  process.exit(1);
1256
2151
  }
@@ -1315,9 +2210,13 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
1315
2210
  if (comparison.cost) {
1316
2211
  const c = comparison.cost;
1317
2212
  const sign = c.delta > 0 ? "+" : "";
2213
+ const change = c.deltaPercent === null ? `${sign}$${Math.abs(c.delta).toFixed(2)}, no % from a $0 baseline` : `${sign}${c.deltaPercent.toFixed(1)}%`;
1318
2214
  console.log(
1319
- ` Cost: baseline $${c.baselineTotal.toFixed(2)} -> candidate $${c.candidateTotal.toFixed(2)} (${sign}${c.deltaPercent.toFixed(1)}%)`
2215
+ ` Cost: baseline $${c.baselineTotal.toFixed(2)} -> candidate $${c.candidateTotal.toFixed(2)} (${change})`
1320
2216
  );
2217
+ if (!c.certified) {
2218
+ console.log(` not certified: ${c.reason ?? "costs are not comparable"}`);
2219
+ }
1321
2220
  }
1322
2221
  const baselineRef = Array.isArray(baseline) ? baseline[0] : baseline;
1323
2222
  const stable = Math.max(
@@ -1362,6 +2261,17 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
1362
2261
  const reason = evaluateScorerErrorRateGate(comparison, maxScorerErrorRate);
1363
2262
  if (reason) refuseToGate(reason);
1364
2263
  }
2264
+ const itemRates = compareItemErrorRates(baseline, candidate, maxItemErrorRate);
2265
+ const multiRunCompare = itemRates.some((r) => r.runIndex > 0);
2266
+ for (const rate of itemRates) {
2267
+ if (rate.failed > 0 && !rate.exceeded) {
2268
+ console.error(
2269
+ `[axl-eval] WARNING: ${describeItemErrorRate(rate, multiRunCompare)}, within the ${formatPercent(rate.limit)} limit; its scores cover only the surviving items.`
2270
+ );
2271
+ }
2272
+ }
2273
+ const coverageReason = evaluateItemErrorRateGate(baseline, candidate, maxItemErrorRate);
2274
+ if (coverageReason) refuseToGate(coverageReason);
1365
2275
  if (failOnRegression && comparison.regressions.length > 0) {
1366
2276
  const hasSignificance = scorerNames.some((n) => comparison.scorers[n].significant != null);
1367
2277
  if (hasSignificance) {
@@ -1375,7 +2285,16 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
1375
2285
  }
1376
2286
  }
1377
2287
  async function runRescore(args, signal) {
1378
- const { outputPath, configArg, conditions, concurrency, scorerNames, paths } = parseEvalArgs(args);
2288
+ const {
2289
+ outputPath,
2290
+ configArg,
2291
+ conditions,
2292
+ concurrency,
2293
+ scorerNames,
2294
+ budget,
2295
+ maxItemErrorRate,
2296
+ paths
2297
+ } = parseEvalArgs(args);
1379
2298
  if (paths.length < 2) {
1380
2299
  console.error("Usage: axl-eval rescore <results.json> <eval-file> [--output <file>]");
1381
2300
  process.exit(1);
@@ -1386,14 +2305,21 @@ async function runRescore(args, signal) {
1386
2305
  );
1387
2306
  process.exit(1);
1388
2307
  }
2308
+ if (maxItemErrorRate != null) {
2309
+ console.error(
2310
+ "Error: --max-item-error-rate is not supported with rescore; item failures belong to the source run \u2014 gate them with `axl-eval compare`."
2311
+ );
2312
+ process.exit(1);
2313
+ }
1389
2314
  const itemConcurrency = concurrency ?? envInt("AXL_EVAL_CONCURRENCY");
1390
2315
  const [resultsPath, evalFilePath] = paths;
1391
2316
  const raw = JSON.parse(await (0, import_promises.readFile)(resultsPath, "utf-8"));
1392
2317
  const results = Array.isArray(raw) ? raw : [raw];
1393
2318
  const runtime = await getRuntime(configArg, conditions);
2319
+ let exitCode = 0;
1394
2320
  try {
1395
- const mod = await (0, import_axl.importModule)(path.resolve(evalFilePath), import_meta.url);
1396
- const evalConfig = (0, import_axl.pickDefault)(mod);
2321
+ const mod = await (0, import_axl4.importModule)(path.resolve(evalFilePath), import_meta.url);
2322
+ const evalConfig = (0, import_axl4.pickDefault)(mod);
1397
2323
  if (!evalConfig || typeof evalConfig !== "object") {
1398
2324
  console.error(
1399
2325
  `Error: ${evalFilePath} does not export an eval config object \u2014 got ${evalConfig === null ? "null" : typeof evalConfig}.`
@@ -1405,32 +2331,52 @@ async function runRescore(args, signal) {
1405
2331
  process.exit(1);
1406
2332
  }
1407
2333
  const rescored = [];
1408
- for (const resultData of results) {
1409
- if (signal.aborted) break;
1410
- rescored.push(
1411
- await rescore(resultData, evalConfig.scorers, runtime, {
1412
- signal,
1413
- ...itemConcurrency != null ? { concurrency: itemConcurrency } : {}
1414
- })
1415
- );
1416
- }
1417
- for (const r of rescored) {
1418
- console.log("\n" + formatTable(r) + "\n");
2334
+ let budgetUsable = true;
2335
+ try {
2336
+ for (const resultData of results) {
2337
+ if (signal.aborted) break;
2338
+ rescored.push(
2339
+ await rescore(resultData, evalConfig.scorers, runtime, {
2340
+ signal,
2341
+ ...itemConcurrency != null ? { concurrency: itemConcurrency } : {},
2342
+ // Gates the NEW judging spend only — the source run's cost is history
2343
+ // and is not counted against this limit.
2344
+ ...budget != null ? { budget } : {}
2345
+ })
2346
+ );
2347
+ }
2348
+ } catch (err) {
2349
+ if (err?.code === "INVALID_BUDGET") {
2350
+ console.error(`[axl-eval] ${err instanceof Error ? err.message : String(err)}`);
2351
+ exitCode = 1;
2352
+ budgetUsable = false;
2353
+ } else {
2354
+ throw err;
2355
+ }
1419
2356
  }
1420
- if (outputPath) {
1421
- const output = Array.isArray(raw) ? rescored : rescored[0];
1422
- const outputDir = path.dirname(path.resolve(outputPath));
1423
- await (0, import_promises.mkdir)(outputDir, { recursive: true });
1424
- await (0, import_promises.writeFile)(outputPath, JSON.stringify(output, null, 2), "utf-8");
1425
- console.log(`Rescored results saved to ${outputPath}`);
2357
+ if (budgetUsable) {
2358
+ for (const r of rescored) {
2359
+ console.log("\n" + formatTable(r) + "\n");
2360
+ }
2361
+ if (outputPath) {
2362
+ const output = Array.isArray(raw) ? rescored : rescored[0];
2363
+ const outputDir = path.dirname(path.resolve(outputPath));
2364
+ await (0, import_promises.mkdir)(outputDir, { recursive: true });
2365
+ await (0, import_promises.writeFile)(outputPath, JSON.stringify(output, null, 2), "utf-8");
2366
+ console.log(`Rescored results saved to ${outputPath}`);
2367
+ }
2368
+ for (const r of rescored) {
2369
+ if (reportBudgetStop(r, `rescore ${resultsPath}`)) exitCode = 1;
2370
+ }
1426
2371
  }
1427
2372
  } finally {
1428
2373
  await runtime.shutdown().catch(() => {
1429
2374
  });
1430
2375
  }
2376
+ if (exitCode !== 0) process.exit(exitCode);
1431
2377
  }
1432
2378
  function collectEvalFiles(p) {
1433
- if (p.includes("*")) return (0, import_axl.expandGlob)(p, process.cwd());
2379
+ if (p.includes("*")) return (0, import_axl4.expandGlob)(p, process.cwd());
1434
2380
  const resolved = path.resolve(p);
1435
2381
  try {
1436
2382
  const stat = (0, import_node_fs.statSync)(resolved);
@@ -1485,11 +2431,15 @@ function formatTable(result) {
1485
2431
  }
1486
2432
  lines.push(...formatModelTimingLines(result.summary.modelTiming, maxNameLen));
1487
2433
  const durationSec = (result.duration / 1e3).toFixed(1);
1488
- const costStr = result.totalCost > 0 ? `$${result.totalCost.toFixed(2)}` : "$0.00";
2434
+ const accounting = readAccounting(result);
1489
2435
  lines.push("");
1490
2436
  lines.push(
1491
- ` Failures: ${result.summary.failures}/${result.summary.count} | Cost: ${costStr} | Duration: ${durationSec}s`
2437
+ ` Failures: ${result.summary.failures}/${result.summary.count} | Cost: ${formatKnownSpend(accounting)} | Duration: ${durationSec}s`
1492
2438
  );
2439
+ const budgetLine = formatBudgetLine(accounting);
2440
+ if (budgetLine) lines.push(budgetLine);
2441
+ const coverageLine = formatCoverageLine(result);
2442
+ if (coverageLine) lines.push(coverageLine);
1493
2443
  const itemsWithErrors = result.items.filter((i) => i.scorerErrors?.length);
1494
2444
  if (itemsWithErrors.length > 0) {
1495
2445
  const uniqueErrors = [...new Set(itemsWithErrors.flatMap((i) => i.scorerErrors))];
@@ -1533,10 +2483,11 @@ function formatMultiRunTable(summary) {
1533
2483
  ` ${"Timing".padEnd(maxNameLen)} ${((summary.timing.mean / 1e3).toFixed(2) + " \xB1 " + (summary.timing.std / 1e3).toFixed(2) + "s").padStart(colWidth)}`
1534
2484
  );
1535
2485
  }
1536
- const costStr = summary.totalCost > 0 ? `$${summary.totalCost.toFixed(2)}` : "$0.00";
1537
2486
  const durationStr = (summary.totalDuration / 1e3).toFixed(1);
1538
2487
  lines.push("");
1539
- lines.push(` Total Cost: ${costStr} | Total Duration: ${durationStr}s`);
2488
+ lines.push(
2489
+ ` Total Cost: ${formatKnownSpend(summary.accounting)} | Total Duration: ${durationStr}s`
2490
+ );
1540
2491
  return lines.join("\n");
1541
2492
  }
1542
2493
  function reportDegraded(result, label) {
@@ -1561,18 +2512,29 @@ function reportFullySkippedScorers(result, label) {
1561
2512
  }
1562
2513
  }
1563
2514
  }
2515
+ function reportBudgetStop(result, label) {
2516
+ const message = budgetStopMessage(result, label);
2517
+ if (!message) return false;
2518
+ console.error(message);
2519
+ return true;
2520
+ }
2521
+ function reportItemErrorRate(result, label, wipeout) {
2522
+ const message = itemErrorRateMessage(result, label);
2523
+ if (!message) return false;
2524
+ if (!wipeout) console.error(message);
2525
+ return true;
2526
+ }
1564
2527
  function reportTotalWipeout(result, label) {
1565
- const { count, failures } = result.summary;
1566
- if (count === 0 || failures < count) return false;
2528
+ if (!isTotalWipeout(result)) return false;
1567
2529
  console.error(
1568
- `[axl-eval] FAILED: ${label} \u2014 all ${count} item(s) errored in the workflow (0 succeeded); the eval produced no scorable output.`
2530
+ `[axl-eval] FAILED: ${label} \u2014 all ${result.summary.count} item(s) errored in the workflow (0 succeeded); the eval produced no scorable output.`
1569
2531
  );
1570
2532
  return true;
1571
2533
  }
1572
2534
  async function resolveRuntimeFromConfig(configPath) {
1573
2535
  try {
1574
- const mod = await (0, import_axl.importModule)(configPath, import_meta.url);
1575
- const runtime = (0, import_axl.resolveRuntime)(mod);
2536
+ const mod = await (0, import_axl4.importModule)(configPath, import_meta.url);
2537
+ const runtime = (0, import_axl4.resolveRuntime)(mod);
1576
2538
  if (!runtime || typeof runtime.execute !== "function") {
1577
2539
  console.error(`Config must export a default AxlRuntime instance.`);
1578
2540
  if (runtime) {
@@ -1609,7 +2571,7 @@ async function resolveRuntimeFromConfig(configPath) {
1609
2571
  }
1610
2572
  async function getRuntime(configArg, conditions) {
1611
2573
  if (conditions && conditions.length > 0) {
1612
- await (0, import_axl.registerConditions)(conditions);
2574
+ await (0, import_axl4.registerConditions)(conditions);
1613
2575
  }
1614
2576
  if (configArg) {
1615
2577
  const configPath = path.resolve(process.cwd(), configArg);
@@ -1621,7 +2583,7 @@ async function getRuntime(configArg, conditions) {
1621
2583
  console.error(`[axl-eval] Loading config from ${configPath}`);
1622
2584
  return resolveRuntimeFromConfig(configPath);
1623
2585
  }
1624
- const found = (0, import_axl.findConfig)(process.cwd());
2586
+ const found = (0, import_axl4.findConfig)(process.cwd());
1625
2587
  if (found) {
1626
2588
  console.error(`[axl-eval] Auto-detected config: ${found}`);
1627
2589
  return resolveRuntimeFromConfig(found);
@@ -1629,6 +2591,27 @@ async function getRuntime(configArg, conditions) {
1629
2591
  const { AxlRuntime } = await import("@axlsdk/axl");
1630
2592
  return new AxlRuntime();
1631
2593
  }
2594
+ async function writeRequestSidecar(runtime, outputPath, results) {
2595
+ const artifactIds = results.map((result) => result.diagnostics?.artifactId).filter((id) => typeof id === "string" && id !== "");
2596
+ if (artifactIds.length === 0) return;
2597
+ const store = runtime.getDiagnosticArtifactStore();
2598
+ if (!store) return;
2599
+ const records = [];
2600
+ for (const artifactId of artifactIds) {
2601
+ const opened = await store.open(artifactId).catch(() => void 0);
2602
+ if (!opened) continue;
2603
+ for await (const line of opened.lines) {
2604
+ try {
2605
+ records.push(JSON.parse(line));
2606
+ } catch {
2607
+ }
2608
+ }
2609
+ }
2610
+ if (records.length === 0) return;
2611
+ const sidecarPath = outputPath.replace(/\.json$/i, "") + ".requests.jsonl";
2612
+ await (0, import_promises.writeFile)(sidecarPath, serializeRequestRecords(records), "utf-8");
2613
+ console.log(`Captured requests saved to ${sidecarPath}`);
2614
+ }
1632
2615
  async function runEvalCommand(args, signal) {
1633
2616
  const {
1634
2617
  outputPath,
@@ -1636,8 +2619,11 @@ async function runEvalCommand(args, signal) {
1636
2619
  conditions,
1637
2620
  runs,
1638
2621
  captureTraces,
2622
+ captureRequests,
1639
2623
  concurrency,
1640
2624
  scorerNames,
2625
+ budget,
2626
+ maxItemErrorRate,
1641
2627
  paths
1642
2628
  } = parseEvalArgs(args);
1643
2629
  if (paths.length === 0) {
@@ -1663,8 +2649,8 @@ async function runEvalCommand(args, signal) {
1663
2649
  for (const filePath of evalFiles) {
1664
2650
  if (signal.aborted) break;
1665
2651
  try {
1666
- const mod = await (0, import_axl.importModule)(path.resolve(filePath), import_meta.url);
1667
- const evalConfig = (0, import_axl.pickDefault)(mod);
2652
+ const mod = await (0, import_axl4.importModule)(path.resolve(filePath), import_meta.url);
2653
+ const evalConfig = (0, import_axl4.pickDefault)(mod);
1668
2654
  const validationError = validateEvalConfig(evalConfig);
1669
2655
  if (validationError) {
1670
2656
  console.error(`Error: ${filePath} ${validationError}`);
@@ -1672,6 +2658,8 @@ async function runEvalCommand(args, signal) {
1672
2658
  continue;
1673
2659
  }
1674
2660
  evalConfig.concurrency = concurrency ?? envInt("AXL_EVAL_CONCURRENCY") ?? evalConfig.concurrency ?? 5;
2661
+ if (budget != null) evalConfig.budget = budget;
2662
+ if (maxItemErrorRate != null) evalConfig.failOnItemErrorRate = maxItemErrorRate;
1675
2663
  if (scorerNames?.length) {
1676
2664
  const available = evalConfig.scorers.map((s) => s.name);
1677
2665
  const unknown = scorerNames.filter((n) => !available.includes(n));
@@ -1687,7 +2675,7 @@ async function runEvalCommand(args, signal) {
1687
2675
  process.exit(1);
1688
2676
  }
1689
2677
  }
1690
- const exported = (0, import_axl.pickExport)(mod, "executeWorkflow");
2678
+ const exported = (0, import_axl4.pickExport)(mod, "executeWorkflow");
1691
2679
  let customExecute;
1692
2680
  if (typeof exported === "function") {
1693
2681
  customExecute = exported;
@@ -1700,27 +2688,11 @@ async function runEvalCommand(args, signal) {
1700
2688
  }
1701
2689
  let executeWorkflow;
1702
2690
  if (customExecute) {
1703
- executeWorkflow = async (input, rt) => {
1704
- const {
1705
- result,
1706
- cost: trackedCost,
1707
- metadata
1708
- } = await runtime.trackExecution(async () => {
1709
- return customExecute(input, rt);
1710
- });
1711
- return {
1712
- output: result.output,
1713
- cost: result.cost ?? trackedCost,
1714
- metadata: result.metadata ?? metadata
1715
- };
1716
- };
2691
+ executeWorkflow = async (input, rt) => customExecute(input, rt);
1717
2692
  } else if (runtime.getWorkflow(evalConfig.workflow)) {
1718
- executeWorkflow = async (input) => {
1719
- const { result, cost, metadata } = await runtime.trackExecution(async () => {
1720
- return runtime.execute(evalConfig.workflow, input);
1721
- });
1722
- return { output: result, cost, metadata };
1723
- };
2693
+ executeWorkflow = async (input) => ({
2694
+ output: await runtime.execute(evalConfig.workflow, input)
2695
+ });
1724
2696
  } else {
1725
2697
  const exportShape = (() => {
1726
2698
  const top = Object.keys(mod).filter((k) => k !== "default");
@@ -1742,7 +2714,11 @@ async function runEvalCommand(args, signal) {
1742
2714
  failedFiles++;
1743
2715
  continue;
1744
2716
  }
1745
- const runOptions = { signal, ...captureTraces ? { captureTraces: true } : {} };
2717
+ const runOptions = {
2718
+ signal,
2719
+ ...captureTraces ? { captureTraces: true } : {},
2720
+ ...captureRequests ? { captureRequests: true } : {}
2721
+ };
1746
2722
  const scorersRun = scorerNames?.length ? evalConfig.scorers.map((s) => s.name) : void 0;
1747
2723
  const stampFiltered = (result) => {
1748
2724
  if (scorersRun) {
@@ -1795,11 +2771,13 @@ async function runEvalCommand(args, signal) {
1795
2771
  console.log("\n" + formatMultiRunTable(summary) + "\n");
1796
2772
  for (const r of runResults) results.push(r);
1797
2773
  let anyFailing = false;
1798
- for (const r of runResults) {
2774
+ for (const [i, r] of runResults.entries()) {
2775
+ const budgetStopped = reportBudgetStop(r, filePath);
1799
2776
  const wipeout = reportTotalWipeout(r, filePath);
2777
+ const thinned = reportItemErrorRate(r, `${filePath} run ${i + 1}/${runs}`, wipeout);
1800
2778
  const degraded = reportDegraded(r, filePath);
1801
2779
  reportFullySkippedScorers(r, filePath);
1802
- if (wipeout || degraded) anyFailing = true;
2780
+ if (budgetStopped || wipeout || thinned || degraded) anyFailing = true;
1803
2781
  }
1804
2782
  if (anyFailing && !partial) failedFiles++;
1805
2783
  } else {
@@ -1807,10 +2785,12 @@ async function runEvalCommand(args, signal) {
1807
2785
  stampFiltered(result);
1808
2786
  results.push(result);
1809
2787
  console.log("\n" + formatTable(result) + "\n");
2788
+ const budgetStopped = reportBudgetStop(result, filePath);
1810
2789
  const wipeout = reportTotalWipeout(result, filePath);
2790
+ const thinned = reportItemErrorRate(result, filePath, wipeout);
1811
2791
  const degraded = reportDegraded(result, filePath);
1812
2792
  reportFullySkippedScorers(result, filePath);
1813
- if (wipeout || degraded) failedFiles++;
2793
+ if (budgetStopped || wipeout || thinned || degraded) failedFiles++;
1814
2794
  }
1815
2795
  } catch (err) {
1816
2796
  console.error(
@@ -1825,6 +2805,7 @@ async function runEvalCommand(args, signal) {
1825
2805
  await (0, import_promises.mkdir)(outputDir, { recursive: true });
1826
2806
  await (0, import_promises.writeFile)(outputPath, JSON.stringify(output, null, 2), "utf-8");
1827
2807
  console.log(`Results saved to ${outputPath}`);
2808
+ await writeRequestSidecar(runtime, outputPath, results);
1828
2809
  }
1829
2810
  } finally {
1830
2811
  await runtime.shutdown().catch(() => {