@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.
- package/CHANGELOG.md +16 -0
- package/dist/artifacts/manifest.d.ts +4 -1
- package/dist/artifacts/manifest.d.ts.map +1 -1
- package/dist/artifacts/types.d.ts +58 -0
- package/dist/artifacts/types.d.ts.map +1 -1
- package/dist/comparison/eligibility.d.ts +26 -0
- package/dist/comparison/eligibility.d.ts.map +1 -0
- package/dist/comparison/index.d.ts +2 -0
- package/dist/comparison/index.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +590 -392
- package/dist/runner/executor.d.ts.map +1 -1
- package/dist/runner/runner.d.ts.map +1 -1
- package/dist/runner/types.d.ts +13 -1
- package/dist/runner/types.d.ts.map +1 -1
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/supabase.d.ts.map +1 -1
- package/dist/storage/types.d.ts +5 -1
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/artifacts/manifest.test.ts +87 -1
- package/src/artifacts/manifest.ts +21 -29
- package/src/artifacts/types.ts +173 -0
- package/src/comparison/eligibility.test.ts +101 -0
- package/src/comparison/eligibility.ts +116 -0
- package/src/comparison/index.ts +8 -0
- package/src/index.ts +3 -0
- package/src/runner/executor.test.ts +50 -0
- package/src/runner/executor.ts +74 -2
- package/src/runner/runner.ts +20 -0
- package/src/runner/types.ts +7 -1
- package/src/scenario/schema.ts +1 -1
- package/src/storage/local.test.ts +39 -0
- package/src/storage/local.ts +9 -1
- package/src/storage/supabase.ts +9 -1
- package/src/storage/types.ts +5 -1
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -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',
|
package/src/runner/executor.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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,
|
package/src/runner/runner.ts
CHANGED
|
@@ -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
|
|
package/src/runner/types.ts
CHANGED
|
@@ -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;
|
package/src/scenario/schema.ts
CHANGED
|
@@ -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 () => {
|
package/src/storage/local.ts
CHANGED
|
@@ -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 =
|
|
385
|
+
const hasRegression =
|
|
386
|
+
comparison.delta !== undefined && comparison.delta.successRate < -regressionThreshold;
|
|
379
387
|
|
|
380
388
|
return {
|
|
381
389
|
baseline,
|
package/src/storage/supabase.ts
CHANGED
|
@@ -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 =
|
|
410
|
+
const hasRegression =
|
|
411
|
+
comparison.delta !== undefined && comparison.delta.successRate < -regressionThreshold;
|
|
404
412
|
|
|
405
413
|
return {
|
|
406
414
|
baseline,
|
package/src/storage/types.ts
CHANGED
|
@@ -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;
|