@artemiskit/core 0.3.0 → 0.4.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +10 -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 +42 -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 +20169 -13138
  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/supabase.d.ts +1 -1
  35. package/dist/storage/supabase.d.ts.map +1 -1
  36. package/dist/storage/types.d.ts +6 -2
  37. package/dist/storage/types.d.ts.map +1 -1
  38. package/dist/tools/fixture-executor.d.ts +10 -0
  39. package/dist/tools/fixture-executor.d.ts.map +1 -0
  40. package/dist/tools/index.d.ts +4 -0
  41. package/dist/tools/index.d.ts.map +1 -0
  42. package/dist/tools/schema-validator.d.ts +10 -0
  43. package/dist/tools/schema-validator.d.ts.map +1 -0
  44. package/dist/tools/types.d.ts +50 -0
  45. package/dist/tools/types.d.ts.map +1 -0
  46. package/package.json +2 -1
  47. package/src/adapters/registry.ts +7 -0
  48. package/src/adapters/types.test.ts +21 -0
  49. package/src/adapters/types.ts +18 -0
  50. package/src/agent-evaluation/index.ts +2 -0
  51. package/src/agent-evaluation/scorer.test.ts +1194 -0
  52. package/src/agent-evaluation/scorer.ts +640 -0
  53. package/src/agent-evaluation/types.test.ts +27 -0
  54. package/src/agent-evaluation/types.ts +43 -0
  55. package/src/artifacts/manifest.test.ts +66 -19
  56. package/src/artifacts/manifest.ts +18 -5
  57. package/src/artifacts/types.ts +49 -0
  58. package/src/evaluators/index.ts +3 -0
  59. package/src/evaluators/json-schema.test.ts +130 -0
  60. package/src/evaluators/json-schema.ts +38 -63
  61. package/src/evaluators/llm-grader.test.ts +80 -0
  62. package/src/evaluators/llm-grader.ts +44 -6
  63. package/src/evaluators/tool-trace.test.ts +46 -0
  64. package/src/evaluators/tool-trace.ts +50 -0
  65. package/src/evaluators/types.ts +20 -0
  66. package/src/index.ts +6 -0
  67. package/src/runner/executor.test.ts +340 -0
  68. package/src/runner/executor.ts +289 -20
  69. package/src/runner/release-validation.test.ts +169 -0
  70. package/src/runner/runner.ts +5 -1
  71. package/src/runner/types.ts +4 -0
  72. package/src/scenario/schema.ts +56 -1
  73. package/src/storage/supabase.test.ts +65 -1
  74. package/src/storage/supabase.ts +17 -2
  75. package/src/storage/types.ts +12 -2
  76. package/src/tools/fixture-executor.test.ts +88 -0
  77. package/src/tools/fixture-executor.ts +112 -0
  78. package/src/tools/index.ts +3 -0
  79. package/src/tools/schema-validator.test.ts +32 -0
  80. package/src/tools/schema-validator.ts +56 -0
  81. package/src/tools/types.ts +80 -0
  82. package/adapters/openai/dist/index.js +0 -5626
@@ -12,35 +12,29 @@ describe('createRunManifest', () => {
12
12
  id: 'case-1',
13
13
  name: 'Test Case 1',
14
14
  ok: true,
15
+ status: 'passed',
15
16
  latencyMs: 100,
16
17
  prompt: 'Hello',
17
18
  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
- ],
19
+ tokens: { prompt: 10, completion: 5, total: 15 },
20
+ score: 1,
21
+ matcherType: 'contains',
22
+ expected: { type: 'contains', values: ['Hi'], mode: 'any' },
23
+ tags: [],
27
24
  },
28
25
  {
29
26
  id: 'case-2',
30
27
  name: 'Test Case 2',
31
28
  ok: false,
29
+ status: 'failed',
32
30
  latencyMs: 200,
33
31
  prompt: 'Goodbye',
34
32
  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
- ],
33
+ tokens: { prompt: 8, completion: 4, total: 12 },
34
+ score: 0,
35
+ matcherType: 'contains',
36
+ expected: { type: 'contains', values: ['Bye'], mode: 'any' },
37
+ tags: [],
44
38
  },
45
39
  ];
46
40
 
@@ -60,7 +54,7 @@ describe('createRunManifest', () => {
60
54
  endTime,
61
55
  });
62
56
 
63
- expect(manifest.version).toBe('1.0');
57
+ expect(manifest.version).toBe('1.1');
64
58
  expect(manifest.project).toBe('test-project');
65
59
  expect(manifest.run_id).toBeTruthy();
66
60
  expect(manifest.run_id.length).toBe(12);
