@artemiskit/core 0.3.0 → 0.4.1

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +4 -0
  3. package/dist/adapters/registry.d.ts.map +1 -1
  4. package/dist/adapters/types.d.ts +20 -2
  5. package/dist/adapters/types.d.ts.map +1 -1
  6. package/dist/agent-evaluation/index.d.ts +3 -0
  7. package/dist/agent-evaluation/index.d.ts.map +1 -0
  8. package/dist/agent-evaluation/scorer.d.ts +35 -0
  9. package/dist/agent-evaluation/scorer.d.ts.map +1 -0
  10. package/dist/agent-evaluation/types.d.ts +37 -0
  11. package/dist/agent-evaluation/types.d.ts.map +1 -0
  12. package/dist/artifacts/manifest.d.ts.map +1 -1
  13. package/dist/artifacts/types.d.ts +52 -0
  14. package/dist/artifacts/types.d.ts.map +1 -1
  15. package/dist/evaluators/index.d.ts +1 -0
  16. package/dist/evaluators/index.d.ts.map +1 -1
  17. package/dist/evaluators/json-schema.d.ts +0 -1
  18. package/dist/evaluators/json-schema.d.ts.map +1 -1
  19. package/dist/evaluators/llm-grader.d.ts +2 -0
  20. package/dist/evaluators/llm-grader.d.ts.map +1 -1
  21. package/dist/evaluators/tool-trace.d.ts +7 -0
  22. package/dist/evaluators/tool-trace.d.ts.map +1 -0
  23. package/dist/evaluators/types.d.ts +20 -0
  24. package/dist/evaluators/types.d.ts.map +1 -1
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +20281 -13144
  28. package/dist/runner/executor.d.ts.map +1 -1
  29. package/dist/runner/runner.d.ts.map +1 -1
  30. package/dist/runner/types.d.ts +4 -0
  31. package/dist/runner/types.d.ts.map +1 -1
  32. package/dist/scenario/schema.d.ts +721 -63
  33. package/dist/scenario/schema.d.ts.map +1 -1
  34. package/dist/storage/local.d.ts +1 -1
  35. package/dist/storage/local.d.ts.map +1 -1
  36. package/dist/storage/supabase.d.ts +1 -1
  37. package/dist/storage/supabase.d.ts.map +1 -1
  38. package/dist/storage/types.d.ts +6 -2
  39. package/dist/storage/types.d.ts.map +1 -1
  40. package/dist/tools/fixture-executor.d.ts +10 -0
  41. package/dist/tools/fixture-executor.d.ts.map +1 -0
  42. package/dist/tools/index.d.ts +4 -0
  43. package/dist/tools/index.d.ts.map +1 -0
  44. package/dist/tools/schema-validator.d.ts +10 -0
  45. package/dist/tools/schema-validator.d.ts.map +1 -0
  46. package/dist/tools/types.d.ts +50 -0
  47. package/dist/tools/types.d.ts.map +1 -0
  48. package/package.json +2 -1
  49. package/src/adapters/registry.ts +7 -0
  50. package/src/adapters/types.test.ts +21 -0
  51. package/src/adapters/types.ts +18 -0
  52. package/src/agent-evaluation/index.ts +2 -0
  53. package/src/agent-evaluation/scorer.test.ts +1194 -0
  54. package/src/agent-evaluation/scorer.ts +640 -0
  55. package/src/agent-evaluation/types.test.ts +27 -0
  56. package/src/agent-evaluation/types.ts +43 -0
  57. package/src/artifacts/manifest.test.ts +90 -19
  58. package/src/artifacts/manifest.ts +18 -5
  59. package/src/artifacts/types.ts +133 -0
  60. package/src/evaluators/index.ts +3 -0
  61. package/src/evaluators/json-schema.test.ts +130 -0
  62. package/src/evaluators/json-schema.ts +38 -63
  63. package/src/evaluators/llm-grader.test.ts +80 -0
  64. package/src/evaluators/llm-grader.ts +44 -6
  65. package/src/evaluators/tool-trace.test.ts +46 -0
  66. package/src/evaluators/tool-trace.ts +50 -0
  67. package/src/evaluators/types.ts +20 -0
  68. package/src/index.ts +6 -0
  69. package/src/runner/executor.test.ts +374 -0
  70. package/src/runner/executor.ts +349 -22
  71. package/src/runner/release-validation.test.ts +169 -0
  72. package/src/runner/runner.ts +7 -1
  73. package/src/runner/types.ts +4 -0
  74. package/src/scenario/schema.ts +56 -1
  75. package/src/storage/local.test.ts +24 -0
  76. package/src/storage/local.ts +13 -2
  77. package/src/storage/supabase.test.ts +111 -1
  78. package/src/storage/supabase.ts +26 -3
  79. package/src/storage/types.ts +12 -2
  80. package/src/tools/fixture-executor.test.ts +88 -0
  81. package/src/tools/fixture-executor.ts +112 -0
  82. package/src/tools/index.ts +3 -0
  83. package/src/tools/schema-validator.test.ts +32 -0
  84. package/src/tools/schema-validator.ts +56 -0
  85. package/src/tools/types.ts +80 -0
  86. package/adapters/openai/dist/index.js +0 -5626
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { describe, expect, test } from 'bun:test';
6
6
  import { createRunManifest } from './manifest';
