@axlsdk/eval 0.23.3 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -30,18 +30,31 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ DEFAULT_COPY_MAX_BYTES: () => DEFAULT_COPY_MAX_BYTES,
34
+ DEFAULT_SIDECAR_MAX_BYTES: () => DEFAULT_SIDECAR_MAX_BYTES,
35
+ aggregateAccounting: () => aggregateAccounting,
33
36
  aggregateRuns: () => aggregateRuns,
34
37
  dataset: () => dataset,
35
38
  defineEval: () => defineEval,
36
39
  evalCompare: () => evalCompare,
40
+ evaluateItemErrorRateGate: () => evaluateItemErrorRateGate,
37
41
  evaluateScorerErrorRateGate: () => evaluateScorerErrorRateGate,
38
42
  evaluateScorerTolerance: () => evaluateScorerTolerance,
43
+ isBudgetStopped: () => isBudgetStopped,
39
44
  llmScorer: () => llmScorer,
40
45
  normalizeScorerResult: () => normalizeScorerResult,
41
46
  pairedBootstrapCI: () => pairedBootstrapCI,
47
+ parseRequestRecords: () => parseRequestRecords,
48
+ readAccounting: () => readAccounting,
49
+ refusedWork: () => refusedWork,
42
50
  rescore: () => rescore,
51
+ resolveCaptureLimits: () => resolveCaptureLimits,
43
52
  runEval: () => runEval,
44
- scorer: () => scorer
53
+ scorer: () => scorer,
54
+ serializeRequestRecords: () => serializeRequestRecords,
55
+ toDiagnosticManifest: () => toDiagnosticManifest,
56
+ unavailableManifest: () => unavailableManifest,
57
+ validateRequestSidecar: () => validateRequestSidecar
45
58
  });
46
59
  module.exports = __toCommonJS(index_exports);
47
60
 
@@ -249,6 +262,9 @@ function defineEval(config) {
249
262
  return config;
250
263
  }
251
264
 
265
+ // src/runner.ts
266
+ var import_axl3 = require("@axlsdk/axl");
267
+
252
268
  // src/utils.ts
