@axlsdk/eval 0.13.5 → 0.13.7

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
@@ -28,76 +28,223 @@ var import_node_fs2 = require("fs");
28
28
  var import_promises = require("fs/promises");
29
29
  var path = __toESM(require("path"), 1);
30
30
 
31
- // src/compare.ts
31
+ // src/bootstrap.ts
32
+ function pairedBootstrapCI(differences, options) {
33
+ const n = differences.length;
34
+ if (n === 0) return { lower: 0, upper: 0, mean: 0, pRegression: 0, pImprovement: 0 };
35
+ const nResamples = options?.nResamples ?? 1e3;
36
+ const alpha = options?.alpha ?? 0.05;
37
+ const mean = differences.reduce((a, b) => a + b, 0) / n;
38
+ if (n === 1)
39
+ return {
40
+ lower: differences[0],
41
+ upper: differences[0],
42
+ mean,
43
+ pRegression: mean < 0 ? 1 : 0,
44
+ pImprovement: mean > 0 ? 1 : 0
45
+ };
46
+ if (nResamples <= 0)
47
+ return {
48
+ lower: round(mean),
49
+ upper: round(mean),
50
+ mean: round(mean),
51
+ pRegression: mean < 0 ? 1 : 0,
52
+ pImprovement: mean > 0 ? 1 : 0
53
+ };
54
+ const rng = options?.seed != null ? xorshift32(options.seed) : () => Math.random();
55
+ const resampleMeans = new Array(nResamples);
56
+ for (let r = 0; r < nResamples; r++) {
57
+ let sum = 0;
58
+ for (let i = 0; i < n; i++) {
59
+ sum += differences[Math.floor(rng() * n)];
60
+ }
61
+ resampleMeans[r] = sum / n;
62
+ }
63
+ resampleMeans.sort((a, b) => a - b);
64
+ const lowerIdx = Math.floor(alpha / 2 * nResamples);
65
+ const upperIdx = Math.floor((1 - alpha / 2) * nResamples) - 1;
66
+ let belowZero = 0;
67
+ for (let i = 0; i < nResamples; i++) {
68
+ if (resampleMeans[i] < 0) belowZero++;
69
+ else break;
70
+ }
71
+ let aboveZero = 0;
72
+ for (let i = nResamples - 1; i >= 0; i--) {
73
+ if (resampleMeans[i] > 0) aboveZero++;
74
+ else break;
75
+ }
76
+ return {
77
+ lower: round(resampleMeans[Math.max(0, lowerIdx)]),
78
+ upper: round(resampleMeans[Math.min(nResamples - 1, upperIdx)]),
79
+ mean: round(mean),
80
+ pRegression: round(belowZero / nResamples),
81
+ pImprovement: round(aboveZero / nResamples)
82
+ };
83
+ }
32
84
  function round(n) {
33
- return Math.round(n * 100) / 100;
85
+ return Math.round(n * 1e4) / 1e4;
86
+ }
87
+ function xorshift32(seed) {
88
+ let state = seed | 0 || 1;
89
+ return () => {
90
+ state ^= state << 13;
91
+ state ^= state >> 17;
92
+ state ^= state << 5;
93
+ return (state >>> 0) / 4294967296;
94
+ };
34
95
  }
