@valerypopoff/rivet2-evaluations 2.3.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.
@@ -0,0 +1,555 @@
1
+ import { assertPortableJson, canonicalStringify } from './canonical.js';
2
+ function readQuotedPathKey(source, start) {
3
+ const quote = source[start];
4
+ if (quote !== '"' && quote !== "'")
5
+ return undefined;
6
+ let cursor = start + 1;
7
+ let key = '';
8
+ while (cursor < source.length) {
9
+ const character = source[cursor++];
10
+ if (character === quote)
11
+ return { key, cursor };
12
+ if (character !== '\\') {
13
+ key += character;
14
+ continue;
15
+ }
16
+ const escaped = source[cursor++];
17
+ if (escaped === undefined)
18
+ return undefined;
19
+ if (escaped === 'u') {
20
+ const hex = source.slice(cursor, cursor + 4);
21
+ if (!/^[0-9a-fA-F]{4}$/.test(hex))
22
+ return undefined;
23
+ key += String.fromCharCode(Number.parseInt(hex, 16));
24
+ cursor += 4;
25
+ continue;
26
+ }
27
+ const escapes = {
28
+ '"': '"',
29
+ "'": "'",
30
+ '\\': '\\',
31
+ '/': '/',
32
+ b: '\b',
33
+ f: '\f',
34
+ n: '\n',
35
+ r: '\r',
36
+ t: '\t',
37
+ };
38
+ if (!(escaped in escapes))
39
+ return undefined;
40
+ key += escapes[escaped];
41
+ }
42
+ return undefined;
43
+ }
44
+ function parseEvaluationOutputPath(path) {
45
+ const source = path.trim();
46
+ if (source === '$')
47
+ return [];
48
+ if (!source.startsWith('$') || source.length === 1)
49
+ return undefined;
50
+ let cursor = 1;
51
+ const segments = [];
52
+ // Validate every character. A partially parsed selector could otherwise
53
+ // assert against a different output than the author wrote (for example
54
+ // `$.answer!`). Dot notation intentionally remains identifier-only; quoted
55
+ // bracket notation covers output names containing spaces, dots, or dashes.
56
+ while (cursor < source.length) {
57
+ if (source[cursor] === '.') {
58
+ cursor += 1;
59
+ while (/\s/u.test(source[cursor] ?? ''))
60
+ cursor += 1;
61
+ const identifier = /^[A-Za-z_$][\w$]*/u.exec(source.slice(cursor));
62
+ if (!identifier)
63
+ return undefined;
64
+ segments.push({ kind: 'property', key: identifier[0] });
65
+ cursor += identifier[0].length;
66
+ }
67
+ else if (source[cursor] === '[') {
68
+ cursor += 1;
69
+ while (/\s/u.test(source[cursor] ?? ''))
70
+ cursor += 1;
71
+ const quoted = readQuotedPathKey(source, cursor);
72
+ if (quoted) {
73
+ segments.push({ kind: 'property', key: quoted.key });
74
+ cursor = quoted.cursor;
75
+ }
76
+ else {
77
+ const index = /^\d+/u.exec(source.slice(cursor));
78
+ if (!index)
79
+ return undefined;
80
+ segments.push({ kind: 'index', index: Number(index[0]) });
81
+ cursor += index[0].length;
82
+ }
83
+ while (/\s/u.test(source[cursor] ?? ''))
84
+ cursor += 1;
85
+ if (source[cursor] !== ']')
86
+ return undefined;
87
+ cursor += 1;
88
+ }
89
+ else {
90
+ return undefined;
91
+ }
92
+ }
93
+ return segments;
94
+ }
95
+ /**
96
+ * Validates the exact JSON-path subset supported by evaluation assertions.
97
+ * UI authoring and runtime execution intentionally share this parser so a
98
+ * path cannot pass preflight and then resolve with different grammar.
99
+ */
100
+ export function isEvaluationOutputPathSyntaxValid(path) {
101
+ return parseEvaluationOutputPath(path) !== undefined;
102
+ }
103
+ /** The Graph Output that owns an assertion path, including nested paths. */
104
+ export function getEvaluationTopLevelOutputId(path) {
105
+ const first = parseEvaluationOutputPath(path)?.[0];
106
+ return first?.kind === 'property' ? first.key : undefined;
107
+ }
108
+ function getPath(value, path) {
109
+ const segments = parseEvaluationOutputPath(path);
110
+ if (!segments)
111
+ return undefined;
112
+ let current = value;
113
+ for (const segment of segments) {
114
+ if (Array.isArray(current)) {
115
+ if (segment.kind !== 'index')
116
+ return undefined;
117
+ const index = segment.index;
118
+ if (!Number.isInteger(index) || index < 0 || index >= current.length)
119
+ return undefined;
120
+ current = current[index];
121
+ continue;
122
+ }
123
+ if (segment.kind !== 'property')
124
+ return undefined;
125
+ if (current === null || typeof current !== 'object' || !Object.hasOwn(current, segment.key))
126
+ return undefined;
127
+ current = current[segment.key];
128
+ }
129
+ return current;
130
+ }
131
+ function expectedValue(assertion, testCase) {
132
+ if (assertion.expected.kind === 'literal')
133
+ return assertion.expected.value;
134
+ const value = testCase.values[assertion.expected.fieldId];
135
+ if (value === undefined)
136
+ throw new Error(`Expected dataset field "${assertion.expected.fieldId}" is missing.`);
137
+ return value;
138
+ }
139
+ function typeMatches(actual, expected) {
140
+ if (typeof expected !== 'string')
141
+ return false;
142
+ if (actual === undefined)
143
+ return false;
144
+ if (expected === 'array')
145
+ return Array.isArray(actual);
146
+ if (expected === 'null')
147
+ return actual === null;
148
+ if (expected === 'object')
149
+ return actual !== null && typeof actual === 'object' && !Array.isArray(actual);
150
+ return typeof actual === expected;
151
+ }
152
+ function expectedTextList(assertion, expected) {
153
+ if (!Array.isArray(expected) || expected.length === 0 || expected.some((value) => typeof value !== 'string')) {
154
+ throw new Error(`Quality check "${assertion.name}" requires a non-empty array of expected text values.`);
155
+ }
156
+ return expected;
157
+ }
158
+ function assertionConfigurationError(assertion, message) {
159
+ throw new Error(`Quality check "${assertion.name}" ${message}`);
160
+ }
161
+ const ASSERTION_TYPE_NAMES = new Set(['array', 'boolean', 'null', 'number', 'object', 'string']);
162
+ function expectedTypeName(assertion, expected) {
163
+ if (typeof expected !== 'string' || !ASSERTION_TYPE_NAMES.has(expected)) {
164
+ assertionConfigurationError(assertion, 'requires one of these expected type names: array, boolean, null, number, object, or string.');
165
+ }
166
+ return expected;
167
+ }
168
+ function expectedString(assertion, expected, description) {
169
+ if (typeof expected !== 'string') {
170
+ assertionConfigurationError(assertion, `requires ${description} to be a string.`);
171
+ }
172
+ return expected;
173
+ }
174
+ function expectedFiniteNumber(assertion, expected, description) {
175
+ if (typeof expected !== 'number' || !Number.isFinite(expected)) {
176
+ assertionConfigurationError(assertion, `requires ${description} to be a finite number.`);
177
+ }
178
+ return expected;
179
+ }
180
+ function expectedNumberRange(assertion, expected) {
181
+ if (!Array.isArray(expected) ||
182
+ expected.length !== 2 ||
183
+ typeof expected[0] !== 'number' ||
184
+ !Number.isFinite(expected[0]) ||
185
+ typeof expected[1] !== 'number' ||
186
+ !Number.isFinite(expected[1])) {
187
+ assertionConfigurationError(assertion, 'requires an expected range containing exactly two finite numbers.');
188
+ }
189
+ if (expected[0] > expected[1]) {
190
+ assertionConfigurationError(assertion, 'requires its expected range minimum to be no greater than its maximum.');
191
+ }
192
+ return expected;
193
+ }
194
+ function expectedSet(assertion, expected) {
195
+ if (!Array.isArray(expected))
196
+ assertionConfigurationError(assertion, 'requires the expected set to be an array.');
197
+ return expected;
198
+ }
199
+ function operatorDescription(operator) {
200
+ switch (operator) {
201
+ case 'equals':
202
+ return 'equals';
203
+ case 'not-equals':
204
+ return 'does not equal';
205
+ case 'contains':
206
+ return 'contains the expected text';
207
+ case 'matches-regex':
208
+ return 'matches the regular expression';
209
+ case 'type-is':
210
+ return 'has the expected type';
211
+ case 'json-schema':
212
+ return 'matches the JSON Schema';
213
+ case 'number-at-least':
214
+ return 'is at least the expected value';
215
+ case 'number-at-most':
216
+ return 'is at most the expected value';
217
+ case 'number-between':
218
+ return 'is inside the expected range';
219
+ case 'array-includes':
220
+ return 'includes the expected value';
221
+ case 'set-overlaps':
222
+ return 'overlaps the expected set';
223
+ case 'contains-any':
224
+ return 'contains any expected text';
225
+ case 'contains-all':
226
+ return 'contains every expected text';
227
+ }
228
+ }
229
+ /**
230
+ * Validates author-controlled expected data without needing target outputs.
231
+ * The runner uses this before scheduling work; assertion execution uses the
232
+ * same function so direct callers cannot observe a looser contract.
233
+ */
234
+ export function validateEvaluationAssertionExpectedValue(assertion, expected) {
235
+ assertPortableJson(expected, `quality check ${assertion.name} expected value`);
236
+ switch (assertion.operator) {
237
+ case 'equals':
238
+ case 'not-equals':
239
+ case 'array-includes':
240
+ return;
241
+ case 'contains':
242
+ expectedString(assertion, expected, 'its expected text');
243
+ return;
244
+ case 'matches-regex': {
245
+ const pattern = expectedString(assertion, expected, 'its expected regular expression');
246
+ try {
247
+ new RegExp(pattern);
248
+ }
249
+ catch {
250
+ assertionConfigurationError(assertion, 'contains an invalid expected regular expression.');
251
+ }
252
+ return;
253
+ }
254
+ case 'type-is':
255
+ expectedTypeName(assertion, expected);
256
+ return;
257
+ case 'json-schema':
258
+ assertSupportedJsonSchema(assertion, expected);
259
+ return;
260
+ case 'number-at-least':
261
+ expectedFiniteNumber(assertion, expected, 'its expected minimum');
262
+ return;
263
+ case 'number-at-most':
264
+ expectedFiniteNumber(assertion, expected, 'its expected maximum');
265
+ return;
266
+ case 'number-between':
267
+ expectedNumberRange(assertion, expected);
268
+ return;
269
+ case 'set-overlaps':
270
+ expectedSet(assertion, expected);
271
+ return;
272
+ case 'contains-any':
273
+ case 'contains-all':
274
+ expectedTextList(assertion, expected);
275
+ return;
276
+ }
277
+ }
278
+ export function validateEvaluationAssertionConfiguration(assertion, expected) {
279
+ if (!isEvaluationOutputPathSyntaxValid(assertion.outputPath)) {
280
+ assertionConfigurationError(assertion, `uses invalid output path "${assertion.outputPath}".`);
281
+ }
282
+ validateEvaluationAssertionExpectedValue(assertion, expected);
283
+ }
284
+ const SUPPORTED_SCHEMA_KEYWORDS = new Set([
285
+ 'type',
286
+ 'enum',
287
+ 'const',
288
+ 'minLength',
289
+ 'maxLength',
290
+ 'pattern',
291
+ 'minimum',
292
+ 'maximum',
293
+ 'minItems',
294
+ 'maxItems',
295
+ 'items',
296
+ 'required',
297
+ 'properties',
298
+ 'additionalProperties',
299
+ ]);
300
+ const SUPPORTED_SCHEMA_TYPES = new Set(['array', 'boolean', 'integer', 'null', 'number', 'object', 'string']);
301
+ function schemaRecord(assertion, schema, path) {
302
+ if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
303
+ assertionConfigurationError(assertion, `requires the JSON Schema at ${path} to be an object.`);
304
+ }
305
+ return schema;
306
+ }
307
+ function assertNonNegativeInteger(assertion, value, path) {
308
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
309
+ assertionConfigurationError(assertion, `requires ${path} to be a non-negative safe integer.`);
310
+ }
311
+ }
312
+ /**
313
+ * Validates the exact JSON Schema subset implemented below. Rejecting unknown
314
+ * constraints is essential: silently ignoring one can turn a schema that
315
+ * appears strict into a false quality pass.
316
+ */
317
+ function assertSupportedJsonSchema(assertion, schema, path = '$') {
318
+ const objectSchema = schemaRecord(assertion, schema, path);
319
+ for (const keyword of Object.keys(objectSchema)) {
320
+ if (!SUPPORTED_SCHEMA_KEYWORDS.has(keyword)) {
321
+ assertionConfigurationError(assertion, `uses unsupported JSON Schema keyword "${keyword}" at ${path}.`);
322
+ }
323
+ }
324
+ if (objectSchema.type !== undefined) {
325
+ if (typeof objectSchema.type !== 'string' || !SUPPORTED_SCHEMA_TYPES.has(objectSchema.type)) {
326
+ assertionConfigurationError(assertion, `requires ${path}.type to be one of: array, boolean, integer, null, number, object, or string.`);
327
+ }
328
+ }
329
+ if (objectSchema.enum !== undefined && (!Array.isArray(objectSchema.enum) || objectSchema.enum.length === 0)) {
330
+ assertionConfigurationError(assertion, `requires ${path}.enum to be a non-empty array.`);
331
+ }
332
+ for (const keyword of ['minLength', 'maxLength', 'minItems', 'maxItems']) {
333
+ if (objectSchema[keyword] !== undefined)
334
+ assertNonNegativeInteger(assertion, objectSchema[keyword], `${path}.${keyword}`);
335
+ }
336
+ if (typeof objectSchema.minLength === 'number' &&
337
+ typeof objectSchema.maxLength === 'number' &&
338
+ objectSchema.minLength > objectSchema.maxLength) {
339
+ assertionConfigurationError(assertion, `requires ${path}.minLength to be no greater than ${path}.maxLength.`);
340
+ }
341
+ if (typeof objectSchema.minItems === 'number' &&
342
+ typeof objectSchema.maxItems === 'number' &&
343
+ objectSchema.minItems > objectSchema.maxItems) {
344
+ assertionConfigurationError(assertion, `requires ${path}.minItems to be no greater than ${path}.maxItems.`);
345
+ }
346
+ for (const keyword of ['minimum', 'maximum']) {
347
+ const value = objectSchema[keyword];
348
+ if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value))) {
349
+ assertionConfigurationError(assertion, `requires ${path}.${keyword} to be a finite number.`);
350
+ }
351
+ }
352
+ if (typeof objectSchema.minimum === 'number' &&
353
+ typeof objectSchema.maximum === 'number' &&
354
+ objectSchema.minimum > objectSchema.maximum) {
355
+ assertionConfigurationError(assertion, `requires ${path}.minimum to be no greater than ${path}.maximum.`);
356
+ }
357
+ if (objectSchema.pattern !== undefined) {
358
+ if (typeof objectSchema.pattern !== 'string') {
359
+ assertionConfigurationError(assertion, `requires ${path}.pattern to be a string.`);
360
+ }
361
+ try {
362
+ new RegExp(objectSchema.pattern);
363
+ }
364
+ catch {
365
+ assertionConfigurationError(assertion, `contains an invalid regular expression at ${path}.pattern.`);
366
+ }
367
+ }
368
+ if (objectSchema.required !== undefined) {
369
+ if (!Array.isArray(objectSchema.required) ||
370
+ objectSchema.required.some((key) => typeof key !== 'string' || key.length === 0) ||
371
+ new Set(objectSchema.required).size !== objectSchema.required.length) {
372
+ assertionConfigurationError(assertion, `requires ${path}.required to contain unique, non-empty strings.`);
373
+ }
374
+ }
375
+ if (objectSchema.additionalProperties !== undefined && typeof objectSchema.additionalProperties !== 'boolean') {
376
+ assertionConfigurationError(assertion, `supports only a boolean ${path}.additionalProperties value.`);
377
+ }
378
+ if (objectSchema.items !== undefined) {
379
+ assertSupportedJsonSchema(assertion, objectSchema.items, `${path}.items`);
380
+ }
381
+ if (objectSchema.properties !== undefined) {
382
+ const properties = schemaRecord(assertion, objectSchema.properties, `${path}.properties`);
383
+ for (const [key, childSchema] of Object.entries(properties)) {
384
+ assertSupportedJsonSchema(assertion, childSchema, `${path}.properties[${JSON.stringify(key)}]`);
385
+ }
386
+ }
387
+ }
388
+ function schemaTypeMatches(actual, expected) {
389
+ if (expected === 'integer')
390
+ return typeof actual === 'number' && Number.isInteger(actual);
391
+ return typeMatches(actual, expected);
392
+ }
393
+ /** Evaluate the validated, deliberately portable JSON Schema subset. */
394
+ function matchesSchema(actual, schema) {
395
+ if (actual === undefined)
396
+ return false;
397
+ const objectSchema = schema;
398
+ const declaredType = objectSchema.type;
399
+ if (typeof declaredType === 'string' && !schemaTypeMatches(actual, declaredType))
400
+ return false;
401
+ if (Array.isArray(objectSchema.enum) &&
402
+ !objectSchema.enum.some((candidate) => canonicalStringify(candidate) === canonicalStringify(actual ?? null)))
403
+ return false;
404
+ if (Object.hasOwn(objectSchema, 'const') &&
405
+ canonicalStringify(objectSchema.const) !== canonicalStringify(actual ?? null))
406
+ return false;
407
+ if (typeof actual === 'string') {
408
+ if (typeof objectSchema.minLength === 'number' && actual.length < objectSchema.minLength)
409
+ return false;
410
+ if (typeof objectSchema.maxLength === 'number' && actual.length > objectSchema.maxLength)
411
+ return false;
412
+ if (typeof objectSchema.pattern === 'string') {
413
+ if (!new RegExp(objectSchema.pattern).test(actual))
414
+ return false;
415
+ }
416
+ }
417
+ if (typeof actual === 'number') {
418
+ if (typeof objectSchema.minimum === 'number' && actual < objectSchema.minimum)
419
+ return false;
420
+ if (typeof objectSchema.maximum === 'number' && actual > objectSchema.maximum)
421
+ return false;
422
+ }
423
+ if (Array.isArray(actual)) {
424
+ if (typeof objectSchema.minItems === 'number' && actual.length < objectSchema.minItems)
425
+ return false;
426
+ if (typeof objectSchema.maxItems === 'number' && actual.length > objectSchema.maxItems)
427
+ return false;
428
+ if (objectSchema.items !== undefined && !actual.every((item) => matchesSchema(item, objectSchema.items)))
429
+ return false;
430
+ }
431
+ if (actual !== null && typeof actual === 'object' && !Array.isArray(actual)) {
432
+ const record = actual;
433
+ if (Array.isArray(objectSchema.required) &&
434
+ !objectSchema.required.every((key) => typeof key === 'string' && Object.hasOwn(record, key)))
435
+ return false;
436
+ const properties = objectSchema.properties;
437
+ if (properties !== undefined) {
438
+ const propertySchemas = properties;
439
+ for (const [key, childSchema] of Object.entries(propertySchemas)) {
440
+ if (Object.hasOwn(record, key) && !matchesSchema(record[key], childSchema))
441
+ return false;
442
+ }
443
+ }
444
+ if (objectSchema.additionalProperties === false) {
445
+ const propertySchemas = (properties ?? {});
446
+ if (Object.keys(record).some((key) => !Object.hasOwn(propertySchemas, key)))
447
+ return false;
448
+ }
449
+ }
450
+ return true;
451
+ }
452
+ export function evaluateAssertion(assertion, outputs, testCase) {
453
+ const outputRoot = outputs;
454
+ const actual = getPath(outputRoot, assertion.outputPath);
455
+ const actualFound = actual !== undefined;
456
+ const expected = expectedValue(assertion, testCase);
457
+ validateEvaluationAssertionConfiguration(assertion, expected);
458
+ let passed = false;
459
+ switch (assertion.operator) {
460
+ case 'equals':
461
+ passed = actual !== undefined && canonicalStringify(actual) === canonicalStringify(expected);
462
+ break;
463
+ case 'not-equals':
464
+ // A configured path must resolve before any comparison can pass. Treating
465
+ // a missing value as "not equal" would let a typo in an output path turn
466
+ // into a false quality pass.
467
+ passed = actual !== undefined && canonicalStringify(actual) !== canonicalStringify(expected);
468
+ break;
469
+ case 'contains':
470
+ {
471
+ const expectedText = expected;
472
+ passed = typeof actual === 'string' && actual.includes(expectedText);
473
+ }
474
+ break;
475
+ case 'matches-regex':
476
+ // Invalid author-supplied expressions are evaluator configuration
477
+ // errors. Let the runner report that quality could not be evaluated instead
478
+ // of reporting a normal failed quality assertion.
479
+ {
480
+ const pattern = new RegExp(expected);
481
+ passed = typeof actual === 'string' && pattern.test(actual);
482
+ }
483
+ break;
484
+ case 'type-is':
485
+ passed = typeMatches(actual, expected);
486
+ break;
487
+ case 'json-schema':
488
+ passed = matchesSchema(actual, expected);
489
+ break;
490
+ case 'number-at-least':
491
+ {
492
+ const minimum = expected;
493
+ passed = typeof actual === 'number' && actual >= minimum;
494
+ }
495
+ break;
496
+ case 'number-at-most':
497
+ {
498
+ const maximum = expected;
499
+ passed = typeof actual === 'number' && actual <= maximum;
500
+ }
501
+ break;
502
+ case 'number-between':
503
+ {
504
+ const [minimum, maximum] = expected;
505
+ passed = typeof actual === 'number' && actual >= minimum && actual <= maximum;
506
+ }
507
+ break;
508
+ case 'array-includes':
509
+ passed =
510
+ Array.isArray(actual) && actual.some((item) => canonicalStringify(item) === canonicalStringify(expected));
511
+ break;
512
+ case 'set-overlaps':
513
+ {
514
+ const candidates = expected;
515
+ passed =
516
+ Array.isArray(actual) &&
517
+ candidates.some((candidate) => actual.some((item) => canonicalStringify(item) === canonicalStringify(candidate)));
518
+ }
519
+ break;
520
+ case 'contains-any':
521
+ {
522
+ const expectedTexts = expected;
523
+ passed = typeof actual === 'string' && expectedTexts.some((candidate) => actual.includes(candidate));
524
+ }
525
+ break;
526
+ case 'contains-all':
527
+ {
528
+ const expectedTexts = expected;
529
+ passed = typeof actual === 'string' && expectedTexts.every((candidate) => actual.includes(candidate));
530
+ }
531
+ break;
532
+ }
533
+ const requirement = operatorDescription(assertion.operator);
534
+ return {
535
+ id: assertion.id,
536
+ kind: 'assertion',
537
+ name: assertion.name,
538
+ required: assertion.required !== false,
539
+ status: passed ? 'passed' : 'failed',
540
+ message: passed
541
+ ? `Target output ${requirement}.`
542
+ : actualFound
543
+ ? `Target output does not satisfy the requirement: ${requirement}.`
544
+ : `Target output path "${assertion.outputPath}" does not exist.`,
545
+ evidence: {
546
+ actual: actual ?? null,
547
+ actualFound,
548
+ expected,
549
+ outputPath: assertion.outputPath,
550
+ operator: assertion.operator,
551
+ expectedSource: assertion.expected.kind,
552
+ ...(assertion.expected.kind === 'dataset-field' ? { expectedFieldId: assertion.expected.fieldId } : {}),
553
+ },
554
+ };
555
+ }
@@ -0,0 +1,109 @@
1
+ export function assertPortableJson(value, path = '$', stack = new Set()) {
2
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
3
+ return;
4
+ if (typeof value === 'number') {
5
+ if (!Number.isFinite(value))
6
+ throw new Error(`${path} must not contain a non-finite number.`);
7
+ return;
8
+ }
9
+ if (typeof value !== 'object')
10
+ throw new Error(`${path} must be portable JSON.`);
11
+ if (stack.has(value))
12
+ throw new Error(`${path} must not contain a cycle.`);
13
+ if (Array.isArray(value)) {
14
+ if (Object.getOwnPropertySymbols(value).length > 0) {
15
+ throw new Error(`${path} must not contain symbol-keyed array properties.`);
16
+ }
17
+ stack.add(value);
18
+ try {
19
+ for (let index = 0; index < value.length; index += 1) {
20
+ const descriptor = Object.getOwnPropertyDescriptor(value, index);
21
+ if (!descriptor)
22
+ throw new Error(`${path}[${index}] must not be a sparse array entry.`);
23
+ if (!descriptor.enumerable || descriptor.get || descriptor.set) {
24
+ throw new Error(`${path}[${index}] must be an enumerable data property.`);
25
+ }
26
+ assertPortableJson(descriptor.value, `${path}[${index}]`, stack);
27
+ }
28
+ if (Object.getOwnPropertyNames(value).length !== value.length + 1) {
29
+ throw new Error(`${path} must not contain hidden or extra array properties.`);
30
+ }
31
+ }
32
+ finally {
33
+ stack.delete(value);
34
+ }
35
+ return;
36
+ }
37
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
38
+ throw new Error(`${path} must be a plain object.`);
39
+ }
40
+ if (Object.getOwnPropertySymbols(value).length > 0) {
41
+ throw new Error(`${path} must not contain symbol-keyed object properties.`);
42
+ }
43
+ const ownPropertyNames = Object.getOwnPropertyNames(value);
44
+ const keys = Object.keys(value);
45
+ if (ownPropertyNames.length !== keys.length) {
46
+ throw new Error(`${path} must not contain non-enumerable object properties.`);
47
+ }
48
+ stack.add(value);
49
+ try {
50
+ for (const key of keys) {
51
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
52
+ if (!descriptor || descriptor.get || descriptor.set) {
53
+ throw new Error(`${path}.${key} must be an enumerable data property.`);
54
+ }
55
+ assertPortableJson(descriptor.value, `${path}.${key}`, stack);
56
+ }
57
+ }
58
+ finally {
59
+ stack.delete(value);
60
+ }
61
+ }
62
+ export function canonicalizePortableJson(value) {
63
+ assertPortableJson(value);
64
+ if (value === null || typeof value !== 'object')
65
+ return value;
66
+ if (Array.isArray(value))
67
+ return value.map(canonicalizePortableJson);
68
+ return Object.fromEntries(Object.keys(value)
69
+ .sort()
70
+ .map((key) => [key, canonicalizePortableJson(value[key])]));
71
+ }
72
+ /**
73
+ * Evaluation inputs and observations cross concurrent graph-run boundaries.
74
+ * Clone them before handing them to an adapter so a Code or evaluator graph
75
+ * cannot mutate a dataset case, another trial, or the persisted run model.
76
+ */
77
+ export function clonePortableJson(value) {
78
+ assertPortableJson(value);
79
+ return structuredClone(value);
80
+ }
81
+ export function canonicalStringify(value) {
82
+ return JSON.stringify(canonicalizePortableJson(value));
83
+ }
84
+ /** Stable, synchronously available content fingerprint for project-local identities. */
85
+ export function fingerprint(value) {
86
+ const source = canonicalStringify(value);
87
+ let hash = 0xcbf29ce484222325n;
88
+ for (let index = 0; index < source.length; index += 1) {
89
+ hash ^= BigInt(source.charCodeAt(index));
90
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n);
91
+ }
92
+ return `fnv1a64:${hash.toString(16).padStart(16, '0')}`;
93
+ }
94
+ export function fingerprintEvaluationDataset(value) {
95
+ return fingerprint({ datasetId: value.id, fields: value.fields, cases: value.cases });
96
+ }
97
+ /**
98
+ * A dataset fingerprint is the link between a compact run summary and its
99
+ * historical input cases. Stores must reject a mismatched key rather than
100
+ * accepting a snapshot that can no longer prove which cases were run.
101
+ */
102
+ export function assertEvaluationDatasetSnapshot(snapshot) {
103
+ if (snapshot.dataset.projectId !== undefined && snapshot.dataset.projectId !== snapshot.projectId) {
104
+ throw new Error('An evaluation dataset snapshot must belong to its project.');
105
+ }
106
+ if (fingerprintEvaluationDataset(snapshot.dataset) !== snapshot.fingerprint) {
107
+ throw new Error('An evaluation dataset snapshot fingerprint must match its dataset ID, fields, and cases.');
108
+ }
109
+ }