@artemiskit/core 0.5.1 → 0.5.3

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.
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { RunManifest } from '../artifacts';
3
+ import { assessComparisonEligibility, isComparisonAvailable } from './eligibility';
4
+
5
+ function manifest(overrides: Partial<RunManifest> = {}): RunManifest {
6
+ return {
7
+ version: '1.3',
8
+ run_id: 'run-id',
9
+ project: 'project',
10
+ start_time: '2026-09-10T00:00:00.000Z',
11
+ end_time: '2026-09-10T00:00:01.000Z',
12
+ duration_ms: 1000,
13
+ config: { scenario: 'customer-service', provider: 'openai', model: 'model-a' },
14
+ workload_identity: {
15
+ schema_version: '1',
16
+ workload: { schema_version: '1', algorithm: 'sha256', digest: 'a'.repeat(64) },
17
+ rubric: { schema_version: '1', algorithm: 'sha256', digest: 'b'.repeat(64) },
18
+ },
19
+ execution_provenance: {
20
+ schema_version: '1',
21
+ target: { provider: 'openai', requested_models: ['model-a'], generation: { temperature: 0 } },
22
+ },
23
+ metrics: {
24
+ success_rate: 1,
25
+ total_cases: 1,
26
+ passed_cases: 1,
27
+ failed_cases: 0,
28
+ median_latency_ms: 1,
29
+ p95_latency_ms: 1,
30
+ total_tokens: 1,
31
+ total_prompt_tokens: 1,
32
+ total_completion_tokens: 0,
33
+ },
34
+ git: { commit: 'commit', branch: 'main', dirty: false },
35
+ provenance: { run_by: 'test' },
36
+ cases: [],
37
+ environment: { node_version: 'test', platform: 'test', arch: 'test' },
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ describe('assessComparisonEligibility', () => {
43
+ test('accepts matching declared workload, rubric, and execution configuration', () => {
44
+ const eligibility = assessComparisonEligibility(manifest(), manifest({ run_id: 'current' }));
45
+
46
+ expect(eligibility).toEqual({ schema_version: '1', status: 'compatible', reasons: [] });
47
+ expect(isComparisonAvailable(eligibility)).toBe(true);
48
+ });
49
+
50
+ test('qualifies legacy artifacts instead of presenting them as fully compatible', () => {
51
+ const legacy = manifest({ workload_identity: undefined, execution_provenance: undefined });
52
+ const eligibility = assessComparisonEligibility(legacy, manifest({ run_id: 'current' }));
53
+
54
+ expect(eligibility.status).toBe('qualified');
55
+ expect(eligibility.reasons.map((reason) => reason.code)).toEqual([
56
+ 'workload_identity_missing',
57
+ 'rubric_identity_missing',
58
+ 'execution_provenance_missing',
59
+ ]);
60
+ expect(isComparisonAvailable(eligibility)).toBe(true);
61
+ });
62
+
63
+ test('qualifies a deliberate target-model change without treating it as the same execution', () => {
64
+ const current = manifest({
65
+ run_id: 'current',
66
+ execution_provenance: {
67
+ schema_version: '1',
68
+ target: {
69
+ provider: 'openai',
70
+ requested_models: ['model-b'],
71
+ generation: { temperature: 0 },
72
+ },
73
+ },
74
+ });
75
+
76
+ const eligibility = assessComparisonEligibility(manifest(), current);
77
+
78
+ expect(eligibility.status).toBe('qualified');
79
+ expect(eligibility.reasons).toEqual([{ code: 'target_model_changed' }]);
80
+ });
81
+
82
+ test('refuses deltas when workload or rubric evidence differs', () => {
83
+ const current = manifest({
84
+ run_id: 'current',
85
+ workload_identity: {
86
+ schema_version: '1',
87
+ workload: { schema_version: '1', algorithm: 'sha256', digest: 'c'.repeat(64) },
88
+ rubric: { schema_version: '1', algorithm: 'sha256', digest: 'd'.repeat(64) },
89
+ },
90
+ });
91
+
92
+ const eligibility = assessComparisonEligibility(manifest(), current);
93
+
94
+ expect(eligibility.status).toBe('incomparable');
95
+ expect(eligibility.reasons.map((reason) => reason.code)).toEqual([
96
+ 'workload_mismatch',
97
+ 'rubric_mismatch',
98
+ ]);
99
+ expect(isComparisonAvailable(eligibility)).toBe(false);
100
+ });
101
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Compatibility decisions for comparisons of saved scenario-evaluation runs.
3
+ *
4
+ * This contract compares declared evidence only. A matching digest proves the
5
+ * same canonical workload/rubric was declared; it does not attest to provider
6
+ * behaviour or replace a future assessment-profile compatibility decision.
7
+ */
8
+
9
+ import type { RunManifest } from '../artifacts/types';
10
+
11
+ export type ComparisonEligibilityStatus = 'compatible' | 'qualified' | 'incomparable';
12
+
13
+ export type ComparisonEligibilityReasonCode =
14
+ | 'scenario_mismatch'
15
+ | 'workload_identity_missing'
16
+ | 'rubric_identity_missing'
17
+ | 'workload_mismatch'
18
+ | 'rubric_mismatch'
19
+ | 'execution_provenance_missing'
20
+ | 'target_provider_changed'
21
+ | 'target_model_changed'
22
+ | 'generation_settings_changed';
23
+
24
+ /** A bounded, machine-readable reason for a comparison decision. */
25
+ export interface ComparisonEligibilityReason {
26
+ code: ComparisonEligibilityReasonCode;
27
+ }
28
+
29
+ /**
30
+ * Versioned decision that accompanies every comparison. `qualified` permits
31
+ * a visibly-qualified delta; `incomparable` prohibits a delta entirely.
32
+ */
33
+ export interface ComparisonEligibility {
34
+ schema_version: '1';
35
+ status: ComparisonEligibilityStatus;
36
+ reasons: ComparisonEligibilityReason[];
37
+ }
38
+
39
+ export function assessComparisonEligibility(
40
+ baseline: RunManifest,
41
+ current: RunManifest
42
+ ): ComparisonEligibility {
43
+ const reasons: ComparisonEligibilityReason[] = [];
44
+
45
+ if (baseline.config.scenario !== current.config.scenario) {
46
+ reasons.push({ code: 'scenario_mismatch' });
47
+ }
48
+
49
+ const baselineIdentity = baseline.workload_identity;
50
+ const currentIdentity = current.workload_identity;
51
+ if (!baselineIdentity || !currentIdentity) {
52
+ if (!baselineIdentity || !currentIdentity) reasons.push({ code: 'workload_identity_missing' });
53
+ if (!baselineIdentity || !currentIdentity) reasons.push({ code: 'rubric_identity_missing' });
54
+ } else {
55
+ if (baselineIdentity.workload.digest !== currentIdentity.workload.digest) {
56
+ reasons.push({ code: 'workload_mismatch' });
57
+ }
58
+ if (baselineIdentity.rubric.digest !== currentIdentity.rubric.digest) {
59
+ reasons.push({ code: 'rubric_mismatch' });
60
+ }
61
+ }
62
+
63
+ if (reasons.some((reason) => isIncomparableReason(reason.code))) {
64
+ return { schema_version: '1', status: 'incomparable', reasons };
65
+ }
66
+
67
+ const baselineExecution = baseline.execution_provenance;
68
+ const currentExecution = current.execution_provenance;
69
+ if (!baselineExecution || !currentExecution) {
70
+ reasons.push({ code: 'execution_provenance_missing' });
71
+ } else {
72
+ if (baselineExecution.target.provider !== currentExecution.target.provider) {
73
+ reasons.push({ code: 'target_provider_changed' });
74
+ }
75
+ if (
76
+ !sameStringSet(
77
+ baselineExecution.target.requested_models,
78
+ currentExecution.target.requested_models
79
+ )
80
+ ) {
81
+ reasons.push({ code: 'target_model_changed' });
82
+ }
83
+ if (!sameGeneration(baselineExecution.target.generation, currentExecution.target.generation)) {
84
+ reasons.push({ code: 'generation_settings_changed' });
85
+ }
86
+ }
87
+
88
+ return {
89
+ schema_version: '1',
90
+ status: reasons.length === 0 ? 'compatible' : 'qualified',
91
+ reasons,
92
+ };
93
+ }
94
+
95
+ export function isComparisonAvailable(eligibility: ComparisonEligibility): boolean {
96
+ return eligibility.status !== 'incomparable';
97
+ }
98
+
99
+ function isIncomparableReason(code: ComparisonEligibilityReasonCode): boolean {
100
+ return code === 'scenario_mismatch' || code === 'workload_mismatch' || code === 'rubric_mismatch';
101
+ }
102
+
103
+ function sameStringSet(left?: string[], right?: string[]): boolean {
104
+ return JSON.stringify([...(left ?? [])].sort()) === JSON.stringify([...(right ?? [])].sort());
105
+ }
106
+
107
+ function sameGeneration(
108
+ left?: { temperature?: number; max_tokens?: number; seed?: number },
109
+ right?: { temperature?: number; max_tokens?: number; seed?: number }
110
+ ): boolean {
111
+ return (
112
+ left?.temperature === right?.temperature &&
113
+ left?.max_tokens === right?.max_tokens &&
114
+ left?.seed === right?.seed
115
+ );
116
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ assessComparisonEligibility,
3
+ isComparisonAvailable,
4
+ type ComparisonEligibility,
5
+ type ComparisonEligibilityReason,
6
+ type ComparisonEligibilityReasonCode,
7
+ type ComparisonEligibilityStatus,
8
+ } from './eligibility';
package/src/index.ts CHANGED
@@ -24,6 +24,9 @@ export * from './artifacts';
24
24
  // Provenance
25
25
  export * from './provenance';
26
26
 
27
+ // Comparison eligibility
28
+ export * from './comparison';
29
+
27
30
  // Utilities
28
31
  export * from './utils';
29
32
 
@@ -317,6 +317,56 @@ describe('executeCase measurement integrity', () => {
317
317
  expect(result.target?.observed_models).toBeUndefined();
318
318
  });
319
319
 
320
+ it('records retried target failures as excluded retry-chain attempts', async () => {
321
+ let calls = 0;
322
+ const retryClient: ModelClient = {
323
+ ...client,
324
+ generate: async () => {
325
+ calls++;
326
+ if (calls === 1) throw new Error('temporary provider failure');
327
+ return {
328
+ id: 'response',
329
+ model: 'target-model',
330
+ text: 'target response',
331
+ tokens: { prompt: 1, completion: 1, total: 2 },
332
+ latencyMs: 1,
333
+ finishReason: 'stop',
334
+ };
335
+ },
336
+ };
337
+ registerEvaluator('custom', {
338
+ type: 'custom',
339
+ evaluate: async () => ({ passed: true, score: 1 }),
340
+ });
341
+
342
+ const result = await executeCase(scenario.cases[0], {
343
+ client: retryClient,
344
+ scenario,
345
+ retries: 1,
346
+ runId: 'assurance-run',
347
+ repetition: { index: 2, total: 3 },
348
+ });
349
+
350
+ expect(result.attempts).toBe(2);
351
+ expect(result.attempt_evidence).toEqual([
352
+ expect.objectContaining({
353
+ attempt_id: 'assurance-run:custom-evaluation:1',
354
+ retry_chain_id: 'assurance-run:custom-evaluation',
355
+ repetition_index: 2,
356
+ attempt_number: 1,
357
+ status: 'error',
358
+ included_in_outcome: false,
359
+ error_code: 'target_error',
360
+ }),
361
+ expect.objectContaining({
362
+ attempt_id: 'assurance-run:custom-evaluation:2',
363
+ attempt_number: 2,
364
+ status: 'passed',
365
+ included_in_outcome: true,
366
+ }),
367
+ ]);
368
+ });
369
+
320
370
  it('retains only the bounded evidence contract rather than evaluator details', async () => {
321
371
  const evaluator: Evaluator = {
322
372
  type: 'custom',
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type {
6
+ CaseAttemptEvidence,
6
7
  CaseEvaluationEvidence,
7
8
  CaseEvaluationStatus,
8
9
  CaseRedactionInfo,
@@ -99,18 +100,50 @@ export async function executeCase(
99
100
  context: ExecutorContext
100
101
  ): Promise<CaseResult> {
101
102
  const { timeout, retries = 0 } = context;
103
+ if (!Number.isSafeInteger(retries) || retries < 0 || retries > 99) {
104
+ throw new RangeError('retries must be a whole number between 0 and 99');
105
+ }
102
106
  const caseStartTime = Date.now();
103
107
  const requestedModel = testCase.model || context.requestedModel || context.scenario.model;
108
+ const retryChainId = `${context.runId ?? 'untracked'}:${testCase.id}`;
109
+ const repetitionIndex = context.repetition?.index ?? 1;
110
+ const attemptEvidence: CaseAttemptEvidence[] = [];
104
111
 
105
112
  let lastError: Error | null = null;
106
113
 
107
114
  for (let attempt = 0; attempt <= retries; attempt++) {
115
+ const attemptStartTime = Date.now();
108
116
  try {
109
117
  const result = await executeCaseAttempt(testCase, context, timeout);
110
- return { ...result, attempts: attempt + 1 };
118
+ return withAttemptEvidence(result, attemptEvidence, {
119
+ retryChainId,
120
+ repetitionIndex,
121
+ attemptNumber: attempt + 1,
122
+ includedInOutcome: true,
123
+ latencyMs: result.latencyMs,
124
+ });
111
125
  } catch (error) {
112
126
  lastError = error as Error;
113
- if (error instanceof ToolLoopError) return { ...error.caseResult, attempts: attempt + 1 };
127
+ if (error instanceof ToolLoopError) {
128
+ return withAttemptEvidence(error.caseResult, attemptEvidence, {
129
+ retryChainId,
130
+ repetitionIndex,
131
+ attemptNumber: attempt + 1,
132
+ includedInOutcome: true,
133
+ latencyMs: error.caseResult.latencyMs,
134
+ errorCode: 'tool_error',
135
+ });
136
+ }
137
+ attemptEvidence.push({
138
+ attempt_id: `${retryChainId}:${attempt + 1}`,
139
+ retry_chain_id: retryChainId,
140
+ repetition_index: repetitionIndex,
141
+ attempt_number: attempt + 1,
142
+ status: 'error',
143
+ included_in_outcome: false,
144
+ latency_ms: Date.now() - attemptStartTime,
145
+ error_code: error instanceof TimeoutError ? 'timeout' : 'target_error',
146
+ });
114
147
  if (attempt < retries) {
115
148
  // Wait before retry with exponential backoff
116
149
  await sleep(2 ** attempt * 1000);
@@ -126,6 +159,10 @@ export async function executeCase(
126
159
  ok: false,
127
160
  status: 'error',
128
161
  attempts: retries + 1,
162
+ attempt_evidence: attemptEvidence.map((entry, index) => ({
163
+ ...entry,
164
+ included_in_outcome: index === attemptEvidence.length - 1,
165
+ })),
129
166
  score: 0,
130
167
  matcherType: testCase.expected.type,
131
168
  reason: `Failed after ${retries + 1} attempts: ${lastError?.message}`,
@@ -140,6 +177,41 @@ export async function executeCase(
140
177
  };
141
178
  }
142
179
 
180
+ function withAttemptEvidence(
181
+ result: CaseResult,
182
+ priorAttempts: CaseAttemptEvidence[],
183
+ input: {
184
+ retryChainId: string;
185
+ repetitionIndex: number;
186
+ attemptNumber: number;
187
+ includedInOutcome: boolean;
188
+ latencyMs: number;
189
+ errorCode?: CaseAttemptEvidence['error_code'];
190
+ }
191
+ ): CaseResult {
192
+ return {
193
+ ...result,
194
+ attempts: input.attemptNumber,
195
+ attempt_evidence: [
196
+ ...priorAttempts,
197
+ {
198
+ attempt_id: `${input.retryChainId}:${input.attemptNumber}`,
199
+ retry_chain_id: input.retryChainId,
200
+ repetition_index: input.repetitionIndex,
201
+ attempt_number: input.attemptNumber,
202
+ status: getTerminalStatus(result),
203
+ included_in_outcome: input.includedInOutcome,
204
+ latency_ms: input.latencyMs,
205
+ ...(input.errorCode ? { error_code: input.errorCode } : {}),
206
+ },
207
+ ],
208
+ };
209
+ }
210
+
211
+ function getTerminalStatus(result: CaseResult): CaseEvaluationStatus {
212
+ return result.status ?? (result.ok ? 'passed' : result.error ? 'error' : 'failed');
213
+ }
214
+
143
215
  async function executeCaseAttempt(
144
216
  testCase: TestCase,
145
217
  context: ExecutorContext,
@@ -2,6 +2,7 @@
2
2
  * Scenario runner - main entry point for running test scenarios
3
3
  */
4
4
 
5
+ import { nanoid } from 'nanoid';
5
6
  import { createRunManifest } from '../artifacts/manifest';
6
7
  import type { CaseResult, ManifestRedactionInfo } from '../artifacts/types';
7
8
  import { createExecutionProvenance, createWorkloadIdentity } from '../provenance';
@@ -22,6 +23,8 @@ export async function runScenario(options: RunOptions): Promise<RunResult> {
22
23
  concurrency = 1,
23
24
  timeout,
24
25
  retries,
26
+ repetition = { index: 1, total: 1 },
27
+ costProvenance,
25
28
  redaction,
26
29
  toolExecutor,
27
30
  onCaseComplete,
@@ -42,6 +45,7 @@ export async function runScenario(options: RunOptions): Promise<RunResult> {
42
45
  onProgress?.(`Running ${cases.length} test cases...`);
43
46
 
44
47
  const startTime = new Date();
48
+ const runId = nanoid(12);
45
49
  const results: CaseResult[] = [];
46
50
 
47
51
  if (concurrency === 1) {
@@ -54,6 +58,8 @@ export async function runScenario(options: RunOptions): Promise<RunResult> {
54
58
  requestedModel: resolvedConfig?.model,
55
59
  timeout: testCase.timeout || timeout,
56
60
  retries: testCase.retries ?? retries,
61
+ runId,
62
+ repetition,
57
63
  redaction,
58
64
  toolExecutor,
59
65
  });
@@ -74,6 +80,8 @@ export async function runScenario(options: RunOptions): Promise<RunResult> {
74
80
  requestedModel: resolvedConfig?.model,
75
81
  timeout: testCase.timeout || timeout,
76
82
  retries: testCase.retries ?? retries,
83
+ runId,
84
+ repetition,
77
85
  redaction,
78
86
  toolExecutor,
79
87
  });
@@ -132,9 +140,21 @@ export async function runScenario(options: RunOptions): Promise<RunResult> {
132
140
  seed: scenario.seed,
133
141
  cases: results,
134
142
  }),
143
+ attemptEvidence: {
144
+ schema_version: '1',
145
+ repetition,
146
+ retry_policy: {
147
+ default_max_retries: retries ?? 0,
148
+ backoff: 'exponential',
149
+ initial_delay_ms: 1000,
150
+ },
151
+ ...(timeout ? { timeout: { default_ms: timeout } } : {}),
152
+ },
153
+ costProvenance,
135
154
  cases: results,
136
155
  startTime,
137
156
  endTime,
157
+ runId,
138
158
  redaction: redactionInfo,
139
159
  });
140
160
 
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ModelClient } from '../adapters/types';
6
- import type { CaseResult, ResolvedConfig, RunManifest } from '../artifacts/types';
6
+ import type { CaseResult, CostProvenance, ResolvedConfig, RunManifest } from '../artifacts/types';
7
7
  import type { RedactionConfig } from '../redaction/types';
8
8
  import type { Scenario } from '../scenario/schema';
9
9
  import type { ToolExecutor } from '../tools';
@@ -28,6 +28,10 @@ export interface RunOptions {
28
28
  timeout?: number;
29
29
  /** Number of retries per case */
30
30
  retries?: number;
31
+ /** One-based coordinate for an independently planned repetition. */
32
+ repetition?: { index: number; total: number };
33
+ /** Attested or operator-supplied monetary evidence; omitted means unavailable. */
34
+ costProvenance?: CostProvenance;
31
35
  /** Redaction configuration (CLI overrides scenario) */
32
36
  redaction?: RedactionConfig;
33
37
  /** SDK-only executor for explicitly supplied real tools. */
@@ -60,6 +64,8 @@ export interface ExecutorContext {
60
64
  requestedModel?: string;
61
65
  timeout?: number;
62
66
  retries?: number;
67
+ runId?: string;
68
+ repetition?: { index: number; total: number };
63
69
  /** Redaction configuration for this execution */
64
70
  redaction?: RedactionConfig;
65
71
  toolExecutor?: ToolExecutor;
@@ -225,7 +225,7 @@ export const TestCaseSchema = z.object({
225
225
  tags: z.array(z.string()).optional().default([]),
226
226
  metadata: z.record(z.unknown()).optional().default({}),
227
227
  timeout: z.number().optional(),
228
- retries: z.number().optional().default(0),
228
+ retries: z.number().int().min(0).max(99).optional().default(0),
229
229
  provider: ProviderSchema.optional(),
230
230
  model: z.string().optional(),
231
231
  variables: VariablesSchema.optional(),
@@ -179,6 +179,45 @@ describe('LocalStorageAdapter', () => {
179
179
  expect(comparison.delta.successRate).toBeCloseTo(0.1, 2);
180
180
  expect(comparison.delta.latency).toBe(-30);
181
181
  expect(comparison.delta.tokens).toBe(100);
182
+ expect(comparison.eligibility.status).toBe('qualified');
183
+ });
184
+
185
+ test('withholds deltas for incompatible workload evidence', async () => {
186
+ const baseline = {
187
+ ...mockManifest,
188
+ run_id: 'incompatible-baseline',
189
+ workload_identity: {
190
+ schema_version: '1' as const,
191
+ workload: {
192
+ schema_version: '1' as const,
193
+ algorithm: 'sha256' as const,
194
+ digest: 'a'.repeat(64),
195
+ },
196
+ rubric: {
197
+ schema_version: '1' as const,
198
+ algorithm: 'sha256' as const,
199
+ digest: 'b'.repeat(64),
200
+ },
201
+ },
202
+ execution_provenance: { schema_version: '1' as const, target: { provider: 'openai' } },
203
+ };
204
+ const current = {
205
+ ...baseline,
206
+ run_id: 'incompatible-current',
207
+ workload_identity: {
208
+ ...baseline.workload_identity,
209
+ workload: { ...baseline.workload_identity.workload, digest: 'c'.repeat(64) },
210
+ },
211
+ };
212
+
213
+ await storage.save(baseline);
214
+ await storage.save(current);
215
+
216
+ const comparison = await storage.compare('incompatible-baseline', 'incompatible-current');
217
+
218
+ expect(comparison.eligibility.status).toBe('incomparable');
219
+ expect(comparison.eligibility.reasons).toEqual([{ code: 'workload_mismatch' }]);
220
+ expect(comparison.delta).toBeUndefined();
182
221
  });
183
222
 
184
223
  test('handles empty storage gracefully', async () => {
@@ -12,6 +12,7 @@ import {
12
12
  assertRunManifestIntegrity,
13
13
  isRunManifest,
14
14
  } from '../artifacts/types';
15
+ import { assessComparisonEligibility, isComparisonAvailable } from '../comparison';
15
16
  import type {
16
17
  BaselineMetadata,
17
18
  BaselineStorageAdapter,
@@ -212,9 +213,15 @@ export class LocalStorageAdapter implements BaselineStorageAdapter {
212
213
  this.loadRun(currentId),
213
214
  ]);
214
215
 
216
+ const eligibility = assessComparisonEligibility(baseline, current);
217
+ if (!isComparisonAvailable(eligibility)) {
218
+ return { baseline, current, eligibility };
219
+ }
220
+
215
221
  return {
216
222
  baseline,
217
223
  current,
224
+ eligibility,
218
225
  delta: {
219
226
  successRate: current.metrics.success_rate - baseline.metrics.success_rate,
220
227
  latency: current.metrics.median_latency_ms - baseline.metrics.median_latency_ms,
@@ -375,7 +382,8 @@ export class LocalStorageAdapter implements BaselineStorageAdapter {
375
382
  const comparison = await this.compare(baseline.runId, runId);
376
383
 
377
384
  // Check for regression (negative delta in success rate)
378
- const hasRegression = comparison.delta.successRate < -regressionThreshold;
385
+ const hasRegression =
386
+ comparison.delta !== undefined && comparison.delta.successRate < -regressionThreshold;
379
387
 
380
388
  return {
381
389
  baseline,
@@ -9,6 +9,7 @@ import {
9
9
  assertRunManifestIntegrity,
10
10
  getCaseEvaluationStatus,
11
11
  } from '../artifacts/types';
12
+ import { assessComparisonEligibility, isComparisonAvailable } from '../comparison';
12
13
  import type {
13
14
  AnalyticsStorageAdapter,
14
15
  BaselineMetadata,
@@ -199,9 +200,15 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
199
200
  async compare(baselineId: string, currentId: string): Promise<ComparisonResult> {
200
201
  const [baseline, current] = await Promise.all([this.load(baselineId), this.load(currentId)]);
201
202
 
203
+ const eligibility = assessComparisonEligibility(baseline, current);
204
+ if (!isComparisonAvailable(eligibility)) {
205
+ return { baseline, current, eligibility };
206
+ }
207
+
202
208
  return {
203
209
  baseline,
204
210
  current,
211
+ eligibility,
205
212
  delta: {
206
213
  successRate: current.metrics.success_rate - baseline.metrics.success_rate,
207
214
  latency: current.metrics.median_latency_ms - baseline.metrics.median_latency_ms,
@@ -400,7 +407,8 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
400
407
  const comparison = await this.compare(baseline.runId, runId);
401
408
 
402
409
  // Check for regression (success rate dropped by more than threshold)
403
- const hasRegression = comparison.delta.successRate < -regressionThreshold;
410
+ const hasRegression =
411
+ comparison.delta !== undefined && comparison.delta.successRate < -regressionThreshold;
404
412
 
405
413
  return {
406
414
  baseline,
@@ -9,6 +9,7 @@ import type {
9
9
  RunManifest,
10
10
  StressManifest,
11
11
  } from '../artifacts/types';
12
+ import type { ComparisonEligibility } from '../comparison';
12
13
 
13
14
  /**
14
15
  * Run listing item
@@ -30,7 +31,10 @@ export interface RunListItem {
30
31
  export interface ComparisonResult {
31
32
  baseline: RunManifest;
32
33
  current: RunManifest;
33
- delta: {
34
+ /** Compatibility decision made before any metric delta is calculated. */
35
+ eligibility: ComparisonEligibility;
36
+ /** Absent when workloads or rubrics are incomparable. */
37
+ delta?: {
34
38
  successRate: number;
35
39
  latency: number;
36
40
  tokens: number;