@artemiskit/core 0.5.0 → 0.5.2

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.
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type {
6
+ CaseAttemptEvidence,
6
7
  CaseEvaluationEvidence,
7
8
  CaseEvaluationStatus,
8
9
  CaseRedactionInfo,
@@ -99,17 +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();
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[] = [];
103
111
 
104
112
  let lastError: Error | null = null;
105
113
 
106
114
  for (let attempt = 0; attempt <= retries; attempt++) {
115
+ const attemptStartTime = Date.now();
107
116
  try {
108
117
  const result = await executeCaseAttempt(testCase, context, timeout);
109
- 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
+ });
110
125
  } catch (error) {
111
126
  lastError = error as Error;
112
- 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
+ });
113
147
  if (attempt < retries) {
114
148
  // Wait before retry with exponential backoff
115
149
  await sleep(2 ** attempt * 1000);
@@ -125,6 +159,10 @@ export async function executeCase(
125
159
  ok: false,
126
160
  status: 'error',
127
161
  attempts: retries + 1,
162
+ attempt_evidence: attemptEvidence.map((entry, index) => ({
163
+ ...entry,
164
+ included_in_outcome: index === attemptEvidence.length - 1,
165
+ })),
128
166
  score: 0,
129
167
  matcherType: testCase.expected.type,
130
168
  reason: `Failed after ${retries + 1} attempts: ${lastError?.message}`,
@@ -135,15 +173,51 @@ export async function executeCase(
135
173
  expected: testCase.expected,
136
174
  tags: testCase.tags,
137
175
  error: lastError?.message,
176
+ target: targetEvidence(context.client.provider, requestedModel),
138
177
  };
139
178
  }
140
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
+
141
215
  async function executeCaseAttempt(
142
216
  testCase: TestCase,
143
217
  context: ExecutorContext,
144
218
  timeout?: number
145
219
  ): Promise<CaseResult> {
146
- const { client, scenario, redaction: cliRedaction, toolExecutor } = context;
220
+ const { client, scenario, requestedModel, redaction: cliRedaction, toolExecutor } = context;
147
221
 
148
222
  // Merge scenario-level and case-level variables (case overrides scenario)
149
223
  const variables = mergeVariables(scenario.variables, testCase.variables);
@@ -171,7 +245,7 @@ async function executeCaseAttempt(
171
245
  const generate = () =>
172
246
  client.generate({
173
247
  prompt: loopPrompt,
174
- model: testCase.model || scenario.model,
248
+ model: testCase.model || requestedModel || scenario.model,
175
249
  temperature: scenario.temperature,
176
250
  maxTokens: scenario.maxTokens,
177
251
  seed: scenario.seed,
@@ -182,6 +256,7 @@ async function executeCaseAttempt(
182
256
  let result = timeout
183
257
  ? await Promise.race([generatePromise, createTimeout(timeout)])
184
258
  : await generatePromise;
259
+ const observedModels = [result.model];
185
260
  const generationMetrics = {
186
261
  latencyMs: result.latencyMs,
187
262
  tokens: { ...result.tokens },
@@ -198,7 +273,12 @@ async function executeCaseAttempt(
198
273
  terminationReason: 'tool_error',
199
274
  },
200
275
  generationMetrics,
201
- 'TOOL_EXECUTOR_REQUIRED'
276
+ 'TOOL_EXECUTOR_REQUIRED',
277
+ targetEvidence(
278
+ client.provider,
279
+ testCase.model || requestedModel || scenario.model,
280
+ observedModels
281
+ )
202
282
  );
203
283
  }
204
284
  const executor =
@@ -225,7 +305,12 @@ async function executeCaseAttempt(
225
305
  terminationReason: 'duplicate_call',
226
306
  },
227
307
  generationMetrics,
228
- 'TOOL_DUPLICATE_CALL'
308
+ 'TOOL_DUPLICATE_CALL',
309
+ targetEvidence(
310
+ client.provider,
311
+ testCase.model || requestedModel || scenario.model,
312
+ observedModels
313
+ )
229
314
  );
230
315
  }
231
316
  seenCalls.add(fingerprint);
@@ -263,7 +348,12 @@ async function executeCaseAttempt(
263
348
  : 'tool_error',
264
349
  },
265
350
  generationMetrics,
266
- execution.error?.code ?? 'TOOL_EXECUTION_FAILED'
351
+ execution.error?.code ?? 'TOOL_EXECUTION_FAILED',
352
+ targetEvidence(
353
+ client.provider,
354
+ testCase.model || requestedModel || scenario.model,
355
+ observedModels
356
+ )
267
357
  );
268
358
  }
269
359
  const content = JSON.stringify(execution.result ?? {});
@@ -281,7 +371,12 @@ async function executeCaseAttempt(
281
371
  toolTrace,
282
372
  { status: 'error', steps: step + 1, terminationReason: 'timeout' },
283
373
  generationMetrics,
284
- 'TOOL_LOOP_TIMEOUT'
374
+ 'TOOL_LOOP_TIMEOUT',
375
+ targetEvidence(
376
+ client.provider,
377
+ testCase.model || requestedModel || scenario.model,
378
+ observedModels
379
+ )
285
380
  );
286
381
  }