@@ -86,6 +80,10 @@ describe('createRunManifest', () => {
86
80
  expect(manifest.metrics.passed_cases).toBe(1);
87
81
  expect(manifest.metrics.failed_cases).toBe(1);
88
82
  expect(manifest.metrics.success_rate).toBe(0.5);
83
+ expect(manifest.metrics.total_attempts).toBe(2);
84
+ expect(manifest.metrics.valid_evaluations).toBe(2);
85
+ expect(manifest.metrics.invalid_evaluations).toBe(0);
86
+ expect(manifest.metrics.outcome_rate_denominator).toBe(2);
89
87
  expect(manifest.metrics.total_tokens).toBe(27); // (10+5) + (8+4)
90
88
  expect(manifest.metrics.total_prompt_tokens).toBe(18); // 10 + 8
91
89
  expect(manifest.metrics.total_completion_tokens).toBe(9); // 5 + 4
@@ -129,6 +127,10 @@ describe('createRunManifest', () => {
129
127
  expect(manifest.metrics.passed_cases).toBe(0);
130
128
  expect(manifest.metrics.failed_cases).toBe(0);
131
129
  expect(manifest.metrics.success_rate).toBe(0);
130
+ expect(manifest.metrics.total_attempts).toBe(0);
131
+ expect(manifest.metrics.valid_evaluations).toBe(0);
132
+ expect(manifest.metrics.invalid_evaluations).toBe(0);
133
+ expect(manifest.metrics.outcome_rate_denominator).toBe(0);
132
134
  expect(manifest.metrics.median_latency_ms).toBe(0);
133
135
  expect(manifest.metrics.p95_latency_ms).toBe(0);
134
136
  });
@@ -203,4 +205,49 @@ describe('createRunManifest', () => {
203
205
  expect(manifest.metrics.median_latency_ms).toBe(300);
204
206
  expect(manifest.metrics.p95_latency_ms).toBe(500);
205
207
  });
208
+
209
+ test('excludes invalid and target-error measurements from the success-rate denominator', () => {
210
+ const manifest = createRunManifest({
211
+ project: 'test-project',
212
+ config: { scenario: 'test-scenario', provider: 'openai' },
213
+ cases: [
214
+ { ...mockCases[0], attempts: 2 },
215
+ { ...mockCases[1], status: 'invalid', error: undefined },
216
+ { ...mockCases[1], id: 'case-3', status: 'error', error: 'provider unavailable' },
217
+ ],
218
+ startTime: new Date(),
219
+ endTime: new Date(),
220
+ });
221
+
222
+ expect(manifest.metrics).toMatchObject({
223
+ total_attempts: 4,
224
+ total_cases: 3,
225
+ valid_evaluations: 1,
226
+ invalid_evaluations: 2,
227
+ outcome_rate_denominator: 1,
228
+ passed_cases: 1,
229
+ failed_cases: 0,
230
+ success_rate: 1,
231
+ });
232
+ });
233
+
234
+ test('maps historical manifests without a status using the documented legacy mapping', () => {
235
+ const manifest = createRunManifest({
236
+ project: 'test-project',
237
+ config: { scenario: 'legacy', provider: 'openai' },
238
+ cases: [
239
+ { ...mockCases[0], status: undefined },
240
+ { ...mockCases[1], status: undefined, error: 'legacy execution failure' },
241
+ ],
242
+ startTime: new Date(),
243
+ endTime: new Date(),
244
+ });
245
+
246
+ expect(manifest.metrics).toMatchObject({
247
+ valid_evaluations: 1,
248
+ invalid_evaluations: 1,
249
+ outcome_rate_denominator: 1,
250
+ success_rate: 1,
251
+ });
252
+ });
206
253
  });