7
+ import { assertRunManifestIntegrity } from './types';
7
8
  import type { CaseResult } from './types';
8
9
 
9
10
  describe('createRunManifest', () => {
@@ -12,35 +13,29 @@ describe('createRunManifest', () => {
12
13
  id: 'case-1',
13
14
  name: 'Test Case 1',
14
15
  ok: true,
16
+ status: 'passed',
15
17
  latencyMs: 100,
16
18
  prompt: 'Hello',
17
19
  response: 'Hi there!',
18
- tokens: { prompt: 10, completion: 5 },
19
- evaluations: [
20
- {
21
- type: 'contains',
22
- passed: true,
23
- score: 1,
24
- reason: 'Contains expected value',
25
- },
26
- ],
20
+ tokens: { prompt: 10, completion: 5, total: 15 },
21
+ score: 1,
22
+ matcherType: 'contains',
23
+ expected: { type: 'contains', values: ['Hi'], mode: 'any' },
24
+ tags: [],
27
25
  },
28
26
  {
29
27
  id: 'case-2',
30
28
  name: 'Test Case 2',
31
29
  ok: false,
30
+ status: 'failed',
32
31
  latencyMs: 200,
33
32
  prompt: 'Goodbye',
34
33
  response: 'See you!',
35
- tokens: { prompt: 8, completion: 4 },
36
- evaluations: [
37
- {
38
- type: 'contains',
39
- passed: false,
40
- score: 0,
41
- reason: 'Missing expected value',
42
- },
43
- ],
34
+ tokens: { prompt: 8, completion: 4, total: 12 },
35
+ score: 0,
36
+ matcherType: 'contains',
37
+ expected: { type: 'contains', values: ['Bye'], mode: 'any' },
38
+ tags: [],
44
39
  },
45
40
  ];
46
41
 
@@ -60,7 +55,7 @@ describe('createRunManifest', () => {
60
55
  endTime,
61
56
  });
62
57
 
63
- expect(manifest.version).toBe('1.0');
58
+ expect(manifest.version).toBe('1.1');
64
59
  expect(manifest.project).toBe('test-project');
65
60
  expect(manifest.run_id).toBeTruthy();
66
61
  expect(manifest.run_id.length).toBe(12);
@@ -86,6 +81,10 @@ describe('createRunManifest', () => {
86
81
  expect(manifest.metrics.passed_cases).toBe(1);
87
82
  expect(manifest.metrics.failed_cases).toBe(1);
88
83
  expect(manifest.metrics.success_rate).toBe(0.5);
84
+ expect(manifest.metrics.total_attempts).toBe(2);
85
+ expect(manifest.metrics.valid_evaluations).toBe(2);
86
+ expect(manifest.metrics.invalid_evaluations).toBe(0);
87
+ expect(manifest.metrics.outcome_rate_denominator).toBe(2);
89
88
  expect(manifest.metrics.total_tokens).toBe(27); // (10+5) + (8+4)
90
89
  expect(manifest.metrics.total_prompt_tokens).toBe(18); // 10 + 8
91
90
  expect(manifest.metrics.total_completion_tokens).toBe(9); // 5 + 4
@@ -129,6 +128,10 @@ describe('createRunManifest', () => {
129
128
  expect(manifest.metrics.passed_cases).toBe(0);
130
129
  expect(manifest.metrics.failed_cases).toBe(0);
131
130
  expect(manifest.metrics.success_rate).toBe(0);
131
+ expect(manifest.metrics.total_attempts).toBe(0);
132
+ expect(manifest.metrics.valid_evaluations).toBe(0);
133
+ expect(manifest.metrics.invalid_evaluations).toBe(0);
134
+ expect(manifest.metrics.outcome_rate_denominator).toBe(0);
132
135
  expect(manifest.metrics.median_latency_ms).toBe(0);
133
136
  expect(manifest.metrics.p95_latency_ms).toBe(0);
134
137
  });
@@ -203,4 +206,72 @@ describe('createRunManifest', () => {
203
206
  expect(manifest.metrics.median_latency_ms).toBe(300);
204
207
  expect(manifest.metrics.p95_latency_ms).toBe(500);
205
208
  });
209
+
210
+ test('excludes invalid and target-error measurements from the success-rate denominator', () => {
211
+ const manifest = createRunManifest({
212
+ project: 'test-project',
213
+ config: { scenario: 'test-scenario', provider: 'openai' },
214
+ cases: [
215
+ { ...mockCases[0], attempts: 2 },
216
+ { ...mockCases[1], status: 'invalid', error: undefined },
217
+ { ...mockCases[1], id: 'case-3', status: 'error', error: 'provider unavailable' },
218
+ ],
219
+ startTime: new Date(),
220
+ endTime: new Date(),
221
+ });
222
+
223
+ expect(manifest.metrics).toMatchObject({
224
+ total_attempts: 4,
225
+ total_cases: 3,
226
+ valid_evaluations: 1,
227
+ invalid_evaluations: 2,
228
+ outcome_rate_denominator: 1,
229
+ passed_cases: 1,
230
+ failed_cases: 0,
231
+ success_rate: 1,
232
+ });
233
+ });
234
+
235
+ test('maps historical manifests without a status using the documented legacy mapping', () => {
236
+ const manifest = createRunManifest({
237
+ project: 'test-project',
238
+ config: { scenario: 'legacy', provider: 'openai' },
239
+ cases: [
240
+ { ...mockCases[0], status: undefined },
241
+ { ...mockCases[1], status: undefined, error: 'legacy execution failure' },
242
+ ],
243
+ startTime: new Date(),
244
+ endTime: new Date(),
245
+ });
246
+
247
+ expect(manifest.metrics).toMatchObject({
248
+ valid_evaluations: 1,
249
+ invalid_evaluations: 1,
250
+ outcome_rate_denominator: 1,
251
+ success_rate: 1,
252
+ });
253
+ });
254
+
255
+ test('accepts historical cases without integrity fields but rejects malformed new evidence', () => {
256
+ const historical = createRunManifest({
257
+ project: 'test-project',
258
+ config: { scenario: 'legacy', provider: 'openai' },
259
+ cases: [{ ...mockCases[0], status: undefined, evidence: undefined }],
260
+ startTime: new Date(),
261
+ endTime: new Date(),
262
+ });
263
+ expect(() => assertRunManifestIntegrity(historical)).not.toThrow();
264
+
265
+ const malformed = {
266
+ ...historical,
267
+ cases: [
268
+ {
269
+ ...mockCases[0],
270
+ status: 'passed',
271
+ evidence: { evaluator: 'custom', validation: { status: 'unknown' } },
272
+ },
273
+ ],
274
+ };
275
+ expect(() => assertRunManifestIntegrity(malformed)).toThrow('invalid evidence validation');
276
+ });
206
277
  });
@@ -15,6 +15,7 @@ import type {
15
15
  RunManifest,
16
16
  RunMetrics,
17
17
  } from './types';
18
+ import { getCaseEvaluationStatus } from './types';
18
19
 
19
20
  /**
20
21
  * Create a new run manifest
@@ -49,7 +50,7 @@ export function createRunManifest(options: {
49
50
  const environment = getEnvironmentInfo();
50
51
 
51
52
  return {
52
- version: '1.0',
53
+ version: '1.1',
53
54
  run_id: nanoid(12),
54
55
  project,
55
56
  start_time: startTime.toISOString(),
@@ -74,7 +75,11 @@ export function createRunManifest(options: {
74
75
  * Calculate metrics from case results
75
76
  */
76
77
  function calculateMetrics(cases: CaseResult[], model?: string): RunMetrics {
77
- const passedCases = cases.filter((c) => c.ok);
78
+ const passedCases = cases.filter((c) => getCaseEvaluationStatus(c) === 'passed');
79
+ const validCases = cases.filter((c) => {
80
+ const status = getCaseEvaluationStatus(c);
81
+ return status === 'passed' || status === 'failed';
82
+ });
78
83
  const latencies = cases.map((c) => c.latencyMs).sort((a, b) => a - b);
79
84
 
80
85
  const medianLatency = latencies.length > 0 ? latencies[Math.floor(latencies.length / 2)] : 0;
@@ -87,7 +92,11 @@ function calculateMetrics(cases: CaseResult[], model?: string): RunMetrics {
87
92
 
88
93
  // Calculate cost if model is provided
89
94
  let cost: CostEstimateInfo | undefined;
90
- if (model && (totalPromptTokens > 0 || totalCompletionTokens > 0)) {
95
+ if (
96
+ model &&
97
+ !model.toLowerCase().includes('ling-') &&
98
+ (totalPromptTokens > 0 || totalCompletionTokens > 0)
99
+ ) {
91
100
  const costEstimate = estimateCost(totalPromptTokens, totalCompletionTokens, model);
92
101
  const pricing = getModelPricing(model);
93
102
  cost = {
@@ -103,10 +112,14 @@ function calculateMetrics(cases: CaseResult[], model?: string): RunMetrics {
103
112
  }
104
113
 
105
114
  return {
106
- success_rate: cases.length > 0 ? passedCases.length / cases.length : 0,
115
+ success_rate: validCases.length > 0 ? passedCases.length / validCases.length : 0,
116
+ total_attempts: cases.reduce((sum, c) => sum + (c.attempts ?? 1), 0),
107
117
  total_cases: cases.length,
118
+ valid_evaluations: validCases.length,
119
+ invalid_evaluations: cases.length - validCases.length,
120
+ outcome_rate_denominator: validCases.length,
108
121
  passed_cases: passedCases.length,
109
- failed_cases: cases.length - passedCases.length,
122
+ failed_cases: validCases.length - passedCases.length,
110
123
  median_latency_ms: medianLatency,
111
124
  p95_latency_ms: p95Latency,
112
125
  total_tokens: totalPromptTokens + totalCompletionTokens,
@@ -16,6 +16,8 @@ export interface CaseRedactionInfo {
16
16
  promptRedacted: boolean;
17
17
  /** Whether response was redacted */
18
18
  responseRedacted: boolean;
19
+ /** Whether evaluator reason text was redacted */
20
+ reasonRedacted?: boolean;
19
21
  /** Number of redactions in this case */
20
22
  redactionCount: number;
21
23
  }
@@ -34,6 +36,8 @@ export interface ManifestRedactionInfo {
34
36
  summary: {
35
37
  promptsRedacted: number;
36
38
  responsesRedacted: number;
39
+ /** Cases whose evaluator reason text was redacted. */
40
+ reasonsRedacted?: number;
37
41
  totalRedactions: number;
38
42
  };
39
43
  }
@@ -42,6 +46,26 @@ export interface ManifestRedactionInfo {
42
46
  // Case Result Types
43
47
  // ============================================================================
44
48
 
49
+ /**
50
+ * Terminal status of a case measurement.
51
+ *
52
+ * `invalid` means a target response was available but could not be evaluated
53
+ * reliably. `error` means execution did not produce a usable target response.
54
+ */
55
+ export type CaseEvaluationStatus = 'passed' | 'failed' | 'invalid' | 'error';
56
+
57
+ /** Reviewed, bounded evaluator evidence retained in a run artifact. */
58
+ export interface CaseEvaluationEvidence {
59
+ evaluator: string;
60
+ score?: number;
61
+ threshold?: number;
62
+ model?: string;
63
+ validation?: {
64
+ status: 'valid' | 'invalid';
65
+ code?: string;
66
+ };
67
+ }
68
+
45
69
  /**
46
70
  * Individual test case result
47
71
  */
@@ -49,6 +73,10 @@ export interface CaseResult {
49
73
  id: string;
50
74
  name?: string;
51
75
  ok: boolean;
76
+ /** Present in manifest v1.1+. Missing values use the documented legacy mapping. */
77
+ status?: CaseEvaluationStatus;
78
+ /** Number of execution attempts represented by this terminal result. */
79
+ attempts?: number;
52
80
  score: number;
53
81
  matcherType: string;
54
82
  reason?: string;
@@ -63,8 +91,14 @@ export interface CaseResult {
63
91
  expected: object;
64
92
  tags: string[];
65
93
  error?: string;
94
+ /** Sanitized evaluator evidence; arbitrary evaluator details are never stored here. */
95
+ evidence?: CaseEvaluationEvidence;
66
96
  /** Redaction information for this case */
67
97
  redaction?: CaseRedactionInfo;
98
+ /** Ordered tool activity captured for an enabled tool loop. */
99
+ toolTrace?: import('../tools').ToolTraceEntry[];
100
+ /** Terminal status for an enabled tool loop. */
101
+ toolLoop?: import('../tools').ToolLoopSummary;
68
102
  }
69
103
 
70
104
  /**
@@ -91,7 +125,15 @@ export interface CostEstimateInfo {
91
125
  */
92
126
  export interface RunMetrics {
93
127
  success_rate: number;
128
+ /** Number of execution attempts, including retries. */
129
+ total_attempts?: number;
94
130
  total_cases: number;
131
+ /** Case results with a valid evaluator outcome (passed or failed). */
132
+ valid_evaluations?: number;
133
+ /** Case results excluded from outcome rates (invalid or error). */
134
+ invalid_evaluations?: number;
135
+ /** Denominator used for success_rate; zero produces a success_rate of zero. */
136
+ outcome_rate_denominator?: number;
95
137
  passed_cases: number;
96
138
  failed_cases: number;
97
139
  median_latency_ms: number;
@@ -240,6 +282,97 @@ export interface RunManifest {
240
282
  redaction?: ManifestRedactionInfo;
241
283
  }
242
284
 
285
+ /**
286
+ * Read a case status from both v1.1 artifacts and historical v1.0 artifacts.
287
+ * Historical records cannot distinguish evaluator failures from ordinary failed
288
+ * criteria unless they set the legacy `error` field.
289
+ */
290
+ export function getCaseEvaluationStatus(caseResult: CaseResult): CaseEvaluationStatus {
291
+ if (
292
+ caseResult.status === 'passed' ||
293
+ caseResult.status === 'failed' ||
294
+ caseResult.status === 'invalid' ||
295
+ caseResult.status === 'error'
296
+ ) {
297
+ return caseResult.status;
298
+ }
299
+ if (caseResult.ok) return 'passed';
300
+ return caseResult.error ? 'error' : 'failed';
301
+ }
302
+
303
+ /**
304
+ * Reject untrusted integrity-bearing fields before a run manifest is persisted
305
+ * or treated as a standard run. Historical manifests remain supported because
306
+ * status and evidence are optional in the v1.0 contract.
307
+ */
308
+ export function assertRunManifestIntegrity(manifest: unknown): asserts manifest is RunManifest {
309
+ if (!isRecord(manifest) || !Array.isArray(manifest.cases)) {
310
+ throw new Error('Invalid run manifest: expected an object with a cases array');
311
+ }
312
+
313
+ for (const [index, caseResult] of manifest.cases.entries()) {
314
+ if (!isRecord(caseResult)) {
315
+ throw new Error(`Invalid run manifest: case ${index} is not an object`);
316
+ }
317
+
318
+ if (
319
+ caseResult.status !== undefined &&
320
+ caseResult.status !== 'passed' &&
321
+ caseResult.status !== 'failed' &&
322
+ caseResult.status !== 'invalid' &&
323
+ caseResult.status !== 'error'
324
+ ) {
325
+ throw new Error(`Invalid run manifest: case ${index} has an unknown status`);
326
+ }
327
+
328
+ if (caseResult.evidence !== undefined) {
329
+ assertCaseEvaluationEvidence(caseResult.evidence, index);
330
+ }
331
+ }
332
+ }
333
+
334
+ function assertCaseEvaluationEvidence(evidence: unknown, caseIndex: number): void {
335
+ if (
336
+ !isRecord(evidence) ||
337
+ typeof evidence.evaluator !== 'string' ||
338
+ evidence.evaluator.length > 100
339
+ ) {
340
+ throw new Error(`Invalid run manifest: case ${caseIndex} has malformed evaluator evidence`);
341
+ }
342
+
343
+ if (evidence.score !== undefined && !isUnitIntervalNumber(evidence.score)) {
344
+ throw new Error(`Invalid run manifest: case ${caseIndex} has an invalid evidence score`);
345
+ }
346
+ if (evidence.threshold !== undefined && !isUnitIntervalNumber(evidence.threshold)) {
347
+ throw new Error(`Invalid run manifest: case ${caseIndex} has an invalid evidence threshold`);
348
+ }
349
+ if (
350
+ evidence.model !== undefined &&
351
+ (typeof evidence.model !== 'string' || evidence.model.length > 200)
352
+ ) {
353
+ throw new Error(`Invalid run manifest: case ${caseIndex} has an invalid evidence model`);
354
+ }
355
+
356
+ if (evidence.validation !== undefined) {
357
+ if (
358
+ !isRecord(evidence.validation) ||
359
+ (evidence.validation.status !== 'valid' && evidence.validation.status !== 'invalid') ||
360
+ (evidence.validation.code !== undefined &&
361
+ (typeof evidence.validation.code !== 'string' || evidence.validation.code.length > 100))
362
+ ) {
363
+ throw new Error(`Invalid run manifest: case ${caseIndex} has invalid evidence validation`);
364
+ }
365
+ }
366
+ }
367
+
368
+ function isUnitIntervalNumber(value: unknown): value is number {
369
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
370
+ }
371
+
372
+ function isRecord(value: unknown): value is Record<string, unknown> {
373
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
374
+ }
375
+
243
376
  // ============================================================================
244
377
  // Red Team Manifest Types
245
378
  // ============================================================================
@@ -12,6 +12,7 @@ import { LLMGraderEvaluator } from './llm-grader';
12
12
  import { NotContainsEvaluator } from './not-contains';
13
13
  import { RegexEvaluator } from './regex';
14
14
  import { SimilarityEvaluator } from './similarity';
15
+ import { ToolTraceEvaluator } from './tool-trace';
15
16
  import type { Evaluator } from './types';
16
17
 
17
18
  const evaluators = new Map<string, Evaluator>();
@@ -25,6 +26,7 @@ evaluators.set('json_schema', new JsonSchemaEvaluator());
25
26
  evaluators.set('llm_grader', new LLMGraderEvaluator());
26
27
  evaluators.set('similarity', new SimilarityEvaluator());
27
28
  evaluators.set('inline', new InlineEvaluator());
29
+ evaluators.set('tool_trace', new ToolTraceEvaluator());
28
30
 
29
31
  /**
30
32
  * Get an evaluator by type
@@ -63,3 +65,4 @@ export { JsonSchemaEvaluator } from './json-schema';
63
65
  export { LLMGraderEvaluator } from './llm-grader';
64
66
  export { SimilarityEvaluator } from './similarity';
65
67
  export { InlineEvaluator, SUPPORTED_EXPRESSIONS } from './inline';
68
+ export { ToolTraceEvaluator } from './tool-trace';
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import type { Expected } from '../scenario/schema';
3
+ import { JsonSchemaEvaluator } from './json-schema';
4
+
5
+ const expected: Expected = {
6
+ type: 'json_schema',
7
+ schema: {
8
+ type: 'object',
9
+ required: ['profile', 'status', 'version'],
10
+ additionalProperties: false,
11
+ properties: {
12
+ profile: {
13
+ type: 'object',
14
+ required: ['roles'],
15
+ additionalProperties: false,
16
+ properties: {
17
+ roles: {
18
+ type: 'array',
19
+ items: { type: 'string', enum: ['admin', 'member'] },
20
+ },
21
+ },
22
+ },
23
+ status: { enum: ['active', 'disabled'] },
24
+ version: { const: 'v1' },
25
+ },
26
+ },
27
+ };
28
+
29
+ describe('JsonSchemaEvaluator', () => {
30
+ const evaluator = new JsonSchemaEvaluator();
31
+
32
+ it('accepts a response matching a nested JSON schema', async () => {
33
+ const result = await evaluator.evaluate(
34
+ JSON.stringify({ profile: { roles: ['admin'] }, status: 'active', version: 'v1' }),
35
+ expected
36
+ );
37
+
38
+ expect(result.passed).toBe(true);
39
+ expect(result.score).toBe(1);
40
+ });
41
+
42
+ it('rejects a missing nested required property', async () => {
43
+ const result = await evaluator.evaluate(
44
+ JSON.stringify({ profile: {}, status: 'active', version: 'v1' }),
45
+ expected
46
+ );
47
+
48
+ expect(result.passed).toBe(false);
49
+ expect(result.score).toBe(0);
50
+ });
51
+
52
+ it('rejects values outside enum constraints', async () => {
53
+ const result = await evaluator.evaluate(
54
+ JSON.stringify({ profile: { roles: ['owner'] }, status: 'pending', version: 'v1' }),
55
+ expected
56
+ );
57
+
58
+ expect(result.passed).toBe(false);
59
+ expect(result.reason).toContain('allowed values');
60
+ });
61
+
62
+ it('rejects values that violate const constraints', async () => {
63
+ const result = await evaluator.evaluate(
64
+ JSON.stringify({ profile: { roles: ['member'] }, status: 'active', version: 'v2' }),
65
+ expected
66
+ );
67
+
68
+ expect(result.passed).toBe(false);
69
+ expect(result.reason).toContain('constant');
70
+ });
71
+
72
+ it('rejects additional properties in closed objects', async () => {
73
+ const result = await evaluator.evaluate(
74
+ JSON.stringify({
75
+ profile: { roles: ['admin'], internal: true },
76
+ status: 'active',
77
+ version: 'v1',
78
+ }),
79
+ expected
80
+ );
81
+
82
+ expect(result.passed).toBe(false);
83
+ expect(result.reason).toContain('additional properties');
84
+ });
85
+
86
+ it('reports invalid schemas without exposing response content', async () => {
87
+ const sensitiveResponse = 'sensitive-response-value';
88
+ const result = await evaluator.evaluate(sensitiveResponse, {
89
+ type: 'json_schema',
90
+ schema: { type: 'not-a-json-schema-type' },
91
+ });
92
+
93
+ expect(result).toEqual({
94
+ passed: false,
95
+ score: 0,
96
+ reason: 'Invalid JSON schema',
97
+ details: { error: 'Invalid JSON schema' },
98
+ });
99
+ expect(JSON.stringify(result)).not.toContain('sensitive-response-value');
100
+ });
101
+
102
+ it('validates distinct schema objects with the same $id independently', async () => {
103
+ const firstExpected: Expected = {
104
+ type: 'json_schema',
105
+ schema: {
106
+ $id: 'https://schemas.example.test/shared-result',
107
+ type: 'object',
108
+ required: ['first'],
109
+ properties: { first: { const: true } },
110
+ },
111
+ };
112
+ const secondExpected: Expected = {
113
+ type: 'json_schema',
114
+ schema: {
115
+ $id: 'https://schemas.example.test/shared-result',
116
+ type: 'object',
117
+ required: ['second'],
118
+ properties: { second: { const: true } },
119
+ },
120
+ };
121
+
122
+ const firstResult = await evaluator.evaluate('{"first":true}', firstExpected);
123
+ const cachedResult = await evaluator.evaluate('{"first":true}', firstExpected);
124
+ const distinctResult = await evaluator.evaluate('{"second":true}', secondExpected);
125
+
126
+ expect(firstResult.passed).toBe(true);
127
+ expect(cachedResult.passed).toBe(true);
128
+ expect(distinctResult.passed).toBe(true);
129
+ });
130
+ });
@@ -2,10 +2,21 @@
2
2
  * JSON Schema evaluator - validates response against a JSON schema
3
3
  */
4
4
 
5
- import { z } from 'zod';
5
+ import Ajv, { type ValidateFunction } from 'ajv';
6
6
  import type { Expected } from '../scenario/schema';
7
7
  import type { Evaluator, EvaluatorResult } from './types';
8
8
 
9
+ const validators = new WeakMap<object, ValidateFunction>();
10
+
11
+ function getValidator(schema: Record<string, unknown>): ValidateFunction {
12
+ const cached = validators.get(schema);
13
+ if (cached) return cached;
14
+
15
+ const validator = new Ajv({ allErrors: true, strict: false }).compile(schema);
16
+ validators.set(schema, validator);
17
+ return validator;
18
+ }
19
+
9
20
  export class JsonSchemaEvaluator implements Evaluator {
10
21
  readonly type = 'json_schema';
11
22
 
@@ -14,6 +25,18 @@ export class JsonSchemaEvaluator implements Evaluator {
14
25
  throw new Error('Invalid expected type for JsonSchemaEvaluator');
15
26
  }
16
27
 
28
+ let validator: ValidateFunction;
29
+ try {
30
+ validator = getValidator(expected.schema);
31
+ } catch {
32
+ return {
33
+ passed: false,
34
+ score: 0,
35
+ reason: 'Invalid JSON schema',
36
+ details: { error: 'Invalid JSON schema' },
37
+ };
38
+ }
39
+
17
40
  let parsed: unknown;
18
41
  try {
19
42
  const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/);
@@ -28,71 +51,23 @@ export class JsonSchemaEvaluator implements Evaluator {
28
51
  };
29
52
  }
30
53
 
31
- try {
32
- const zodSchema = this.jsonSchemaToZod(expected.schema);
33
- const result = zodSchema.safeParse(parsed);
34
-
35
- if (result.success) {
36
- return {
37
- passed: true,
38
- score: 1,
39
- reason: 'Response matches JSON schema',
40
- details: { parsed },
41
- };
42
- }
43
- const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
44
- return {
45
- passed: false,
46
- score: 0,
47
- reason: `Schema validation failed: ${issues.join(', ')}`,
48
- details: {
49
- parsed,
50
- errors: issues,
51
- },
52
- };
53
- } catch (error) {
54
+ if (validator(parsed)) {
54
55
  return {
55
- passed: false,
56
- score: 0,
57
- reason: `Schema error: ${(error as Error).message}`,
58
- details: { error: (error as Error).message },
56
+ passed: true,
57
+ score: 1,
58
+ reason: 'Response matches JSON schema',
59
+ details: { parsed },
59
60
  };
60
61
  }
61
- }
62
62
 
63
- private jsonSchemaToZod(schema: Record<string, unknown>): z.ZodTypeAny {
64
- const type = schema.type as string;
65
-
66
- switch (type) {
67
- case 'string':
68
- return z.string();
69
- case 'number':
70
- return z.number();
71
- case 'integer':
72
- return z.number().int();
73
- case 'boolean':
74
- return z.boolean();
75
- case 'null':
76
- return z.null();
77
- case 'array':
78
- if (schema.items) {
79
- return z.array(this.jsonSchemaToZod(schema.items as Record<string, unknown>));
80
- }
81
- return z.array(z.unknown());
82
- case 'object':
83
- if (schema.properties) {
84
- const shape: Record<string, z.ZodTypeAny> = {};
85
- const required = (schema.required as string[]) || [];
86
-
87
- for (const [key, value] of Object.entries(schema.properties as Record<string, unknown>)) {
88
- const fieldSchema = this.jsonSchemaToZod(value as Record<string, unknown>);
89
- shape[key] = required.includes(key) ? fieldSchema : fieldSchema.optional();
90
- }
91
- return z.object(shape);
92
- }
93
- return z.record(z.unknown());
94
- default:
95
- return z.unknown();
96
- }
63
+ const errors = (validator.errors ?? []).map(
64
+ (error) => `${error.instancePath || '/'} ${error.message ?? 'is invalid'}`
65
+ );
66
+ return {
67
+ passed: false,
68
+ score: 0,
69
+ reason: `Schema validation failed: ${errors.join('; ')}`,
70
+ details: { parsed, errors },
71
+ };
97
72
  }
98
73
  }