287
382
  const requestTimeout = timeout ? Math.min(timeout, remainingLoopTime) : remainingLoopTime;
@@ -298,9 +393,15 @@ async function executeCaseAttempt(
298
393
  terminationReason: timedOut ? 'timeout' : 'tool_error',
299
394
  },
300
395
  generationMetrics,
301
- timedOut ? 'TOOL_LOOP_TIMEOUT' : 'TOOL_GENERATION_FAILED'
396
+ timedOut ? 'TOOL_LOOP_TIMEOUT' : 'TOOL_GENERATION_FAILED',
397
+ targetEvidence(
398
+ client.provider,
399
+ testCase.model || requestedModel || scenario.model,
400
+ observedModels
401
+ )
302
402
  );
303
403
  }
404
+ observedModels.push(result.model);
304
405
  generationMetrics.latencyMs += result.latencyMs;
305
406
  generationMetrics.tokens.prompt += result.tokens.prompt;
306
407
  generationMetrics.tokens.completion += result.tokens.completion;
@@ -316,7 +417,12 @@ async function executeCaseAttempt(
316
417
  terminationReason: 'max_steps',
317
418
  },
318
419
  generationMetrics,
319
- 'TOOL_LOOP_MAX_STEPS'
420
+ 'TOOL_LOOP_MAX_STEPS',
421
+ targetEvidence(
422
+ client.provider,
423
+ testCase.model || requestedModel || scenario.model,
424
+ observedModels
425
+ )
320
426
  );
321
427
  }
322
428
  toolLoop = { status: 'completed', steps: toolTrace.length, terminationReason: 'completed' };
@@ -435,17 +541,44 @@ async function executeCaseAttempt(
435
541
  tags: testCase.tags,
436
542
  redaction: redactionInfo,
437
543
  evidence: finalEvidence,
544
+ target: targetEvidence(
545
+ client.provider,
546
+ testCase.model || requestedModel || scenario.model,
547
+ observedModels
548
+ ),
438
549
  toolTrace: toolTrace.length ? toolTrace : undefined,
439
550
  toolLoop,
440
551
  };
441
552
  }
442
553
 
554
+ function targetEvidence(
555
+ provider: string,
556
+ requestedModel?: string,
557
+ observedModels?: unknown[]
558
+ ): NonNullable<CaseResult['target']> {
559
+ const observed = [
560
+ ...new Set(
561
+ (observedModels ?? []).filter(
562
+ (model): model is string => typeof model === 'string' && model.length > 0
563
+ )
564
+ ),
565
+ ]
566
+ .map((model) => sanitizeArtifactText(model, 200))
567
+ .filter((model): model is string => Boolean(model));
568
+ return {
569
+ provider: sanitizeArtifactText(provider, 100) ?? 'unknown',
570
+ ...(requestedModel ? { requested_model: sanitizeArtifactText(requestedModel, 200) } : {}),
571
+ ...(observed.length ? { observed_models: observed } : {}),
572
+ };
573
+ }
574
+
443
575
  function createToolLoopError(
444
576
  testCase: TestCase,
445
577
  toolTrace: ToolTraceEntry[],
446
578
  toolLoop: ToolLoopSummary,
447
579
  generationMetrics: Pick<CaseResult, 'latencyMs' | 'tokens'>,
448
- code: string
580
+ code: string,
581
+ target?: CaseResult['target']
449
582
  ): ToolLoopError {
450
583
  return new ToolLoopError(code, {
451
584
  id: testCase.id,
@@ -462,6 +595,7 @@ function createToolLoopError(
462
595
  expected: testCase.expected,
463
596
  tags: testCase.tags,
464
597
  error: code,
598
+ target,
465
599
  toolTrace,
466
600
  toolLoop,
467
601
  });
@@ -96,6 +96,13 @@ describe('release validation: fixture-backed workflow cases', () => {
96
96
  workload: { algorithm: 'sha256' },
97
97
  rubric: { algorithm: 'sha256' },
98
98
  });
99
+ expect(result.manifest.execution_provenance).toMatchObject({
100
+ schema_version: '1',
101
+ target: {
102
+ provider: 'fixture',
103
+ observed_models: ['fixture-model'],
104
+ },
105
+ });
99
106
  });