35
- function evalCompare(baseline, candidate) {
36
- if (baseline.dataset !== candidate.dataset) {
96
+
97
+ // src/compare.ts
98
+ function round2(n) {
99
+ return Math.round(n * 1e3) / 1e3;
100
+ }
101
+ var DEFAULT_LLM_THRESHOLD = 0.05;
102
+ var DEFAULT_DETERMINISTIC_THRESHOLD = 0;
103
+ var LEGACY_THRESHOLD = 0.1;
104
+ function resolveThreshold(scorerName, options, metadata) {
105
+ if (typeof options?.thresholds === "number") return options.thresholds;
106
+ if (options?.thresholds && typeof options.thresholds === "object") {
107
+ const perScorer = options.thresholds[scorerName];
108
+ if (perScorer != null) return perScorer;
109
+ }
110
+ const scorerTypes = metadata.scorerTypes;
111
+ if (scorerTypes && scorerName in scorerTypes) {
112
+ return scorerTypes[scorerName] === "llm" ? DEFAULT_LLM_THRESHOLD : DEFAULT_DETERMINISTIC_THRESHOLD;
113
+ }
114
+ return LEGACY_THRESHOLD;
115
+ }
116
+ function evalCompare(baseline, candidate, options) {
117
+ const baselineRuns = Array.isArray(baseline) ? baseline : [baseline];
118
+ const candidateRuns = Array.isArray(candidate) ? candidate : [candidate];
119
+ if (baselineRuns.length === 0 || candidateRuns.length === 0) {
120
+ throw new Error("Cannot compare empty eval result arrays");
121
+ }
122
+ const baselineRef = baselineRuns[0];
123
+ const candidateRef = candidateRuns[0];
124
+ if (baselineRef.dataset !== candidateRef.dataset) {
37
125
  throw new Error(
38
- `Cannot compare evals from different datasets: "${baseline.dataset}" vs "${candidate.dataset}"`
126
+ `Cannot compare evals from different datasets: "${baselineRef.dataset}" vs "${candidateRef.dataset}"`
39
127
  );
40
128
  }
41
- const baselineScorerNames = Object.keys(baseline.summary.scorers).sort();
42
- const candidateScorerNames = Object.keys(candidate.summary.scorers).sort();
129
+ const baselineScorerNames = Object.keys(baselineRef.summary.scorers).sort();
130
+ const candidateScorerNames = Object.keys(candidateRef.summary.scorers).sort();
43
131
  if (JSON.stringify(baselineScorerNames) !== JSON.stringify(candidateScorerNames)) {
44
132
  throw new Error(`Cannot compare evals with different scorers`);
45
133
  }
134
+ const pairedDiffs = {};
135
+ for (const name of baselineScorerNames) {
136
+ pairedDiffs[name] = [];
137
+ }
138
+ const runCount = Math.min(baselineRuns.length, candidateRuns.length);
139
+ for (let r = 0; r < runCount; r++) {
140
+ const bRun = baselineRuns[r];
141
+ const cRun = candidateRuns[r];
142
+ const minLength = Math.min(bRun.items.length, cRun.items.length);
143
+ for (let i = 0; i < minLength; i++) {
144
+ const bItem = bRun.items[i];
145
+ const cItem = cRun.items[i];
146
+ if (bItem.error || cItem.error) continue;
147
+ for (const name of baselineScorerNames) {
148
+ const bScore = bItem.scores[name];
149
+ const cScore = cItem.scores[name];
150
+ if (bScore != null && cScore != null) {
151
+ pairedDiffs[name].push(cScore - bScore);
152
+ }
153
+ }
154
+ }
155
+ }
156
+ const baselineMeans = {};
157
+ const candidateMeans = {};
158
+ for (const name of baselineScorerNames) {
159
+ baselineMeans[name] = baselineRuns.reduce((sum, r) => sum + (r.summary.scorers[name]?.mean ?? 0), 0) / baselineRuns.length;
160
+ candidateMeans[name] = candidateRuns.reduce((sum, r) => sum + (r.summary.scorers[name]?.mean ?? 0), 0) / candidateRuns.length;
161
+ }
46
162
  const scorers = {};
47
163
  for (const name of baselineScorerNames) {
48
- const bMean = baseline.summary.scorers[name].mean;
49
- const cMean = candidate.summary.scorers[name].mean;
50
- const delta = round(cMean - bMean);
51
- const deltaPercent = bMean > 0 ? round(delta / bMean * 100) : 0;
52
- scorers[name] = { baselineMean: bMean, candidateMean: cMean, delta, deltaPercent };
164
+ const bMeanRaw = baselineMeans[name];
165
+ const cMeanRaw = candidateMeans[name];
166
+ const deltaRaw = cMeanRaw - bMeanRaw;
167
+ const bMean = round2(bMeanRaw);
168
+ const cMean = round2(cMeanRaw);
169
+ const delta = round2(deltaRaw);
170
+ const deltaPercent = bMeanRaw > 0 ? round2(deltaRaw / bMeanRaw * 100) : 0;
171
+ const threshold = resolveThreshold(name, options, baselineRef.metadata);
172
+ const entry = {
173
+ baselineMean: bMean,
174
+ candidateMean: cMean,
175
+ delta,
176
+ deltaPercent
177
+ };
178
+ const diffs = pairedDiffs[name];
179
+ if (diffs.length >= 2) {
180
+ const ci = pairedBootstrapCI(diffs);
181
+ entry.ci = { lower: ci.lower, upper: ci.upper };
182
+ entry.pRegression = ci.pRegression;
183
+ entry.pImprovement = ci.pImprovement;
184
+ entry.n = diffs.length;
185
+ const ciExcludesZero = ci.lower > 0 || ci.upper < 0;
186
+ entry.significant = ciExcludesZero && Math.abs(ci.mean) >= threshold;
187
+ }
188
+ scorers[name] = entry;
53
189
  }
54
190
  const regressions = [];
55
191
  const improvements = [];
56
- const minLength = Math.min(baseline.items.length, candidate.items.length);
57
- for (let i = 0; i < minLength; i++) {
58
- const bItem = baseline.items[i];
59
- const cItem = candidate.items[i];
60
- if (bItem.error || cItem.error) continue;
192
+ const itemCount = Math.min(
193
+ ...baselineRuns.map((r) => r.items.length),
194
+ ...candidateRuns.map((r) => r.items.length)
195
+ );
196
+ for (let i = 0; i < itemCount; i++) {
197
+ const hasBaselineError = baselineRuns.some((r) => r.items[i]?.error);
198
+ const hasCandidateError = candidateRuns.some((r) => r.items[i]?.error);
199
+ if (hasBaselineError || hasCandidateError) continue;
61
200
  for (const name of baselineScorerNames) {
62
- const bScore = bItem.scores[name];
63
- const cScore = cItem.scores[name];
64
- if (bScore == null || cScore == null) continue;
65
- const delta = round(cScore - bScore);
66
- if (delta < -0.1)
201
+ const bScores = baselineRuns.map((r) => r.items[i]?.scores[name]).filter((s) => s != null);
202
+ const cScores = candidateRuns.map((r) => r.items[i]?.scores[name]).filter((s) => s != null);
203
+ if (bScores.length === 0 || cScores.length === 0) continue;
204
+ const bAvg = bScores.reduce((a, b) => a + b, 0) / bScores.length;
205
+ const cAvg = cScores.reduce((a, b) => a + b, 0) / cScores.length;
206
+ const delta = round2(cAvg - bAvg);
207
+ const threshold = resolveThreshold(name, options, baselineRef.metadata);
208
+ if (delta < -threshold)
67
209
  regressions.push({
68
210
  itemIndex: i,
69
- input: bItem.input,
211
+ input: baselineRef.items[i]?.input,
70
212
  scorer: name,
71
- baselineScore: bScore,
72
- candidateScore: cScore,
213
+ baselineScore: round2(bAvg),
214
+ candidateScore: round2(cAvg),
73
215
  delta
74
216
  });
75
- else if (delta > 0.1)
217
+ else if (delta > threshold)
76
218
  improvements.push({
77
219
  itemIndex: i,
78
- input: bItem.input,
220
+ input: baselineRef.items[i]?.input,
79
221
  scorer: name,
80
- baselineScore: bScore,
81
- candidateScore: cScore,
222
+ baselineScore: round2(bAvg),
223
+ candidateScore: round2(cAvg),
82
224
  delta
83
225
  });
84
226
  }
85
227
  }
86
228
  let timing;
87
- if (baseline.summary.timing && candidate.summary.timing) {
88
- const bMean = baseline.summary.timing.mean;
89
- const cMean = candidate.summary.timing.mean;
90
- const delta = round(cMean - bMean);
91
- const deltaPercent = bMean > 0 ? round(delta / bMean * 100) : 0;
92
- timing = { baselineMean: bMean, candidateMean: cMean, delta, deltaPercent };
229
+ const baselineTimings = baselineRuns.filter((r) => r.summary.timing).map((r) => r.summary.timing.mean);
230
+ const candidateTimings = candidateRuns.filter((r) => r.summary.timing).map((r) => r.summary.timing.mean);
231
+ if (baselineTimings.length > 0 && candidateTimings.length > 0) {
232
+ const bMean = baselineTimings.reduce((a, b) => a + b, 0) / baselineTimings.length;
233
+ const cMean = candidateTimings.reduce((a, b) => a + b, 0) / candidateTimings.length;
234
+ const delta = round2(cMean - bMean);
235
+ const deltaPercent = bMean > 0 ? round2((cMean - bMean) / bMean * 100) : 0;
236
+ timing = { baselineMean: round2(bMean), candidateMean: round2(cMean), delta, deltaPercent };
93
237
  }
94
238
  let cost;
95
- if (baseline.totalCost > 0 || candidate.totalCost > 0) {
96
- const delta = round(candidate.totalCost - baseline.totalCost);
97
- const deltaPercent = baseline.totalCost > 0 ? round(delta / baseline.totalCost * 100) : 0;
239
+ const baselineAvgCost = baselineRuns.reduce((sum, r) => sum + r.totalCost, 0) / baselineRuns.length;
240
+ const candidateAvgCost = candidateRuns.reduce((sum, r) => sum + r.totalCost, 0) / candidateRuns.length;
241
+ if (baselineAvgCost > 0 || candidateAvgCost > 0) {
242
+ const deltaRaw = candidateAvgCost - baselineAvgCost;
243
+ const delta = round2(deltaRaw);
244
+ const deltaPercent = baselineAvgCost > 0 ? round2(deltaRaw / baselineAvgCost * 100) : 0;
98
245
  cost = {
99
- baselineTotal: baseline.totalCost,
100
- candidateTotal: candidate.totalCost,
246
+ baselineTotal: round2(baselineAvgCost),
247
+ candidateTotal: round2(candidateAvgCost),
101
248
  delta,
102
249
  deltaPercent
103
250
  };
@@ -108,8 +255,11 @@ function evalCompare(baseline, candidate) {
108
255
  if (Math.abs(s.delta) > 1e-3) {
109
256
  const direction = s.delta > 0 ? "improves" : "regresses";
110
257
  const sign = s.delta > 0 ? "+" : "";
258
+ let sigLabel = "";
259
+ if (s.significant === true) sigLabel = " (significant)";
260
+ else if (s.significant === false) sigLabel = " (not significant)";
111
261
  parts.push(
112
- `'${name}' ${direction} by ${sign}${s.deltaPercent.toFixed(1)}% (${s.baselineMean.toFixed(2)} -> ${s.candidateMean.toFixed(2)})`
262
+ `'${name}' ${direction} by ${sign}${s.deltaPercent.toFixed(1)}% (${s.baselineMean.toFixed(2)} -> ${s.candidateMean.toFixed(2)})${sigLabel}`
113
263
  );
114
264
  }
115
265
  }
@@ -121,10 +271,10 @@ function evalCompare(baseline, candidate) {
121
271
  const dir = cost.delta > 0 ? "more expensive" : "cheaper";
122
272
  parts.push(`${Math.abs(cost.deltaPercent).toFixed(0)}% ${dir}`);
123
273
  }
124
- const summaryStr = `candidate ${parts.length > 0 ? parts.join(", ") : "no significant changes"} with ${regressions.length} regressions and ${improvements.length} improvements`;
274
+ const summaryStr = `candidate ${parts.length > 0 ? parts.join(", ") : "no meaningful changes"} with ${regressions.length} regressions and ${improvements.length} improvements`;
125
275
  return {
126
- baseline: { id: baseline.id, metadata: baseline.metadata },
127
- candidate: { id: candidate.id, metadata: candidate.metadata },
276
+ baseline: { id: baselineRef.id, metadata: baselineRef.metadata },
277
+ candidate: { id: candidateRef.id, metadata: candidateRef.metadata },
128
278
  scorers,
129
279
  timing,
130
280
  cost,
@@ -139,8 +289,7 @@ function normalizeScorerResult(result) {
139
289
  return typeof result === "number" ? { score: result } : result;
140
290
  }
141
291
 
142
- // src/runner.ts
143
- var import_node_crypto = require("crypto");
292
+ // src/utils.ts
144
293
  function computeStats(scores) {
145
294
  if (scores.length === 0) return { mean: 0, min: 0, max: 0, p50: 0, p95: 0 };
146
295
  const sorted = [...scores].sort((a, b) => a - b);
@@ -149,11 +298,14 @@ function computeStats(scores) {
149
298
  const max = sorted[sorted.length - 1];
150
299
  const p50 = sorted[Math.floor(sorted.length * 0.5)];
151
300
  const p95 = sorted[Math.floor(sorted.length * 0.95)];
152
- return { mean: round2(mean), min: round2(min), max: round2(max), p50: round2(p50), p95: round2(p95) };
301
+ return { mean: round3(mean), min: round3(min), max: round3(max), p50: round3(p50), p95: round3(p95) };
153
302
  }
154
- function round2(n) {
303
+ function round3(n) {
155
304
  return Math.round(n * 1e3) / 1e3;
156
305
  }
306
+
307
+ // src/runner.ts
308
+ var import_node_crypto = require("crypto");
157
309
  function parseCost(cost) {
158
310
  const match = cost.match(/^\$?([\d.]+)$/);
159
311
  if (!match) throw new Error(`Invalid cost format: "${cost}"`);
@@ -201,6 +353,7 @@ async function runEval(config, executeWorkflow, runtime) {
201
353
  evalItem.duration = Date.now() - itemStart;
202
354
  evalItem.output = result.output;
203
355
  evalItem.cost = result.cost;
356
+ if (result.metadata) evalItem.metadata = result.metadata;
204
357
  if (result.cost != null) {
205
358
  totalCost += result.cost;
206
359
  }
@@ -243,9 +396,9 @@ async function runEval(config, executeWorkflow, runtime) {
243
396
  cost: scorerResult.cost
244
397
  };
245
398
  } else {
246
- evalItem.scores[scorer.name] = round2(scorerResult.score);
399
+ evalItem.scores[scorer.name] = round3(scorerResult.score);
247
400
  evalItem.scoreDetails[scorer.name] = {
248
- score: round2(scorerResult.score),
401
+ score: round3(scorerResult.score),
249
402
  metadata: scorerResult.metadata,
250
403
  duration: scorerDuration,
251
404
  cost: scorerResult.cost
@@ -290,11 +443,42 @@ async function runEval(config, executeWorkflow, runtime) {
290
443
  }
291
444
  const durations = evalItems.filter((i) => !i.error && i.duration != null).map((i) => i.duration);
292
445
  const timing = durations.length > 0 ? computeStats(durations) : void 0;
446
+ const scorerTypes = {};
447
+ for (const s of config.scorers) {
448
+ scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
449
+ }
450
+ const totalModelCalls = /* @__PURE__ */ new Map();
451
+ for (const item of evalItems) {
452
+ const itemCounts = item.metadata?.modelCallCounts;
453
+ if (itemCounts && typeof itemCounts === "object") {
454
+ for (const [m, count] of Object.entries(itemCounts)) {
455
+ if (typeof count === "number")
456
+ totalModelCalls.set(m, (totalModelCalls.get(m) ?? 0) + count);
457
+ }
458
+ } else {
459
+ const itemModels = item.metadata?.models;
460
+ if (Array.isArray(itemModels)) {
461
+ for (const m of itemModels) {
462
+ if (typeof m === "string") totalModelCalls.set(m, (totalModelCalls.get(m) ?? 0) + 1);
463
+ }
464
+ }
465
+ }
466
+ }
467
+ const modelsMeta = {};
468
+ if (totalModelCalls.size > 0) {
469
+ const sorted = [...totalModelCalls.entries()].sort((a, b) => b[1] - a[1]);
470
+ modelsMeta.models = sorted.map(([m]) => m);
471
+ modelsMeta.modelCounts = Object.fromEntries(sorted);
472
+ }
293
473
  return {
294
474
  id,
295
475
  workflow: config.workflow,
296
476
  dataset: config.dataset.name,
297
- metadata: config.metadata ?? {},
477
+ metadata: {
478
+ ...config.metadata,
479
+ scorerTypes,
480
+ ...modelsMeta
481
+ },
298
482
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
299
483
  totalCost,
300
484
  duration: Date.now() - startTime,
@@ -303,6 +487,182 @@ async function runEval(config, executeWorkflow, runtime) {
303
487
  };
304
488
  }
305
489
 
490
+ // src/rescore.ts
491
+ var import_node_crypto2 = require("crypto");
492
+ async function rescore(result, scorers, runtime, options) {
493
+ const startTime = Date.now();
494
+ const concurrency = options?.concurrency ?? 5;
495
+ const scorerContext = {
496
+ resolveProvider: (uri) => {
497
+ if (typeof runtime.resolveProvider !== "function") {
498
+ throw new Error(
499
+ `LLM scorers require a runtime with resolveProvider(). Ensure you are using a real AxlRuntime instance, not a mock.`
500
+ );
501
+ }
502
+ return runtime.resolveProvider(uri);
503
+ }
504
+ };
505
+ const rescored = new Array(result.items.length);
506
+ let totalCost = 0;
507
+ async function rescoreItem(original, itemIndex) {
508
+ if (original.error) {
509
+ rescored[itemIndex] = {
510
+ input: original.input,
511
+ annotations: original.annotations,
512
+ output: original.output,
513
+ error: original.error,
514
+ scores: {}
515
+ };
516
+ return;
517
+ }
518
+ const item = {
519
+ input: original.input,
520
+ annotations: original.annotations,
521
+ output: original.output,
522
+ metadata: original.metadata,
523
+ scores: {},
524
+ scoreDetails: {}
525
+ };
526
+ let itemScorerCost = 0;
527
+ for (const scorer of scorers) {
528
+ const scorerStart = Date.now();
529
+ try {
530
+ const raw = await scorer.score(item.output, item.input, item.annotations, scorerContext);
531
+ const scorerResult = normalizeScorerResult(raw);
532
+ if (scorerResult.cost != null) {
533
+ itemScorerCost += scorerResult.cost;
534
+ totalCost += scorerResult.cost;
535
+ }
536
+ const scorerDuration = Date.now() - scorerStart;
537
+ if (!Number.isFinite(scorerResult.score) || scorerResult.score < 0 || scorerResult.score > 1) {
538
+ if (!item.scorerErrors) item.scorerErrors = [];
539
+ item.scorerErrors.push(
540
+ `Scorer "${scorer.name}" returned out-of-range score ${scorerResult.score} for input ${JSON.stringify(item.input)}`
541
+ );
542
+ item.scores[scorer.name] = null;
543
+ item.scoreDetails[scorer.name] = {
544
+ score: null,
545
+ metadata: scorerResult.metadata,
546
+ duration: scorerDuration,
547
+ cost: scorerResult.cost
548
+ };
549
+ } else {
550
+ item.scores[scorer.name] = round3(scorerResult.score);
551
+ item.scoreDetails[scorer.name] = {
552
+ score: round3(scorerResult.score),
553
+ metadata: scorerResult.metadata,
554
+ duration: scorerDuration,
555
+ cost: scorerResult.cost
556
+ };
557
+ }
558
+ } catch (err) {
559
+ const errCost = typeof err?.cost === "number" ? err.cost : void 0;
560
+ if (errCost != null) {
561
+ itemScorerCost += errCost;
562
+ totalCost += errCost;
563
+ }
564
+ if (!item.scorerErrors) item.scorerErrors = [];
565
+ item.scorerErrors.push(
566
+ `Scorer "${scorer.name}" threw: ${err instanceof Error ? err.message : String(err)}`
567
+ );
568
+ item.scores[scorer.name] = null;
569
+ item.scoreDetails[scorer.name] = {
570
+ score: null,
571
+ duration: Date.now() - scorerStart,
572
+ cost: errCost
573
+ };
574
+ }
575
+ }
576
+ item.scorerCost = itemScorerCost > 0 ? itemScorerCost : void 0;
577
+ rescored[itemIndex] = item;
578
+ }
579
+ let index = 0;
580
+ async function runNext() {
581
+ while (index < result.items.length) {
582
+ const currentIndex = index++;
583
+ await rescoreItem(result.items[currentIndex], currentIndex);
584
+ }
585
+ }
586
+ const workers = Array.from(
587
+ { length: Math.min(concurrency, result.items.length) },
588
+ () => runNext()
589
+ );
590
+ await Promise.all(workers);
591
+ const failures = rescored.filter((i) => i.error).length;
592
+ const scorerNames = scorers.map((s) => s.name);
593
+ const scorerStats = {};
594
+ for (const name of scorerNames) {
595
+ const scores = rescored.filter((i) => !i.error && i.scores[name] != null).map((i) => i.scores[name]);
596
+ scorerStats[name] = computeStats(scores);
597
+ }
598
+ const scorerTypes = {};
599
+ for (const s of scorers) {
600
+ scorerTypes[s.name] = s.isLlm ? "llm" : "deterministic";
601
+ }
602
+ return {
603
+ id: (0, import_node_crypto2.randomUUID)(),
604
+ workflow: result.workflow,
605
+ dataset: result.dataset,
606
+ metadata: (() => {
607
+ const { runGroupId: _, runIndex: __, ...rest } = result.metadata;
608
+ return { ...rest, rescored: true, originalId: result.id, scorerTypes };
609
+ })(),
610
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
611
+ totalCost,
612
+ duration: Date.now() - startTime,
613
+ items: rescored,
614
+ summary: { count: result.items.length, failures, scorers: scorerStats }
615
+ };
616
+ }
617
+
618
+ // src/multi-run.ts
619
+ var import_node_crypto3 = require("crypto");
620
+ function std(values, mean) {
621
+ if (values.length <= 1) return 0;
622
+ const sumSqDiffs = values.reduce((sum, v) => sum + (v - mean) ** 2, 0);
623
+ return Math.sqrt(sumSqDiffs / (values.length - 1));
624
+ }
625
+ function aggregateRuns(runs) {
626
+ if (runs.length === 0) throw new Error("Cannot aggregate zero runs");
627
+ const runGroupId = runs[0].metadata.runGroupId ?? (0, import_node_crypto3.randomUUID)();
628
+ const { workflow, dataset } = runs[0];
629
+ const totalCost = runs.reduce((sum, r) => sum + r.totalCost, 0);
630
+ const totalDuration = runs.reduce((sum, r) => sum + r.duration, 0);
631
+ const scorerNames = Object.keys(runs[0].summary.scorers);
632
+ const scorers = {};
633
+ for (const name of scorerNames) {
634
+ const means = runs.map((r) => r.summary.scorers[name]?.mean ?? 0);
635
+ const meanOfMeans = means.reduce((a, b) => a + b, 0) / means.length;
636
+ const minMean = Math.min(...means);
637
+ const maxMean = Math.max(...means);
638
+ scorers[name] = {
639
+ mean: round3(meanOfMeans),
640
+ std: round3(std(means, meanOfMeans)),
641
+ min: round3(minMean),
642
+ max: round3(maxMean)
643
+ };
644
+ }
645
+ let timing;
646
+ const timingMeans = runs.filter((r) => r.summary.timing).map((r) => r.summary.timing.mean);
647
+ if (timingMeans.length === runs.length && timingMeans.length > 0) {
648
+ const meanTiming = timingMeans.reduce((a, b) => a + b, 0) / timingMeans.length;
649
+ timing = {
650
+ mean: round3(meanTiming),
651
+ std: round3(std(timingMeans, meanTiming))
652
+ };
653
+ }
654
+ return {
655
+ runGroupId,
656
+ runCount: runs.length,
657
+ workflow,
658
+ dataset,
659
+ totalCost: round3(totalCost),
660
+ totalDuration: round3(totalDuration),
661
+ scorers,
662
+ timing
663
+ };
664
+ }
665
+
306
666
  // src/cli-utils.ts
307
667
  var import_node_path = require("path");
308
668
  var import_node_fs = require("fs");
@@ -367,17 +727,27 @@ async function registerConditions(conditions) {
367
727
 
368
728
  // src/cli.ts
369
729
  var import_meta = {};
370
- var KNOWN_FLAGS = /* @__PURE__ */ new Set(["--output", "--config", "--conditions", "--fail-on-regression"]);
730
+ var KNOWN_FLAGS = /* @__PURE__ */ new Set([
731
+ "--output",
732
+ "--config",
733
+ "--conditions",
734
+ "--fail-on-regression",
735
+ "--threshold",
736
+ "--runs"
737
+ ]);
371
738
  async function main() {
372
739
  const args = process.argv.slice(2);
373
740
  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
374
741
  console.log(`
375
742
  Usage:
376
743
  axl-eval <path> Run eval file(s)
744
+ axl-eval <path> --runs <n> Run eval N times (multi-run)
377
745
  axl-eval <path> --output <file> Save results to JSON
378
746
  axl-eval <path> --config <file> Use config file for runtime
379
747
  axl-eval <path> --conditions <list> Node.js import conditions (comma-separated)
748
+ axl-eval rescore <results> <eval-file> Re-run scorers on saved outputs
380
749
  axl-eval compare <a> <b> Compare two eval result files
750
+ axl-eval compare <a> <b> --threshold <v> Set regression threshold (global or per-scorer)
381
751
  axl-eval compare <a> <b> --fail-on-regression Exit 1 if regressions
382
752
 
383
753
  Config auto-detection (when --config is not specified):
@@ -393,18 +763,40 @@ When no config is found, a bare AxlRuntime is created (providers from env vars).
393
763
  await runCompare(args.slice(1));
394
764
  return;
395
765
  }
766
+ if (args[0] === "rescore") {
767
+ await runRescore(args.slice(1));
768
+ return;
769
+ }
396
770
  await runEvalCommand(args);
397
771
  }
772
+ function parseThresholdArg(args) {
773
+ const idx = args.indexOf("--threshold");
774
+ if (idx === -1 || idx + 1 >= args.length) return void 0;
775
+ const value = args[idx + 1];
776
+ if (/^[\d.]+$/.test(value)) return parseFloat(value);
777
+ const map = {};
778
+ for (const pair of value.split(",")) {
779
+ const [key, val] = pair.split("=");
780
+ if (key && val != null) map[key.trim()] = parseFloat(val.trim());
781
+ }
782
+ return Object.keys(map).length > 0 ? map : void 0;
783
+ }
398
784
  async function runCompare(args) {
399
785
  const failOnRegression = args.includes("--fail-on-regression");
400
- const files = args.filter((a) => !a.startsWith("--"));
786
+ const thresholds = parseThresholdArg(args);
787
+ const files = args.filter(
788
+ (a, i) => !a.startsWith("--") && !(i > 0 && args[i - 1] === "--threshold")
789
+ );
401
790
  if (files.length !== 2) {
402
- console.error("Usage: axl-eval compare <baseline.json> <candidate.json>");
791
+ console.error("Usage: axl-eval compare <baseline.json> <candidate.json> [--threshold <value>]");
403
792
  process.exit(1);
404
793
  }
405
- const baseline = JSON.parse(await (0, import_promises.readFile)(files[0], "utf-8"));
406
- const candidate = JSON.parse(await (0, import_promises.readFile)(files[1], "utf-8"));
407
- const comparison = evalCompare(baseline, candidate);
794
+ const baselineRaw = JSON.parse(await (0, import_promises.readFile)(files[0], "utf-8"));
795
+ const candidateRaw = JSON.parse(await (0, import_promises.readFile)(files[1], "utf-8"));
796
+ const baseline = baselineRaw;
797
+ const candidate = candidateRaw;
798
+ const compareOptions = thresholds != null ? { thresholds } : void 0;
799
+ const comparison = evalCompare(baseline, candidate, compareOptions);
408
800
  console.log(
409
801
  `
410
802
  Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${comparison.candidate.id.slice(0, 8)})
@@ -412,14 +804,26 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
412
804
  );
413
805
  const scorerNames = Object.keys(comparison.scorers);
414
806
  const maxNameLen = Math.max(...scorerNames.map((n) => n.length), 6);
415
- console.log(` ${"Scorer".padEnd(maxNameLen)} Baseline Candidate Delta Change`);
416
- console.log(` ${"".padEnd(maxNameLen, "-")} -------- --------- -------- ------`);
807
+ const hasCI = scorerNames.some((n) => comparison.scorers[n].ci != null);
808
+ const ciHeader = hasCI ? " CI 95% Sig" : "";
809
+ const ciRule = hasCI ? " ---------------- ---" : "";
810
+ console.log(` ${"Scorer".padEnd(maxNameLen)} Baseline Candidate Delta Change${ciHeader}`);
811
+ console.log(` ${"".padEnd(maxNameLen, "-")} -------- --------- -------- ------${ciRule}`);
417
812
  for (const name of scorerNames) {
418
813
  const s = comparison.scorers[name];
419
814
  const sign = s.delta > 0 ? "+" : "";
420
- console.log(
421
- ` ${name.padEnd(maxNameLen)} ${s.baselineMean.toFixed(3).padStart(8)} ${s.candidateMean.toFixed(3).padStart(9)} ${(sign + s.delta.toFixed(3)).padStart(8)} ${(sign + s.deltaPercent.toFixed(1) + "%").padStart(6)}`
422
- );
815
+ let line = ` ${name.padEnd(maxNameLen)} ${s.baselineMean.toFixed(3).padStart(8)} ${s.candidateMean.toFixed(3).padStart(9)} ${(sign + s.delta.toFixed(3)).padStart(8)} ${(sign + s.deltaPercent.toFixed(1) + "%").padStart(6)}`;
816
+ if (hasCI) {
817
+ if (s.ci) {
818
+ const lo = (s.ci.lower >= 0 ? "+" : "") + s.ci.lower.toFixed(4);
819
+ const hi = (s.ci.upper >= 0 ? "+" : "") + s.ci.upper.toFixed(4);
820
+ const sig = s.significant ? " *" : " ";
821
+ line += ` [${lo}, ${hi}]${sig}`;
822
+ } else {
823
+ line += " \u2014".padEnd(22);
824
+ }
825
+ }
826
+ console.log(line);
423
827
  }
424
828
  if (comparison.timing) {
425
829
  const t = comparison.timing;
@@ -436,9 +840,10 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
436
840
  ` Cost: baseline $${c.baselineTotal.toFixed(2)} -> candidate $${c.candidateTotal.toFixed(2)} (${sign}${c.deltaPercent.toFixed(1)}%)`
437
841
  );
438
842
  }
843
+ const baselineRef = Array.isArray(baseline) ? baseline[0] : baseline;
439
844
  const stable = Math.max(
440
845
  0,
441
- baseline.items.length - comparison.regressions.length - comparison.improvements.length
846
+ baselineRef.items.length - comparison.regressions.length - comparison.improvements.length
442
847
  );
443
848
  console.log(
444
849
  `
@@ -446,8 +851,52 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
446
851
  `
447
852
  );
448
853
  if (failOnRegression && comparison.regressions.length > 0) {
854
+ const hasSignificance = scorerNames.some((n) => comparison.scorers[n].significant != null);
855
+ if (hasSignificance) {
856
+ const hasSignificantRegression = scorerNames.some(
857
+ (n) => comparison.scorers[n].significant === true && comparison.scorers[n].delta < 0
858
+ );
859
+ if (hasSignificantRegression) process.exit(1);
860
+ } else {
861
+ process.exit(1);
862
+ }
863
+ }
864
+ }
865
+ async function runRescore(args) {
866
+ const { outputPath, configArg, conditions, paths } = parseEvalArgs(args);
867
+ if (paths.length < 2) {
868
+ console.error("Usage: axl-eval rescore <results.json> <eval-file> [--output <file>]");
449
869
  process.exit(1);
450
870
  }
871
+ const [resultsPath, evalFilePath] = paths;
872
+ const raw = JSON.parse(await (0, import_promises.readFile)(resultsPath, "utf-8"));
873
+ const results = Array.isArray(raw) ? raw : [raw];
874
+ const runtime = await getRuntime(configArg, conditions);
875
+ try {
876
+ const mod = await importModule(path.resolve(evalFilePath), import_meta.url);
877
+ const evalConfig = mod.default?.default ?? mod.default ?? mod.config ?? mod;
878
+ if (!evalConfig.scorers || evalConfig.scorers.length === 0) {
879
+ console.error(`Error: ${evalFilePath} does not export scorers`);
880
+ process.exit(1);
881
+ }
882
+ const rescored = [];
883
+ for (const resultData of results) {
884
+ rescored.push(await rescore(resultData, evalConfig.scorers, runtime));
885
+ }
886
+ for (const r of rescored) {
887
+ console.log("\n" + formatTable(r) + "\n");
888
+ }
889
+ if (outputPath) {
890
+ const output = Array.isArray(raw) ? rescored : rescored[0];
891
+ const outputDir = path.dirname(path.resolve(outputPath));
892
+ await (0, import_promises.mkdir)(outputDir, { recursive: true });
893
+ await (0, import_promises.writeFile)(outputPath, JSON.stringify(output, null, 2), "utf-8");
894
+ console.log(`Rescored results saved to ${outputPath}`);
895
+ }
896
+ } finally {
897
+ await runtime.shutdown().catch(() => {
898
+ });
899
+ }
451
900
  }
452
901
  function collectEvalFiles(p) {
453
902
  const resolved = path.resolve(p);
@@ -514,6 +963,35 @@ function formatTable(result) {
514
963
  }
515
964
  return lines.join("\n");
516
965
  }
966
+ function formatMultiRunTable(summary) {
967
+ const lines = [];
968
+ const scorerNames = Object.keys(summary.scorers);
969
+ const maxNameLen = Math.max(...scorerNames.map((n) => n.length), "Scorer".length);
970
+ const colWidth = 16;
971
+ lines.push(`Eval: ${summary.workflow} x ${summary.dataset} \u2014 ${summary.runCount} runs`);
972
+ lines.push(
973
+ ` ${"Scorer".padEnd(maxNameLen)} ${"Mean \xB1 Std".padStart(colWidth)} ${"Min".padStart(8)} ${"Max".padStart(8)}`
974
+ );
975
+ const ruleLen = maxNameLen + 2 + colWidth + 2 + 8 + 2 + 8;
976
+ lines.push(" " + "\u2500".repeat(ruleLen));
977
+ for (const name of scorerNames) {
978
+ const s = summary.scorers[name];
979
+ const meanStd = `${s.mean.toFixed(3)} \xB1 ${s.std.toFixed(3)}`;
980
+ lines.push(
981
+ ` ${name.padEnd(maxNameLen)} ${meanStd.padStart(colWidth)} ${s.min.toFixed(3).padStart(8)} ${s.max.toFixed(3).padStart(8)}`
982
+ );
983
+ }
984
+ if (summary.timing) {
985
+ lines.push(
986
+ ` ${"Timing".padEnd(maxNameLen)} ${((summary.timing.mean / 1e3).toFixed(2) + " \xB1 " + (summary.timing.std / 1e3).toFixed(2) + "s").padStart(colWidth)}`
987
+ );
988
+ }
989
+ const costStr = summary.totalCost > 0 ? `$${summary.totalCost.toFixed(2)}` : "$0.00";
990
+ const durationStr = (summary.totalDuration / 1e3).toFixed(1);
991
+ lines.push("");
992
+ lines.push(` Total Cost: ${costStr} | Total Duration: ${durationStr}s`);
993
+ return lines.join("\n");
994
+ }
517
995
  async function resolveRuntimeFromConfig(configPath) {
518
996
  try {
519
997
  const mod = await importModule(configPath, import_meta.url);
@@ -578,10 +1056,11 @@ function parseEvalArgs(args) {
578
1056
  let outputPath;
579
1057
  let configArg;
580
1058
  let conditions = [];
1059
+ let runs = 1;
581
1060
  const paths = [];
582
1061
  for (let i = 0; i < args.length; i++) {
583
1062
  const arg = args[i];
584
- if (arg === "--output" || arg === "--config" || arg === "--conditions") {
1063
+ if (arg === "--output" || arg === "--config" || arg === "--conditions" || arg === "--runs") {
585
1064
  if (i + 1 >= args.length) {
586
1065
  console.error(`Error: ${arg} requires a value`);
587
1066
  process.exit(1);
@@ -589,6 +1068,7 @@ function parseEvalArgs(args) {
589
1068
  const value = args[++i];
590
1069
  if (arg === "--output") outputPath = value;
591
1070
  else if (arg === "--config") configArg = value;
1071
+ else if (arg === "--runs") runs = Math.max(1, parseInt(value, 10) || 1);
592
1072
  else
593
1073
  conditions = value.split(",").map((c) => c.trim()).filter(Boolean);
594
1074
  } else if (arg.startsWith("--")) {
@@ -600,10 +1080,10 @@ function parseEvalArgs(args) {
600
1080
  paths.push(arg);
601
1081
  }
602
1082
  }
603
- return { outputPath, configArg, conditions, paths };
1083
+ return { outputPath, configArg, conditions, runs, paths };
604
1084
  }
605
1085
  async function runEvalCommand(args) {
606
- const { outputPath, configArg, conditions, paths } = parseEvalArgs(args);
1086
+ const { outputPath, configArg, conditions, runs, paths } = parseEvalArgs(args);
607
1087
  if (paths.length === 0) {
608
1088
  console.error("Error: No eval file path provided");
609
1089
  process.exit(1);
@@ -632,17 +1112,25 @@ async function runEvalCommand(args) {
632
1112
  let executeWorkflow;
633
1113
  if (mod.executeWorkflow) {
634
1114
  executeWorkflow = async (input, rt) => {
635
- const { result: result2, cost: trackedCost } = await runtime.trackCost(async () => {
1115
+ const {
1116
+ result,
1117
+ cost: trackedCost,
1118
+ metadata
1119
+ } = await runtime.trackExecution(async () => {
636
1120
  return mod.executeWorkflow(input, rt);
637
1121
  });
638
- return { output: result2.output, cost: result2.cost ?? trackedCost };
1122
+ return {
1123
+ output: result.output,
1124
+ cost: result.cost ?? trackedCost,
1125
+ metadata: result.metadata ?? metadata
1126
+ };
639
1127
  };
640
1128
  } else if (runtime.getWorkflow(evalConfig.workflow)) {
641
1129
  executeWorkflow = async (input) => {
642
- const { result: result2, cost } = await runtime.trackCost(async () => {
1130
+ const { result, cost, metadata } = await runtime.trackExecution(async () => {
643
1131
  return runtime.execute(evalConfig.workflow, input);
644
1132
  });
645
- return { output: result2, cost };
1133
+ return { output: result, cost, metadata };
646
1134
  };
647
1135
  } else {
648
1136
  console.warn(
@@ -650,9 +1138,25 @@ async function runEvalCommand(args) {
650
1138
  );
651
1139
  executeWorkflow = async (input) => ({ output: input });
652
1140
  }
653
- const result = await runEval(evalConfig, executeWorkflow, runtime);
654
- results.push(result);
655
- console.log("\n" + formatTable(result) + "\n");
1141
+ if (runs > 1) {
1142
+ const { randomUUID: randomUUID4 } = await import("crypto");
1143
+ const runGroupId = randomUUID4();
1144
+ const runResults = [];
1145
+ for (let r = 0; r < runs; r++) {
1146
+ console.error(`[axl-eval] Run ${r + 1}/${runs}...`);
1147
+ const result = await runEval(evalConfig, executeWorkflow, runtime);
1148
+ result.metadata.runGroupId = runGroupId;
1149
+ result.metadata.runIndex = r;
1150
+ runResults.push(result);
1151
+ results.push(result);
1152
+ }
1153
+ const summary = aggregateRuns(runResults);
1154
+ console.log("\n" + formatMultiRunTable(summary) + "\n");
1155
+ } else {
1156
+ const result = await runEval(evalConfig, executeWorkflow, runtime);
1157
+ results.push(result);
1158
+ console.log("\n" + formatTable(result) + "\n");
1159
+ }
656
1160
  } catch (err) {
657
1161
  console.error(
658
1162
  `Error running eval ${filePath}: ${err instanceof Error ? err.message : String(err)}`