253
269
  function computeStats(scores) {
254
270
  if (scores.length === 0) return { mean: 0, min: 0, max: 0, p50: 0, p95: 0 };
@@ -263,15 +279,35 @@ function computeStats(scores) {
263
279
  function round(n) {
264
280
  return Math.round(n * 1e3) / 1e3;
265
281
  }
282
+ function formatPercent(rate) {
283
+ return `${Number((rate * 100).toFixed(1))}%`;
284
+ }
285
+ var DEFAULT_ITEM_ERROR_RATE_LIMIT = 0.05;
286
+ function isErrorRateLimit(value) {
287
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
288
+ }
289
+ function evaluateItemErrorRate(items, count, limit) {
290
+ const failed = items.failed;
291
+ const attempted = count - items.cancelled - items.budget_skipped - items.budget_interrupted;
292
+ const rate = attempted > 0 ? failed / attempted : 0;
293
+ return { failed, attempted, rate, limit, exceeded: attempted > 0 && rate > limit };
294
+ }
266
295
  function scorerCounts(items, name) {
267
296
  let scored = 0;
268
297
  let failed = 0;
269
298
  let skipped = 0;
270
299
  for (const i of items) {
271
300
  if (i.error) continue;
301
+ const detail = i.scoreDetails?.[name];
302
+ if (detail?.outcome !== void 0) {
303
+ if (detail.outcome === "scored") scored++;
304
+ else if (detail.outcome === "failed") failed++;
305
+ else if (detail.outcome === "skipped") skipped++;
306
+ continue;
307
+ }
272
308
  if (i.scores[name] != null) scored++;
273
- else if (i.scoreDetails?.[name]?.skipped === true) skipped++;
274
- else if (i.scoreDetails?.[name]?.duration != null) failed++;
309
+ else if (detail?.skipped === true) skipped++;
310
+ else if (detail?.duration != null) failed++;
275
311
  }
276
312
  return { scored, failed, skipped };
277
313
  }
@@ -296,81 +332,380 @@ async function mapWithConcurrency(items, concurrency, task) {
296
332
  return results;
297
333
  }
298
334
 
335
+ // src/accounting.ts
336
+ var import_axl2 = require("@axlsdk/axl");
337
+ var USAGE_KEYS = [
338
+ "inputTokens",
339
+ "outputTokens",
340
+ "reasoningTokens",
341
+ "cachedTokens",
342
+ "cacheWriteTokens",
343
+ "audioSeconds"
344
+ ];
345
+ function zeroUsage() {
346
+ return {
347
+ inputTokens: 0,
348
+ outputTokens: 0,
349
+ reasoningTokens: 0,
350
+ cachedTokens: 0,
351
+ cacheWriteTokens: 0,
352
+ audioSeconds: 0
353
+ };
354
+ }
355
+ function emptyAccounting() {
356
+ return {
357
+ version: 1,
358
+ currency: "USD",
359
+ knownCost: 0,
360
+ completeness: "complete",
361
+ reasons: {},
362
+ usage: zeroUsage(),
363
+ operations: { total: 0, settled: 0, unknown: 0, denied: 0, byKind: {} },
364
+ breakdown: { generation: 0, judging: 0, external: 0 },
365
+ provenance: {}
366
+ };
367
+ }
368
+ function uninstrumentedAccounting() {
369
+ return { ...emptyAccounting(), completeness: "incomplete", reasons: { uninstrumented: 1 } };
370
+ }
371
+ function isInstrumented(runtime) {
372
+ return typeof runtime?.trackOutcome === "function";
373
+ }
374
+ async function trackScope(runtime, fn, options) {
375
+ if (!isInstrumented(runtime)) {
376
+ try {
377
+ return {
378
+ status: "fulfilled",
379
+ value: await fn(),
380
+ accounting: uninstrumentedAccounting(),
381
+ instrumented: false
382
+ };
383
+ } catch (error) {
384
+ return {
385
+ status: "rejected",
386
+ error,
387
+ accounting: uninstrumentedAccounting(),
388
+ instrumented: false
389
+ };
390
+ }
391
+ }
392
+ const outcome = await runtime.trackOutcome(fn, options);
393
+ return { ...outcome, instrumented: true };
394
+ }
395
+ function parseBudget(budget) {
396
+ const match = typeof budget === "string" ? budget.match(/^\$?(\d+(?:\.\d+)?)$/) : null;
397
+ if (!match) {
398
+ throw new import_axl2.AxlError(
399
+ "INVALID_BUDGET",
400
+ `Invalid budget "${String(budget)}": expected a non-negative USD amount like "$1" or "0.50".`
401
+ );
402
+ }
403
+ const limit = Number(match[1]);
404
+ if (!Number.isFinite(limit)) {
405
+ throw new import_axl2.AxlError("INVALID_BUDGET", `Invalid budget "${budget}": not a finite amount.`);
406
+ }
407
+ return limit;
408
+ }
409
+ function usableCost(value) {
410
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
411
+ }
412
+ function isAdmissionDenied(err) {
413
+ return (0, import_axl2.isAdmissionDeniedError)(err);
414
+ }
415
+ function readAccounting(result) {
416
+ if (result?.accounting) return result.accounting;
417
+ const rescored = result?.metadata?.rescored === true;
418
+ return {
419
+ ...emptyAccounting(),
420
+ knownCost: usableCost(result?.totalCost),
421
+ completeness: "unverified",
422
+ scope: rescored ? "rescore" : "run"
423
+ };
424
+ }
425
+ function refusedWork(coverage) {
426
+ if (!coverage) return 0;
427
+ return refusedIn(coverage.items) + Object.values(coverage.scorers ?? {}).reduce((n, s) => n + refusedIn(s), 0);
428
+ }
429
+ function refusedIn(counts) {
430
+ return atLeastZero(counts?.budget_skipped) + atLeastZero(counts?.budget_interrupted);
431
+ }
432
+ function atLeastZero(value) {
433
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
434
+ }
435
+ function isBudgetStopped(summary) {
436
+ if (summary.budget?.status !== "closed") return false;
437
+ return refusedWork(summary.coverage) > 0;
438
+ }
439
+ function addUsage(target, source) {
440
+ if (!source) return;
441
+ for (const key of USAGE_KEYS) {
442
+ const value = source[key];
443
+ if (typeof value === "number" && Number.isFinite(value)) target[key] += value;
444
+ }
445
+ }
446
+ function addCounts(target, source) {
447
+ if (!source) return;
448
+ for (const [key, count] of Object.entries(source)) {
449
+ if (typeof count === "number" && Number.isFinite(count)) {
450
+ target[key] = (target[key] ?? 0) + count;
451
+ }
452
+ }
453
+ }
454
+ function aggregateAccounting(inputs) {
455
+ const out = emptyAccounting();
456
+ let anyUnverified = false;
457
+ let anyIncomplete = false;
458
+ for (const input of inputs) {
459
+ if (!input) continue;
460
+ if (input.completeness === "unverified") anyUnverified = true;
461
+ else if (input.completeness === "incomplete") anyIncomplete = true;
462
+ out.knownCost += usableCost(input.knownCost);
463
+ addUsage(out.usage, input.usage);
464
+ addCounts(out.reasons, input.reasons);
465
+ addCounts(out.operations.byKind, input.operations?.byKind);
466
+ addCounts(out.provenance, input.provenance);
467
+ out.operations.total += input.operations?.total ?? 0;
468
+ out.operations.settled += input.operations?.settled ?? 0;
469
+ out.operations.unknown += input.operations?.unknown ?? 0;
470
+ out.operations.denied += input.operations?.denied ?? 0;
471
+ out.breakdown.generation += usableCost(input.breakdown?.generation);
472
+ out.breakdown.judging += usableCost(input.breakdown?.judging);
473
+ out.breakdown.external += usableCost(input.breakdown?.external);
474
+ }
475
+ out.completeness = anyUnverified ? "unverified" : anyIncomplete ? "incomplete" : "complete";
476
+ return out;
477
+ }
478
+
299
479
  // src/score-item.ts
300
- async function scoreItem(item, scorers, scorerConcurrency, scorerContext, signal) {
480
+ function describeInput(input) {
481
+ let rendered;
482
+ try {
483
+ rendered = JSON.stringify(input) ?? String(input);
484
+ } catch {
485
+ rendered = "[unserializable input]";
486
+ }
487
+ return rendered.length > 200 ? `${rendered.slice(0, 200)}\u2026` : rendered;
488
+ }
489
+ async function scoreItem(item, scorers, context) {
301
490
  const scoreDetails = item.scoreDetails ??= {};
302
491
  for (const s of scorers) {
303
492
  item.scores[s.name] = null;
304
- scoreDetails[s.name] = { score: null };
493
+ scoreDetails[s.name] = { score: null, outcome: "cancelled" };
305
494
  }
306
- let itemScorerCost = 0;
495
+ const { runtime, scorerContext, signal, admission } = context;
307
496
  const scorerErrorsByName = {};
308
- await mapWithConcurrency(scorers, scorerConcurrency, async (scorer2) => {
309
- if (signal?.aborted) return;
497
+ await mapWithConcurrency(scorers, context.scorerConcurrency, async (scorer2) => {
498
+ const record = (detail, outcome2) => {
499
+ scoreDetails[scorer2.name] = { ...detail, outcome: outcome2 };
500
+ };
501
+ if (signal?.aborted) {
502
+ record({ score: null }, "cancelled");
503
+ return;
504
+ }
505
+ if (admission?.closed && scorer2.isLlm) {
506
+ record({ score: null }, "budget_skipped");
507
+ return;
508
+ }
310
509
  const scorerStart = Date.now();
510
+ let applicable = true;
311
511
  try {
312
- if (scorer2.applies && !scorer2.applies(item.output, item.input, item.annotations)) {
313
- item.scores[scorer2.name] = null;
314
- scoreDetails[scorer2.name] = { score: null, skipped: true };
512
+ applicable = !scorer2.applies || scorer2.applies(item.output, item.input, item.annotations);
513
+ } catch (err) {
514
+ scorerErrorsByName[scorer2.name] = `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`;
515
+ record({ score: null, duration: Date.now() - scorerStart }, "failed");
516
+ return;
517
+ }
518
+ if (!applicable) {
519
+ record({ score: null, skipped: true }, "skipped");
520
+ return;
521
+ }
522
+ const outcome = await trackScope(
523
+ runtime,
524
+ async () => scorer2.score(item.output, item.input, item.annotations, scorerContext),
525
+ // The scorer stamp is merged OVER the enclosing item's correlation, so a
526
+ // judge's records keep the case index and add the judge's name -- which is
527
+ // what lets `scoreDetails[name].diagnostics` point at this judge's own
528
+ // calls rather than at the whole item's.
529
+ { purpose: "judging", captureCorrelation: { scorer: scorer2.name } }
530
+ );
531
+ const duration = Date.now() - scorerStart;
532
+ const { accounting } = outcome;
533
+ const measured = accounting.operations.total > 0 || accounting.operations.denied > 0;
534
+ const withCost = (detail, callerCost) => ({
535
+ ...detail,
536
+ ...measured ? { accounting, cost: accounting.knownCost } : {},
537
+ ...!measured && !outcome.instrumented && callerCost != null ? { cost: callerCost } : {}
538
+ });
539
+ if (outcome.status === "rejected") {
540
+ const err = outcome.error;
541
+ if (err?.name === "AbortError") {
542
+ record(withCost({ score: null, duration }, void 0), "cancelled");
315
543
  return;
316
544
  }
317
- const raw = await scorer2.score(item.output, item.input, item.annotations, scorerContext);
318
- const scorerResult = normalizeScorerResult(raw);
319
- if (scorerResult.cost != null) {
320
- itemScorerCost += scorerResult.cost;
545
+ if (isAdmissionDenied(err)) {
546
+ context.onClosure?.("operation");
547
+ record(withCost({ score: null, duration }, void 0), "budget_interrupted");
548
+ return;
321
549
  }
322
- const scorerDuration = Date.now() - scorerStart;
550
+ scorerErrorsByName[scorer2.name] = `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`;
551
+ record(withCost({ score: null, duration }, extractScorerErrorCost(err)), "failed");
552
+ if (admission?.closed) context.onClosure?.("scorer");
553
+ return;
554
+ }
555
+ try {
556
+ const scorerResult = normalizeScorerResult(outcome.value);
557
+ const callerCost = typeof scorerResult.cost === "number" && Number.isFinite(scorerResult.cost) && scorerResult.cost >= 0 ? scorerResult.cost : void 0;
323
558
  if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
324
- scorerErrorsByName[scorer2.name] = `Scorer "${scorer2.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`;
325
- item.scores[scorer2.name] = null;
326
- scoreDetails[scorer2.name] = {
327
- score: null,
328
- metadata: scorerResult.metadata,
329
- duration: scorerDuration,
330
- cost: scorerResult.cost
331
- };
559
+ scorerErrorsByName[scorer2.name] = `Scorer "${scorer2.name}" returned out-of-range score ${scorerResult.score} for input ${describeInput(item.input)}`;
560
+ record(
561
+ withCost({ score: null, metadata: scorerResult.metadata, duration }, callerCost),
562
+ "failed"
563
+ );
332
564
  } else {
333
565
  item.scores[scorer2.name] = round(scorerResult.score);
334
- scoreDetails[scorer2.name] = {
335
- score: round(scorerResult.score),
336
- metadata: scorerResult.metadata,
337
- duration: scorerDuration,
338
- cost: scorerResult.cost
339
- };
566
+ record(
567
+ withCost(
568
+ {
569
+ score: round(scorerResult.score),
570
+ metadata: scorerResult.metadata,
571
+ duration
572
+ },
573
+ callerCost
574
+ ),
575
+ "scored"
576
+ );
340
577
  }
341
578
  } catch (err) {
342
- if (err?.name === "AbortError") return;
343
- const errCost = extractScorerErrorCost(err);
344
- if (errCost != null) itemScorerCost += errCost;
345
579
  scorerErrorsByName[scorer2.name] = `Scorer "${scorer2.name}" threw: ${err instanceof Error ? err.message : String(err)}`;
346
- item.scores[scorer2.name] = null;
347
- scoreDetails[scorer2.name] = {
348
- score: null,
349
- duration: Date.now() - scorerStart,
350
- cost: errCost
351
- };
580
+ record(withCost({ score: null, duration }, void 0), "failed");
352
581
  }
582
+ if (admission?.closed) context.onClosure?.("scorer");
353
583
  });
354
584
  const orderedErrors = scorers.map((s) => scorerErrorsByName[s.name]).filter((e) => e != null);
355
585
  if (orderedErrors.length > 0) item.scorerErrors = orderedErrors;
356
- item.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
357
- return itemScorerCost;
586
+ }
587
+
588
+ // src/diagnostics.ts
589
+ var DEFAULT_COPY_MAX_BYTES = 16 * 1024 * 1024;
590
+ function resolveCaptureLimits(option) {
591
+ if (option === void 0 || option === false) return void 0;
592
+ if (option === true) return {};
593
+ return {
594
+ ...option.maxRecordBytes !== void 0 ? { maxRecordBytes: option.maxRecordBytes } : {},
595
+ ...option.maxRunBytes !== void 0 ? { maxRunBytes: option.maxRunBytes } : {},
596
+ ...option.maxQueueBytes !== void 0 ? { maxQueueBytes: option.maxQueueBytes } : {}
597
+ };
598
+ }
599
+ function toDiagnosticManifest(manifest) {
600
+ return {
601
+ version: 1,
602
+ artifactId: manifest.artifactId,
603
+ fidelity: "runtime_request",
604
+ status: manifest.status,
605
+ ...manifest.reason !== void 0 ? { reason: manifest.reason } : {},
606
+ records: manifest.records,
607
+ bytes: manifest.bytes,
608
+ redaction: manifest.redaction,
609
+ ...manifest.expiresAt !== void 0 ? { expiresAt: manifest.expiresAt } : {}
610
+ };
611
+ }
612
+ function unavailableManifest(artifactId, reason) {
613
+ return {
614
+ version: 1,
615
+ artifactId,
616
+ fidelity: "runtime_request",
617
+ status: "unavailable",
618
+ reason,
619
+ records: 0,
620
+ bytes: 0,
621
+ redaction: "none"
622
+ };
623
+ }
624
+ var DEFAULT_SIDECAR_MAX_BYTES = 16 * 1024 * 1024;
625
+ var DEFAULT_SIDECAR_MAX_RECORD_BYTES = 4 * 1024 * 1024;
626
+ function validateRequestSidecar(text, options) {
627
+ if (typeof text !== "string") {
628
+ return { ok: false, reason: "requests sidecar must be a JSONL string" };
629
+ }
630
+ const maxBytes = options?.maxBytes ?? DEFAULT_SIDECAR_MAX_BYTES;
631
+ const maxRecordBytes = options?.maxRecordBytes ?? DEFAULT_SIDECAR_MAX_RECORD_BYTES;
632
+ const bytes = Buffer.byteLength(text, "utf-8");
633
+ if (bytes > maxBytes) {
634
+ return { ok: false, reason: `requests sidecar exceeds ${maxBytes} bytes` };
635
+ }
636
+ const lines = text.split("\n").filter((line) => line.trim() !== "");
637
+ for (let i = 0; i < lines.length; i++) {
638
+ const lineBytes = Buffer.byteLength(lines[i], "utf-8");
639
+ if (lineBytes > maxRecordBytes) {
640
+ return {
641
+ ok: false,
642
+ reason: `requests sidecar line ${i + 1} exceeds ${maxRecordBytes} bytes`
643
+ };
644
+ }
645
+ let parsed;
646
+ try {
647
+ parsed = JSON.parse(lines[i]);
648
+ } catch {
649
+ return { ok: false, reason: `requests sidecar line ${i + 1} is not valid JSON` };
650
+ }
651
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
652
+ return { ok: false, reason: `requests sidecar line ${i + 1} is not an object` };
653
+ }
654
+ const record = parsed;
655
+ if (record.v !== 1) {
656
+ return {
657
+ ok: false,
658
+ reason: `requests sidecar line ${i + 1} declares unsupported codec version ${String(record.v)}`
659
+ };
660
+ }
661
+ if (typeof record.operationId !== "string" || record.operationId === "") {
662
+ return { ok: false, reason: `requests sidecar line ${i + 1} has no operationId` };
663
+ }
664
+ if (record.phase !== "start" && record.phase !== "attempt" && record.phase !== "end") {
665
+ return { ok: false, reason: `requests sidecar line ${i + 1} has an unknown phase` };
666
+ }
667
+ }
668
+ return { ok: true, lines, bytes };
669
+ }
670
+ function parseRequestRecords(lines) {
671
+ return lines.map((line) => JSON.parse(line));
672
+ }
673
+ function serializeRequestRecords(records) {
674
+ return records.map((record) => JSON.stringify(record)).join("\n") + (records.length ? "\n" : "");
358
675
  }
359
676
 
360
677
  // src/runner.ts
361
678
  var import_node_crypto = require("crypto");
362
- function parseCost(cost) {
363
- const match = cost.match(/^\$?([\d.]+)$/);
364
- if (!match) throw new Error(`Invalid cost format: "${cost}"`);
365
- return parseFloat(match[1]);
366
- }
679
+ var RESERVED_METADATA_KEYS = [
680
+ "models",
681
+ "modelCallCounts",
682
+ "workflows",
683
+ "workflowCallCounts",
684
+ "tokens",
685
+ "agentCalls"
686
+ ];
687
+ var ITEM_OUTCOMES = [
688
+ "completed",
689
+ "failed",
690
+ "cancelled",
691
+ "budget_skipped",
692
+ "budget_interrupted"
693
+ ];
694
+ var SCORER_OUTCOMES = [
695
+ "scored",
696
+ "failed",
697
+ "skipped",
698
+ "cancelled",
699
+ "budget_skipped",
700
+ "budget_interrupted"
701
+ ];
367
702
  function extractUserCost(result, label = "executeWorkflow") {
368
703
  if (result === null || typeof result !== "object") return void 0;
369
704
  const raw = result.cost;
370
705
  if (raw === void 0) return void 0;
371
706
  if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return raw;
372
707
  console.warn(
373
- `[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).`
708
+ `[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.`
374
709
  );
375
710
  return void 0;
376
711
  }
@@ -393,26 +728,146 @@ function extractUserMetadata(result, label = "executeWorkflow") {
393
728
  }
394
729
  return meta;
395
730
  }
396
- function mergeMetadata(tracked, user) {
397
- const merged = { ...tracked, ...user };
398
- for (const [list, counts] of [
399
- ["models", "modelCallCounts"],
400
- ["workflows", "workflowCallCounts"]
401
- ]) {
402
- if (user && Object.hasOwn(user, list) && !Object.hasOwn(user, counts)) {
403
- delete merged[counts];
731
+ function splitCallerMetadata(user) {
732
+ if (!user) return {};
733
+ let reserved;
734
+ const safe = {};
735
+ for (const [key, value] of Object.entries(user)) {
736
+ if (RESERVED_METADATA_KEYS.includes(key)) {
737
+ (reserved ??= {})[key] = value;
738
+ } else {
739
+ safe[key] = value;
740
+ }
741
+ }
742
+ return { reserved, ...Object.keys(safe).length > 0 ? { safe } : {} };
743
+ }
744
+ function isAbortError(err) {
745
+ return err?.name === "AbortError";
746
+ }
747
+ async function stageCapture(runtime, ownerId, option) {
748
+ const limits = resolveCaptureLimits(option);
749
+ if (!limits) return void 0;
750
+ const staged = await runtime.stageDiagnosticArtifact({ kind: "eval", id: ownerId });
751
+ return {
752
+ artifactId: staged.artifactId,
753
+ channel: new import_axl3.RequestCaptureChannel({
754
+ sink: staged.sink,
755
+ ...limits,
756
+ // Redaction is the RUNTIME's policy, not the caller's: a run cannot opt
757
+ // out of compliance mode by asking for diagnostics.
758
+ redact: runtime.isRedactEnabled()
759
+ })
760
+ };
761
+ }
762
+ async function finishCapture(runtime, capture) {
763
+ const status = await capture.channel.close();
764
+ try {
765
+ return await runtime.finalizeDiagnosticArtifact(
766
+ capture.artifactId,
767
+ status.status,
768
+ status.reason,
769
+ // What the channel ACTUALLY applied. The store sees only scrubbed bytes
770
+ // and cannot tell, so a manifest without this reports every compliance-
771
+ // mode artifact as unredacted.
772
+ status.redaction
773
+ );
774
+ } catch {
775
+ return void 0;
776
+ }
777
+ }
778
+ async function abandonCapture(runtime, capture) {
779
+ if (!capture) return;
780
+ await capture.channel.close().catch(() => void 0);
781
+ await runtime.rollbackDiagnosticArtifact(capture.artifactId).catch(() => void 0);
782
+ }
783
+ function attachOperationRefs(items, channel) {
784
+ const byItem = /* @__PURE__ */ new Map();
785
+ const byScorer = /* @__PURE__ */ new Map();
786
+ for (const entry of channel.operations()) {
787
+ if (entry.caseIndex === void 0) continue;
788
+ const ref = {
789
+ operationId: entry.operationId,
790
+ kind: entry.kind,
791
+ ...entry.turn !== void 0 ? { turn: entry.turn } : {},
792
+ ...entry.attempt !== void 0 ? { attempt: entry.attempt } : {},
793
+ status: entry.status
794
+ };
795
+ if (entry.scorer !== void 0) {
796
+ const forItem = byScorer.get(entry.caseIndex) ?? /* @__PURE__ */ new Map();
797
+ const list = forItem.get(entry.scorer) ?? [];
798
+ list.push(ref);
799
+ forItem.set(entry.scorer, list);
800
+ byScorer.set(entry.caseIndex, forItem);
801
+ } else {
802
+ const list = byItem.get(entry.caseIndex) ?? [];
803
+ list.push(ref);
804
+ byItem.set(entry.caseIndex, list);
805
+ }
806
+ }
807
+ for (const [caseIndex, operations] of byItem) {
808
+ const item = items[caseIndex];
809
+ if (!item) continue;
810
+ item.diagnostics = { operations: [...item.diagnostics?.operations ?? [], ...operations] };
811
+ }
812
+ for (const [caseIndex, forItem] of byScorer) {
813
+ for (const [scorer2, operations] of forItem) {
814
+ const detail = items[caseIndex]?.scoreDetails?.[scorer2];
815
+ if (detail) detail.diagnostics = { operations };
404
816
  }
405
817
  }
406
- return merged;
818
+ }
819
+ var MAX_CAUSE_DEPTH = 8;
820
+ function isProviderErrorLike(value) {
821
+ if (value instanceof import_axl3.ProviderError) return true;
822
+ if (typeof value !== "object" || value === null) return false;
823
+ const candidate = value;
824
+ return candidate.code === "PROVIDER_ERROR" && candidate.name === "ProviderError";
825
+ }
826
+ function describeItemFailure(thrown) {
827
+ let current = thrown;
828
+ for (let depth = 0; depth <= MAX_CAUSE_DEPTH; depth++) {
829
+ if (typeof current !== "object" || current === null) break;
830
+ if (isProviderErrorLike(current)) {
831
+ const { name: name2, provider, status, retryable, requestId } = current;
832
+ return {
833
+ name: typeof name2 === "string" ? name2 : "ProviderError",
834
+ ...typeof provider === "string" ? { provider } : {},
835
+ ...typeof status === "number" && Number.isFinite(status) ? { status } : {},
836
+ ...typeof retryable === "boolean" ? { retryable } : {},
837
+ ...typeof requestId === "string" ? { requestId } : {}
838
+ };
839
+ }
840
+ current = current.cause;
841
+ }
842
+ const name = thrown?.name;
843
+ return typeof thrown === "object" && thrown !== null && typeof name === "string" && name ? { name } : void 0;
407
844
  }
408
845
  async function runEval(config, executeWorkflow, runtime, options) {
409
846
  const startTime = Date.now();
410
847
  const id = (0, import_node_crypto.randomUUID)();
411
- const items = await config.dataset.getItems();
848
+ const itemErrorRateLimit = config.failOnItemErrorRate ?? DEFAULT_ITEM_ERROR_RATE_LIMIT;
849
+ if (!isErrorRateLimit(itemErrorRateLimit)) {
850
+ throw new import_axl3.AxlError(
851
+ "INVALID_ITEM_ERROR_RATE",
852
+ `Invalid failOnItemErrorRate (${String(itemErrorRateLimit)}): expected a number in [0, 1]; 1 disables the gate.`
853
+ );
854
+ }
855
+ const admission = config.budget != null ? new import_axl3.AdmissionController({ limit: parseBudget(config.budget) }) : void 0;
856
+ const capture = await stageCapture(runtime, id, options?.captureRequests);
857
+ let items;
858
+ try {
859
+ items = await config.dataset.getItems();
860
+ } catch (error) {
861
+ await abandonCapture(runtime, capture);
862
+ throw error;
863
+ }
412
864
  const droppedAnnotationKeys = [...config.dataset.droppedAnnotationKeys ?? []];
413
865
  const concurrency = config.concurrency ?? 5;
414
866
  const scorerConcurrency = config.scorerConcurrency ?? 5;
415
- const budgetLimit = config.budget ? parseCost(config.budget) : void 0;
867
+ let closedBy;
868
+ const noteClosure = (source) => {
869
+ if (closedBy === void 0 && admission?.closed) closedBy = source;
870
+ };
416
871
  const scorerContext = {
417
872
  resolveProvider: (uri) => {
418
873
  if (typeof runtime.resolveProvider !== "function") {
@@ -425,8 +880,7 @@ async function runEval(config, executeWorkflow, runtime, options) {
425
880
  signal: options?.signal
426
881
  };
427
882
  const evalItems = new Array(items.length);
428
- let totalCost = 0;
429
- let budgetExceeded = false;
883
+ const callerReported = { costItems: 0, costTotal: 0, metadataItems: 0 };
430
884
  const runCallSamples = /* @__PURE__ */ new Map();
431
885
  function absorbTiming(rollup, evalItem) {
432
886
  if (!rollup) return;
@@ -448,26 +902,28 @@ async function runEval(config, executeWorkflow, runtime, options) {
448
902
  }
449
903
  evalItem.timing = perItem;
450
904
  }
905
+ function stub(item, outcome, error) {
906
+ return {
907
+ input: item.input,
908
+ annotations: item.annotations,
909
+ output: null,
910
+ error,
911
+ outcome,
912
+ accounting: emptyAccounting(),
913
+ cost: 0,
914
+ scorerCost: 0,
915
+ scores: {}
916
+ };
917
+ }
451
918
  async function processItem(item, itemIndex) {
452
919
  if (options?.signal?.aborted) {
453
- evalItems[itemIndex] = {
454
- input: item.input,
455
- annotations: item.annotations,
456
- output: null,
457
- error: "Cancelled",
458
- scores: {}
459
- };
920
+ evalItems[itemIndex] = stub(item, "cancelled", "Cancelled");
460
921
  options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
461
922
  return;
462
923
  }
463
- if (budgetExceeded) {
464
- evalItems[itemIndex] = {
465
- input: item.input,
466
- annotations: item.annotations,
467
- output: null,
468
- error: "Budget exceeded",
469
- scores: {}
470
- };
924
+ if (admission?.closed) {
925
+ noteClosure("case");
926
+ evalItems[itemIndex] = stub(item, "budget_skipped", "Budget exceeded");
471
927
  options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
472
928
  return;
473
929
  }
@@ -478,77 +934,124 @@ async function runEval(config, executeWorkflow, runtime, options) {
478
934
  scores: {}
479
935
  };
480
936
  const itemStart = Date.now();
481
- try {
482
- if (options?.captureTraces) {
483
- const tracked = await runtime.trackExecution(
937
+ const itemOutcome = await trackScope(
938
+ runtime,
939
+ async () => {
940
+ const generation = await trackScope(
941
+ runtime,
484
942
  async () => executeWorkflow(item.input, runtime),
485
- { captureTraces: true, captureTimingSamples: true }
943
+ {
944
+ purpose: "generation",
945
+ captureTraces: options?.captureTraces === true,
946
+ captureTimingSamples: true
947
+ }
486
948
  );
487
949
  evalItem.duration = Date.now() - itemStart;
488
- evalItem.output = tracked.result.output;
489
- evalItem.cost = extractUserCost(tracked.result) ?? tracked.cost;
490
- if (tracked.unpriced) evalItem.unpriced = true;
491
- evalItem.metadata = mergeMetadata(tracked.metadata, extractUserMetadata(tracked.result));
492
- absorbTiming(tracked.modelTiming, evalItem);
493
- if (tracked.traces && tracked.traces.length > 0) {
494
- evalItem.traces = tracked.traces;
950
+ absorbTiming(generation.modelTiming, evalItem);
951
+ if (generation.traces && generation.traces.length > 0) {
952
+ evalItem.traces = generation.traces;
495
953
  }
496
- if (evalItem.cost != null) {
497
- totalCost += evalItem.cost;
954
+ if (generation.status === "rejected") {
955
+ const err = generation.error;
956
+ if (isAdmissionDenied(err)) {
957
+ noteClosure("operation");
958
+ evalItem.outcome = "budget_interrupted";
959
+ evalItem.error = "Budget interrupted";
960
+ } else if (isAbortError(err)) {
961
+ evalItem.outcome = "cancelled";
962
+ evalItem.error = "Cancelled";
963
+ } else {
964
+ evalItem.outcome = "failed";
965
+ const failure = describeItemFailure(err);
966
+ if (failure) evalItem.failure = failure;
967
+ evalItem.error = err instanceof Error ? err.message : String(err);
968
+ if (admission?.closed) noteClosure("case");
969
+ }
970
+ return;
498
971
  }
499
- } else {
500
- let trackedTiming;
501
- let trackedMetadata;
502
- let result;
503
- if (typeof runtime.trackExecution === "function") {
504
- const tracked = await runtime.trackExecution(
505
- async () => executeWorkflow(item.input, runtime),
506
- { captureTraces: false, captureTimingSamples: true }
507
- );
508
- result = tracked.result;
509
- trackedTiming = tracked.modelTiming;
510
- trackedMetadata = tracked.metadata;
511
- } else {
512
- result = await executeWorkflow(item.input, runtime);
513
- }
514
- evalItem.duration = Date.now() - itemStart;
972
+ const result = generation.value;
973
+ evalItem.outcome = "completed";
515
974
  evalItem.output = result.output;
516
- evalItem.cost = extractUserCost(result);
517
- const meta = extractUserMetadata(result);
518
- if (trackedMetadata || meta) evalItem.metadata = mergeMetadata(trackedMetadata, meta);
519
- absorbTiming(trackedTiming, evalItem);
520
- if (evalItem.cost != null) {
521
- totalCost += evalItem.cost;
975
+ const callerCost = extractUserCost(result);
976
+ if (callerCost !== void 0) {
977
+ (evalItem.callerReport ??= {}).cost = callerCost;
978
+ callerReported.costItems += 1;
979
+ callerReported.costTotal += callerCost;
522
980
  }
523
- }
524
- } catch (err) {
525
- evalItem.duration = Date.now() - itemStart;
526
- evalItem.error = err instanceof Error ? err.message : String(err);
527
- if (options?.captureTraces) {
528
- const captured = err.axlCapturedTraces;
529
- if (Array.isArray(captured) && captured.length > 0) {
530
- evalItem.traces = captured;
981
+ const { reserved, safe } = splitCallerMetadata(extractUserMetadata(result));
982
+ if (reserved) {
983
+ (evalItem.callerReport ??= {}).metadata = reserved;
984
+ callerReported.metadataItems += 1;
985
+ }
986
+ if (generation.metadata || safe) {
987
+ evalItem.metadata = { ...generation.metadata, ...safe };
531
988
  }
989
+ if (admission?.closed) noteClosure("case");
990
+ await scoreItem(evalItem, config.scorers, {
991
+ runtime,
992
+ scorerContext,
993
+ scorerConcurrency,
994
+ signal: options?.signal,
995
+ admission,
996
+ onClosure: noteClosure
997
+ });
998
+ },
999
+ {
1000
+ purpose: "generation",
1001
+ // Every record produced anywhere under this item — workflow turns,
1002
+ // nested asks, tool continuations, judges — is stamped with the case
1003
+ // index, which is how the result can point an item at its own requests.
1004
+ ...capture ? { captureCorrelation: { caseIndex: itemIndex } } : {}
532
1005
  }
533
- evalItems[itemIndex] = evalItem;
534
- options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
535
- return;
536
- }
537
- if (budgetLimit != null && totalCost > budgetLimit) {
538
- budgetExceeded = true;
539
- }
540
- const itemScorerCost = await scoreItem(
541
- evalItem,
542
- config.scorers,
543
- scorerConcurrency,
544
- scorerContext,
545
- options?.signal
546
1006
  );
547
- totalCost += itemScorerCost;
1007
+ if (itemOutcome.status === "rejected") throw itemOutcome.error;
1008
+ evalItem.accounting = itemOutcome.accounting;
1009
+ evalItem.cost = itemOutcome.accounting.breakdown.generation;
1010
+ evalItem.scorerCost = itemOutcome.accounting.breakdown.judging;
1011
+ if (itemOutcome.accounting.completeness !== "complete") evalItem.unpriced = true;
548
1012
  evalItems[itemIndex] = evalItem;
549
1013
  options?.onProgress?.({ type: "item_done", itemIndex, totalItems: items.length });
550
1014
  }
551
- await mapWithConcurrency(items, concurrency, (item, i) => processItem(item, i));
1015
+ const runOutcome = await trackScope(
1016
+ runtime,
1017
+ async () => {
1018
+ await mapWithConcurrency(items, concurrency, (item, i) => processItem(item, i));
1019
+ },
1020
+ // The capture channel is declared ONCE, at the run scope: its byte budget
1021
+ // and pending queue are per RUN, and every nested item/scorer scope
1022
+ // inherits it rather than opening a competing budget of its own.
1023
+ { admission, ...capture ? { capture: capture.channel } : {} }
1024
+ );
1025
+ if (runOutcome.status === "rejected") {
1026
+ await abandonCapture(runtime, capture);
1027
+ throw runOutcome.error;
1028
+ }
1029
+ let diagnostics;
1030
+ if (capture) {
1031
+ try {
1032
+ attachOperationRefs(evalItems, capture.channel);
1033
+ } catch (error) {
1034
+ await abandonCapture(runtime, capture);
1035
+ throw error;
1036
+ }
1037
+ const manifest = await finishCapture(runtime, capture);
1038
+ diagnostics = manifest ? toDiagnosticManifest(manifest) : (
1039
+ // An artifact that was never sealed is never committed, so publishing
1040
+ // its id would point a reader at bytes the sweeper is about to reclaim.
1041
+ unavailableManifest("", "the diagnostic artifact could not be finalized")
1042
+ );
1043
+ }
1044
+ const accounting = {
1045
+ ...runOutcome.accounting,
1046
+ scope: "run",
1047
+ ...admission ? {
1048
+ budget: {
1049
+ ...admission.snapshot(),
1050
+ ...closedBy ? { closedBy } : {}
1051
+ }
1052
+ } : {},
1053
+ ...callerReported.costItems > 0 || callerReported.metadataItems > 0 ? { callerReported } : {}
1054
+ };
552
1055
  const failures = evalItems.filter((i) => i.error).length;
553
1056
  const scorerNames = config.scorers.map((s) => s.name);
554
1057
  const scorerStats = {};
@@ -557,6 +1060,7 @@ async function runEval(config, executeWorkflow, runtime, options) {
557
1060
  const { scored, failed, skipped } = scorerCounts(evalItems, name);
558
1061
  scorerStats[name] = { ...computeStats(scores), scored, failed, skipped };
559
1062
  }
1063
+ const coverage = buildCoverage(evalItems, scorerNames);
560
1064
  const scorerTypes = {};
561
1065
  for (const s of config.scorers) {
562
1066
  scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
@@ -582,21 +1086,10 @@ async function runEval(config, executeWorkflow, runtime, options) {
582
1086
  }
583
1087
  }
584
1088
  }
1089
+ const itemErrorRate = evaluateItemErrorRate(coverage.items, items.length, itemErrorRateLimit);
585
1090
  const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
586
1091
  const timing = durations.length > 0 ? computeStats(durations) : void 0;
587
- const modelTiming = runCallSamples.size > 0 ? Object.fromEntries(
588
- [...runCallSamples.entries()].map(([model, samples]) => {
589
- const firstToken = samples.map((s) => s.firstTokenMs).filter((v) => v != null);
590
- const stats = {
591
- calls: samples.length,
592
- wireMs: computeStats(samples.map((s) => s.wireMs)),
593
- queuedMs: computeStats(samples.map((s) => s.queuedMs)),
594
- retryMs: computeStats(samples.map((s) => s.retryMs)),
595
- ...firstToken.length > 0 ? { firstTokenMs: computeStats(firstToken), firstTokenCalls: firstToken.length } : {}
596
- };
597
- return [model, stats];
598
- })
599
- ) : void 0;
1092
+ const modelTiming = buildModelTiming(runCallSamples);
600
1093
  const totalModelCalls = /* @__PURE__ */ new Map();
601
1094
  for (const item of evalItems) {
602
1095
  const itemCounts = item.metadata?.modelCallCounts;
@@ -659,20 +1152,64 @@ async function runEval(config, executeWorkflow, runtime, options) {
659
1152
  ...droppedAnnotationKeys.length > 0 ? { droppedAnnotationKeys } : {}
660
1153
  },
661
1154
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
662
- totalCost,
663
- ...evalItems.some((item) => item.unpriced) ? { unpriced: true } : {},
1155
+ totalCost: accounting.knownCost,
1156
+ ...accounting.completeness !== "complete" ? { unpriced: true } : {},
1157
+ accounting,
1158
+ ...diagnostics ? { diagnostics } : {},
664
1159
  duration: Date.now() - startTime,
665
1160
  items: evalItems,
666
1161
  summary: {
667
1162
  count: items.length,
668
1163
  failures,
1164
+ coverage,
669
1165
  scorers: scorerStats,
670
1166
  timing,
671
1167
  ...modelTiming ? { modelTiming } : {},
672
- ...degraded ? { degraded } : {}
1168
+ ...degraded ? { degraded } : {},
1169
+ ...itemErrorRate.failed > 0 ? { itemErrorRate } : {}
673
1170
  }
674
1171
  };
675
1172
  }
1173
+ function buildCoverage(items, scorerNames) {
1174
+ const coverage = {
1175
+ items: Object.fromEntries(ITEM_OUTCOMES.map((o) => [o, 0])),
1176
+ scorers: {}
1177
+ };
1178
+ for (const name of scorerNames) {
1179
+ coverage.scorers[name] = Object.fromEntries(SCORER_OUTCOMES.map((o) => [o, 0]));
1180
+ }
1181
+ for (const item of items) {
1182
+ const outcome = item.outcome ?? (item.error ? "failed" : "completed");
1183
+ if (coverage.items[outcome] !== void 0) coverage.items[outcome] += 1;
1184
+ for (const name of scorerNames) {
1185
+ const detail = item.scoreDetails?.[name];
1186
+ if (!detail?.outcome) continue;
1187
+ const bucket = coverage.scorers[name];
1188
+ if (bucket[detail.outcome] !== void 0) bucket[detail.outcome] += 1;
1189
+ }
1190
+ }
1191
+ return coverage;
1192
+ }
1193
+ function buildModelTiming(runCallSamples) {
1194
+ if (runCallSamples.size === 0) return void 0;
1195
+ return Object.fromEntries(
1196
+ [...runCallSamples.entries()].map(([model, samples]) => {
1197
+ const firstToken = samples.map((s) => s.firstTokenMs).filter((v) => v != null);
1198
+ return [
1199
+ model,
1200
+ {
1201
+ calls: samples.length,
1202
+ wireMs: computeStats(samples.map((s) => s.wireMs)),
1203
+ queuedMs: computeStats(samples.map((s) => s.queuedMs)),
1204
+ retryMs: computeStats(samples.map((s) => s.retryMs)),
1205
+ // Custom providers may omit the optional field; that call adds 0.
1206
+ rateLimitRetries: samples.reduce((sum, s) => sum + (s.rateLimitRetries ?? 0), 0),
1207
+ ...firstToken.length > 0 ? { firstTokenMs: computeStats(firstToken), firstTokenCalls: firstToken.length } : {}
1208
+ }
1209
+ ];
1210
+ })
1211
+ );
1212
+ }
676
1213
 
677
1214
  // src/bootstrap.ts
678
1215
  function pairedBootstrapCI(differences, options) {
@@ -741,6 +1278,69 @@ function xorshift32(seed) {
741
1278
  }
742
1279
 
743
1280
  // src/compare.ts
1281
+ function certifyCost(baselineRuns, candidateRuns, scorerNames) {
1282
+ for (const [side, runs] of [
1283
+ ["baseline", baselineRuns],
1284
+ ["candidate", candidateRuns]
1285
+ ]) {
1286
+ for (const run of runs) {
1287
+ const accounting = readAccounting(run);
1288
+ if (accounting.completeness === "unverified") {
1289
+ return `${side} run ${run.id} carries no accounting (unverified) \u2014 its total is not a measured figure.`;
1290
+ }
1291
+ if (accounting.completeness === "incomplete") {
1292
+ const reasons = Object.keys(accounting.reasons).join(", ") || "unknown spend";
1293
+ return `${side} run ${run.id} has incomplete accounting (${reasons}) \u2014 its total is a lower bound.`;
1294
+ }
1295
+ }
1296
+ }
1297
+ const scopes = /* @__PURE__ */ new Map();
1298
+ for (const [side, runs] of [
1299
+ ["baseline", baselineRuns],
1300
+ ["candidate", candidateRuns]
1301
+ ]) {
1302
+ for (const run of runs) {
1303
+ const scope = readAccounting(run).scope;
1304
+ const seen = scopes.get(side);
1305
+ if (seen !== void 0 && seen !== scope) {
1306
+ return `${side} mixes accounting scopes ("${seen}" and "${scope}") \u2014 a rescore total covers judging only and cannot be averaged with a full run.`;
1307
+ }
1308
+ scopes.set(side, scope);
1309
+ }
1310
+ }
1311
+ const baselineScope = scopes.get("baseline");
1312
+ const candidateScope = scopes.get("candidate");
1313
+ if (baselineScope !== candidateScope) {
1314
+ return `accounting scope differs (baseline "${baselineScope}" vs candidate "${candidateScope}") \u2014 a rescore total covers judging only.`;
1315
+ }
1316
+ const runCount = Math.min(baselineRuns.length, candidateRuns.length);
1317
+ for (let r = 0; r < runCount; r++) {
1318
+ const bRun = baselineRuns[r];
1319
+ const cRun = candidateRuns[r];
1320
+ if (bRun.items.length !== cRun.items.length) {
1321
+ return `item counts differ (baseline ${bRun.items.length} vs candidate ${cRun.items.length}) \u2014 the runs did different amounts of work.`;
1322
+ }
1323
+ for (let i = 0; i < bRun.items.length; i++) {
1324
+ const bItem = bRun.items[i];
1325
+ const cItem = cRun.items[i];
1326
+ const bRan = !bItem.error;
1327
+ const cRan = !cItem.error;
1328
+ if (bRan !== cRan) {
1329
+ const stopped = bRan ? cItem : bItem;
1330
+ 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.`;
1331
+ }
1332
+ if (!bRan) continue;
1333
+ for (const name of scorerNames) {
1334
+ const bScored = bItem.scores[name] != null;
1335
+ const cScored = cItem.scores[name] != null;
1336
+ if (bScored !== cScored) {
1337
+ return `scorer coverage differs for "${name}" at item ${i}: only ${bScored ? "baseline" : "candidate"} produced a score.`;
1338
+ }
1339
+ }
1340
+ }
1341
+ }
1342
+ return null;
1343
+ }
744
1344
  var DEFAULT_LLM_THRESHOLD = 0.05;
745
1345
  var DEFAULT_DETERMINISTIC_THRESHOLD = 0;
746
1346
  var LEGACY_THRESHOLD = 0.1;
@@ -905,17 +1505,21 @@ function evalCompare(baseline, candidate, options) {
905
1505
  timing = { baselineMean: round(bMean), candidateMean: round(cMean), delta, deltaPercent };
906
1506
  }
907
1507
  let cost;
908
- const baselineAvgCost = baselineRuns.reduce((sum, r) => sum + r.totalCost, 0) / baselineRuns.length;
909
- const candidateAvgCost = candidateRuns.reduce((sum, r) => sum + r.totalCost, 0) / candidateRuns.length;
910
- if (baselineAvgCost > 0 || candidateAvgCost > 0) {
1508
+ const baselineAvgCost = baselineRuns.reduce((sum, r) => sum + readAccounting(r).knownCost, 0) / baselineRuns.length;
1509
+ const candidateAvgCost = candidateRuns.reduce((sum, r) => sum + readAccounting(r).knownCost, 0) / candidateRuns.length;
1510
+ const hasAccounting = [...baselineRuns, ...candidateRuns].some((r) => r.accounting != null);
1511
+ if (baselineAvgCost > 0 || candidateAvgCost > 0 || hasAccounting) {
911
1512
  const deltaRaw = candidateAvgCost - baselineAvgCost;
912
1513
  const delta = round(deltaRaw);
913
- const deltaPercent = baselineAvgCost > 0 ? round(deltaRaw / baselineAvgCost * 100) : 0;
1514
+ const deltaPercent = baselineAvgCost > 0 ? round(deltaRaw / baselineAvgCost * 100) : null;
1515
+ const verdict = certifyCost(baselineRuns, candidateRuns, baselineScorerNames);
914
1516
  cost = {
915
1517
  baselineTotal: round(baselineAvgCost),
916
1518
  candidateTotal: round(candidateAvgCost),
917
1519
  delta,
918
- deltaPercent
1520
+ deltaPercent,
1521
+ certified: verdict === null,
1522
+ ...verdict !== null ? { reason: verdict } : {}
919
1523
  };
920
1524
  }
921
1525
  const parts = [];
@@ -936,7 +1540,7 @@ function evalCompare(baseline, candidate, options) {
936
1540
  const dir = timing.delta > 0 ? "slower" : "faster";
937
1541
  parts.push(`${Math.abs(timing.deltaPercent).toFixed(0)}% ${dir}`);
938
1542
  }
939
- if (cost && Math.abs(cost.deltaPercent) > 1) {
1543
+ if (cost?.certified && cost.deltaPercent !== null && Math.abs(cost.deltaPercent) > 1) {
940
1544
  const dir = cost.delta > 0 ? "more expensive" : "cheaper";
941
1545
  parts.push(`${Math.abs(cost.deltaPercent).toFixed(0)}% ${dir}`);
942
1546
  }
@@ -1001,13 +1605,150 @@ function evaluateScorerErrorRateGate(comparison, maxScorerErrorRate) {
1001
1605
  }
1002
1606
  return null;
1003
1607
  }
1608
+ function compareItemErrorRates(baseline, candidate, maxItemErrorRate = DEFAULT_ITEM_ERROR_RATE_LIMIT) {
1609
+ if (!isErrorRateLimit(maxItemErrorRate)) {
1610
+ throw new Error(
1611
+ `Invalid maxItemErrorRate (${String(maxItemErrorRate)}): expected a number in [0, 1].`
1612
+ );
1613
+ }
1614
+ const baselineRuns = Array.isArray(baseline) ? baseline : [baseline];
1615
+ const candidateRuns = Array.isArray(candidate) ? candidate : [candidate];
1616
+ const runCount = Math.min(baselineRuns.length, candidateRuns.length);
1617
+ const rates = [];
1618
+ for (const [side, runs] of [
1619
+ ["baseline", baselineRuns],
1620
+ ["candidate", candidateRuns]
1621
+ ]) {
1622
+ for (let r = 0; r < runCount; r++) {
1623
+ const run = runs[r];
1624
+ const coverage = run.summary.coverage?.items;
1625
+ const legacy = coverage === void 0;
1626
+ const verdict = legacy ? evaluateItemErrorRate(
1627
+ buildCoverage(run.items, []).items,
1628
+ run.items.length,
1629
+ maxItemErrorRate
1630
+ ) : evaluateItemErrorRate(coverage, run.summary.count, maxItemErrorRate);
1631
+ rates.push({ side, runIndex: r, runId: run.id, legacy, ...verdict });
1632
+ }
1633
+ }
1634
+ return rates;
1635
+ }
1636
+ function describeItemErrorRate(rate, multiRun) {
1637
+ const which = multiRun ? ` run ${rate.runIndex + 1} (${rate.runId})` : ` (${rate.runId})`;
1638
+ const legacy = rate.legacy ? ", derived from the items of a pre-0.24 artifact" : "";
1639
+ return `${rate.side}${which} item error rate ${formatPercent(rate.rate)} (${rate.failed}/${rate.attempted} attempted items failed${legacy})`;
1640
+ }
1641
+ function evaluateItemErrorRateGate(baseline, candidate, maxItemErrorRate = DEFAULT_ITEM_ERROR_RATE_LIMIT) {
1642
+ const rates = compareItemErrorRates(baseline, candidate, maxItemErrorRate);
1643
+ const multiRun = rates.some((r) => r.runIndex > 0);
1644
+ const over = rates.find((r) => r.exceeded);
1645
+ if (!over) return null;
1646
+ 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).`;
1647
+ }
1004
1648
 
1005
1649
  // src/rescore.ts
1650
+ var import_axl4 = require("@axlsdk/axl");
1006
1651
  var import_node_crypto2 = require("crypto");
1652
+ var COPY_SHARE_OF_RUN_BOUND = 0.75;
1653
+ function describeFailure(error) {
1654
+ return error instanceof Error ? error.message : String(error);
1655
+ }
1656
+ function isDiagnosticsUnavailable(error) {
1657
+ return error?.code === "DIAGNOSTICS_UNAVAILABLE";
1658
+ }
1659
+ async function beginCapture(source, ownerId, runtime, options) {
1660
+ const limits = resolveCaptureLimits(options?.captureRequests);
1661
+ if (!limits) return {};
1662
+ const open = (artifactId, sink, carriedBytes) => ({
1663
+ artifactId,
1664
+ channel: new import_axl4.RequestCaptureChannel({
1665
+ sink,
1666
+ ...limits,
1667
+ carriedBytes,
1668
+ // Redaction is the RUNTIME's policy, not the caller's: a rescore cannot
1669
+ // opt out of compliance mode by asking for diagnostics.
1670
+ redact: runtime.isRedactEnabled()
1671
+ })
1672
+ });
1673
+ const sourceId = source.diagnostics?.artifactId;
1674
+ let stagedId;
1675
+ try {
1676
+ if (!sourceId) {
1677
+ const staged = await runtime.stageDiagnosticArtifact({ kind: "eval", id: ownerId });
1678
+ stagedId = staged.artifactId;
1679
+ return { capture: { ...open(staged.artifactId, staged.sink, 0), copied: false } };
1680
+ }
1681
+ const runBound = limits.maxRunBytes ?? DEFAULT_COPY_MAX_BYTES;
1682
+ const maxBytes = Math.max(1, Math.floor(runBound * COPY_SHARE_OF_RUN_BOUND));
1683
+ const copied = await runtime.copyDiagnosticArtifact(
1684
+ sourceId,
1685
+ { kind: "eval", id: ownerId },
1686
+ { maxBytes }
1687
+ );
1688
+ if (!copied) {
1689
+ return {
1690
+ degraded: unavailableManifest(
1691
+ "",
1692
+ "the source run's captured requests are no longer available"
1693
+ )
1694
+ };
1695
+ }
1696
+ stagedId = copied.artifactId;
1697
+ const capture = {
1698
+ ...open(copied.artifactId, copied.sink, copied.bytes),
1699
+ copied: true,
1700
+ carriedRedaction: copied.redaction
1701
+ };
1702
+ if (copied.truncated) {
1703
+ capture.copyTruncated = `the copied source records stopped at ${maxBytes} bytes, the share of the ${runBound} byte run limit reserved for them`;
1704
+ }
1705
+ return { capture };
1706
+ } catch (error) {
1707
+ if (stagedId !== void 0) {
1708
+ await runtime.rollbackDiagnosticArtifact(stagedId).catch(() => void 0);
1709
+ }
1710
+ if (isDiagnosticsUnavailable(error)) throw error;
1711
+ return {
1712
+ degraded: unavailableManifest(
1713
+ "",
1714
+ `the captured requests could not be carried forward: ${describeFailure(error)}`
1715
+ )
1716
+ };
1717
+ }
1718
+ }
1719
+ async function abandonCapture2(runtime, capture) {
1720
+ if (!capture) return;
1721
+ await capture.channel.close().catch(() => void 0);
1722
+ await runtime.rollbackDiagnosticArtifact(capture.artifactId).catch(() => void 0);
1723
+ }
1724
+ async function finishCapture2(runtime, capture) {
1725
+ const status = await capture.channel.close();
1726
+ const reason = [capture.copyTruncated, status.reason].filter(Boolean).join("; ") || void 0;
1727
+ const truncated = capture.copyTruncated !== void 0 || status.status === "truncated";
1728
+ const finalStatus = status.status === "unavailable" ? "unavailable" : truncated ? "truncated" : status.status;
1729
+ const redaction = status.redaction === "applied" && (capture.carriedRedaction ?? "applied") === "applied" ? "applied" : "none";
1730
+ try {
1731
+ const manifest = await runtime.finalizeDiagnosticArtifact(
1732
+ capture.artifactId,
1733
+ finalStatus,
1734
+ reason,
1735
+ redaction
1736
+ );
1737
+ return toDiagnosticManifest(manifest);
1738
+ } catch {
1739
+ return unavailableManifest("", "the diagnostic artifact could not be finalized");
1740
+ }
1741
+ }
1007
1742
  async function rescore(result, scorers, runtime, options) {
1008
1743
  const startTime = Date.now();
1744
+ const rescoredId = (0, import_node_crypto2.randomUUID)();
1009
1745
  const concurrency = options?.concurrency ?? 5;
1010
1746
  const scorerConcurrency = options?.scorerConcurrency ?? 5;
1747
+ const admission = options?.budget != null ? new import_axl4.AdmissionController({ limit: parseBudget(options.budget) }) : void 0;
1748
+ let closedBy;
1749
+ const noteClosure = (source) => {
1750
+ if (closedBy === void 0 && admission?.closed) closedBy = source;
1751
+ };
1011
1752
  const scorerContext = {
1012
1753
  resolveProvider: (uri) => {
1013
1754
  if (typeof runtime.resolveProvider !== "function") {
@@ -1020,7 +1761,8 @@ async function rescore(result, scorers, runtime, options) {
1020
1761
  signal: options?.signal
1021
1762
  };
1022
1763
  const rescored = new Array(result.items.length);
1023
- let totalCost = 0;
1764
+ const { capture, degraded } = await beginCapture(result, rescoredId, runtime, options);
1765
+ const carryDiagnostics = capture?.copied === true;
1024
1766
  async function rescoreItem(original, itemIndex) {
1025
1767
  if (options?.signal?.aborted) {
1026
1768
  rescored[itemIndex] = {
@@ -1028,16 +1770,31 @@ async function rescore(result, scorers, runtime, options) {
1028
1770
  annotations: original.annotations,
1029
1771
  output: original.output,
1030
1772
  error: "cancelled",
1773
+ outcome: "cancelled",
1774
+ accounting: emptyAccounting(),
1031
1775
  scores: {}
1032
1776
  };
1033
1777
  return;
1034
1778
  }
1035
1779
  if (original.error) {
1780
+ const accounting2 = emptyAccounting();
1036
1781
  rescored[itemIndex] = {
1037
1782
  input: original.input,
1038
1783
  annotations: original.annotations,
1039
1784
  output: original.output,
1040
1785
  error: original.error,
1786
+ ...original.outcome ? { outcome: original.outcome } : {},
1787
+ // The failure cause is a fact about the source run too.
1788
+ ...original.failure ? { failure: original.failure } : {},
1789
+ accounting: accounting2,
1790
+ // Same rule as a scored item: an item carrying `accounting` always
1791
+ // carries both compat views, so no reader falls through to the legacy
1792
+ // "no accounting → trust the caller's number" branch and reports the
1793
+ // SOURCE run's spend as this rescore's. A passthrough did no work, so
1794
+ // both are a measured $0; the source's spend lives in the run-level
1795
+ // `accounting.source` provenance and nowhere else.
1796
+ cost: accounting2.breakdown.generation,
1797
+ scorerCost: accounting2.breakdown.judging,
1041
1798
  scores: {}
1042
1799
  };
1043
1800
  return;
@@ -1048,20 +1805,77 @@ async function rescore(result, scorers, runtime, options) {
1048
1805
  output: original.output,
1049
1806
  metadata: original.metadata,
1050
1807
  traces: original.traces,
1808
+ outcome: "completed",
1809
+ // Carried forward VERBATIM, original operation ids included. The rescore
1810
+ // did not re-run the workflow, so these still describe the calls that
1811
+ // produced this output; reminting the ids would break the only link back
1812
+ // to the run that made them. Only carried when the source records were
1813
+ // actually copied into this rescore's artifact — a dangling reference is
1814
+ // worse than none.
1815
+ ...carryDiagnostics && original.diagnostics ? { diagnostics: original.diagnostics } : {},
1051
1816
  scores: {},
1052
1817
  scoreDetails: {}
1053
1818
  };
1054
- const itemScorerCost = await scoreItem(
1055
- item,
1056
- scorers,
1057
- scorerConcurrency,
1058
- scorerContext,
1059
- options?.signal
1819
+ const itemOutcome = await trackScope(
1820
+ runtime,
1821
+ async () => {
1822
+ await scoreItem(item, scorers, {
1823
+ runtime,
1824
+ scorerContext,
1825
+ scorerConcurrency,
1826
+ signal: options?.signal,
1827
+ admission,
1828
+ onClosure: noteClosure
1829
+ });
1830
+ },
1831
+ {
1832
+ purpose: "judging",
1833
+ // Every record produced under this item is stamped with the case index,
1834
+ // which is how a `ScorerDetail` can point at its own judge calls.
1835
+ ...capture ? { captureCorrelation: { caseIndex: itemIndex } } : {}
1836
+ }
1060
1837
  );
1061
- totalCost += itemScorerCost;
1838
+ if (itemOutcome.status === "rejected") throw itemOutcome.error;
1839
+ item.accounting = itemOutcome.accounting;
1840
+ item.cost = itemOutcome.accounting.breakdown.generation;
1841
+ item.scorerCost = itemOutcome.accounting.breakdown.judging;
1842
+ if (itemOutcome.accounting.completeness !== "complete") item.unpriced = true;
1062
1843
  rescored[itemIndex] = item;
1063
1844
  }
1064
- await mapWithConcurrency(result.items, concurrency, (item, i) => rescoreItem(item, i));
1845
+ const runOutcome = await trackScope(
1846
+ runtime,
1847
+ async () => {
1848
+ await mapWithConcurrency(result.items, concurrency, (item, i) => rescoreItem(item, i));
1849
+ },
1850
+ // The channel is declared ONCE, at the run scope: its byte budget and
1851
+ // pending queue are per rescore, and every nested item/scorer scope inherits
1852
+ // it rather than opening a competing budget of its own.
1853
+ { purpose: "judging", admission, ...capture ? { capture: capture.channel } : {} }
1854
+ );
1855
+ if (runOutcome.status === "rejected") {
1856
+ await abandonCapture2(runtime, capture);
1857
+ throw runOutcome.error;
1858
+ }
1859
+ const sourceAccounting = result.accounting;
1860
+ const generation = sourceAccounting?.scope === "rescore" ? sourceAccounting.source?.generation ?? null : sourceAccounting ?? null;
1861
+ let diagnostics = degraded;
1862
+ if (capture) {
1863
+ try {
1864
+ attachOperationRefs(rescored, capture.channel);
1865
+ } catch (error) {
1866
+ await abandonCapture2(runtime, capture);
1867
+ throw error;
1868
+ }
1869
+ diagnostics = await finishCapture2(runtime, capture);
1870
+ }
1871
+ const accounting = {
1872
+ ...runOutcome.accounting,
1873
+ scope: "rescore",
1874
+ // `?? null` is load-bearing: a legacy source has no accounting and must read
1875
+ // as "unknown generation", never as a synthesized complete record.
1876
+ source: { runId: result.id, generation },
1877
+ ...admission ? { budget: { ...admission.snapshot(), ...closedBy ? { closedBy } : {} } } : {}
1878
+ };
1065
1879
  const failures = rescored.filter((i) => i.error).length;
1066
1880
  const scorerNames = scorers.map((s) => s.name);
1067
1881
  const scorerStats = {};
@@ -1075,7 +1889,7 @@ async function rescore(result, scorers, runtime, options) {
1075
1889
  scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
1076
1890
  }
1077
1891
  return {
1078
- id: (0, import_node_crypto2.randomUUID)(),
1892
+ id: rescoredId,
1079
1893
  dataset: result.dataset,
1080
1894
  metadata: (() => {
1081
1895
  const rest = { ...result.metadata };
@@ -1099,10 +1913,24 @@ async function rescore(result, scorers, runtime, options) {
1099
1913
  return merged;
1100
1914
  })(),
1101
1915
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1102
- totalCost,
1916
+ totalCost: accounting.knownCost,
1917
+ ...accounting.completeness !== "complete" ? { unpriced: true } : {},
1918
+ accounting,
1919
+ ...diagnostics ? { diagnostics } : {},
1103
1920
  duration: Date.now() - startTime,
1104
1921
  items: rescored,
1105
- summary: { count: result.items.length, failures, scorers: scorerStats }
1922
+ summary: {
1923
+ count: result.items.length,
1924
+ failures,
1925
+ coverage: buildCoverage(rescored, scorerNames),
1926
+ scorers: scorerStats,
1927
+ // A rescore makes no generation calls, so the source run's wall-clock
1928
+ // and provider latency are still the only measurements of these items.
1929
+ // `modelTiming` cannot be rebuilt from `item.timing` (sums, not per-call
1930
+ // samples): carry both forward.
1931
+ ...result.summary.timing ? { timing: { ...result.summary.timing } } : {},
1932
+ ...result.summary.modelTiming ? { modelTiming: structuredClone(result.summary.modelTiming) } : {}
1933
+ }
1106
1934
  };
1107
1935
  }
1108
1936
 
@@ -1172,6 +2000,7 @@ function aggregateRuns(runs) {
1172
2000
  workflows,
1173
2001
  dataset: dataset2,
1174
2002
  totalCost: round(totalCost),
2003
+ accounting: aggregateAccounting(runs.map((r) => readAccounting(r))),
1175
2004
  totalDuration: round(totalDuration),
1176
2005
  scorers,
1177
2006
  timing
@@ -1179,17 +2008,30 @@ function aggregateRuns(runs) {
1179
2008
  }
1180
2009
  // Annotate the CommonJS export names for ESM import in node:
1181
2010
  0 && (module.exports = {
2011
+ DEFAULT_COPY_MAX_BYTES,
2012
+ DEFAULT_SIDECAR_MAX_BYTES,
2013
+ aggregateAccounting,
1182
2014
  aggregateRuns,
1183
2015
  dataset,
1184
2016
  defineEval,
1185
2017
  evalCompare,
2018
+ evaluateItemErrorRateGate,
1186
2019
  evaluateScorerErrorRateGate,
1187
2020
  evaluateScorerTolerance,
2021
+ isBudgetStopped,
1188
2022
  llmScorer,
1189
2023
  normalizeScorerResult,
1190
2024
  pairedBootstrapCI,
2025
+ parseRequestRecords,
2026
+ readAccounting,
2027
+ refusedWork,
1191
2028
  rescore,
2029
+ resolveCaptureLimits,
1192
2030
  runEval,
1193
- scorer
2031
+ scorer,
2032
+ serializeRequestRecords,
2033
+ toDiagnosticManifest,
2034
+ unavailableManifest,
2035
+ validateRequestSidecar
1194
2036
  });
1195
2037
  //# sourceMappingURL=index.cjs.map