100
107
 
101
108
  test('RV-02 retains independent logistics tool evidence through a fixture-backed workflow', async () => {
@@ -171,4 +178,33 @@ describe('release validation: fixture-backed workflow cases', () => {
171
178
  toolTrace: [{ toolCall: { id: 'capacity-1' }, result: { available: true } }],
172
179
  });
173
180
  });
181
+
182
+ test('records a CLI-resolved model for each case when the scenario does not declare one', async () => {
183
+ const scenario = ScenarioSchema.parse({
184
+ name: 'resolved model evidence',
185
+ cases: [
186
+ {
187
+ id: 'cli-model-override',
188
+ prompt: 'Respond with assured',
189
+ expected: { type: 'exact', value: 'assured' },
190
+ },
191
+ ],
192
+ });
193
+
194
+ const result = await runScenario({
195
+ scenario,
196
+ client: fixtureClient(['assured']),
197
+ resolvedConfig: {
198
+ provider: 'fixture',
199
+ model: 'fixture-cli-model',
200
+ source: { provider: 'cli', model: 'cli' },
201
+ },
202
+ });
203
+
204
+ expect(result.cases[0].target).toEqual({
205
+ provider: 'fixture',
206
+ requested_model: 'fixture-cli-model',
207
+ observed_models: ['fixture-model'],
208
+ });
209
+ });
174
210
  });
@@ -2,9 +2,10 @@
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
- import { createWorkloadIdentity } from '../provenance';
8
+ import { createExecutionProvenance, createWorkloadIdentity } from '../provenance';
8
9
  import { Redactor } from '../redaction';
9
10
  import { executeCase } from './executor';
10
11
  import type { RunOptions, RunResult } from './types';
@@ -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) {
@@ -51,8 +55,11 @@ export async function runScenario(options: RunOptions): Promise<RunResult> {
51
55
  const result = await executeCase(testCase, {
52
56
  client,
53
57
  scenario,
58
+ requestedModel: resolvedConfig?.model,
54
59
  timeout: testCase.timeout || timeout,
55
60
  retries: testCase.retries ?? retries,
61
+ runId,
62
+ repetition,
56
63
  redaction,
57
64
  toolExecutor,
58
65
  });
@@ -70,8 +77,11 @@ export async function runScenario(options: RunOptions): Promise<RunResult> {
70
77
  const result = await executeCase(testCase, {
71
78
  client,
72
79
  scenario,
80
+ requestedModel: resolvedConfig?.model,
73
81
  timeout: testCase.timeout || timeout,
74
82
  retries: testCase.retries ?? retries,
83
+ runId,
84
+ repetition,
75
85
  redaction,
76
86
  toolExecutor,
77
87
  });
@@ -122,9 +132,29 @@ export async function runScenario(options: RunOptions): Promise<RunResult> {
122
132
  },
123
133
  resolvedConfig,
124
134
  workloadIdentity: createWorkloadIdentity(scenario),
135
+ executionProvenance: createExecutionProvenance({
136
+ provider: client.provider,
137
+ requestedModel: resolvedConfig?.model || scenario.model,
138
+ temperature: resolvedConfig?.temperature ?? scenario.temperature,
139
+ maxTokens: resolvedConfig?.max_tokens ?? scenario.maxTokens,
140
+ seed: scenario.seed,
141
+ cases: results,
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,
125
154
  cases: results,
126
155
  startTime,
127
156
  endTime,
157
+ runId,
128
158
  redaction: redactionInfo,
129
159
  });
130
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. */
@@ -56,8 +60,12 @@ export interface RunResult {
56
60
  export interface ExecutorContext {
57
61
  client: ModelClient;
58
62
  scenario: Scenario;
63
+ /** Effective model selected outside the scenario, such as a CLI override. */
64
+ requestedModel?: string;
59
65
  timeout?: number;
60
66
  retries?: number;
67
+ runId?: string;
68
+ repetition?: { index: number; total: number };
61
69
  /** Redaction configuration for this execution */
62
70
  redaction?: RedactionConfig;
63
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(),