@@ -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,
@@ -42,6 +42,26 @@ export interface ManifestRedactionInfo {
42
42
  // Case Result Types
43
43
  // ============================================================================
44
44
 
45
+ /**
46
+ * Terminal status of a case measurement.
47
+ *
48
+ * `invalid` means a target response was available but could not be evaluated
49
+ * reliably. `error` means execution did not produce a usable target response.
50
+ */
51
+ export type CaseEvaluationStatus = 'passed' | 'failed' | 'invalid' | 'error';
52
+
53
+ /** Reviewed, bounded evaluator evidence retained in a run artifact. */
54
+ export interface CaseEvaluationEvidence {
55
+ evaluator: string;
56
+ score?: number;
57
+ threshold?: number;
58
+ model?: string;
59
+ validation?: {
60
+ status: 'valid' | 'invalid';
61
+ code?: string;
62
+ };
63
+ }
64
+
45
65
  /**
46
66
  * Individual test case result
47
67
  */
@@ -49,6 +69,10 @@ export interface CaseResult {
49
69
  id: string;
50
70
  name?: string;
51
71
  ok: boolean;
72
+ /** Present in manifest v1.1+. Missing values use the documented legacy mapping. */
73
+ status?: CaseEvaluationStatus;
74
+ /** Number of execution attempts represented by this terminal result. */
75
+ attempts?: number;
52
76
  score: number;
53
77
  matcherType: string;
54
78
  reason?: string;
@@ -63,8 +87,14 @@ export interface CaseResult {
63
87
  expected: object;
64
88
  tags: string[];
65
89
  error?: string;
90
+ /** Sanitized evaluator evidence; arbitrary evaluator details are never stored here. */
91
+ evidence?: CaseEvaluationEvidence;
66
92
  /** Redaction information for this case */
67
93
  redaction?: CaseRedactionInfo;
94
+ /** Ordered tool activity captured for an enabled tool loop. */
95
+ toolTrace?: import('../tools').ToolTraceEntry[];
96
+ /** Terminal status for an enabled tool loop. */
97
+ toolLoop?: import('../tools').ToolLoopSummary;
68
98
  }
69
99
 
70
100
  /**
@@ -91,7 +121,15 @@ export interface CostEstimateInfo {
91
121
  */
92
122
  export interface RunMetrics {
93
123
  success_rate: number;
124
+ /** Number of execution attempts, including retries. */
125
+ total_attempts?: number;
94
126
  total_cases: number;
127
+ /** Case results with a valid evaluator outcome (passed or failed). */
128
+ valid_evaluations?: number;
129
+ /** Case results excluded from outcome rates (invalid or error). */
130
+ invalid_evaluations?: number;
131
+ /** Denominator used for success_rate; zero produces a success_rate of zero. */
132
+ outcome_rate_denominator?: number;
95
133
  passed_cases: number;
96
134
  failed_cases: number;
97
135
  median_latency_ms: number;
@@ -240,6 +278,17 @@ export interface RunManifest {
240
278
  redaction?: ManifestRedactionInfo;
241
279
  }
242
280
 
281
+ /**
282
+ * Read a case status from both v1.1 artifacts and historical v1.0 artifacts.
283
+ * Historical records cannot distinguish evaluator failures from ordinary failed
284
+ * criteria unless they set the legacy `error` field.
285
+ */
286
+ export function getCaseEvaluationStatus(caseResult: CaseResult): CaseEvaluationStatus {
287
+ if (caseResult.status) return caseResult.status;
288
+ if (caseResult.ok) return 'passed';
289
+ return caseResult.error ? 'error' : 'failed';
290
+ }
291
+
243
292
  // ============================================================================
244
293
  // Red Team Manifest Types
245
294
  // ============================================================================
@@ -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
  }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ModelClient } from '../adapters/types';
3
+ import { ScenarioSchema } from '../scenario/schema';
4
+ import { LLMGraderEvaluator } from './llm-grader';
5
+
6
+ function graderCase(strict: boolean) {
7
+ return ScenarioSchema.parse({
8
+ name: 'grader parsing',
9
+ cases: [
10
+ {
11
+ id: 'grade',
12
+ prompt: 'grade this',
13
+ expected: { type: 'llm_grader', rubric: 'Be correct', threshold: 0.7, strict },
14
+ },
15
+ ],
16
+ }).cases[0].expected;
17
+ }
18
+
19
+ function clientWith(text: string): ModelClient {
20
+ return {
21
+ provider: 'test',
22
+ generate: async () => ({
23
+ id: 'judge-response',
24
+ model: 'judge-model',
25
+ text,
26
+ tokens: { prompt: 1, completion: 1, total: 2 },
27
+ latencyMs: 1,
28
+ finishReason: 'stop',
29
+ }),
30
+ capabilities: async () => ({
31
+ streaming: false,
32
+ functionCalling: false,
33
+ toolUse: false,
34
+ maxContext: 1,
35
+ }),
36
+ };
37
+ }
38
+
39
+ describe('LLMGraderEvaluator strict parsing', () => {
40
+ test('accepts finite numeric boundary scores in exact JSON', async () => {
41
+ const evaluator = new LLMGraderEvaluator();
42
+ const passing = await evaluator.evaluate('response', graderCase(true), {
43
+ client: clientWith('{"score":1,"reason":"complete"}'),
44
+ });
45
+ const failing = await evaluator.evaluate('response', graderCase(true), {
46
+ client: clientWith('{"score":0,"reason":"missing requirement"}'),
47
+ });
48
+
49
+ expect(passing).toMatchObject({ passed: true, score: 1, status: 'passed' });
50
+ expect(failing).toMatchObject({ passed: false, score: 0, status: 'failed' });
51
+ });
52
+
53
+ test('marks malformed, coerced, and out-of-range strict judge scores invalid', async () => {
54
+ const evaluator = new LLMGraderEvaluator();
55
+ for (const text of [
56
+ 'Score: 1',
57
+ '{"score":"1","reason":"coerced"}',
58
+ '{"score":1.01,"reason":"out of range"}',
59
+ '```json\n{"score":1,"reason":"wrapped"}\n```',
60
+ ]) {
61
+ const result = await evaluator.evaluate('response', graderCase(true), {
62
+ client: clientWith(text),
63
+ });
64
+ expect(result).toMatchObject({
65
+ passed: false,
66
+ score: 0,
67
+ status: 'invalid',
68
+ evidence: { validation: { status: 'invalid', code: 'grader_failure' } },
69
+ });
70
+ }
71
+ });
72
+
73
+ test('keeps permissive parsing for non-strict legacy graders', async () => {
74
+ const result = await new LLMGraderEvaluator().evaluate('response', graderCase(false), {
75
+ client: clientWith('Score: 0.8'),
76
+ });
77
+
78
+ expect(result).toMatchObject({ passed: true, score: 0.8, status: 'passed' });
79
+ });
80
+ });