@artemiskit/core 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/adapters/factory.d.ts +1 -1
- package/dist/adapters/index.d.ts +3 -3
- package/dist/adapters/registry.d.ts +1 -1
- package/dist/agent-evaluation/index.d.ts +2 -2
- package/dist/agent-evaluation/scorer.d.ts +1 -1
- package/dist/agent-workflow/catalog.d.ts +27 -0
- package/dist/agent-workflow/catalog.d.ts.map +1 -0
- package/dist/agent-workflow/index.d.ts +7 -0
- package/dist/agent-workflow/index.d.ts.map +1 -0
- package/dist/agent-workflow/parser.d.ts +7 -0
- package/dist/agent-workflow/parser.d.ts.map +1 -0
- package/dist/agent-workflow/schema.d.ts +664 -0
- package/dist/agent-workflow/schema.d.ts.map +1 -0
- package/dist/agent-workflow/simulated-tools.d.ts +35 -0
- package/dist/agent-workflow/simulated-tools.d.ts.map +1 -0
- package/dist/agent-workflow/target.d.ts +230 -0
- package/dist/agent-workflow/target.d.ts.map +1 -0
- package/dist/artifacts/index.d.ts +2 -2
- package/dist/artifacts/manifest.d.ts +1 -1
- package/dist/artifacts/types.d.ts +2 -2
- 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/evaluators/combined.d.ts +2 -2
- package/dist/evaluators/contains.d.ts +2 -2
- package/dist/evaluators/exact.d.ts +2 -2
- package/dist/evaluators/fuzzy.d.ts +2 -2
- package/dist/evaluators/index.d.ts +13 -13
- package/dist/evaluators/inline.d.ts +2 -2
- package/dist/evaluators/json-schema.d.ts +2 -2
- package/dist/evaluators/llm-grader.d.ts +2 -2
- package/dist/evaluators/not-contains.d.ts +2 -2
- package/dist/evaluators/regex.d.ts +2 -2
- package/dist/evaluators/similarity.d.ts +2 -2
- package/dist/evaluators/tool-trace.d.ts +2 -2
- package/dist/evaluators/types.d.ts +3 -3
- package/dist/index.d.ts +15 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16346 -14967
- package/dist/provenance/execution-provenance.d.ts +1 -1
- package/dist/provenance/git.d.ts +1 -1
- package/dist/provenance/index.d.ts +4 -4
- package/dist/provenance/workload-identity.d.ts +2 -2
- package/dist/redaction/index.d.ts +2 -2
- package/dist/redaction/redactor.d.ts +1 -1
- package/dist/runner/executor.d.ts +3 -3
- package/dist/runner/index.d.ts +3 -3
- package/dist/runner/runner.d.ts +1 -1
- package/dist/runner/types.d.ts +5 -5
- package/dist/scenario/index.d.ts +4 -4
- package/dist/scenario/parser.d.ts +1 -1
- package/dist/scenario/variables.d.ts +1 -1
- package/dist/storage/factory.d.ts +1 -1
- package/dist/storage/index.d.ts +4 -4
- package/dist/storage/local.d.ts +2 -2
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/supabase.d.ts +2 -2
- package/dist/storage/supabase.d.ts.map +1 -1
- package/dist/storage/types.d.ts +6 -2
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/tools/fixture-executor.d.ts +2 -2
- package/dist/tools/index.d.ts +3 -3
- package/dist/tools/types.d.ts +1 -1
- package/dist/utils/index.d.ts +2 -2
- package/dist/validator/index.d.ts +2 -2
- package/dist/validator/validator.d.ts +1 -1
- package/package.json +4 -4
- package/src/agent-workflow/catalog.ts +219 -0
- package/src/agent-workflow/index.ts +6 -0
- package/src/agent-workflow/parser.ts +43 -0
- package/src/agent-workflow/schema.test.ts +218 -0
- package/src/agent-workflow/schema.ts +254 -0
- package/src/agent-workflow/simulated-tools.test.ts +177 -0
- package/src/agent-workflow/simulated-tools.ts +230 -0
- package/src/agent-workflow/target.test.ts +276 -0
- package/src/agent-workflow/target.ts +292 -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 +6 -0
- 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,292 @@
|
|
|
1
|
+
import Ajv from 'ajv';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { GenerateOptions, ModelClient, TokenUsage, ToolCall } from '../adapters/types';
|
|
4
|
+
|
|
5
|
+
const identifier = z.string().min(1).max(256);
|
|
6
|
+
const toolName = z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$/);
|
|
7
|
+
const text = z.string().max(1_000_000);
|
|
8
|
+
const toolCallSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
id: identifier,
|
|
11
|
+
type: z.literal('function'),
|
|
12
|
+
function: z.object({ name: toolName, arguments: text }).strict(),
|
|
13
|
+
})
|
|
14
|
+
.strict();
|
|
15
|
+
const messageSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
role: z.enum(['system', 'user', 'assistant', 'tool']),
|
|
18
|
+
content: text,
|
|
19
|
+
toolCallId: identifier.optional(),
|
|
20
|
+
tool_calls: z.array(toolCallSchema).min(1).max(100).optional(),
|
|
21
|
+
})
|
|
22
|
+
.strict();
|
|
23
|
+
const timeoutSchema = z.number().int().min(1).max(2_147_483_647);
|
|
24
|
+
const requestSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
messages: z.array(messageSchema).min(1).max(1000),
|
|
27
|
+
tools: z
|
|
28
|
+
.array(
|
|
29
|
+
z
|
|
30
|
+
.object({
|
|
31
|
+
type: z.literal('function'),
|
|
32
|
+
function: z
|
|
33
|
+
.object({
|
|
34
|
+
name: toolName,
|
|
35
|
+
description: z.string().max(10_000).optional(),
|
|
36
|
+
parameters: z.record(z.unknown()),
|
|
37
|
+
})
|
|
38
|
+
.strict(),
|
|
39
|
+
})
|
|
40
|
+
.strict()
|
|
41
|
+
)
|
|
42
|
+
.max(100),
|
|
43
|
+
model: identifier.optional(),
|
|
44
|
+
generation: z
|
|
45
|
+
.object({
|
|
46
|
+
maxTokens: z.number().int().positive().max(1_000_000),
|
|
47
|
+
temperature: z.number().finite().min(0).max(2).optional(),
|
|
48
|
+
topP: z.number().finite().min(0).max(1).optional(),
|
|
49
|
+
seed: z.number().int().safe().optional(),
|
|
50
|
+
stop: z.array(z.string().min(1).max(1000)).max(16).optional(),
|
|
51
|
+
})
|
|
52
|
+
.strict(),
|
|
53
|
+
budgets: z
|
|
54
|
+
.object({
|
|
55
|
+
timeoutMs: timeoutSchema,
|
|
56
|
+
maxToolCalls: z.number().int().min(0).max(100),
|
|
57
|
+
})
|
|
58
|
+
.strict(),
|
|
59
|
+
})
|
|
60
|
+
.strict();
|
|
61
|
+
const resultSchema = z.object({
|
|
62
|
+
id: identifier,
|
|
63
|
+
model: identifier,
|
|
64
|
+
text,
|
|
65
|
+
tokens: z.object({
|
|
66
|
+
prompt: z.number().int().nonnegative().safe(),
|
|
67
|
+
completion: z.number().int().nonnegative().safe(),
|
|
68
|
+
total: z.number().int().nonnegative().safe(),
|
|
69
|
+
}),
|
|
70
|
+
latencyMs: z.number().finite().nonnegative(),
|
|
71
|
+
finishReason: z
|
|
72
|
+
.enum(['stop', 'length', 'function_call', 'tool_calls', 'content_filter'])
|
|
73
|
+
.optional(),
|
|
74
|
+
toolCalls: z.array(toolCallSchema).max(100).optional(),
|
|
75
|
+
functionCall: z.unknown().optional(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export type AgentTurnRequest = z.infer<typeof requestSchema>;
|
|
79
|
+
export type AgentTargetFailure = {
|
|
80
|
+
status: 'unsupported' | 'invalid' | 'error';
|
|
81
|
+
code:
|
|
82
|
+
| 'invalid_request'
|
|
83
|
+
| 'tool_use_unsupported'
|
|
84
|
+
| 'invalid_response'
|
|
85
|
+
| 'target_error'
|
|
86
|
+
| 'timeout'
|
|
87
|
+
| 'aborted';
|
|
88
|
+
};
|
|
89
|
+
export type AgentTargetCapabilities = {
|
|
90
|
+
status: 'available';
|
|
91
|
+
toolUse: boolean;
|
|
92
|
+
/** ModelClient has no AbortSignal contract. Timeouts bound waiting, not transport work. */
|
|
93
|
+
transportCancellation: false;
|
|
94
|
+
};
|
|
95
|
+
export type AgentTurnResult =
|
|
96
|
+
| AgentTargetFailure
|
|
97
|
+
| {
|
|
98
|
+
status: 'completed';
|
|
99
|
+
id: string;
|
|
100
|
+
model: string;
|
|
101
|
+
message: { role: 'assistant'; content: string; tool_calls?: ToolCall[] };
|
|
102
|
+
/** Adapter-reported counts only; zero can mean unavailable in existing adapters. */
|
|
103
|
+
tokens: TokenUsage;
|
|
104
|
+
latencyMs: number;
|
|
105
|
+
finishReason?: 'stop' | 'length' | 'tool_calls' | 'content_filter';
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** One bounded model turn. Tool execution, policy enforcement, and scoring belong to the harness. */
|
|
109
|
+
export interface AgentTarget {
|
|
110
|
+
readonly provider: string;
|
|
111
|
+
capabilities(
|
|
112
|
+
options: { timeoutMs: number },
|
|
113
|
+
signal?: AbortSignal
|
|
114
|
+
): Promise<AgentTargetCapabilities | AgentTargetFailure>;
|
|
115
|
+
turn(request: AgentTurnRequest, signal?: AbortSignal): Promise<AgentTurnResult>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const failure = (
|
|
119
|
+
status: AgentTargetFailure['status'],
|
|
120
|
+
code: AgentTargetFailure['code']
|
|
121
|
+
): AgentTargetFailure => ({ status, code });
|
|
122
|
+
|
|
123
|
+
/** Do not expose provider exception text, which can contain credentials or customer content. */
|
|
124
|
+
function bounded<T>(
|
|
125
|
+
run: () => Promise<T>,
|
|
126
|
+
timeoutMs: number,
|
|
127
|
+
signal?: AbortSignal
|
|
128
|
+
): Promise<T | AgentTargetFailure> {
|
|
129
|
+
if (signal?.aborted) return Promise.resolve(failure('error', 'aborted'));
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
let finished = false;
|
|
132
|
+
const finish = (value: T | AgentTargetFailure) => {
|
|
133
|
+
if (finished) return;
|
|
134
|
+
finished = true;
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
signal?.removeEventListener('abort', abort);
|
|
137
|
+
resolve(value);
|
|
138
|
+
};
|
|
139
|
+
const abort = () => finish(failure('error', 'aborted'));
|
|
140
|
+
const timer = setTimeout(() => finish(failure('error', 'timeout')), timeoutMs);
|
|
141
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
142
|
+
Promise.resolve()
|
|
143
|
+
.then<T | AgentTargetFailure>(() => (signal?.aborted ? failure('error', 'aborted') : run()))
|
|
144
|
+
.then(finish, () => finish(failure('error', 'target_error')));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function validTranscript(messages: AgentTurnRequest['messages']): boolean {
|
|
149
|
+
const seen = new Set<string>();
|
|
150
|
+
const pending = new Set<string>();
|
|
151
|
+
for (const message of messages) {
|
|
152
|
+
if (message.role === 'tool') {
|
|
153
|
+
if (!message.toolCallId || message.tool_calls || !pending.delete(message.toolCallId))
|
|
154
|
+
return false;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (message.toolCallId || pending.size || (message.tool_calls && message.role !== 'assistant'))
|
|
158
|
+
return false;
|
|
159
|
+
for (const call of message.tool_calls ?? []) {
|
|
160
|
+
if (seen.has(call.id)) return false;
|
|
161
|
+
try {
|
|
162
|
+
const args: unknown = JSON.parse(call.function.arguments);
|
|
163
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) return false;
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
seen.add(call.id);
|
|
168
|
+
pending.add(call.id);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return pending.size === 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Bridge existing adapters without provider-specific dispatch. Callers must configure adapter
|
|
176
|
+
* transport timeouts/retries separately: cancellation here cannot stop an in-flight provider call.
|
|
177
|
+
* Returned text/tool arguments are working conversation data, not sanitized retained evidence.
|
|
178
|
+
*/
|
|
179
|
+
export function createModelClientTarget(client: ModelClient): AgentTarget {
|
|
180
|
+
if (
|
|
181
|
+
!client ||
|
|
182
|
+
!identifier.safeParse(client.provider).success ||
|
|
183
|
+
typeof client.generate !== 'function' ||
|
|
184
|
+
typeof client.capabilities !== 'function'
|
|
185
|
+
) {
|
|
186
|
+
throw new TypeError('Invalid ModelClient');
|
|
187
|
+
}
|
|
188
|
+
const readCapabilities = async (): Promise<AgentTargetCapabilities | AgentTargetFailure> => {
|
|
189
|
+
const value = await client.capabilities();
|
|
190
|
+
if (!value || typeof value.toolUse !== 'boolean') return failure('invalid', 'invalid_response');
|
|
191
|
+
return { status: 'available', toolUse: value.toolUse, transportCancellation: false };
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
provider: client.provider,
|
|
195
|
+
async capabilities(options, signal) {
|
|
196
|
+
if (!timeoutSchema.safeParse(options?.timeoutMs).success)
|
|
197
|
+
return failure('invalid', 'invalid_request');
|
|
198
|
+
return bounded(readCapabilities, options.timeoutMs, signal);
|
|
199
|
+
},
|
|
200
|
+
async turn(request, signal) {
|
|
201
|
+
let parsed: ReturnType<typeof requestSchema.safeParse>;
|
|
202
|
+
try {
|
|
203
|
+
parsed = requestSchema.safeParse(request);
|
|
204
|
+
} catch {
|
|
205
|
+
return failure('invalid', 'invalid_request');
|
|
206
|
+
}
|
|
207
|
+
if (!parsed.success || !validTranscript(parsed.data.messages))
|
|
208
|
+
return failure('invalid', 'invalid_request');
|
|
209
|
+
const input = parsed.data;
|
|
210
|
+
const validators = new Map<string, ReturnType<Ajv['compile']>>();
|
|
211
|
+
try {
|
|
212
|
+
const ajv = new Ajv({ strict: false, allErrors: false, validateFormats: false });
|
|
213
|
+
for (const tool of input.tools) {
|
|
214
|
+
if (validators.has(tool.function.name)) return failure('invalid', 'invalid_request');
|
|
215
|
+
// Compile a detached JSON schema without remote loading or async validation.
|
|
216
|
+
const schema = JSON.parse(JSON.stringify(tool.function.parameters));
|
|
217
|
+
if (schema.$async) return failure('invalid', 'invalid_request');
|
|
218
|
+
const validate = ajv.compile(schema);
|
|
219
|
+
if ('$async' in validate && validate.$async) return failure('invalid', 'invalid_request');
|
|
220
|
+
validators.set(tool.function.name, validate);
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
return failure('invalid', 'invalid_request');
|
|
224
|
+
}
|
|
225
|
+
const started = Date.now();
|
|
226
|
+
return bounded(
|
|
227
|
+
async (): Promise<AgentTurnResult> => {
|
|
228
|
+
const capabilities = await readCapabilities();
|
|
229
|
+
if (capabilities.status !== 'available') return capabilities;
|
|
230
|
+
if (input.tools.length && !capabilities.toolUse)
|
|
231
|
+
return failure('unsupported', 'tool_use_unsupported');
|
|
232
|
+
if (signal?.aborted) return failure('error', 'aborted');
|
|
233
|
+
if (Date.now() - started >= input.budgets.timeoutMs) return failure('error', 'timeout');
|
|
234
|
+
const options: GenerateOptions = {
|
|
235
|
+
prompt: input.messages,
|
|
236
|
+
tools: input.tools,
|
|
237
|
+
model: input.model,
|
|
238
|
+
...input.generation,
|
|
239
|
+
};
|
|
240
|
+
const generated = resultSchema.safeParse(await client.generate(options));
|
|
241
|
+
if (!generated.success) return failure('invalid', 'invalid_response');
|
|
242
|
+
const result = generated.data;
|
|
243
|
+
const calls = result.toolCalls ?? [];
|
|
244
|
+
if (
|
|
245
|
+
result.functionCall !== undefined ||
|
|
246
|
+
result.finishReason === 'function_call' ||
|
|
247
|
+
(result.finishReason === 'tool_calls' && calls.length === 0) ||
|
|
248
|
+
calls.length > input.budgets.maxToolCalls ||
|
|
249
|
+
result.tokens.total !== result.tokens.prompt + result.tokens.completion
|
|
250
|
+
)
|
|
251
|
+
return failure('invalid', 'invalid_response');
|
|
252
|
+
const ids = new Set(
|
|
253
|
+
input.messages.flatMap((message) => message.tool_calls?.map((call) => call.id) ?? [])
|
|
254
|
+
);
|
|
255
|
+
for (const call of calls) {
|
|
256
|
+
if (ids.has(call.id)) return failure('invalid', 'invalid_response');
|
|
257
|
+
ids.add(call.id);
|
|
258
|
+
const validate = validators.get(call.function.name);
|
|
259
|
+
try {
|
|
260
|
+
const args: unknown = JSON.parse(call.function.arguments);
|
|
261
|
+
if (
|
|
262
|
+
!args ||
|
|
263
|
+
typeof args !== 'object' ||
|
|
264
|
+
Array.isArray(args) ||
|
|
265
|
+
!validate ||
|
|
266
|
+
validate(args) !== true
|
|
267
|
+
)
|
|
268
|
+
return failure('invalid', 'invalid_response');
|
|
269
|
+
} catch {
|
|
270
|
+
return failure('invalid', 'invalid_response');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
status: 'completed',
|
|
275
|
+
id: result.id,
|
|
276
|
+
model: result.model,
|
|
277
|
+
message: {
|
|
278
|
+
role: 'assistant',
|
|
279
|
+
content: result.text,
|
|
280
|
+
...(calls.length ? { tool_calls: calls } : {}),
|
|
281
|
+
},
|
|
282
|
+
tokens: result.tokens,
|
|
283
|
+
latencyMs: result.latencyMs,
|
|
284
|
+
finishReason: result.finishReason,
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
input.budgets.timeoutMs,
|
|
288
|
+
signal
|
|
289
|
+
);
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -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
|
@@ -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
|
|
|
@@ -39,5 +42,8 @@ export * from './tools';
|
|
|
39
42
|
// Real-agent evaluation contracts
|
|
40
43
|
export * from './agent-evaluation';
|
|
41
44
|
|
|
45
|
+
// Versioned workflow contracts and single-step primitives
|
|
46
|
+
export * from './agent-workflow';
|
|
47
|
+
|
|
42
48
|
// Validator
|
|
43
49
|
export * from './validator';
|
|
@@ -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;
|