@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.
- package/LICENSE +7 -0
- package/README.md +27 -0
- package/dist/cjs/bundle.cjs +3160 -0
- package/dist/cjs/bundle.cjs.map +7 -0
- package/dist/esm/assertions.js +555 -0
- package/dist/esm/canonical.js +109 -0
- package/dist/esm/dataTypes.js +103 -0
- package/dist/esm/datasetTransfer.js +146 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/normalization.js +369 -0
- package/dist/esm/runner.js +1767 -0
- package/dist/esm/serialization.js +277 -0
- package/dist/esm/store.js +88 -0
- package/dist/esm/suiteTransfer.js +63 -0
- package/dist/esm/types.js +16 -0
- package/dist/types/assertions.d.ts +17 -0
- package/dist/types/canonical.d.ts +23 -0
- package/dist/types/dataTypes.d.ts +19 -0
- package/dist/types/datasetTransfer.d.ts +26 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/normalization.d.ts +11 -0
- package/dist/types/runner.d.ts +66 -0
- package/dist/types/serialization.d.ts +6 -0
- package/dist/types/store.d.ts +37 -0
- package/dist/types/suiteTransfer.d.ts +22 -0
- package/dist/types/types.d.ts +429 -0
- package/package.json +42 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { assertPortableJson } from './canonical.js';
|
|
2
|
+
export const portableEvaluationDataTypes = [
|
|
3
|
+
'any',
|
|
4
|
+
'any[]',
|
|
5
|
+
'string',
|
|
6
|
+
'string[]',
|
|
7
|
+
'date',
|
|
8
|
+
'date[]',
|
|
9
|
+
'time',
|
|
10
|
+
'time[]',
|
|
11
|
+
'datetime',
|
|
12
|
+
'datetime[]',
|
|
13
|
+
'number',
|
|
14
|
+
'number[]',
|
|
15
|
+
'vector',
|
|
16
|
+
'vector[]',
|
|
17
|
+
'boolean',
|
|
18
|
+
'boolean[]',
|
|
19
|
+
'object',
|
|
20
|
+
'object[]',
|
|
21
|
+
];
|
|
22
|
+
const portableEvaluationDataTypeSet = new Set(portableEvaluationDataTypes);
|
|
23
|
+
export function isPortableEvaluationDataType(dataType) {
|
|
24
|
+
return portableEvaluationDataTypeSet.has(dataType);
|
|
25
|
+
}
|
|
26
|
+
function isObject(value) {
|
|
27
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
function isFiniteNumber(value) {
|
|
30
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
31
|
+
}
|
|
32
|
+
function isArrayOf(value, predicate) {
|
|
33
|
+
return Array.isArray(value) && value.every(predicate);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Checks a portable dataset value against the Rivet data type selected for an
|
|
37
|
+
* evaluation field. This is shared by authoring UI and execution validation so
|
|
38
|
+
* a value cannot look valid in the editor and then fail under a different rule
|
|
39
|
+
* in the runner.
|
|
40
|
+
*/
|
|
41
|
+
export function isEvaluationValueCompatibleWithDataType(value, dataType) {
|
|
42
|
+
try {
|
|
43
|
+
assertPortableJson(value);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (dataType === 'any')
|
|
49
|
+
return true;
|
|
50
|
+
if (dataType === 'any[]')
|
|
51
|
+
return Array.isArray(value);
|
|
52
|
+
if (dataType === 'string' || dataType === 'date' || dataType === 'time' || dataType === 'datetime') {
|
|
53
|
+
return typeof value === 'string';
|
|
54
|
+
}
|
|
55
|
+
if (dataType === 'string[]' || dataType === 'date[]' || dataType === 'time[]' || dataType === 'datetime[]') {
|
|
56
|
+
return isArrayOf(value, (item) => typeof item === 'string');
|
|
57
|
+
}
|
|
58
|
+
if (dataType === 'number')
|
|
59
|
+
return isFiniteNumber(value);
|
|
60
|
+
if (dataType === 'number[]' || dataType === 'vector')
|
|
61
|
+
return isArrayOf(value, isFiniteNumber);
|
|
62
|
+
if (dataType === 'vector[]') {
|
|
63
|
+
return isArrayOf(value, (item) => isArrayOf(item, isFiniteNumber));
|
|
64
|
+
}
|
|
65
|
+
if (dataType === 'boolean')
|
|
66
|
+
return typeof value === 'boolean';
|
|
67
|
+
if (dataType === 'boolean[]')
|
|
68
|
+
return isArrayOf(value, (item) => typeof item === 'boolean');
|
|
69
|
+
if (dataType === 'object')
|
|
70
|
+
return isObject(value);
|
|
71
|
+
if (dataType === 'object[]')
|
|
72
|
+
return isArrayOf(value, isObject);
|
|
73
|
+
// Functions, binary/media values, and provider-specific structured types
|
|
74
|
+
// do not have a safe portable dataset representation here. Unknown future
|
|
75
|
+
// types must also fail closed until their exact JSON contract is defined.
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
/** Shared declared-type check for graph/dataset binding authoring and execution validation. */
|
|
79
|
+
export function areEvaluationDataTypesCompatible(sourceDataType, targetDataType) {
|
|
80
|
+
return sourceDataType === 'any' || targetDataType === 'any' || sourceDataType === targetDataType;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Enforces the declared portable JSON contract at explicit transfer
|
|
84
|
+
* boundaries. Durable local drafts remain recoverable even if an older build
|
|
85
|
+
* wrote an unsupported type; imports and exports fail with the precise field
|
|
86
|
+
* and case that needs repair instead of deferring the error until execution.
|
|
87
|
+
*/
|
|
88
|
+
export function assertEvaluationDatasetValuesMatchDeclaredTypes(dataset) {
|
|
89
|
+
const fieldsById = new Map(dataset.fields.map((field) => [field.id, field]));
|
|
90
|
+
for (const [fieldIndex, field] of dataset.fields.entries()) {
|
|
91
|
+
if (!isPortableEvaluationDataType(field.dataType)) {
|
|
92
|
+
throw new Error(`fields[${fieldIndex}].dataType "${field.dataType}" has no portable evaluation JSON representation.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const [caseIndex, testCase] of dataset.cases.entries()) {
|
|
96
|
+
for (const [fieldId, value] of Object.entries(testCase.values)) {
|
|
97
|
+
const field = fieldsById.get(fieldId);
|
|
98
|
+
if (field && !isEvaluationValueCompatibleWithDataType(value, field.dataType)) {
|
|
99
|
+
throw new Error(`cases[${caseIndex}].values.${fieldId} is not compatible with declared type "${field.dataType}".`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { assertPortableJson, fingerprintEvaluationDataset } from './canonical.js';
|
|
2
|
+
import { assertEvaluationDatasetValuesMatchDeclaredTypes } from './dataTypes.js';
|
|
3
|
+
const DATASET_EXPORT_VERSION = 1;
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function requireString(value, path) {
|
|
8
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
9
|
+
throw new Error(`${path} must be a non-empty string.`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function requireText(value, path) {
|
|
13
|
+
if (typeof value !== 'string')
|
|
14
|
+
throw new Error(`${path} must be a string.`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function parseFields(value) {
|
|
18
|
+
if (!Array.isArray(value))
|
|
19
|
+
throw new Error('Evaluation dataset fields must be an array.');
|
|
20
|
+
const ids = new Set();
|
|
21
|
+
return value.map((field, index) => {
|
|
22
|
+
if (!isRecord(field))
|
|
23
|
+
throw new Error(`fields[${index}] must be an object.`);
|
|
24
|
+
const id = requireString(field.id, `fields[${index}].id`);
|
|
25
|
+
if (ids.has(id))
|
|
26
|
+
throw new Error(`Evaluation dataset has duplicate field id "${id}".`);
|
|
27
|
+
ids.add(id);
|
|
28
|
+
const role = field.role;
|
|
29
|
+
if (role !== 'input' && role !== 'expected' && role !== 'metadata') {
|
|
30
|
+
throw new Error(`fields[${index}].role must be input, expected, or metadata.`);
|
|
31
|
+
}
|
|
32
|
+
if (field.description !== undefined && typeof field.description !== 'string') {
|
|
33
|
+
throw new Error(`fields[${index}].description must be a string.`);
|
|
34
|
+
}
|
|
35
|
+
if (field.required !== undefined && typeof field.required !== 'boolean') {
|
|
36
|
+
throw new Error(`fields[${index}].required must be a boolean.`);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
id,
|
|
40
|
+
name: requireText(field.name, `fields[${index}].name`),
|
|
41
|
+
dataType: requireString(field.dataType, `fields[${index}].dataType`),
|
|
42
|
+
role,
|
|
43
|
+
...(typeof field.description === 'string' ? { description: field.description } : {}),
|
|
44
|
+
...(typeof field.required === 'boolean' ? { required: field.required } : {}),
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function parseCases(value, fieldIds) {
|
|
49
|
+
if (!Array.isArray(value))
|
|
50
|
+
throw new Error('Evaluation dataset cases must be an array.');
|
|
51
|
+
const ids = new Set();
|
|
52
|
+
return value.map((testCase, index) => {
|
|
53
|
+
if (!isRecord(testCase))
|
|
54
|
+
throw new Error(`cases[${index}] must be an object.`);
|
|
55
|
+
const id = requireString(testCase.id, `cases[${index}].id`);
|
|
56
|
+
if (ids.has(id))
|
|
57
|
+
throw new Error(`Evaluation dataset has duplicate case id "${id}".`);
|
|
58
|
+
ids.add(id);
|
|
59
|
+
if (!isRecord(testCase.values))
|
|
60
|
+
throw new Error(`cases[${index}].values must be an object.`);
|
|
61
|
+
for (const [fieldId, fieldValue] of Object.entries(testCase.values)) {
|
|
62
|
+
if (!fieldIds.has(fieldId))
|
|
63
|
+
throw new Error(`cases[${index}].values references unknown field "${fieldId}".`);
|
|
64
|
+
assertPortableJson(fieldValue, `cases[${index}].values.${fieldId}`);
|
|
65
|
+
}
|
|
66
|
+
if (testCase.tags !== undefined && (!Array.isArray(testCase.tags) || testCase.tags.some((tag) => typeof tag !== 'string'))) {
|
|
67
|
+
throw new Error(`cases[${index}].tags must be an array of strings.`);
|
|
68
|
+
}
|
|
69
|
+
if (testCase.note !== undefined && typeof testCase.note !== 'string')
|
|
70
|
+
throw new Error(`cases[${index}].note must be a string.`);
|
|
71
|
+
if (testCase.enabled !== undefined && typeof testCase.enabled !== 'boolean')
|
|
72
|
+
throw new Error(`cases[${index}].enabled must be a boolean.`);
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
name: requireText(testCase.name, `cases[${index}].name`),
|
|
76
|
+
values: testCase.values,
|
|
77
|
+
...(typeof testCase.enabled === 'boolean' ? { enabled: testCase.enabled } : {}),
|
|
78
|
+
...(Array.isArray(testCase.tags) ? { tags: testCase.tags } : {}),
|
|
79
|
+
...(typeof testCase.note === 'string' ? { note: testCase.note } : {}),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Validates durable evaluation datasets and portable imports. `projectId` is
|
|
85
|
+
* accepted solely for backwards-compatible migration from project-owned
|
|
86
|
+
* datasets; newly created local datasets omit it.
|
|
87
|
+
*/
|
|
88
|
+
export function validateEvaluationDataset(value, scope) {
|
|
89
|
+
if (!isRecord(value))
|
|
90
|
+
throw new Error('Evaluation dataset must be an object.');
|
|
91
|
+
const fields = parseFields(value.fields);
|
|
92
|
+
const importedProjectId = value.projectId === undefined ? undefined : requireString(value.projectId, 'dataset.projectId');
|
|
93
|
+
const projectId = scope?.projectId ?? importedProjectId;
|
|
94
|
+
const dataset = {
|
|
95
|
+
id: scope?.id ?? requireString(value.id, 'dataset.id'),
|
|
96
|
+
name: requireText(value.name, 'dataset.name'),
|
|
97
|
+
...(projectId === undefined ? {} : { projectId }),
|
|
98
|
+
fields,
|
|
99
|
+
cases: parseCases(value.cases, new Set(fields.map((field) => field.id))),
|
|
100
|
+
};
|
|
101
|
+
return { ...dataset, contentFingerprint: fingerprintEvaluationDataset(dataset) };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Converts a legacy project-owned dataset into the application-local form.
|
|
105
|
+
* Field and case identity stay stable, so existing suite bindings continue to
|
|
106
|
+
* work after migration.
|
|
107
|
+
*/
|
|
108
|
+
export function localizeEvaluationDataset(value, id) {
|
|
109
|
+
const dataset = validateEvaluationDataset(value, id === undefined ? undefined : { id });
|
|
110
|
+
const { projectId: _legacyProjectId, ...localDataset } = dataset;
|
|
111
|
+
return { ...localDataset, contentFingerprint: fingerprintEvaluationDataset(localDataset) };
|
|
112
|
+
}
|
|
113
|
+
/** Strict validation for explicit portable import and export boundaries. */
|
|
114
|
+
export function validateEvaluationDatasetForTransfer(value, scope) {
|
|
115
|
+
const dataset = validateEvaluationDataset(value, scope);
|
|
116
|
+
assertEvaluationDatasetValuesMatchDeclaredTypes(dataset);
|
|
117
|
+
return dataset;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Portable interchange envelope for a complete evaluation dataset. CSV is
|
|
121
|
+
* useful for case rows; JSON is the lossless form that also carries field
|
|
122
|
+
* roles and Rivet data types.
|
|
123
|
+
*/
|
|
124
|
+
export function serializeEvaluationDatasetJson(dataset) {
|
|
125
|
+
const validated = validateEvaluationDatasetForTransfer(dataset);
|
|
126
|
+
return JSON.stringify({ version: DATASET_EXPORT_VERSION, dataset: validated }, null, 2);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Reads a dataset export into a destination dataset identity. Import keeps
|
|
130
|
+
* incoming field/case IDs but never lets a file overwrite another resource.
|
|
131
|
+
*/
|
|
132
|
+
export function deserializeEvaluationDatasetJson(source, scope) {
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(source);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new Error('Evaluation dataset JSON is not valid JSON.');
|
|
139
|
+
}
|
|
140
|
+
if (!isRecord(parsed) || parsed.version !== DATASET_EXPORT_VERSION || !isRecord(parsed.dataset)) {
|
|
141
|
+
throw new Error(`Expected an evaluation dataset export with version ${DATASET_EXPORT_VERSION}.`);
|
|
142
|
+
}
|
|
143
|
+
const dataset = validateEvaluationDatasetForTransfer(parsed.dataset, { id: scope.id });
|
|
144
|
+
const { projectId: _legacyProjectId, ...localDataset } = dataset;
|
|
145
|
+
return { ...localDataset, contentFingerprint: fingerprintEvaluationDataset(localDataset) };
|
|
146
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './canonical.js';
|
|
3
|
+
export * from './assertions.js';
|
|
4
|
+
export * from './runner.js';
|
|
5
|
+
export * from './store.js';
|
|
6
|
+
export * from './serialization.js';
|
|
7
|
+
export * from './datasetTransfer.js';
|
|
8
|
+
export * from './suiteTransfer.js';
|
|
9
|
+
export * from './dataTypes.js';
|
|
10
|
+
export * from './normalization.js';
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function isQualityStatus(value) {
|
|
5
|
+
return ['passed', 'failed', 'scored', 'not-evaluated', 'unable-to-evaluate'].includes(String(value));
|
|
6
|
+
}
|
|
7
|
+
function isEvaluationMode(value) {
|
|
8
|
+
return value === 'pass-fail' || value === 'scoring';
|
|
9
|
+
}
|
|
10
|
+
function isQualityStatusForMode(status, evaluationMode) {
|
|
11
|
+
return evaluationMode === 'scoring' ? status !== 'passed' && status !== 'failed' : status !== 'scored';
|
|
12
|
+
}
|
|
13
|
+
function isExecutionStatus(value) {
|
|
14
|
+
return ['completed', 'error', 'canceled'].includes(String(value));
|
|
15
|
+
}
|
|
16
|
+
function isRunExecutionStatus(value) {
|
|
17
|
+
return ['queued', 'running', 'completed', 'canceled', 'error'].includes(String(value));
|
|
18
|
+
}
|
|
19
|
+
function isTerminalRunExecutionStatus(status) {
|
|
20
|
+
return status === 'completed' || status === 'canceled' || status === 'error';
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Orders snapshots for one run without allowing an equal-revision progress
|
|
24
|
+
* update to demote a terminal result. Writers and delayed UI reads share this
|
|
25
|
+
* rule so persistence cannot disagree with what the workspace presents.
|
|
26
|
+
*/
|
|
27
|
+
export function shouldReplaceEvaluationRun(existing, incoming) {
|
|
28
|
+
if (!existing)
|
|
29
|
+
return true;
|
|
30
|
+
const existingRevision = existing.revision ?? 0;
|
|
31
|
+
const incomingRevision = incoming.revision ?? 0;
|
|
32
|
+
if (incomingRevision !== existingRevision)
|
|
33
|
+
return incomingRevision > existingRevision;
|
|
34
|
+
return !isTerminalRunExecutionStatus(existing.executionStatus) || isTerminalRunExecutionStatus(incoming.executionStatus);
|
|
35
|
+
}
|
|
36
|
+
function isPurpose(value) {
|
|
37
|
+
return value === 'evaluation' || value === 'execution-benchmark';
|
|
38
|
+
}
|
|
39
|
+
function isAccountingStatus(value) {
|
|
40
|
+
return value === 'complete' || value === 'partial';
|
|
41
|
+
}
|
|
42
|
+
/** Run records keep scores normalized even though evaluator graphs use 0..100. */
|
|
43
|
+
function isNormalizedScore(value) {
|
|
44
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
45
|
+
}
|
|
46
|
+
function reasonForQualityStatus(status, executionStatus) {
|
|
47
|
+
if (executionStatus === 'canceled')
|
|
48
|
+
return { code: 'canceled', message: 'The execution was canceled.' };
|
|
49
|
+
if (executionStatus === 'error' && status === 'failed') {
|
|
50
|
+
return { code: 'target-error', message: 'The target graph failed before it could produce a valid result.' };
|
|
51
|
+
}
|
|
52
|
+
switch (status) {
|
|
53
|
+
case 'passed':
|
|
54
|
+
return { code: 'checks-passed', message: 'All required quality criteria passed.' };
|
|
55
|
+
case 'failed':
|
|
56
|
+
return { code: 'checks-failed', message: 'One or more required quality criteria failed.' };
|
|
57
|
+
case 'scored':
|
|
58
|
+
return { code: 'scores-complete', message: 'Every requested trial produced a score.' };
|
|
59
|
+
case 'not-evaluated':
|
|
60
|
+
return { code: 'no-trial-quality-checks', message: 'No per-trial quality check evaluated this result.' };
|
|
61
|
+
case 'unable-to-evaluate':
|
|
62
|
+
return { code: 'required-check-error', message: 'A required quality criterion could not be evaluated.' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function reasonForQualityStatusInMode(status, evaluationMode, executionStatus) {
|
|
66
|
+
if (evaluationMode === 'scoring' && status === 'unable-to-evaluate') {
|
|
67
|
+
return { code: 'scores-incomplete', message: 'One or more requested trials did not produce a usable score.' };
|
|
68
|
+
}
|
|
69
|
+
return reasonForQualityStatus(status, executionStatus);
|
|
70
|
+
}
|
|
71
|
+
function isTargetExecutionError(value, observations) {
|
|
72
|
+
if (typeof value.error === 'string' && value.error.length > 0)
|
|
73
|
+
return true;
|
|
74
|
+
return observations.some((observation) => isRecord(observation) &&
|
|
75
|
+
observation.id === 'target-error' &&
|
|
76
|
+
observation.kind === 'assertion' &&
|
|
77
|
+
observation.status === 'error');
|
|
78
|
+
}
|
|
79
|
+
function legacyQualityFromObservations(observations, targetExecutionError) {
|
|
80
|
+
if (targetExecutionError)
|
|
81
|
+
return 'failed';
|
|
82
|
+
const required = observations.filter((observation) => isRecord(observation) && observation.required !== false);
|
|
83
|
+
if (required.some((observation) => observation.status === 'failed'))
|
|
84
|
+
return 'failed';
|
|
85
|
+
if (required.some((observation) => observation.status === 'error'))
|
|
86
|
+
return 'unable-to-evaluate';
|
|
87
|
+
if (required.some((observation) => observation.status === 'passed'))
|
|
88
|
+
return 'passed';
|
|
89
|
+
return 'not-evaluated';
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Scoring runs are new enough that they do not need to preserve a legacy
|
|
93
|
+
* pass/fail quality label. Do still recover a useful status when a partial or
|
|
94
|
+
* hand-edited scoring record lacks its derived status, but never let an old
|
|
95
|
+
* `passed`/`failed` value leak into the scoring UI.
|
|
96
|
+
*/
|
|
97
|
+
function scoringQualityFromObservations(observations) {
|
|
98
|
+
const evaluatorObservations = observations.filter((observation) => isRecord(observation) && observation.kind === 'graph');
|
|
99
|
+
const hasCompleteScore = evaluatorObservations.length > 0 &&
|
|
100
|
+
evaluatorObservations.every((observation) => observation.status === 'scored' &&
|
|
101
|
+
typeof observation.score === 'number' &&
|
|
102
|
+
Number.isFinite(observation.score) &&
|
|
103
|
+
observation.score >= 0 &&
|
|
104
|
+
observation.score <= 1);
|
|
105
|
+
return hasCompleteScore ? 'scored' : 'unable-to-evaluate';
|
|
106
|
+
}
|
|
107
|
+
function isQualityReasonCompatible(reason, status, purpose, executionStatus) {
|
|
108
|
+
if (purpose === 'execution-benchmark') {
|
|
109
|
+
return executionStatus === 'canceled' ? reason.code === 'canceled' : reason.code === 'benchmark';
|
|
110
|
+
}
|
|
111
|
+
if (executionStatus === 'queued' || executionStatus === 'running')
|
|
112
|
+
return reason.code === 'in-progress';
|
|
113
|
+
if (executionStatus === 'canceled')
|
|
114
|
+
return reason.code === 'canceled';
|
|
115
|
+
switch (status) {
|
|
116
|
+
case 'passed':
|
|
117
|
+
return reason.code === 'checks-passed' || reason.code === 'thresholds-passed';
|
|
118
|
+
case 'failed':
|
|
119
|
+
return reason.code === 'checks-failed' || reason.code === 'thresholds-failed' || reason.code === 'target-error';
|
|
120
|
+
case 'scored':
|
|
121
|
+
return reason.code === 'scores-complete';
|
|
122
|
+
case 'not-evaluated':
|
|
123
|
+
return reason.code === 'no-trial-quality-checks' || reason.code === 'no-completed-trials';
|
|
124
|
+
case 'unable-to-evaluate':
|
|
125
|
+
return (reason.code === 'required-check-error' ||
|
|
126
|
+
reason.code === 'required-metric-unavailable' ||
|
|
127
|
+
reason.code === 'scores-incomplete');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function normalizeQualityReason(value, fallback, status, purpose, executionStatus) {
|
|
131
|
+
if (!isRecord(value) || typeof value.code !== 'string' || typeof value.message !== 'string')
|
|
132
|
+
return fallback;
|
|
133
|
+
const reason = value;
|
|
134
|
+
return isQualityReasonCompatible(reason, status, purpose, executionStatus) ? reason : fallback;
|
|
135
|
+
}
|
|
136
|
+
export function normalizeEvaluationTrial(value, purpose = 'evaluation', evaluationMode = 'pass-fail') {
|
|
137
|
+
if (!isRecord(value))
|
|
138
|
+
throw new Error('Evaluation trial must be an object.');
|
|
139
|
+
const legacyStatus = value.status;
|
|
140
|
+
const observations = Array.isArray(value.observations) ? value.observations : [];
|
|
141
|
+
const targetExecutionError = isTargetExecutionError(value, observations);
|
|
142
|
+
const executionStatus = isExecutionStatus(value.executionStatus)
|
|
143
|
+
? value.executionStatus
|
|
144
|
+
: legacyStatus === 'canceled'
|
|
145
|
+
? 'canceled'
|
|
146
|
+
: targetExecutionError
|
|
147
|
+
? 'error'
|
|
148
|
+
: legacyStatus === 'error' &&
|
|
149
|
+
!observations.some((observation) => isRecord(observation) && observation.required !== false && observation.status === 'error')
|
|
150
|
+
? 'error'
|
|
151
|
+
: 'completed';
|
|
152
|
+
const qualityStatus = purpose === 'execution-benchmark' || executionStatus === 'canceled'
|
|
153
|
+
? 'not-evaluated'
|
|
154
|
+
: executionStatus === 'error'
|
|
155
|
+
? evaluationMode === 'scoring'
|
|
156
|
+
? 'unable-to-evaluate'
|
|
157
|
+
: 'failed'
|
|
158
|
+
: evaluationMode === 'scoring'
|
|
159
|
+
? scoringQualityFromObservations(observations)
|
|
160
|
+
: isQualityStatus(value.qualityStatus) && isQualityStatusForMode(value.qualityStatus, evaluationMode)
|
|
161
|
+
? value.qualityStatus
|
|
162
|
+
: legacyQualityFromObservations(observations, targetExecutionError);
|
|
163
|
+
const normalized = structuredClone(value);
|
|
164
|
+
// Legacy runs stored a single status that conflated execution and quality.
|
|
165
|
+
// Read it above, then remove it so normalized v2 values cannot contradict
|
|
166
|
+
// their authoritative executionStatus/qualityStatus pair.
|
|
167
|
+
delete normalized.status;
|
|
168
|
+
return {
|
|
169
|
+
...normalized,
|
|
170
|
+
executionStatus,
|
|
171
|
+
qualityStatus,
|
|
172
|
+
qualityReason: normalizeQualityReason(value.qualityReason, executionStatus === 'canceled'
|
|
173
|
+
? { code: 'canceled', message: 'The execution was canceled.' }
|
|
174
|
+
: purpose === 'execution-benchmark'
|
|
175
|
+
? { code: 'benchmark', message: 'This run measured execution without evaluating output quality.' }
|
|
176
|
+
: reasonForQualityStatusInMode(qualityStatus, evaluationMode, executionStatus), qualityStatus, purpose, executionStatus),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export function normalizeEvaluationAggregate(value, trials) {
|
|
180
|
+
if (!isRecord(value))
|
|
181
|
+
throw new Error('Evaluation aggregate must be an object.');
|
|
182
|
+
const normalized = structuredClone(value);
|
|
183
|
+
// Never let a graph-facing 0..100 value (or corrupt local data) masquerade
|
|
184
|
+
// as the persisted normalized score and render as an impossible 9900/100.
|
|
185
|
+
if (!isNormalizedScore(normalized.meanScore))
|
|
186
|
+
delete normalized.meanScore;
|
|
187
|
+
if (trials) {
|
|
188
|
+
const completed = trials.filter((trial) => trial.executionStatus === 'completed');
|
|
189
|
+
const evaluated = completed.filter((trial) => trial.qualityStatus === 'passed' || trial.qualityStatus === 'failed');
|
|
190
|
+
const passed = evaluated.filter((trial) => trial.qualityStatus === 'passed');
|
|
191
|
+
const failed = evaluated.filter((trial) => trial.qualityStatus === 'failed');
|
|
192
|
+
const notEvaluated = completed.filter((trial) => trial.qualityStatus === 'not-evaluated');
|
|
193
|
+
const unable = completed.filter((trial) => trial.qualityStatus === 'unable-to-evaluate');
|
|
194
|
+
const scored = completed.filter((trial) => trial.qualityStatus === 'scored');
|
|
195
|
+
return {
|
|
196
|
+
...normalized,
|
|
197
|
+
trialCount: trials.length,
|
|
198
|
+
evaluatedTrialCount: evaluated.length,
|
|
199
|
+
notEvaluatedTrialCount: notEvaluated.length,
|
|
200
|
+
unableToEvaluateTrialCount: unable.length,
|
|
201
|
+
passedTrialCount: passed.length,
|
|
202
|
+
failedTrialCount: failed.length,
|
|
203
|
+
erroredTrialCount: trials.filter((trial) => trial.executionStatus === 'error').length,
|
|
204
|
+
canceledTrialCount: trials.filter((trial) => trial.executionStatus === 'canceled').length,
|
|
205
|
+
...(scored.length > 0 || normalized.scoredTrialCount !== undefined
|
|
206
|
+
? { scoredTrialCount: scored.length, missingScoreTrialCount: trials.length - scored.length }
|
|
207
|
+
: {}),
|
|
208
|
+
passRate: evaluated.length === 0 ? 0 : passed.length / evaluated.length,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const trialCount = Number(value.trialCount ?? 0);
|
|
212
|
+
const passed = Number(value.passedTrialCount ?? 0);
|
|
213
|
+
const failed = Number(value.failedTrialCount ?? 0);
|
|
214
|
+
const errored = Number(value.erroredTrialCount ?? 0);
|
|
215
|
+
const canceled = Number(value.canceledTrialCount ?? 0);
|
|
216
|
+
const evaluated = Number(value.evaluatedTrialCount ?? passed + failed);
|
|
217
|
+
const unable = Number(value.unableToEvaluateTrialCount ?? 0);
|
|
218
|
+
const notEvaluated = Number(value.notEvaluatedTrialCount ?? Math.max(0, trialCount - evaluated - unable - errored - canceled));
|
|
219
|
+
return {
|
|
220
|
+
...normalized,
|
|
221
|
+
evaluatedTrialCount: evaluated,
|
|
222
|
+
notEvaluatedTrialCount: notEvaluated,
|
|
223
|
+
unableToEvaluateTrialCount: unable,
|
|
224
|
+
passRate: evaluated === 0 ? 0 : passed / evaluated,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function withoutAuthoritativeCost(aggregate) {
|
|
228
|
+
const normalized = { ...aggregate };
|
|
229
|
+
delete normalized.totalCostUsd;
|
|
230
|
+
delete normalized.averageCostUsd;
|
|
231
|
+
return normalized;
|
|
232
|
+
}
|
|
233
|
+
export function normalizeEvaluationBaselineSnapshot(value) {
|
|
234
|
+
if (!isRecord(value))
|
|
235
|
+
throw new Error('Evaluation baseline snapshot must be an object.');
|
|
236
|
+
const accountingStatus = isAccountingStatus(value.accountingStatus)
|
|
237
|
+
? value.accountingStatus
|
|
238
|
+
: isRecord(value.provenance) && value.provenance.accountingComplete === false
|
|
239
|
+
? 'partial'
|
|
240
|
+
: 'complete';
|
|
241
|
+
const aggregate = normalizeEvaluationAggregate(value.aggregate);
|
|
242
|
+
const purpose = isPurpose(value.purpose) ? value.purpose : 'evaluation';
|
|
243
|
+
const evaluationMode = isEvaluationMode(value.evaluationMode) ? value.evaluationMode : 'pass-fail';
|
|
244
|
+
const hasCompleteScoringAggregate = aggregate.trialCount > 0 &&
|
|
245
|
+
aggregate.scoredTrialCount === aggregate.trialCount &&
|
|
246
|
+
aggregate.missingScoreTrialCount === 0 &&
|
|
247
|
+
aggregate.meanScore !== undefined;
|
|
248
|
+
const qualityStatus = purpose === 'execution-benchmark'
|
|
249
|
+
? 'not-evaluated'
|
|
250
|
+
: evaluationMode === 'scoring'
|
|
251
|
+
? hasCompleteScoringAggregate
|
|
252
|
+
? 'scored'
|
|
253
|
+
: 'unable-to-evaluate'
|
|
254
|
+
: isQualityStatus(value.qualityStatus) && isQualityStatusForMode(value.qualityStatus, evaluationMode)
|
|
255
|
+
? value.qualityStatus
|
|
256
|
+
: aggregate.failedTrialCount > 0 || aggregate.erroredTrialCount > 0
|
|
257
|
+
? 'failed'
|
|
258
|
+
: aggregate.evaluatedTrialCount > 0
|
|
259
|
+
? 'passed'
|
|
260
|
+
: 'not-evaluated';
|
|
261
|
+
const qualityReasonFallback = purpose === 'execution-benchmark'
|
|
262
|
+
? { code: 'benchmark', message: 'This run measured execution without evaluating output quality.' }
|
|
263
|
+
: reasonForQualityStatusInMode(qualityStatus, evaluationMode);
|
|
264
|
+
return {
|
|
265
|
+
...structuredClone(value),
|
|
266
|
+
purpose,
|
|
267
|
+
evaluationMode,
|
|
268
|
+
qualityStatus,
|
|
269
|
+
qualityReason: normalizeQualityReason(value.qualityReason, qualityReasonFallback, qualityStatus, purpose),
|
|
270
|
+
accountingStatus,
|
|
271
|
+
aggregate: accountingStatus === 'partial' ? withoutAuthoritativeCost(aggregate) : aggregate,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
export function normalizeEvaluationRun(value) {
|
|
275
|
+
if (!isRecord(value))
|
|
276
|
+
throw new Error('Evaluation run must be an object.');
|
|
277
|
+
const purpose = isPurpose(value.purpose) ? value.purpose : 'evaluation';
|
|
278
|
+
const evaluationMode = isEvaluationMode(value.evaluationMode) ? value.evaluationMode : 'pass-fail';
|
|
279
|
+
const executionStatus = isRunExecutionStatus(value.executionStatus)
|
|
280
|
+
? value.executionStatus
|
|
281
|
+
: typeof value.completedAt === 'string'
|
|
282
|
+
? 'completed'
|
|
283
|
+
: 'running';
|
|
284
|
+
const trials = Array.isArray(value.trials)
|
|
285
|
+
? value.trials.map((trial) => normalizeEvaluationTrial(trial, purpose, evaluationMode))
|
|
286
|
+
: [];
|
|
287
|
+
const accountingStatus = isAccountingStatus(value.accountingStatus)
|
|
288
|
+
? value.accountingStatus
|
|
289
|
+
: isRecord(value.provenance) && value.provenance.accountingComplete === false
|
|
290
|
+
? 'partial'
|
|
291
|
+
: 'complete';
|
|
292
|
+
let qualityStatus;
|
|
293
|
+
if (purpose === 'execution-benchmark' || executionStatus === 'canceled') {
|
|
294
|
+
qualityStatus = 'not-evaluated';
|
|
295
|
+
}
|
|
296
|
+
else if (executionStatus === 'error') {
|
|
297
|
+
qualityStatus = evaluationMode === 'scoring' ? 'unable-to-evaluate' : 'failed';
|
|
298
|
+
}
|
|
299
|
+
else if (executionStatus === 'queued' || executionStatus === 'running') {
|
|
300
|
+
qualityStatus = 'not-evaluated';
|
|
301
|
+
}
|
|
302
|
+
else if (evaluationMode === 'scoring') {
|
|
303
|
+
const scored = trials.filter((trial) => trial.qualityStatus === 'scored').length;
|
|
304
|
+
qualityStatus = scored > 0 && scored === trials.length ? 'scored' : 'unable-to-evaluate';
|
|
305
|
+
}
|
|
306
|
+
else if (isQualityStatus(value.qualityStatus) && isQualityStatusForMode(value.qualityStatus, evaluationMode)) {
|
|
307
|
+
qualityStatus = value.qualityStatus;
|
|
308
|
+
}
|
|
309
|
+
else if (trials.some((trial) => trial.executionStatus === 'error' || trial.qualityStatus === 'failed')) {
|
|
310
|
+
qualityStatus = 'failed';
|
|
311
|
+
}
|
|
312
|
+
else if (trials.some((trial) => trial.qualityStatus === 'unable-to-evaluate')) {
|
|
313
|
+
qualityStatus = 'unable-to-evaluate';
|
|
314
|
+
}
|
|
315
|
+
else if (trials.some((trial) => trial.qualityStatus === 'passed')) {
|
|
316
|
+
qualityStatus = 'passed';
|
|
317
|
+
}
|
|
318
|
+
else if (value.verdict === 'fail') {
|
|
319
|
+
// Old runs could fail an aggregate threshold without having a per-trial
|
|
320
|
+
// quality observation. Preserve that authoritative failure. Only legacy
|
|
321
|
+
// success-without-evidence is reinterpreted as Not evaluated.
|
|
322
|
+
qualityStatus = 'failed';
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
// Legacy runs used `pass` for a graph that merely completed. If no stored
|
|
326
|
+
// required check proves quality, reinterpret it as Not evaluated instead
|
|
327
|
+
// of preserving the misleading pass label.
|
|
328
|
+
qualityStatus = 'not-evaluated';
|
|
329
|
+
}
|
|
330
|
+
const qualityReasonFallback = executionStatus === 'canceled'
|
|
331
|
+
? { code: 'canceled', message: 'The evaluation run was canceled.' }
|
|
332
|
+
: executionStatus === 'queued' || executionStatus === 'running'
|
|
333
|
+
? { code: 'in-progress', message: 'The run is still in progress.' }
|
|
334
|
+
: purpose === 'execution-benchmark'
|
|
335
|
+
? { code: 'benchmark', message: 'This run measured execution without evaluating output quality.' }
|
|
336
|
+
: (executionStatus === 'error' || trials.some((trial) => trial.executionStatus === 'error')) &&
|
|
337
|
+
qualityStatus === 'failed'
|
|
338
|
+
? { code: 'target-error', message: 'One or more target graph executions failed.' }
|
|
339
|
+
: reasonForQualityStatusInMode(qualityStatus, evaluationMode);
|
|
340
|
+
const provenance = isRecord(value.provenance)
|
|
341
|
+
? {
|
|
342
|
+
...structuredClone(value.provenance),
|
|
343
|
+
accountingComplete: accountingStatus === 'complete',
|
|
344
|
+
}
|
|
345
|
+
: value.provenance;
|
|
346
|
+
const normalized = structuredClone(value);
|
|
347
|
+
// As with legacy trial.status, the old verdict is input-only compatibility
|
|
348
|
+
// data. New v2 values expose one unambiguous qualityStatus instead.
|
|
349
|
+
delete normalized.verdict;
|
|
350
|
+
const aggregate = value.aggregate === undefined ? undefined : normalizeEvaluationAggregate(value.aggregate, trials);
|
|
351
|
+
return {
|
|
352
|
+
...normalized,
|
|
353
|
+
version: 2,
|
|
354
|
+
purpose,
|
|
355
|
+
evaluationMode,
|
|
356
|
+
executionStatus,
|
|
357
|
+
qualityStatus,
|
|
358
|
+
qualityReason: normalizeQualityReason(value.qualityReason, qualityReasonFallback, qualityStatus, purpose, executionStatus),
|
|
359
|
+
accountingStatus,
|
|
360
|
+
provenance: provenance,
|
|
361
|
+
trials,
|
|
362
|
+
thresholdResults: Array.isArray(value.thresholdResults)
|
|
363
|
+
? structuredClone(value.thresholdResults)
|
|
364
|
+
: [],
|
|
365
|
+
...(aggregate === undefined
|
|
366
|
+
? {}
|
|
367
|
+
: { aggregate: accountingStatus === 'partial' ? withoutAuthoritativeCost(aggregate) : aggregate }),
|
|
368
|
+
};
|
|
369
|
+
}
|