@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,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
+ }