@valerypopoff/rivet2-evaluations 2.3.0 → 2.5.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.
@@ -41,7 +41,7 @@ export async function runEvaluationWorkPool(options) {
41
41
  const work = options.work[index];
42
42
  const result = await options.execute(work, index);
43
43
  results[index] = result;
44
- options.onSettled?.(result, index, results);
44
+ await options.onSettled?.(result, index, results);
45
45
  }
46
46
  };
47
47
  await Promise.all(Array.from({ length: Math.min(options.concurrency, options.work.length) }, worker));
@@ -744,8 +744,8 @@ async function runWithTimeout(run, timeoutMs, outer) {
744
744
  outer?.removeEventListener('abort', abort);
745
745
  }
746
746
  }
747
- async function runTrial(input) {
748
- const { project, suite, dataset, purpose, testCase, caseIndex, trialIndex, runId, runGraph, signal } = input;
747
+ export async function runEvaluationTrial(input) {
748
+ const { project, suite, dataset, purpose, testCase, caseIndex, trialIndex, runId, runGraph, signal, trialId } = input;
749
749
  const evaluationMode = getEvaluationSuiteMode(suite);
750
750
  const { inputs, expected } = resolveCaseInputs(suite, dataset, testCase, purpose);
751
751
  const seed = deriveSeed(suite.configuration?.seed, testCase.id, trialIndex);
@@ -792,7 +792,7 @@ async function runTrial(input) {
792
792
  const executionStatus = 'canceled';
793
793
  const qualityStatus = 'not-evaluated';
794
794
  return {
795
- id: makeId('trial'),
795
+ id: trialId ?? makeId('trial'),
796
796
  caseId: testCase.id,
797
797
  caseName: testCase.name,
798
798
  caseIndex,
@@ -919,7 +919,7 @@ async function runTrial(input) {
919
919
  const executionStatus = 'canceled';
920
920
  const qualityStatus = 'not-evaluated';
921
921
  return {
922
- id: makeId('trial'),
922
+ id: trialId ?? makeId('trial'),
923
923
  caseId: testCase.id,
924
924
  caseName: testCase.name,
925
925
  caseIndex,
@@ -1015,7 +1015,7 @@ async function runTrial(input) {
1015
1015
  }
1016
1016
  const totalMetrics = mergeMetrics(targetMetrics, evaluatorMetrics);
1017
1017
  return {
1018
- id: makeId('trial'),
1018
+ id: trialId ?? makeId('trial'),
1019
1019
  caseId: testCase.id,
1020
1020
  caseName: testCase.name,
1021
1021
  caseIndex,
@@ -1042,6 +1042,15 @@ function percentile(values, percentage) {
1042
1042
  const ordered = values.slice().sort((left, right) => left - right);
1043
1043
  return ordered[Math.min(ordered.length - 1, Math.ceil((percentage / 100) * ordered.length) - 1)];
1044
1044
  }
1045
+ function median(values) {
1046
+ if (values.length === 0)
1047
+ return 0;
1048
+ const ordered = values.slice().sort((left, right) => left - right);
1049
+ const upperIndex = Math.floor(ordered.length / 2);
1050
+ if (ordered.length % 2 !== 0)
1051
+ return ordered[upperIndex];
1052
+ return (ordered[upperIndex - 1] + ordered[upperIndex]) / 2;
1053
+ }
1045
1054
  function meanObservationScore(observations) {
1046
1055
  let weightedScore = 0;
1047
1056
  let totalWeight = 0;
@@ -1141,6 +1150,7 @@ function aggregate(trials, evaluationMode = 'pass-fail') {
1141
1150
  const scoringMeanScore = scoringCaseMeans.length === 0
1142
1151
  ? undefined
1143
1152
  : scoringCaseMeans.reduce((sum, score) => sum + score, 0) / scoringCaseMeans.length;
1153
+ const incurredLatencies = incurred.map((trial) => trial.totalMetrics.durationMs);
1144
1154
  const aggregateValue = {
1145
1155
  trialCount: trials.length,
1146
1156
  evaluatedTrialCount: evaluated.length,
@@ -1156,14 +1166,19 @@ function aggregate(trials, evaluationMode = 'pass-fail') {
1156
1166
  scoredTrialCount,
1157
1167
  missingScoreTrialCount: trials.length - scoredTrialCount,
1158
1168
  ...(scoringMeanScore === undefined ? {} : { meanScore: scoringMeanScore }),
1169
+ ...(scoringCaseMeans.length === 0
1170
+ ? {}
1171
+ : {
1172
+ medianScore: median(scoringCaseMeans),
1173
+ p95Score: percentile(scoringCaseMeans, 95),
1174
+ }),
1159
1175
  }
1160
1176
  : meanScore === undefined
1161
1177
  ? {}
1162
1178
  : { meanScore }),
1163
- averageLatencyMs: incurred.length === 0
1164
- ? 0
1165
- : incurred.reduce((sum, trial) => sum + trial.totalMetrics.durationMs, 0) / incurred.length,
1166
- p95LatencyMs: percentile(incurred.map((trial) => trial.totalMetrics.durationMs), 95),
1179
+ averageLatencyMs: incurred.length === 0 ? 0 : incurredLatencies.reduce((sum, durationMs) => sum + durationMs, 0) / incurred.length,
1180
+ medianLatencyMs: median(incurredLatencies),
1181
+ p95LatencyMs: percentile(incurredLatencies, 95),
1167
1182
  ...(incurred.length === 0 || totalCostUsd === undefined
1168
1183
  ? {}
1169
1184
  : { totalCostUsd, averageCostUsd: totalCostUsd / incurred.length }),
@@ -1515,7 +1530,12 @@ function createProvenance(project, suite, dataset, options) {
1515
1530
  accountingComplete: true,
1516
1531
  };
1517
1532
  }
1518
- export async function runEvaluationSuite(options) {
1533
+ /**
1534
+ * Validates an immutable Evaluation submission and expands its enabled cases
1535
+ * into deterministic work. Hosts can persist this plan before any graph side
1536
+ * effect, while local callers continue to use runEvaluationSuite.
1537
+ */
1538
+ export function createEvaluationRunPlan(options) {
1519
1539
  const suite = resolveSuite(options.evaluationData, options.suiteId);
1520
1540
  const purpose = options.purpose ?? 'evaluation';
1521
1541
  const evaluationMode = getEvaluationSuiteMode(suite);
@@ -1525,6 +1545,119 @@ export async function runEvaluationSuite(options) {
1525
1545
  : `Evaluation suite "${suite.name}" has no required quality check or threshold. Add a quality criterion or run it as an execution benchmark.`);
1526
1546
  }
1527
1547
  validateSuite(options.project, suite, options.dataset, purpose);
1548
+ const trialCount = Math.max(1, Math.floor(suite.configuration?.trialCount ?? 1));
1549
+ const work = options.dataset.cases
1550
+ .filter((testCase) => testCase.enabled !== false)
1551
+ .flatMap((testCase, caseIndex) => Array.from({ length: trialCount }, (_, trialIndex) => ({ testCase, caseIndex, trialIndex })));
1552
+ return { suite, purpose, evaluationMode, work };
1553
+ }
1554
+ /**
1555
+ * Applies the same aggregate, quality, accounting, and baseline semantics to
1556
+ * locally scheduled and durably hosted trials. Callers must provide every
1557
+ * settled trial in deterministic case/trial order; never synthesize retries.
1558
+ */
1559
+ export function finalizeEvaluationRun(options) {
1560
+ const run = options.run;
1561
+ const evaluationMode = getEvaluationSuiteMode(options.suite);
1562
+ run.trials = options.trials.slice();
1563
+ run.completedAt = options.completedAt ?? new Date().toISOString();
1564
+ const outcome = aggregate(run.trials, evaluationMode);
1565
+ run.aggregate = outcome.aggregate;
1566
+ run.provenance.accountingComplete = !run.trials.some((trial) => trial.totalMetrics.hasUnknownCost);
1567
+ run.accountingStatus = run.provenance.accountingComplete ? 'complete' : 'partial';
1568
+ if (run.accountingStatus === 'partial' && !run.warnings.includes('Some provider pricing was unavailable. Cost totals are unavailable, and cost requirements cannot be evaluated.')) {
1569
+ run.warnings.push('Some provider pricing was unavailable. Cost totals are unavailable, and cost requirements cannot be evaluated.');
1570
+ }
1571
+ if (options.canceled) {
1572
+ run.executionStatus = 'canceled';
1573
+ run.qualityStatus = 'not-evaluated';
1574
+ run.qualityReason = { code: 'canceled', message: 'The evaluation run was canceled.' };
1575
+ return run;
1576
+ }
1577
+ if (run.purpose === 'execution-benchmark') {
1578
+ run.executionStatus = 'completed';
1579
+ run.qualityStatus = 'not-evaluated';
1580
+ run.qualityReason = { code: 'benchmark', message: 'This run measured execution without evaluating output quality.' };
1581
+ return run;
1582
+ }
1583
+ if (evaluationMode === 'scoring') {
1584
+ run.executionStatus = 'completed';
1585
+ if ((outcome.aggregate.missingScoreTrialCount ?? run.trials.length) > 0) {
1586
+ run.qualityStatus = 'unable-to-evaluate';
1587
+ run.qualityReason = { code: 'scores-incomplete', message: 'One or more requested trials did not produce a usable score. Available averages are shown with coverage.' };
1588
+ run.warnings.push(`Score coverage is ${outcome.aggregate.scoredTrialCount ?? 0} of ${outcome.aggregate.trialCount} requested trials.`);
1589
+ }
1590
+ else if ((outcome.aggregate.scoredTrialCount ?? 0) > 0) {
1591
+ run.qualityStatus = 'scored';
1592
+ run.qualityReason = { code: 'scores-complete', message: 'Every requested trial produced a score. Overall score is the equal-weight average of case averages.' };
1593
+ }
1594
+ else {
1595
+ run.qualityStatus = 'unable-to-evaluate';
1596
+ run.qualityReason = { code: 'scores-incomplete', message: 'No requested trial produced a usable score.' };
1597
+ }
1598
+ return run;
1599
+ }
1600
+ const baseline = options.baseline ?? options.evaluationData.baselines.find((candidate) => candidate.suiteId === options.suite.id);
1601
+ const usableBaseline = baseline && baselineCompatible(run.provenance, baseline) ? baseline : undefined;
1602
+ if (baseline && !usableBaseline && !run.warnings.includes('The suite baseline is stale because its target, dataset, bindings, or evaluator definition changed.')) {
1603
+ run.warnings.push('The suite baseline is stale because its target, dataset, bindings, or evaluator definition changed.');
1604
+ }
1605
+ run.thresholdResults = evaluateThresholdResults(outcome.aggregate, options.suite.thresholds, usableBaseline);
1606
+ const hasPassRateThreshold = options.suite.thresholds?.some((threshold) => threshold.metric === 'pass-rate') ?? false;
1607
+ const hasTargetErrorRateThreshold = options.suite.thresholds?.some((threshold) => threshold.metric === 'target-error-rate') ?? false;
1608
+ const hasUngovernedCheckFailure = !hasPassRateThreshold && run.trials.some((trial) => trial.executionStatus === 'completed' && trial.qualityStatus === 'failed');
1609
+ const hasUngovernedTargetError = !hasTargetErrorRateThreshold && run.trials.some((trial) => trial.executionStatus === 'error');
1610
+ const hasThresholdFailure = run.thresholdResults.some((result) => result.status === 'failed');
1611
+ const hasUnavailableEvidence = run.trials.some((trial) => trial.qualityStatus === 'unable-to-evaluate') || run.thresholdResults.some((result) => result.status === 'unavailable');
1612
+ const hasAuthoritativePass = run.trials.some((trial) => trial.executionStatus === 'completed' && trial.qualityStatus === 'passed') || run.thresholdResults.some((result) => result.status === 'passed');
1613
+ run.executionStatus = 'completed';
1614
+ if (hasUngovernedCheckFailure || hasUngovernedTargetError || hasThresholdFailure) {
1615
+ run.qualityStatus = 'failed';
1616
+ run.qualityReason = { code: hasThresholdFailure ? 'thresholds-failed' : 'checks-failed', message: hasThresholdFailure ? 'One or more required aggregate thresholds failed.' : 'One or more target executions or required quality checks failed.' };
1617
+ }
1618
+ else if (hasUnavailableEvidence) {
1619
+ run.qualityStatus = 'unable-to-evaluate';
1620
+ run.qualityReason = { code: run.thresholdResults.some((result) => result.status === 'unavailable') ? 'required-metric-unavailable' : 'required-check-error', message: run.thresholdResults.some((result) => result.status === 'unavailable') ? 'A required aggregate metric could not be evaluated.' : 'A required quality check could not be evaluated.' };
1621
+ }
1622
+ else if (hasAuthoritativePass) {
1623
+ run.qualityStatus = 'passed';
1624
+ run.qualityReason = { code: run.thresholdResults.length > 0 ? 'thresholds-passed' : 'checks-passed', message: run.thresholdResults.length > 0 ? 'All required quality checks and aggregate thresholds passed.' : 'All required quality checks passed.' };
1625
+ }
1626
+ else {
1627
+ run.qualityStatus = 'not-evaluated';
1628
+ run.qualityReason = { code: 'no-completed-trials', message: 'No completed trial produced an authoritative quality result.' };
1629
+ }
1630
+ return run;
1631
+ }
1632
+ /** Creates a durable running shell only after the complete submission validates. */
1633
+ export function createEvaluationRunShell(options) {
1634
+ const plan = createEvaluationRunPlan(options);
1635
+ const runId = options.runId ?? makeId('evaluation');
1636
+ const run = {
1637
+ version: 2,
1638
+ id: runId,
1639
+ projectId: options.project.metadata.id,
1640
+ suiteId: plan.suite.id,
1641
+ suiteName: plan.suite.name,
1642
+ revision: 0,
1643
+ startedAt: new Date().toISOString(),
1644
+ purpose: plan.purpose,
1645
+ evaluationMode: plan.evaluationMode,
1646
+ executionStatus: 'running',
1647
+ qualityStatus: 'not-evaluated',
1648
+ qualityReason: { code: 'in-progress', message: 'The run is still in progress.' },
1649
+ accountingStatus: 'complete',
1650
+ provenance: createProvenance(options.project, plan.suite, options.dataset, options),
1651
+ trials: [],
1652
+ thresholdResults: [],
1653
+ warnings: [],
1654
+ requestedTrialCount: plan.work.length,
1655
+ };
1656
+ return { run, plan };
1657
+ }
1658
+ export async function runEvaluationSuite(options) {
1659
+ const plan = createEvaluationRunPlan(options);
1660
+ const { suite, purpose, evaluationMode } = plan;
1528
1661
  const runId = options.runId ?? makeId('evaluation');
1529
1662
  const run = {
1530
1663
  version: 2,
@@ -1545,30 +1678,62 @@ export async function runEvaluationSuite(options) {
1545
1678
  thresholdResults: [],
1546
1679
  warnings: [],
1547
1680
  };
1548
- const trialCount = Math.max(1, Math.floor(suite.configuration?.trialCount ?? 1));
1549
1681
  const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(suite.configuration?.concurrency ?? DEFAULT_CONCURRENCY)));
1550
- const work = options.dataset.cases
1551
- .filter((testCase) => testCase.enabled !== false)
1552
- .flatMap((testCase, caseIndex) => Array.from({ length: trialCount }, (_, trialIndex) => ({ testCase, caseIndex, trialIndex })));
1553
- const publish = () => {
1682
+ const work = plan.work;
1683
+ run.requestedTrialCount = work.length;
1684
+ const settledTrials = Array.from({ length: work.length });
1685
+ let settledTrialCount = 0;
1686
+ const nextRevision = () => {
1554
1687
  run.revision = (run.revision ?? 0) + 1;
1555
- // Progress callbacks and stores may retain earlier revisions. Publish a
1556
- // fully detached snapshot so later mutations to warnings, provenance,
1557
- // metrics, observations, or nested trial data cannot rewrite history in
1558
- // place behind the consumer's back.
1559
- options.onUpdate?.(structuredClone(run));
1688
+ return run.revision;
1689
+ };
1690
+ const publishLegacySnapshot = () => {
1691
+ // Legacy consumers may retain earlier revisions. Keep their snapshots
1692
+ // detached, while incremental consumers avoid cloning all prior trials.
1693
+ if (!options.onUpdate)
1694
+ return;
1695
+ run.trials = settledTrials.filter((trial) => trial !== undefined);
1696
+ options.onUpdate(structuredClone(run));
1697
+ };
1698
+ const publishStarted = async () => {
1699
+ const revision = nextRevision();
1700
+ if (options.onEvent) {
1701
+ const shell = structuredClone(run);
1702
+ shell.trials = [];
1703
+ await options.onEvent({ type: 'run-started', revision, run: shell });
1704
+ }
1705
+ publishLegacySnapshot();
1706
+ };
1707
+ const publishSettledTrial = async (trial) => {
1708
+ const revision = nextRevision();
1709
+ await options.onEvent?.({
1710
+ type: 'trial-settled',
1711
+ revision,
1712
+ runId: run.id,
1713
+ projectId: run.projectId,
1714
+ suiteId: run.suiteId,
1715
+ requestedTrialCount: run.requestedTrialCount ?? work.length,
1716
+ settledTrialCount,
1717
+ trial: structuredClone(trial),
1718
+ });
1719
+ publishLegacySnapshot();
1720
+ };
1721
+ const publishFinalized = async () => {
1722
+ const revision = nextRevision();
1723
+ await options.onEvent?.({ type: 'run-finalized', revision, run: structuredClone(run) });
1724
+ publishLegacySnapshot();
1560
1725
  };
1561
1726
  // Publish the running shell before constructing the worker-pool promise:
1562
1727
  // async workers start immediately, so publishing afterwards can otherwise
1563
1728
  // let the first graph call race ahead of the run's initial state.
1564
- publish();
1729
+ await publishStarted();
1565
1730
  const workPool = runEvaluationWorkPool({
1566
1731
  work,
1567
1732
  concurrency,
1568
1733
  signal: options.signal,
1569
1734
  execute: async (task) => {
1570
1735
  try {
1571
- return await runTrial({
1736
+ return await runEvaluationTrial({
1572
1737
  ...task,
1573
1738
  project: options.project,
1574
1739
  suite,
@@ -1612,115 +1777,22 @@ export async function runEvaluationSuite(options) {
1612
1777
  };
1613
1778
  }
1614
1779
  },
1615
- onSettled: (_result, _index, currentResults) => {
1616
- run.trials = currentResults.filter((result) => result !== undefined);
1617
- publish();
1780
+ onSettled: async (result, index) => {
1781
+ settledTrials[index] = result;
1782
+ settledTrialCount += 1;
1783
+ await publishSettledTrial(result);
1618
1784
  },
1619
1785
  });
1620
1786
  const results = await workPool;
1621
- run.trials = results.filter((result) => result !== undefined);
1622
- run.completedAt = new Date().toISOString();
1623
- const outcome = aggregate(run.trials, evaluationMode);
1624
- run.aggregate = outcome.aggregate;
1625
- run.provenance.accountingComplete = !run.trials.some((trial) => trial.totalMetrics.hasUnknownCost);
1626
- run.accountingStatus = run.provenance.accountingComplete ? 'complete' : 'partial';
1627
- if (run.accountingStatus === 'partial') {
1628
- run.warnings.push('Some provider pricing was unavailable. Cost totals are unavailable, and cost requirements cannot be evaluated.');
1629
- }
1630
- if (options.signal?.aborted) {
1631
- run.executionStatus = 'canceled';
1632
- run.qualityStatus = 'not-evaluated';
1633
- run.qualityReason = { code: 'canceled', message: 'The evaluation run was canceled.' };
1634
- }
1635
- else if (purpose === 'execution-benchmark') {
1636
- run.executionStatus = 'completed';
1637
- run.qualityStatus = 'not-evaluated';
1638
- run.qualityReason = {
1639
- code: 'benchmark',
1640
- message: 'This run measured execution without evaluating output quality.',
1641
- };
1642
- }
1643
- else if (evaluationMode === 'scoring') {
1644
- run.executionStatus = 'completed';
1645
- if ((outcome.aggregate.missingScoreTrialCount ?? run.trials.length) > 0) {
1646
- run.qualityStatus = 'unable-to-evaluate';
1647
- run.qualityReason = {
1648
- code: 'scores-incomplete',
1649
- message: 'One or more requested trials did not produce a usable score. Available averages are shown with coverage.',
1650
- };
1651
- run.warnings.push(`Score coverage is ${outcome.aggregate.scoredTrialCount ?? 0} of ${outcome.aggregate.trialCount} requested trials.`);
1652
- }
1653
- else if ((outcome.aggregate.scoredTrialCount ?? 0) > 0) {
1654
- run.qualityStatus = 'scored';
1655
- run.qualityReason = {
1656
- code: 'scores-complete',
1657
- message: 'Every requested trial produced a score. Overall score is the equal-weight average of case averages.',
1658
- };
1659
- }
1660
- else {
1661
- run.qualityStatus = 'unable-to-evaluate';
1662
- run.qualityReason = { code: 'scores-incomplete', message: 'No requested trial produced a usable score.' };
1663
- }
1664
- }
1665
- else {
1666
- const baseline = options.baseline ?? options.evaluationData.baselines.find((candidate) => candidate.suiteId === suite.id);
1667
- const usableBaseline = baseline && baselineCompatible(run.provenance, baseline) ? baseline : undefined;
1668
- if (baseline && !usableBaseline)
1669
- run.warnings.push('The suite baseline is stale because its target, dataset, bindings, or evaluator definition changed.');
1670
- run.thresholdResults = evaluateThresholdResults(outcome.aggregate, suite.thresholds, usableBaseline);
1671
- const hasPassRateThreshold = suite.thresholds?.some((threshold) => threshold.metric === 'pass-rate') ?? false;
1672
- const hasTargetErrorRateThreshold = suite.thresholds?.some((threshold) => threshold.metric === 'target-error-rate') ?? false;
1673
- // Per-trial checks and target execution errors are strict by default. An
1674
- // author can deliberately replace either default with its matching
1675
- // aggregate tolerance, but one kind of threshold must never hide the
1676
- // other kind of failure.
1677
- const hasUngovernedCheckFailure = !hasPassRateThreshold &&
1678
- run.trials.some((trial) => trial.executionStatus === 'completed' && trial.qualityStatus === 'failed');
1679
- const hasUngovernedTargetError = !hasTargetErrorRateThreshold && run.trials.some((trial) => trial.executionStatus === 'error');
1680
- const hasThresholdFailure = run.thresholdResults.some((result) => result.status === 'failed');
1681
- const hasUnavailableEvidence = run.trials.some((trial) => trial.qualityStatus === 'unable-to-evaluate') ||
1682
- run.thresholdResults.some((result) => result.status === 'unavailable');
1683
- const hasAuthoritativePass = run.trials.some((trial) => trial.executionStatus === 'completed' && trial.qualityStatus === 'passed') ||
1684
- run.thresholdResults.some((result) => result.status === 'passed');
1685
- if (hasUngovernedCheckFailure || hasUngovernedTargetError || hasThresholdFailure) {
1686
- run.qualityStatus = 'failed';
1687
- run.qualityReason = {
1688
- code: hasThresholdFailure ? 'thresholds-failed' : 'checks-failed',
1689
- message: hasThresholdFailure
1690
- ? 'One or more required aggregate thresholds failed.'
1691
- : 'One or more target executions or required quality checks failed.',
1692
- };
1693
- }
1694
- else if (hasUnavailableEvidence) {
1695
- run.qualityStatus = 'unable-to-evaluate';
1696
- run.qualityReason = {
1697
- code: run.thresholdResults.some((result) => result.status === 'unavailable')
1698
- ? 'required-metric-unavailable'
1699
- : 'required-check-error',
1700
- message: run.thresholdResults.some((result) => result.status === 'unavailable')
1701
- ? 'A required aggregate metric could not be evaluated.'
1702
- : 'A required quality check could not be evaluated.',
1703
- };
1704
- }
1705
- else if (hasAuthoritativePass) {
1706
- run.qualityStatus = 'passed';
1707
- run.qualityReason = {
1708
- code: run.thresholdResults.length > 0 ? 'thresholds-passed' : 'checks-passed',
1709
- message: run.thresholdResults.length > 0
1710
- ? 'All required quality checks and aggregate thresholds passed.'
1711
- : 'All required quality checks passed.',
1712
- };
1713
- }
1714
- else {
1715
- run.qualityStatus = 'not-evaluated';
1716
- run.qualityReason = {
1717
- code: 'no-completed-trials',
1718
- message: 'No completed trial produced an authoritative quality result.',
1719
- };
1720
- }
1721
- run.executionStatus = 'completed';
1722
- }
1723
- publish();
1787
+ finalizeEvaluationRun({
1788
+ run,
1789
+ trials: results.filter((result) => result !== undefined),
1790
+ suite,
1791
+ evaluationData: options.evaluationData,
1792
+ baseline: options.baseline,
1793
+ canceled: options.signal?.aborted === true,
1794
+ });
1795
+ await publishFinalized();
1724
1796
  return run;
1725
1797
  }
1726
1798
  export async function runEvaluationCases(options) {
@@ -209,6 +209,9 @@ function validateBaselineShape(value, index) {
209
209
  'notEvaluatedTrialCount',
210
210
  'unableToEvaluateTrialCount',
211
211
  'meanScore',
212
+ 'medianScore',
213
+ 'p95Score',
214
+ 'medianLatencyMs',
212
215
  'totalCostUsd',
213
216
  'averageCostUsd',
214
217
  'scoredTrialCount',
package/dist/esm/store.js CHANGED
@@ -1,16 +1,34 @@
1
- import { assertEvaluationDatasetSnapshot } from './canonical.js';
2
- import { normalizeEvaluationRun, shouldReplaceEvaluationRun } from './normalization.js';
1
+ import { assertEvaluationDatasetSnapshot, assertEvaluationRecordingArtifact } from './canonical.js';
2
+ import { normalizeEvaluationRun, reconcileEvaluationRunSnapshots } from './normalization.js';
3
+ import { createEmptyEvaluationLibrary, normalizeEvaluationLibrary } from './library.js';
3
4
  export class InMemoryEvaluationRunStore {
4
5
  #runs = new Map();
5
6
  #datasetSnapshots = new Map();
6
7
  #recordings = new Map();
8
+ #library = createEmptyEvaluationLibrary();
9
+ async getLibrary() {
10
+ return structuredClone(this.#library);
11
+ }
12
+ async putLibrary(library) {
13
+ this.#library = structuredClone(normalizeEvaluationLibrary(library));
14
+ }
7
15
  async put(run) {
8
16
  const normalized = normalizeEvaluationRun(run);
9
17
  const key = `${normalized.projectId}/${normalized.id}`;
10
18
  const existing = this.#runs.get(key);
11
- if (!shouldReplaceEvaluationRun(existing, normalized))
19
+ const next = reconcileEvaluationRunSnapshots(existing, normalized);
20
+ if (next === existing)
12
21
  return;
13
- this.#runs.set(key, structuredClone(normalized));
22
+ this.#runs.set(key, structuredClone(next));
23
+ }
24
+ async updateRunName(input) {
25
+ const key = `${input.projectId}/${input.runId}`;
26
+ const existing = this.#runs.get(key);
27
+ if (!existing)
28
+ return undefined;
29
+ const renamed = normalizeEvaluationRun({ ...existing, name: input.name });
30
+ this.#runs.set(key, structuredClone(renamed));
31
+ return structuredClone(renamed);
14
32
  }
15
33
  async get(input) {
16
34
  const run = this.#runs.get(`${input.projectId}/${input.runId}`);
@@ -43,6 +61,7 @@ export class InMemoryEvaluationRunStore {
43
61
  return snapshot ? structuredClone(snapshot) : undefined;
44
62
  }
45
63
  async putRecording(artifact) {
64
+ assertEvaluationRecordingArtifact(artifact);
46
65
  const key = `${artifact.projectId}/${artifact.reference.id}`;
47
66
  const existing = this.#recordings.get(key);
48
67
  if (existing && (existing.runId !== artifact.runId || existing.trialId !== artifact.trialId)) {
@@ -71,12 +90,13 @@ export class InMemoryEvaluationRunStore {
71
90
  const key = `${input.projectId}/${input.recordingId}`;
72
91
  const artifact = this.#recordings.get(key);
73
92
  if (!artifact)
74
- return;
93
+ return false;
75
94
  artifact.reference = {
76
95
  id: artifact.reference.id,
77
96
  retention: input.retention,
78
97
  ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }),
79
98
  };
99
+ return true;
80
100
  }
81
101
  async promoteBaseline(input) {
82
102
  for (const artifact of this.#recordings.values()) {
@@ -1,4 +1,4 @@
1
- import type { EvaluationDatasetSnapshot, PortableJson } from './types.js';
1
+ import type { EvaluationDatasetSnapshot, EvaluationRecordingArtifact, PortableJson } from './types.js';
2
2
  export declare function assertPortableJson(value: unknown, path?: string, stack?: Set<object>): asserts value is PortableJson;
3
3
  export declare function canonicalizePortableJson(value: unknown): PortableJson;
4
4
  /**
@@ -21,3 +21,5 @@ export declare function fingerprintEvaluationDataset(value: {
21
21
  * accepting a snapshot that can no longer prove which cases were run.
22
22
  */
23
23
  export declare function assertEvaluationDatasetSnapshot(snapshot: EvaluationDatasetSnapshot): void;
24
+ /** Rejects malformed replay evidence at both write and durable-read boundaries. */
25
+ export declare function assertEvaluationRecordingArtifact(value: unknown): asserts value is EvaluationRecordingArtifact;
@@ -8,3 +8,5 @@ export * from './datasetTransfer.js';
8
8
  export * from './suiteTransfer.js';
9
9
  export * from './dataTypes.js';
10
10
  export * from './normalization.js';
11
+ export * from './library.js';
12
+ export * from './librarySync.js';
@@ -0,0 +1,8 @@
1
+ import type { EvaluationLibrary } from './types.js';
2
+ export declare function createEmptyEvaluationLibrary(): EvaluationLibrary;
3
+ /**
4
+ * Repairs a library resource-by-resource so one malformed suite, baseline, or
5
+ * dataset cannot hide unrelated valid resources. The top-level persistence
6
+ * envelope is validated by the owning store before this function is called.
7
+ */
8
+ export declare function normalizeEvaluationLibrary(value: unknown): EvaluationLibrary;
@@ -0,0 +1,17 @@
1
+ import type { EvaluationLibrary, EvaluationLibraryMutation, EvaluationLibraryResourceValue, EvaluationLibraryResourceVersions, EvaluationLibrarySuiteBundle } from './types.js';
2
+ export type { EvaluationLibraryResourceValue, EvaluationLibrarySuiteBundle } from './types.js';
3
+ /**
4
+ * Matches the server's canonical resource versioning while retaining the
5
+ * meaningful distinction between an absent resource and a present JSON value.
6
+ */
7
+ export declare function evaluationLibraryValueEquals(left: unknown, right: unknown): boolean;
8
+ export declare function getEvaluationSuiteBundle(library: EvaluationLibrary, id: string): EvaluationLibrarySuiteBundle | undefined;
9
+ export declare function getEvaluationLibraryResource(library: EvaluationLibrary, kind: 'suite' | 'dataset', id: string): EvaluationLibraryResourceValue;
10
+ /**
11
+ * Builds the smallest resource batch between two already normalized library
12
+ * snapshots. Legacy migration metadata deliberately has its own explicit
13
+ * server operation and is not silently folded into an editor mutation.
14
+ */
15
+ export declare function diffEvaluationLibraryMutation(previous: EvaluationLibrary, next: EvaluationLibrary, versions: EvaluationLibraryResourceVersions): EvaluationLibraryMutation | undefined;
16
+ /** Applies a server-validated resource batch while preserving unrelated resources. */
17
+ export declare function applyEvaluationLibraryMutation(library: EvaluationLibrary, mutation: EvaluationLibraryMutation): EvaluationLibrary;
@@ -5,6 +5,21 @@ import type { EvaluationAggregate, EvaluationBaselineSnapshot, EvaluationRun, Ev
5
5
  * rule so persistence cannot disagree with what the workspace presents.
6
6
  */
7
7
  export declare function shouldReplaceEvaluationRun(existing: Pick<EvaluationRun, 'executionStatus' | 'revision'> | undefined, incoming: Pick<EvaluationRun, 'executionStatus' | 'revision'>): boolean;
8
+ /**
9
+ * Execution snapshots never own a user-assigned run name. Preserve it when a
10
+ * runner publishes a newer progress or terminal revision after a rename.
11
+ */
12
+ export declare function preserveEvaluationRunName(existing: Pick<EvaluationRun, 'name'> | undefined, incoming: EvaluationRun): EvaluationRun;
13
+ /**
14
+ * Reconciles two snapshots of the same run without discarding evidence.
15
+ *
16
+ * Revisions remain authoritative. Within one revision, however, persistence
17
+ * and recording-retention updates can race with a terminal UI snapshot. Keep
18
+ * the snapshot with the fuller trial set, while still allowing a terminal
19
+ * snapshot to complete a progress snapshot and allowing recording references
20
+ * and warnings to enrich already-settled evidence.
21
+ */
22
+ export declare function reconcileEvaluationRunSnapshots(existing: EvaluationRun | undefined, incoming: EvaluationRun): EvaluationRun;
8
23
  export declare function normalizeEvaluationTrial(value: EvaluationTrial | unknown, purpose?: EvaluationRunPurpose, evaluationMode?: EvaluationSuiteMode): EvaluationTrial;
9
24
  export declare function normalizeEvaluationAggregate(value: EvaluationAggregate | unknown, trials?: readonly EvaluationTrial[]): EvaluationAggregate;
10
25
  export declare function normalizeEvaluationBaselineSnapshot(value: EvaluationBaselineSnapshot | unknown): EvaluationBaselineSnapshot;