@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,1767 @@
|
|
|
1
|
+
import { evaluateAssertion, getEvaluationTopLevelOutputId, validateEvaluationAssertionConfiguration, } from './assertions.js';
|
|
2
|
+
import { assertPortableJson, canonicalStringify, clonePortableJson, fingerprint, fingerprintEvaluationDataset, } from './canonical.js';
|
|
3
|
+
import { normalizeEvaluationRun } from './normalization.js';
|
|
4
|
+
import { EvaluationGraphExecutionError } from './types.js';
|
|
5
|
+
import { areEvaluationDataTypesCompatible, isEvaluationValueCompatibleWithDataType } from './dataTypes.js';
|
|
6
|
+
import { findAutoDelegateGraphCandidate } from '@valerypopoff/rivet2-core';
|
|
7
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
8
|
+
const MAX_CONCURRENCY = 32;
|
|
9
|
+
export const LEGACY_EVALUATOR_INPUT_IDS = ['case', 'inputs', 'expected', 'outputs', 'run'];
|
|
10
|
+
export function hasAuthoritativeEvaluationCriteria(suite) {
|
|
11
|
+
if (getEvaluationSuiteMode(suite) === 'scoring')
|
|
12
|
+
return suite.evaluators.length > 0;
|
|
13
|
+
return (suite.assertions.some((assertion) => assertion.required !== false) ||
|
|
14
|
+
suite.evaluators.some((evaluator) => evaluator.required !== false) ||
|
|
15
|
+
(suite.thresholds?.length ?? 0) > 0);
|
|
16
|
+
}
|
|
17
|
+
/** Older saved suites predate scoring and therefore retain pass/fail semantics. */
|
|
18
|
+
export function getEvaluationSuiteMode(suite) {
|
|
19
|
+
return suite.evaluationMode === 'scoring' ? 'scoring' : 'pass-fail';
|
|
20
|
+
}
|
|
21
|
+
/** Existing evaluators with the complete reserved-input contract keep working without migration. */
|
|
22
|
+
export function usesLegacyEvaluatorInputEnvelope(evaluator, graphInputIds) {
|
|
23
|
+
return (evaluator.inputBindings === undefined &&
|
|
24
|
+
LEGACY_EVALUATOR_INPUT_IDS.every((inputId) => graphInputIds.includes(inputId)));
|
|
25
|
+
}
|
|
26
|
+
export async function runEvaluationWorkPool(options) {
|
|
27
|
+
if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1) {
|
|
28
|
+
throw new Error('Evaluation worker-pool concurrency must be a positive safe integer.');
|
|
29
|
+
}
|
|
30
|
+
const results = Array.from({ length: options.work.length });
|
|
31
|
+
let nextIndex = 0;
|
|
32
|
+
const worker = async () => {
|
|
33
|
+
while (true) {
|
|
34
|
+
// Do not dequeue a new case after cancellation. Active work receives
|
|
35
|
+
// the same signal and may still settle with useful partial evidence.
|
|
36
|
+
if (options.signal?.aborted)
|
|
37
|
+
return;
|
|
38
|
+
const index = nextIndex++;
|
|
39
|
+
if (index >= options.work.length)
|
|
40
|
+
return;
|
|
41
|
+
const work = options.work[index];
|
|
42
|
+
const result = await options.execute(work, index);
|
|
43
|
+
results[index] = result;
|
|
44
|
+
options.onSettled?.(result, index, results);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
await Promise.all(Array.from({ length: Math.min(options.concurrency, options.work.length) }, worker));
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
export function createEmptyMetrics() {
|
|
51
|
+
return { durationMs: 0 };
|
|
52
|
+
}
|
|
53
|
+
const executionMetricCountKeys = [
|
|
54
|
+
'inputTokens',
|
|
55
|
+
'outputTokens',
|
|
56
|
+
'cachedInputTokens',
|
|
57
|
+
'reasoningTokens',
|
|
58
|
+
'modelCallCount',
|
|
59
|
+
'toolCallCount',
|
|
60
|
+
'toolFailureCount',
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* Graph runners are host adapters, so their TypeScript return type is not a
|
|
64
|
+
* runtime trust boundary. Copy only the metrics Rivet understands and reject
|
|
65
|
+
* values that would become `null` or otherwise change during JSON storage.
|
|
66
|
+
*/
|
|
67
|
+
function cloneExecutionMetrics(value, path) {
|
|
68
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
69
|
+
throw new Error(`${path} must be an object.`);
|
|
70
|
+
}
|
|
71
|
+
if (!Number.isFinite(value.durationMs) || value.durationMs < 0) {
|
|
72
|
+
throw new Error(`${path}.durationMs must be a non-negative finite number.`);
|
|
73
|
+
}
|
|
74
|
+
const metrics = { durationMs: value.durationMs };
|
|
75
|
+
for (const key of executionMetricCountKeys) {
|
|
76
|
+
const count = value[key];
|
|
77
|
+
if (count === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
if (!Number.isSafeInteger(count) || count < 0) {
|
|
80
|
+
throw new Error(`${path}.${key} must be a non-negative safe integer.`);
|
|
81
|
+
}
|
|
82
|
+
metrics[key] = count;
|
|
83
|
+
}
|
|
84
|
+
if (value.costUsd !== undefined) {
|
|
85
|
+
if (!Number.isFinite(value.costUsd) || value.costUsd < 0) {
|
|
86
|
+
throw new Error(`${path}.costUsd must be a non-negative finite number.`);
|
|
87
|
+
}
|
|
88
|
+
metrics.costUsd = value.costUsd;
|
|
89
|
+
}
|
|
90
|
+
if (value.hasUnknownCost !== undefined) {
|
|
91
|
+
if (typeof value.hasUnknownCost !== 'boolean')
|
|
92
|
+
throw new Error(`${path}.hasUnknownCost must be a boolean.`);
|
|
93
|
+
metrics.hasUnknownCost = value.hasUnknownCost;
|
|
94
|
+
}
|
|
95
|
+
return metrics;
|
|
96
|
+
}
|
|
97
|
+
function tryCloneExecutionMetrics(value, path) {
|
|
98
|
+
if (value === undefined)
|
|
99
|
+
return undefined;
|
|
100
|
+
try {
|
|
101
|
+
return cloneExecutionMetrics(value, path);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function cloneOptionalPortableJson(value, path) {
|
|
108
|
+
if (value === undefined)
|
|
109
|
+
return undefined;
|
|
110
|
+
assertPortableJson(value, path);
|
|
111
|
+
return clonePortableJson(value);
|
|
112
|
+
}
|
|
113
|
+
function tryCloneOptionalPortableJson(value, path) {
|
|
114
|
+
try {
|
|
115
|
+
return cloneOptionalPortableJson(value, path);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolves the provisional recording references created while a suite is
|
|
123
|
+
* running into its final retention policy. The caller persists the returned
|
|
124
|
+
* references through its EvaluationRunStore.
|
|
125
|
+
*
|
|
126
|
+
* A successful candidate recording remains promotable for 24 hours by
|
|
127
|
+
* default. Failed/error trials are retained, and `all` pins every artifact.
|
|
128
|
+
* This deliberately operates per trial: one failed case must not pin every
|
|
129
|
+
* otherwise-successful recording in the suite.
|
|
130
|
+
*/
|
|
131
|
+
export function finalizeEvaluationRecordingRetention(run, policy = 'failures-and-baselines', now = Date.now()) {
|
|
132
|
+
const retainAll = policy === 'all';
|
|
133
|
+
const finalize = (reference, failed) => {
|
|
134
|
+
const retention = retainAll
|
|
135
|
+
? 'retained'
|
|
136
|
+
: failed
|
|
137
|
+
? 'failure'
|
|
138
|
+
: 'temporary';
|
|
139
|
+
return {
|
|
140
|
+
id: reference.id,
|
|
141
|
+
retention,
|
|
142
|
+
...(retention === 'temporary' ? { expiresAt: new Date(now + 24 * 60 * 60 * 1000).toISOString() } : {}),
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
...run,
|
|
147
|
+
trials: run.trials.map((trial) => {
|
|
148
|
+
const targetFailed = trial.executionStatus !== 'completed' ||
|
|
149
|
+
trial.observations.some((observation) => observation.kind === 'assertion' &&
|
|
150
|
+
observation.required &&
|
|
151
|
+
(observation.status === 'failed' || observation.status === 'error'));
|
|
152
|
+
return {
|
|
153
|
+
...trial,
|
|
154
|
+
...(trial.recording === undefined ? {} : { recording: finalize(trial.recording, targetFailed) }),
|
|
155
|
+
observations: trial.observations.map((observation) => ({
|
|
156
|
+
...observation,
|
|
157
|
+
...(observation.recording === undefined
|
|
158
|
+
? {}
|
|
159
|
+
: {
|
|
160
|
+
recording: finalize(observation.recording, observation.status === 'failed' || observation.status === 'error'),
|
|
161
|
+
}),
|
|
162
|
+
})),
|
|
163
|
+
};
|
|
164
|
+
}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function mergeMetrics(...metrics) {
|
|
168
|
+
const numberKeys = [
|
|
169
|
+
'durationMs',
|
|
170
|
+
'inputTokens',
|
|
171
|
+
'outputTokens',
|
|
172
|
+
'cachedInputTokens',
|
|
173
|
+
'reasoningTokens',
|
|
174
|
+
'modelCallCount',
|
|
175
|
+
'toolCallCount',
|
|
176
|
+
'toolFailureCount',
|
|
177
|
+
'costUsd',
|
|
178
|
+
];
|
|
179
|
+
const merged = { durationMs: 0 };
|
|
180
|
+
for (const key of numberKeys) {
|
|
181
|
+
const value = metrics.reduce((sum, item) => sum + (item[key] ?? 0), 0);
|
|
182
|
+
if (value !== 0 || key === 'durationMs')
|
|
183
|
+
merged[key] = value;
|
|
184
|
+
}
|
|
185
|
+
if (metrics.some((item) => item.hasUnknownCost))
|
|
186
|
+
merged.hasUnknownCost = true;
|
|
187
|
+
return merged;
|
|
188
|
+
}
|
|
189
|
+
function makeId(prefix) {
|
|
190
|
+
const cryptoValue = globalThis.crypto;
|
|
191
|
+
if (cryptoValue?.randomUUID)
|
|
192
|
+
return `${prefix}-${cryptoValue.randomUUID()}`;
|
|
193
|
+
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
194
|
+
}
|
|
195
|
+
function toErrorMessage(error) {
|
|
196
|
+
return error instanceof Error ? error.message : String(error);
|
|
197
|
+
}
|
|
198
|
+
function throwIfAborted(signal) {
|
|
199
|
+
if (signal?.aborted)
|
|
200
|
+
throw signal.reason ?? new DOMException('Evaluation canceled.', 'AbortError');
|
|
201
|
+
}
|
|
202
|
+
function isAbort(error) {
|
|
203
|
+
return ((error instanceof DOMException && error.name === 'AbortError') ||
|
|
204
|
+
(typeof error === 'object' &&
|
|
205
|
+
error !== null &&
|
|
206
|
+
'name' in error &&
|
|
207
|
+
error.name === 'AbortError'));
|
|
208
|
+
}
|
|
209
|
+
function resolveSuite(data, id) {
|
|
210
|
+
const suite = data.suites.find((candidate) => candidate.id === id);
|
|
211
|
+
if (!suite)
|
|
212
|
+
throw new Error(`Evaluation suite "${id}" does not exist.`);
|
|
213
|
+
return suite;
|
|
214
|
+
}
|
|
215
|
+
function validateDefinitionIds(suite) {
|
|
216
|
+
const seen = new Map();
|
|
217
|
+
for (const [kind, definitions] of [
|
|
218
|
+
['quality check', suite.assertions],
|
|
219
|
+
['evaluator graph', suite.evaluators],
|
|
220
|
+
['threshold', suite.thresholds ?? []],
|
|
221
|
+
]) {
|
|
222
|
+
for (const definition of definitions) {
|
|
223
|
+
if (typeof definition.id !== 'string' || definition.id.trim().length === 0) {
|
|
224
|
+
throw new Error(`Every evaluation ${kind} must have a non-empty id.`);
|
|
225
|
+
}
|
|
226
|
+
const previous = seen.get(definition.id);
|
|
227
|
+
if (previous) {
|
|
228
|
+
throw new Error(`Evaluation ${kind} id "${definition.id}" duplicates a ${previous} id.`);
|
|
229
|
+
}
|
|
230
|
+
seen.set(definition.id, kind);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const rateThresholdMetrics = new Set([
|
|
235
|
+
'pass-rate',
|
|
236
|
+
'mean-score',
|
|
237
|
+
'target-error-rate',
|
|
238
|
+
'evaluator-error-rate',
|
|
239
|
+
'tool-failure-rate',
|
|
240
|
+
]);
|
|
241
|
+
function formatThresholdResultValue(metric, operator, value) {
|
|
242
|
+
return operator === 'max-regression' || rateThresholdMetrics.has(metric)
|
|
243
|
+
? `${Number((value * 100).toFixed(4))}%`
|
|
244
|
+
: String(value);
|
|
245
|
+
}
|
|
246
|
+
function validateThresholdConfiguration(threshold) {
|
|
247
|
+
const { metric, operator, value } = threshold;
|
|
248
|
+
if (typeof metric !== 'string' || metric.length === 0) {
|
|
249
|
+
throw new Error(`Evaluation threshold "${threshold.id}" must select a metric.`);
|
|
250
|
+
}
|
|
251
|
+
if (typeof operator !== 'string') {
|
|
252
|
+
throw new Error(`Evaluation threshold "${threshold.id}" must select an operator.`);
|
|
253
|
+
}
|
|
254
|
+
if (!Number.isFinite(value))
|
|
255
|
+
throw new Error(`Evaluation threshold "${threshold.id}" must use a finite value.`);
|
|
256
|
+
if (rateThresholdMetrics.has(metric) && (value < 0 || value > 1)) {
|
|
257
|
+
throw new Error(`Evaluation threshold "${threshold.id}" must use a value from 0 to 1.`);
|
|
258
|
+
}
|
|
259
|
+
if (['average-cost', 'total-cost', 'average-latency-ms', 'p95-latency-ms'].includes(metric) && value < 0) {
|
|
260
|
+
throw new Error(`Evaluation threshold "${threshold.id}" cannot use a negative value.`);
|
|
261
|
+
}
|
|
262
|
+
if (operator === 'max-regression') {
|
|
263
|
+
if (value < 0)
|
|
264
|
+
throw new Error(`Evaluation regression threshold "${threshold.id}" cannot be negative.`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const expectedOperator = metric === 'pass-rate' || metric === 'mean-score'
|
|
268
|
+
? 'at-least'
|
|
269
|
+
: metric.startsWith('custom:')
|
|
270
|
+
? undefined
|
|
271
|
+
: 'at-most';
|
|
272
|
+
if ((expectedOperator !== undefined && operator !== expectedOperator) ||
|
|
273
|
+
(expectedOperator === undefined && operator !== 'at-least' && operator !== 'at-most')) {
|
|
274
|
+
throw new Error(`Evaluation threshold "${threshold.id}" uses an incompatible operator for "${metric}".`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function validateSuite(project, suite, dataset, purpose) {
|
|
278
|
+
const evaluationMode = getEvaluationSuiteMode(suite);
|
|
279
|
+
validateDefinitionIds(suite);
|
|
280
|
+
if (dataset.projectId !== undefined && dataset.projectId !== project.metadata.id) {
|
|
281
|
+
throw new Error(`Evaluation dataset "${dataset.name}" belongs to a different project.`);
|
|
282
|
+
}
|
|
283
|
+
if (suite.datasetId !== dataset.id)
|
|
284
|
+
throw new Error(`Suite "${suite.name}" is bound to a different evaluation dataset.`);
|
|
285
|
+
const graph = project.graphs[suite.targetGraphId];
|
|
286
|
+
if (!graph)
|
|
287
|
+
throw new Error(`Target graph "${suite.targetGraphId}" does not exist.`);
|
|
288
|
+
const graphInputs = graph.nodes.filter((node) => node.type === 'graphInput');
|
|
289
|
+
const graphInputIds = graphInputs.map((input) => input.data.id);
|
|
290
|
+
if (new Set(graphInputIds).size !== graphInputIds.length) {
|
|
291
|
+
throw new Error(`Target graph "${suite.targetGraphId}" has duplicate Graph Input ids.`);
|
|
292
|
+
}
|
|
293
|
+
const graphOutputs = graph.nodes.filter((node) => node.type === 'graphOutput');
|
|
294
|
+
const graphOutputIds = graphOutputs.map((output) => output.data.id);
|
|
295
|
+
if (new Set(graphOutputIds).size !== graphOutputIds.length) {
|
|
296
|
+
throw new Error(`Target graph "${suite.targetGraphId}" has duplicate Graph Output ids.`);
|
|
297
|
+
}
|
|
298
|
+
const inputIds = new Set(graphInputIds);
|
|
299
|
+
const graphInputsById = new Map(graphInputs.map((input) => [input.data.id, input]));
|
|
300
|
+
const fields = new Map(dataset.fields.map((field) => [field.id, field]));
|
|
301
|
+
if (fields.size !== dataset.fields.length)
|
|
302
|
+
throw new Error(`Evaluation dataset "${dataset.name}" has duplicate field ids.`);
|
|
303
|
+
const boundGraphInputs = new Set();
|
|
304
|
+
for (const binding of suite.inputBindings) {
|
|
305
|
+
if (boundGraphInputs.has(binding.graphInputId)) {
|
|
306
|
+
throw new Error(`Target graph input "${binding.graphInputId}" is bound more than once.`);
|
|
307
|
+
}
|
|
308
|
+
boundGraphInputs.add(binding.graphInputId);
|
|
309
|
+
if (!inputIds.has(binding.graphInputId))
|
|
310
|
+
throw new Error(`Graph input "${binding.graphInputId}" does not exist on the target graph.`);
|
|
311
|
+
const field = fields.get(binding.datasetFieldId);
|
|
312
|
+
if (!field)
|
|
313
|
+
throw new Error(`Evaluation dataset field "${binding.datasetFieldId}" does not exist.`);
|
|
314
|
+
if (field.role !== 'input')
|
|
315
|
+
throw new Error(`Dataset field "${field.name}" must have the input role to bind it to a graph input.`);
|
|
316
|
+
const graphInput = graphInputsById.get(binding.graphInputId);
|
|
317
|
+
if (!areEvaluationDataTypesCompatible(field.dataType, graphInput.data.dataType)) {
|
|
318
|
+
throw new Error(`Dataset field "${field.name}" (${field.dataType}) is not compatible with graph input "${graphInput.data.id}" (${graphInput.data.dataType}).`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
for (const input of graphInputs) {
|
|
322
|
+
if (suite.inputBindings.some((binding) => binding.graphInputId === input.data.id))
|
|
323
|
+
continue;
|
|
324
|
+
// A connection-driven default cannot be proven before executing the graph;
|
|
325
|
+
// require an explicit dataset binding in that case instead of quietly
|
|
326
|
+
// fabricating a value from the input data type.
|
|
327
|
+
if (input.data.defaultValue === undefined || input.data.useDefaultValueInput) {
|
|
328
|
+
throw new Error(`Target graph input "${input.data.id}" must be bound to an evaluation dataset field or have a static graph default.`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Validate every enabled case before the worker pool starts. The declared
|
|
332
|
+
// field type is part of the dataset contract even when that field is not
|
|
333
|
+
// bound directly to the target graph (for example evaluator metadata).
|
|
334
|
+
for (const testCase of dataset.cases.filter((candidate) => candidate.enabled !== false)) {
|
|
335
|
+
for (const [fieldId, value] of Object.entries(testCase.values)) {
|
|
336
|
+
const field = fields.get(fieldId);
|
|
337
|
+
if (!field) {
|
|
338
|
+
throw new Error(`Case "${testCase.name}" supplies unknown dataset field "${fieldId}".`);
|
|
339
|
+
}
|
|
340
|
+
assertPortableJson(value, `case.${testCase.id}.${fieldId}`);
|
|
341
|
+
if (!isEvaluationValueCompatibleWithDataType(value, field.dataType)) {
|
|
342
|
+
throw new Error(`Case "${testCase.name}" value for "${field.name}" is not compatible with its declared ${field.dataType} type.`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const binding of suite.inputBindings) {
|
|
346
|
+
const value = testCase.values[binding.datasetFieldId];
|
|
347
|
+
if (value === undefined) {
|
|
348
|
+
const field = fields.get(binding.datasetFieldId);
|
|
349
|
+
throw new Error(`Case "${testCase.name}" has no saved value for bound input field "${field.name}".`);
|
|
350
|
+
}
|
|
351
|
+
const graphInput = graphInputsById.get(binding.graphInputId);
|
|
352
|
+
if (!isEvaluationValueCompatibleWithDataType(value, graphInput.data.dataType)) {
|
|
353
|
+
throw new Error(`Case "${testCase.name}" value for "${binding.datasetFieldId}" is not compatible with target graph input "${binding.graphInputId}" (${graphInput.data.dataType}).`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (const field of dataset.fields) {
|
|
357
|
+
const requiredForThisRun = field.required && (purpose === 'evaluation' || field.role === 'input');
|
|
358
|
+
if (requiredForThisRun && testCase.values[field.id] === undefined) {
|
|
359
|
+
throw new Error(`Case "${testCase.name}" is missing required field "${field.name}".`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (purpose === 'evaluation' && evaluationMode === 'pass-fail') {
|
|
364
|
+
for (const assertion of suite.assertions) {
|
|
365
|
+
const outputId = getEvaluationTopLevelOutputId(assertion.outputPath);
|
|
366
|
+
if (assertion.outputPath.trim() !== '$' && outputId === undefined) {
|
|
367
|
+
throw new Error(`Assertion "${assertion.name}" output path must start with a named target Graph Output.`);
|
|
368
|
+
}
|
|
369
|
+
if (outputId !== undefined && !graphOutputIds.includes(outputId)) {
|
|
370
|
+
throw new Error(`Assertion "${assertion.name}" references missing target output "${outputId}".`);
|
|
371
|
+
}
|
|
372
|
+
if (assertion.expected.kind === 'dataset-field') {
|
|
373
|
+
const field = fields.get(assertion.expected.fieldId);
|
|
374
|
+
if (!field)
|
|
375
|
+
throw new Error(`Assertion "${assertion.name}" references a missing expected dataset field.`);
|
|
376
|
+
if (field.role !== 'expected')
|
|
377
|
+
throw new Error(`Assertion "${assertion.name}" must reference a dataset field with the expected role.`);
|
|
378
|
+
for (const testCase of dataset.cases.filter((candidate) => candidate.enabled !== false)) {
|
|
379
|
+
const expected = testCase.values[field.id];
|
|
380
|
+
if (expected === undefined) {
|
|
381
|
+
throw new Error(`Case "${testCase.name}" is missing expected field "${field.name}".`);
|
|
382
|
+
}
|
|
383
|
+
validateEvaluationAssertionConfiguration(assertion, expected);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
validateEvaluationAssertionConfiguration(assertion, assertion.expected.value);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (suite.configuration?.seed !== undefined) {
|
|
392
|
+
const seedInputId = suite.configuration.seedGraphInputId;
|
|
393
|
+
if (!seedInputId)
|
|
394
|
+
throw new Error('A suite seed requires selecting the target graph input that receives it.');
|
|
395
|
+
const seedInput = graphInputsById.get(seedInputId);
|
|
396
|
+
if (!seedInput)
|
|
397
|
+
throw new Error(`The selected seed graph input "${seedInputId}" does not exist on the target graph.`);
|
|
398
|
+
if (seedInput.data.dataType !== 'number' && seedInput.data.dataType !== 'any') {
|
|
399
|
+
throw new Error(`The selected seed graph input "${seedInputId}" must accept a number.`);
|
|
400
|
+
}
|
|
401
|
+
if (suite.inputBindings.some((binding) => binding.graphInputId === seedInputId)) {
|
|
402
|
+
throw new Error(`The selected seed graph input "${seedInputId}" cannot also be bound to a dataset field.`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const evaluator of purpose === 'evaluation' ? suite.evaluators : []) {
|
|
406
|
+
if (evaluator.scoreWeight !== undefined &&
|
|
407
|
+
(!Number.isFinite(evaluator.scoreWeight) || evaluator.scoreWeight <= 0)) {
|
|
408
|
+
throw new Error(`Evaluator graph "${evaluator.name}" score weight must be a positive finite number.`);
|
|
409
|
+
}
|
|
410
|
+
const evaluatorGraph = project.graphs[evaluator.graphId];
|
|
411
|
+
if (!evaluatorGraph)
|
|
412
|
+
throw new Error(`Evaluator graph "${evaluator.graphId}" does not exist.`);
|
|
413
|
+
const evaluatorGraphInputs = evaluatorGraph.nodes.filter((node) => node.type === 'graphInput');
|
|
414
|
+
const evaluatorInputIds = evaluatorGraphInputs.map((input) => input.data.id);
|
|
415
|
+
if (new Set(evaluatorInputIds).size !== evaluatorInputIds.length) {
|
|
416
|
+
throw new Error(`Evaluator graph "${evaluator.name}" has duplicate Graph Input ids.`);
|
|
417
|
+
}
|
|
418
|
+
const evaluatorOutputIds = evaluatorGraph.nodes
|
|
419
|
+
.filter((node) => node.type === 'graphOutput')
|
|
420
|
+
.map((output) => output.data.id);
|
|
421
|
+
if (new Set(evaluatorOutputIds).size !== evaluatorOutputIds.length) {
|
|
422
|
+
throw new Error(`Evaluator graph "${evaluator.name}" has duplicate Graph Output ids.`);
|
|
423
|
+
}
|
|
424
|
+
const evaluatorInputsById = new Map(evaluatorGraphInputs.map((input) => [input.data.id, input]));
|
|
425
|
+
const usesLegacyInputs = usesLegacyEvaluatorInputEnvelope(evaluator, evaluatorInputIds);
|
|
426
|
+
if (usesLegacyInputs) {
|
|
427
|
+
for (const inputId of LEGACY_EVALUATOR_INPUT_IDS) {
|
|
428
|
+
const evaluatorInput = evaluatorInputsById.get(inputId);
|
|
429
|
+
if (evaluatorInput.data.dataType !== 'object' && evaluatorInput.data.dataType !== 'any') {
|
|
430
|
+
throw new Error(`Evaluator graph "${evaluator.name}" legacy input "${inputId}" must accept the object or any data type.`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
for (const evaluatorInput of evaluatorGraphInputs) {
|
|
434
|
+
if (LEGACY_EVALUATOR_INPUT_IDS.includes(evaluatorInput.data.id)) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (evaluatorInput.data.defaultValue === undefined || evaluatorInput.data.useDefaultValueInput) {
|
|
438
|
+
throw new Error(`Evaluator graph "${evaluator.name}" input "${evaluatorInput.data.id}" needs a direct binding or a static graph default.`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
const evaluatorBindings = evaluator.inputBindings ?? [];
|
|
444
|
+
const boundEvaluatorInputs = new Set();
|
|
445
|
+
for (const binding of evaluatorBindings) {
|
|
446
|
+
if (boundEvaluatorInputs.has(binding.graphInputId)) {
|
|
447
|
+
throw new Error(`Evaluator graph "${evaluator.name}" input "${binding.graphInputId}" is bound more than once.`);
|
|
448
|
+
}
|
|
449
|
+
boundEvaluatorInputs.add(binding.graphInputId);
|
|
450
|
+
const evaluatorInput = evaluatorInputsById.get(binding.graphInputId);
|
|
451
|
+
if (!evaluatorInput) {
|
|
452
|
+
throw new Error(`Evaluator graph "${evaluator.name}" input "${binding.graphInputId}" does not exist on the selected graph.`);
|
|
453
|
+
}
|
|
454
|
+
let sourceDataType;
|
|
455
|
+
if (binding.source.kind === 'dataset-field') {
|
|
456
|
+
const field = fields.get(binding.source.fieldId);
|
|
457
|
+
if (!field) {
|
|
458
|
+
throw new Error(`Evaluator graph "${evaluator.name}" binding references missing dataset field "${binding.source.fieldId}".`);
|
|
459
|
+
}
|
|
460
|
+
sourceDataType = field.dataType;
|
|
461
|
+
for (const testCase of dataset.cases.filter((candidate) => candidate.enabled !== false)) {
|
|
462
|
+
const value = testCase.values[field.id];
|
|
463
|
+
if (value === undefined) {
|
|
464
|
+
throw new Error(`Case "${testCase.name}" has no saved value for evaluator input "${binding.graphInputId}" from dataset field "${field.name}".`);
|
|
465
|
+
}
|
|
466
|
+
if (!isEvaluationValueCompatibleWithDataType(value, evaluatorInput.data.dataType)) {
|
|
467
|
+
throw new Error(`Case "${testCase.name}" value from dataset field "${field.name}" is not compatible with evaluator input "${binding.graphInputId}" (${evaluatorInput.data.dataType}).`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else if (binding.source.kind === 'target-output') {
|
|
472
|
+
const outputId = binding.source.outputId;
|
|
473
|
+
const targetOutput = graph.nodes.find((node) => node.type === 'graphOutput' && node.data.id === outputId);
|
|
474
|
+
if (!targetOutput) {
|
|
475
|
+
throw new Error(`Evaluator graph "${evaluator.name}" binding references missing target output "${outputId}".`);
|
|
476
|
+
}
|
|
477
|
+
sourceDataType = targetOutput.data.dataType;
|
|
478
|
+
}
|
|
479
|
+
else if (binding.source.kind === 'context') {
|
|
480
|
+
sourceDataType = 'object';
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
throw new Error(`Evaluator graph "${evaluator.name}" input "${binding.graphInputId}" uses an unsupported source.`);
|
|
484
|
+
}
|
|
485
|
+
if (!areEvaluationDataTypesCompatible(sourceDataType, evaluatorInput.data.dataType)) {
|
|
486
|
+
throw new Error(`Evaluator graph "${evaluator.name}" source for input "${binding.graphInputId}" (${sourceDataType}) is not compatible with ${evaluatorInput.data.dataType}.`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
for (const evaluatorInput of evaluatorGraphInputs) {
|
|
490
|
+
if (boundEvaluatorInputs.has(evaluatorInput.data.id))
|
|
491
|
+
continue;
|
|
492
|
+
if (evaluatorInput.data.defaultValue === undefined || evaluatorInput.data.useDefaultValueInput) {
|
|
493
|
+
throw new Error(`Evaluator graph "${evaluator.name}" input "${evaluatorInput.data.id}" must be bound to a target output, dataset field, or evaluation context value, or have a static graph default.`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const resultOutput = evaluatorGraph.nodes.find((node) => node.type === 'graphOutput' && node.data.id === 'result');
|
|
498
|
+
if (!resultOutput) {
|
|
499
|
+
throw new Error(`Evaluator graph "${evaluator.name}" must declare a Graph Output named "result".`);
|
|
500
|
+
}
|
|
501
|
+
if (resultOutput.data.dataType !== 'object' && resultOutput.data.dataType !== 'any') {
|
|
502
|
+
throw new Error(`Evaluator graph "${evaluator.name}" output "result" must have the object data type.`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (purpose === 'evaluation' && evaluationMode === 'pass-fail') {
|
|
506
|
+
const hasRequiredPerTrialCheck = suite.assertions.some((assertion) => assertion.required !== false) ||
|
|
507
|
+
suite.evaluators.some((evaluator) => evaluator.required !== false);
|
|
508
|
+
const hasEvaluator = suite.evaluators.length > 0;
|
|
509
|
+
for (const threshold of suite.thresholds ?? []) {
|
|
510
|
+
validateThresholdConfiguration(threshold);
|
|
511
|
+
const metric = threshold.metric;
|
|
512
|
+
const supportedExecutionMetric = [
|
|
513
|
+
'target-error-rate',
|
|
514
|
+
'tool-failure-rate',
|
|
515
|
+
'average-cost',
|
|
516
|
+
'total-cost',
|
|
517
|
+
'average-latency-ms',
|
|
518
|
+
'p95-latency-ms',
|
|
519
|
+
].includes(metric);
|
|
520
|
+
const supportedEvaluatorMetric = metric === 'mean-score' ||
|
|
521
|
+
metric === 'evaluator-error-rate' ||
|
|
522
|
+
(metric.startsWith('custom:') && metric.length > 'custom:'.length);
|
|
523
|
+
if (metric === 'pass-rate' && !hasRequiredPerTrialCheck) {
|
|
524
|
+
throw new Error('A pass-rate threshold requires at least one required quality check or evaluator graph.');
|
|
525
|
+
}
|
|
526
|
+
if (supportedEvaluatorMetric && !hasEvaluator) {
|
|
527
|
+
throw new Error(`The "${metric}" threshold requires at least one evaluator graph.`);
|
|
528
|
+
}
|
|
529
|
+
if (metric !== 'pass-rate' && !supportedExecutionMetric && !supportedEvaluatorMetric) {
|
|
530
|
+
throw new Error(`Evaluation threshold metric "${metric}" is not supported.`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
else if (purpose === 'evaluation' && suite.evaluators.length === 0) {
|
|
535
|
+
throw new Error('A scoring evaluation suite requires at least one evaluator graph.');
|
|
536
|
+
}
|
|
537
|
+
const caseIds = new Set(dataset.cases.map((testCase) => testCase.id));
|
|
538
|
+
if (caseIds.size !== dataset.cases.length)
|
|
539
|
+
throw new Error(`Evaluation dataset "${dataset.name}" has duplicate case ids.`);
|
|
540
|
+
if (!dataset.cases.some((testCase) => testCase.enabled !== false)) {
|
|
541
|
+
throw new Error(`Evaluation dataset "${dataset.name}" has no enabled cases.`);
|
|
542
|
+
}
|
|
543
|
+
const configuration = suite.configuration;
|
|
544
|
+
if (configuration?.trialCount !== undefined &&
|
|
545
|
+
(!Number.isSafeInteger(configuration.trialCount) || configuration.trialCount < 1)) {
|
|
546
|
+
throw new Error('Evaluation trial count must be a positive safe integer.');
|
|
547
|
+
}
|
|
548
|
+
if (configuration?.concurrency !== undefined &&
|
|
549
|
+
(!Number.isSafeInteger(configuration.concurrency) ||
|
|
550
|
+
configuration.concurrency < 1 ||
|
|
551
|
+
configuration.concurrency > MAX_CONCURRENCY)) {
|
|
552
|
+
throw new Error(`Evaluation concurrency must be a whole number from 1 to ${MAX_CONCURRENCY}.`);
|
|
553
|
+
}
|
|
554
|
+
if (configuration?.timeoutMs !== undefined &&
|
|
555
|
+
(!Number.isFinite(configuration.timeoutMs) || configuration.timeoutMs <= 0)) {
|
|
556
|
+
throw new Error('Evaluation timeout must be a positive number of milliseconds.');
|
|
557
|
+
}
|
|
558
|
+
if (configuration?.seed !== undefined && (!Number.isSafeInteger(configuration.seed) || configuration.seed < 0)) {
|
|
559
|
+
throw new Error('Evaluation seed must be a non-negative safe integer.');
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function resolveCaseInputs(suite, dataset, testCase, purpose) {
|
|
563
|
+
const fields = new Map(dataset.fields.map((field) => [field.id, field]));
|
|
564
|
+
const inputs = {};
|
|
565
|
+
const expected = {};
|
|
566
|
+
for (const field of dataset.fields) {
|
|
567
|
+
const value = testCase.values[field.id];
|
|
568
|
+
if (value === undefined) {
|
|
569
|
+
const requiredForThisRun = field.required && (purpose === 'evaluation' || field.role === 'input');
|
|
570
|
+
if (requiredForThisRun) {
|
|
571
|
+
throw new Error(`Case "${testCase.name}" is missing required field "${field.name}".`);
|
|
572
|
+
}
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
assertPortableJson(value, `case.${field.id}`);
|
|
576
|
+
if (field.role === 'expected')
|
|
577
|
+
expected[field.id] = clonePortableJson(value);
|
|
578
|
+
}
|
|
579
|
+
for (const binding of suite.inputBindings) {
|
|
580
|
+
const field = fields.get(binding.datasetFieldId);
|
|
581
|
+
const value = testCase.values[field.id];
|
|
582
|
+
if (value === undefined) {
|
|
583
|
+
throw new Error(`Case "${testCase.name}" has no saved value for bound input field "${field.name}".`);
|
|
584
|
+
}
|
|
585
|
+
if (!isEvaluationValueCompatibleWithDataType(value, field.dataType)) {
|
|
586
|
+
throw new Error(`Case "${testCase.name}" input "${field.name}" is not compatible with its declared ${field.dataType} type.`);
|
|
587
|
+
}
|
|
588
|
+
inputs[binding.graphInputId] = clonePortableJson(value);
|
|
589
|
+
}
|
|
590
|
+
return { inputs, expected };
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Evaluators need the case identity and tags as well as its field values. Do
|
|
594
|
+
* not pass the mutable dataset case object itself: this portable snapshot is
|
|
595
|
+
* the stable evaluator-graph contract for a particular trial.
|
|
596
|
+
*/
|
|
597
|
+
function createEvaluatorCaseInput(testCase) {
|
|
598
|
+
return {
|
|
599
|
+
id: testCase.id,
|
|
600
|
+
name: testCase.name,
|
|
601
|
+
enabled: testCase.enabled !== false,
|
|
602
|
+
...(testCase.tags === undefined ? {} : { tags: [...testCase.tags] }),
|
|
603
|
+
...(testCase.note === undefined ? {} : { note: testCase.note }),
|
|
604
|
+
values: clonePortableJson(testCase.values),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function createEvaluatorGraphInputs(input) {
|
|
608
|
+
const { evaluator, project, testCase, targetInputs, expected, targetOutputs, run } = input;
|
|
609
|
+
const contextValues = {
|
|
610
|
+
case: createEvaluatorCaseInput(testCase),
|
|
611
|
+
inputs: clonePortableJson(targetInputs),
|
|
612
|
+
expected: clonePortableJson(expected),
|
|
613
|
+
outputs: clonePortableJson(targetOutputs),
|
|
614
|
+
run: clonePortableJson(run),
|
|
615
|
+
};
|
|
616
|
+
const evaluatorGraphInputs = project.graphs[evaluator.graphId]?.nodes.filter((node) => node.type === 'graphInput') ?? [];
|
|
617
|
+
const evaluatorGraphInputIds = evaluatorGraphInputs.map((node) => node.data.id);
|
|
618
|
+
if (usesLegacyEvaluatorInputEnvelope(evaluator, evaluatorGraphInputIds))
|
|
619
|
+
return contextValues;
|
|
620
|
+
const evaluatorInputsById = new Map(evaluatorGraphInputs.map((node) => [node.data.id, node]));
|
|
621
|
+
const evaluatorInputs = {};
|
|
622
|
+
for (const binding of evaluator.inputBindings ?? []) {
|
|
623
|
+
if (binding.source.kind === 'dataset-field') {
|
|
624
|
+
const value = testCase.values[binding.source.fieldId];
|
|
625
|
+
if (value === undefined) {
|
|
626
|
+
throw new Error(`Case "${testCase.name}" has no value for evaluator input "${binding.graphInputId}" from dataset field "${binding.source.fieldId}".`);
|
|
627
|
+
}
|
|
628
|
+
evaluatorInputs[binding.graphInputId] = clonePortableJson(value);
|
|
629
|
+
}
|
|
630
|
+
else if (binding.source.kind === 'target-output') {
|
|
631
|
+
if (!Object.hasOwn(targetOutputs, binding.source.outputId)) {
|
|
632
|
+
throw new Error(`Target graph did not produce output "${binding.source.outputId}" required by evaluator input "${binding.graphInputId}".`);
|
|
633
|
+
}
|
|
634
|
+
const targetOutputValue = targetOutputs[binding.source.outputId];
|
|
635
|
+
const evaluatorInput = evaluatorInputsById.get(binding.graphInputId);
|
|
636
|
+
if (evaluatorInput && !isEvaluationValueCompatibleWithDataType(targetOutputValue, evaluatorInput.data.dataType)) {
|
|
637
|
+
throw new Error(`Target output "${binding.source.outputId}" is not compatible with evaluator input "${binding.graphInputId}" (${evaluatorInput.data.dataType}).`);
|
|
638
|
+
}
|
|
639
|
+
evaluatorInputs[binding.graphInputId] = clonePortableJson(targetOutputValue);
|
|
640
|
+
}
|
|
641
|
+
else if (binding.source.kind === 'context') {
|
|
642
|
+
evaluatorInputs[binding.graphInputId] = clonePortableJson(contextValues[binding.source.context]);
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
throw new Error(`Evaluator input "${binding.graphInputId}" uses an unsupported source.`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return evaluatorInputs;
|
|
649
|
+
}
|
|
650
|
+
function hashStableText(value) {
|
|
651
|
+
let hash = 0x811c9dc5;
|
|
652
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
653
|
+
hash ^= value.charCodeAt(index);
|
|
654
|
+
hash = Math.imul(hash, 0x01000193);
|
|
655
|
+
}
|
|
656
|
+
return hash >>> 0;
|
|
657
|
+
}
|
|
658
|
+
function deriveSeed(seed, caseId, trialIndex) {
|
|
659
|
+
if (seed == null)
|
|
660
|
+
return undefined;
|
|
661
|
+
let value = seed >>> 0;
|
|
662
|
+
value = Math.imul(value ^ hashStableText(caseId), 0x45d9f3b);
|
|
663
|
+
value = Math.imul(value ^ (trialIndex + 1), 0x45d9f3b);
|
|
664
|
+
return (value ^ (value >>> 16)) >>> 0;
|
|
665
|
+
}
|
|
666
|
+
function normalizeEvaluatorResult(value, evaluationMode) {
|
|
667
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
668
|
+
throw new Error('Evaluator graph output "result" must be an object.');
|
|
669
|
+
}
|
|
670
|
+
if (evaluationMode === 'pass-fail' && typeof value.passed !== 'boolean') {
|
|
671
|
+
throw new Error('Evaluator graph output "result.passed" must be a boolean.');
|
|
672
|
+
}
|
|
673
|
+
const score = value.score;
|
|
674
|
+
if ((evaluationMode === 'scoring' || score !== undefined) &&
|
|
675
|
+
(typeof score !== 'number' || !Number.isFinite(score) || score < 0 || score > 100)) {
|
|
676
|
+
throw new Error('Evaluator graph output "result.score" must be a number from 0 to 100 (for example, return 85 for 85/100).');
|
|
677
|
+
}
|
|
678
|
+
if (value.message !== undefined && typeof value.message !== 'string') {
|
|
679
|
+
throw new Error('Evaluator graph output "result.message" must be a string.');
|
|
680
|
+
}
|
|
681
|
+
if (value.metrics !== undefined &&
|
|
682
|
+
(typeof value.metrics !== 'object' || value.metrics === null || Array.isArray(value.metrics))) {
|
|
683
|
+
throw new Error('Evaluator graph output "result.metrics" must be an object.');
|
|
684
|
+
}
|
|
685
|
+
const metrics = value.metrics;
|
|
686
|
+
if (metrics && Object.values(metrics).some((entry) => typeof entry !== 'number' || !Number.isFinite(entry))) {
|
|
687
|
+
throw new Error('Evaluator graph output "result.metrics" values must be finite numbers.');
|
|
688
|
+
}
|
|
689
|
+
if (value.evidence !== undefined)
|
|
690
|
+
assertPortableJson(value.evidence, 'evaluator result.evidence');
|
|
691
|
+
return {
|
|
692
|
+
id: '',
|
|
693
|
+
kind: 'graph',
|
|
694
|
+
name: '',
|
|
695
|
+
required: true,
|
|
696
|
+
status: evaluationMode === 'scoring' ? 'scored' : value.passed ? 'passed' : 'failed',
|
|
697
|
+
// Evaluator authors use the same 100-point scale shown by the UI. Runs
|
|
698
|
+
// retain normalized values so historical aggregation and thresholds stay
|
|
699
|
+
// backward compatible with existing evaluation records.
|
|
700
|
+
...(typeof score === 'number' ? { score: score / 100 } : {}),
|
|
701
|
+
...(typeof value.message === 'string' ? { message: value.message } : {}),
|
|
702
|
+
...(value.evidence !== undefined ? { evidence: clonePortableJson(value.evidence) } : {}),
|
|
703
|
+
...(metrics ? { metrics: clonePortableJson(metrics) } : {}),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
async function runWithTimeout(run, timeoutMs, outer) {
|
|
707
|
+
throwIfAborted(outer);
|
|
708
|
+
const controller = new AbortController();
|
|
709
|
+
let rejectCanceled;
|
|
710
|
+
const canceled = new Promise((_, reject) => {
|
|
711
|
+
rejectCanceled = reject;
|
|
712
|
+
});
|
|
713
|
+
const abort = () => {
|
|
714
|
+
const error = outer?.reason ?? new DOMException('Evaluation canceled.', 'AbortError');
|
|
715
|
+
controller.abort(error);
|
|
716
|
+
rejectCanceled?.(error);
|
|
717
|
+
};
|
|
718
|
+
outer?.addEventListener('abort', abort, { once: true });
|
|
719
|
+
// `throwIfAborted` above covers the common case. This second check closes
|
|
720
|
+
// the narrow race where the caller aborts while this timeout wrapper is
|
|
721
|
+
// installing its listener, before the adapter has been started.
|
|
722
|
+
if (outer?.aborted) {
|
|
723
|
+
outer.removeEventListener('abort', abort);
|
|
724
|
+
throw outer.reason ?? new DOMException('Evaluation canceled.', 'AbortError');
|
|
725
|
+
}
|
|
726
|
+
let timeout;
|
|
727
|
+
let timeoutPromise;
|
|
728
|
+
if (timeoutMs != null && timeoutMs > 0) {
|
|
729
|
+
timeoutPromise = new Promise((_, reject) => {
|
|
730
|
+
timeout = setTimeout(() => {
|
|
731
|
+
const error = new DOMException('Evaluation trial timed out.', 'TimeoutError');
|
|
732
|
+
controller.abort(error);
|
|
733
|
+
reject(error);
|
|
734
|
+
}, timeoutMs);
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
const execution = run(controller.signal);
|
|
738
|
+
try {
|
|
739
|
+
return await Promise.race([execution, canceled, ...(timeoutPromise ? [timeoutPromise] : [])]);
|
|
740
|
+
}
|
|
741
|
+
finally {
|
|
742
|
+
if (timeout)
|
|
743
|
+
clearTimeout(timeout);
|
|
744
|
+
outer?.removeEventListener('abort', abort);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
async function runTrial(input) {
|
|
748
|
+
const { project, suite, dataset, purpose, testCase, caseIndex, trialIndex, runId, runGraph, signal } = input;
|
|
749
|
+
const evaluationMode = getEvaluationSuiteMode(suite);
|
|
750
|
+
const { inputs, expected } = resolveCaseInputs(suite, dataset, testCase, purpose);
|
|
751
|
+
const seed = deriveSeed(suite.configuration?.seed, testCase.id, trialIndex);
|
|
752
|
+
if (seed !== undefined && suite.configuration?.seedGraphInputId)
|
|
753
|
+
inputs[suite.configuration.seedGraphInputId] = seed;
|
|
754
|
+
const metadata = {
|
|
755
|
+
evaluationRunId: runId,
|
|
756
|
+
suiteId: suite.id,
|
|
757
|
+
caseId: testCase.id,
|
|
758
|
+
trialIndex,
|
|
759
|
+
phase: 'target',
|
|
760
|
+
};
|
|
761
|
+
let targetMetrics = createEmptyMetrics();
|
|
762
|
+
let evaluatorMetrics = createEmptyMetrics();
|
|
763
|
+
let outputs = {};
|
|
764
|
+
let targetError;
|
|
765
|
+
let recording;
|
|
766
|
+
let targetProviderAttempts;
|
|
767
|
+
const targetStartedAt = Date.now();
|
|
768
|
+
try {
|
|
769
|
+
throwIfAborted(signal);
|
|
770
|
+
const target = await runWithTimeout((attemptSignal) => runGraph({ project, graphId: suite.targetGraphId, inputs, signal: attemptSignal, metadata }), suite.configuration?.timeoutMs, signal);
|
|
771
|
+
targetMetrics = cloneExecutionMetrics(target.metrics, 'target metrics');
|
|
772
|
+
assertPortableJson(target.outputs, 'target outputs');
|
|
773
|
+
outputs = clonePortableJson(target.outputs);
|
|
774
|
+
recording = target.recording === undefined ? undefined : structuredClone(target.recording);
|
|
775
|
+
targetProviderAttempts = cloneOptionalPortableJson(target.providerAttempts, 'target provider attempts');
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
if (error instanceof EvaluationGraphExecutionError) {
|
|
779
|
+
const errorMetrics = tryCloneExecutionMetrics(error.metrics, 'target error metrics');
|
|
780
|
+
targetMetrics = errorMetrics ?? { ...targetMetrics, hasUnknownCost: true };
|
|
781
|
+
recording = error.recording === undefined ? undefined : structuredClone(error.recording);
|
|
782
|
+
targetProviderAttempts = tryCloneOptionalPortableJson(error.providerAttempts, 'target error provider attempts');
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
// A plain adapter failure does not prove that no priced provider work
|
|
786
|
+
// happened before the error. Do not report authoritative zero cost.
|
|
787
|
+
targetMetrics = { ...targetMetrics, hasUnknownCost: true };
|
|
788
|
+
}
|
|
789
|
+
targetError = toErrorMessage(error);
|
|
790
|
+
targetMetrics = { ...targetMetrics, durationMs: Math.max(targetMetrics.durationMs, Date.now() - targetStartedAt) };
|
|
791
|
+
if (isAbort(error) || signal?.aborted) {
|
|
792
|
+
const executionStatus = 'canceled';
|
|
793
|
+
const qualityStatus = 'not-evaluated';
|
|
794
|
+
return {
|
|
795
|
+
id: makeId('trial'),
|
|
796
|
+
caseId: testCase.id,
|
|
797
|
+
caseName: testCase.name,
|
|
798
|
+
caseIndex,
|
|
799
|
+
trialIndex,
|
|
800
|
+
executionStatus,
|
|
801
|
+
qualityStatus,
|
|
802
|
+
qualityReason: { code: 'canceled', message: 'The evaluation trial was canceled.' },
|
|
803
|
+
inputs,
|
|
804
|
+
expected,
|
|
805
|
+
outputs,
|
|
806
|
+
observations: [],
|
|
807
|
+
targetMetrics,
|
|
808
|
+
evaluatorMetrics,
|
|
809
|
+
totalMetrics: { ...mergeMetrics(targetMetrics, evaluatorMetrics), hasUnknownCost: true },
|
|
810
|
+
error: targetError,
|
|
811
|
+
...(seed === undefined ? {} : { seed }),
|
|
812
|
+
...(recording === undefined ? {} : { recording }),
|
|
813
|
+
...(targetProviderAttempts === undefined ? {} : { targetProviderAttempts }),
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const observations = [];
|
|
818
|
+
if (purpose === 'evaluation' && targetError) {
|
|
819
|
+
observations.push({
|
|
820
|
+
id: 'target-error',
|
|
821
|
+
kind: 'assertion',
|
|
822
|
+
name: 'Target graph',
|
|
823
|
+
required: true,
|
|
824
|
+
status: 'error',
|
|
825
|
+
message: targetError,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
else if (purpose === 'evaluation' && evaluationMode === 'pass-fail') {
|
|
829
|
+
for (const assertion of suite.assertions) {
|
|
830
|
+
try {
|
|
831
|
+
observations.push(evaluateAssertion(assertion, outputs, testCase));
|
|
832
|
+
}
|
|
833
|
+
catch (error) {
|
|
834
|
+
observations.push({
|
|
835
|
+
id: assertion.id,
|
|
836
|
+
kind: 'assertion',
|
|
837
|
+
name: assertion.name,
|
|
838
|
+
required: assertion.required !== false,
|
|
839
|
+
status: 'error',
|
|
840
|
+
message: toErrorMessage(error),
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
for (const evaluator of purpose === 'evaluation' ? suite.evaluators : []) {
|
|
846
|
+
if (targetError && (evaluationMode === 'scoring' || !evaluator.runOnTargetError)) {
|
|
847
|
+
observations.push({
|
|
848
|
+
id: evaluator.id,
|
|
849
|
+
kind: 'graph',
|
|
850
|
+
name: evaluator.name,
|
|
851
|
+
required: evaluator.required !== false,
|
|
852
|
+
status: 'skipped',
|
|
853
|
+
message: 'Skipped because the target graph failed.',
|
|
854
|
+
});
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
const start = Date.now();
|
|
858
|
+
let completedAttemptMetrics;
|
|
859
|
+
let completedAttemptRecording;
|
|
860
|
+
let completedAttemptProviderAttempts;
|
|
861
|
+
try {
|
|
862
|
+
const result = await runWithTimeout((attemptSignal) => runGraph({
|
|
863
|
+
project,
|
|
864
|
+
graphId: evaluator.graphId,
|
|
865
|
+
inputs: createEvaluatorGraphInputs({
|
|
866
|
+
evaluator,
|
|
867
|
+
project,
|
|
868
|
+
testCase,
|
|
869
|
+
targetInputs: inputs,
|
|
870
|
+
expected,
|
|
871
|
+
targetOutputs: outputs,
|
|
872
|
+
run: { targetError: targetError ?? null, caseIndex, trialIndex, seed: seed ?? null },
|
|
873
|
+
}),
|
|
874
|
+
signal: attemptSignal,
|
|
875
|
+
metadata: { ...metadata, phase: 'evaluator' },
|
|
876
|
+
}), suite.configuration?.timeoutMs, signal);
|
|
877
|
+
completedAttemptMetrics = cloneExecutionMetrics(result.metrics, `evaluator ${evaluator.name} metrics`);
|
|
878
|
+
completedAttemptRecording = result.recording === undefined ? undefined : structuredClone(result.recording);
|
|
879
|
+
completedAttemptProviderAttempts = cloneOptionalPortableJson(result.providerAttempts, `evaluator ${evaluator.name} provider attempts`);
|
|
880
|
+
evaluatorMetrics = mergeMetrics(evaluatorMetrics, completedAttemptMetrics);
|
|
881
|
+
assertPortableJson(result.outputs, `evaluator ${evaluator.name} outputs`);
|
|
882
|
+
const observation = normalizeEvaluatorResult(result.outputs.result ?? null, evaluationMode);
|
|
883
|
+
observations.push({
|
|
884
|
+
...observation,
|
|
885
|
+
id: evaluator.id,
|
|
886
|
+
name: evaluator.name,
|
|
887
|
+
required: evaluator.required !== false,
|
|
888
|
+
scoreWeight: evaluator.scoreWeight ?? 1,
|
|
889
|
+
durationMs: completedAttemptMetrics.durationMs || Date.now() - start,
|
|
890
|
+
...(completedAttemptMetrics.costUsd === undefined ? {} : { costUsd: completedAttemptMetrics.costUsd }),
|
|
891
|
+
...(completedAttemptProviderAttempts === undefined
|
|
892
|
+
? {}
|
|
893
|
+
: { providerAttempts: completedAttemptProviderAttempts }),
|
|
894
|
+
...(completedAttemptRecording === undefined ? {} : { recording: completedAttemptRecording }),
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
const executionError = error instanceof EvaluationGraphExecutionError ? error : undefined;
|
|
899
|
+
const executionErrorMetrics = tryCloneExecutionMetrics(executionError?.metrics, `evaluator ${evaluator.name} error metrics`);
|
|
900
|
+
const failureMetrics = executionErrorMetrics ?? completedAttemptMetrics;
|
|
901
|
+
const failureProviderAttempts = tryCloneOptionalPortableJson(executionError?.providerAttempts, `evaluator ${evaluator.name} error provider attempts`) ?? completedAttemptProviderAttempts;
|
|
902
|
+
const failureRecording = executionError?.recording === undefined ? completedAttemptRecording : structuredClone(executionError.recording);
|
|
903
|
+
if (executionErrorMetrics) {
|
|
904
|
+
// A failed judge is still a physical graph execution. Its latency,
|
|
905
|
+
// tokens, and cost must remain part of the run rather than vanishing
|
|
906
|
+
// just because the evaluator did not return a valid result object.
|
|
907
|
+
evaluatorMetrics = mergeMetrics(evaluatorMetrics, executionErrorMetrics);
|
|
908
|
+
}
|
|
909
|
+
else if (!completedAttemptMetrics) {
|
|
910
|
+
// Generic adapter failures and runner timeouts do not carry an
|
|
911
|
+
// EvaluationGraphExecutionError payload. Their elapsed time is still
|
|
912
|
+
// physical evaluator work and must remain visible in run metrics.
|
|
913
|
+
evaluatorMetrics = mergeMetrics(evaluatorMetrics, {
|
|
914
|
+
durationMs: Date.now() - start,
|
|
915
|
+
hasUnknownCost: true,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
if (isAbort(error) || signal?.aborted) {
|
|
919
|
+
const executionStatus = 'canceled';
|
|
920
|
+
const qualityStatus = 'not-evaluated';
|
|
921
|
+
return {
|
|
922
|
+
id: makeId('trial'),
|
|
923
|
+
caseId: testCase.id,
|
|
924
|
+
caseName: testCase.name,
|
|
925
|
+
caseIndex,
|
|
926
|
+
trialIndex,
|
|
927
|
+
executionStatus,
|
|
928
|
+
qualityStatus,
|
|
929
|
+
qualityReason: { code: 'canceled', message: 'The evaluation trial was canceled.' },
|
|
930
|
+
inputs,
|
|
931
|
+
expected,
|
|
932
|
+
outputs,
|
|
933
|
+
observations,
|
|
934
|
+
targetMetrics,
|
|
935
|
+
evaluatorMetrics,
|
|
936
|
+
totalMetrics: { ...mergeMetrics(targetMetrics, evaluatorMetrics), hasUnknownCost: true },
|
|
937
|
+
error: toErrorMessage(error),
|
|
938
|
+
...(seed === undefined ? {} : { seed }),
|
|
939
|
+
...(recording === undefined ? {} : { recording }),
|
|
940
|
+
...(targetProviderAttempts === undefined ? {} : { targetProviderAttempts }),
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
observations.push({
|
|
944
|
+
id: evaluator.id,
|
|
945
|
+
kind: 'graph',
|
|
946
|
+
name: evaluator.name,
|
|
947
|
+
required: evaluator.required !== false,
|
|
948
|
+
status: 'error',
|
|
949
|
+
message: toErrorMessage(error),
|
|
950
|
+
durationMs: failureMetrics?.durationMs ?? Date.now() - start,
|
|
951
|
+
...(failureMetrics?.costUsd === undefined ? {} : { costUsd: failureMetrics.costUsd }),
|
|
952
|
+
...(failureProviderAttempts === undefined ? {} : { providerAttempts: failureProviderAttempts }),
|
|
953
|
+
...(failureRecording === undefined ? {} : { recording: failureRecording }),
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const required = observations.filter((entry) => entry.required);
|
|
958
|
+
const executionStatus = targetError === undefined ? 'completed' : 'error';
|
|
959
|
+
let qualityStatus;
|
|
960
|
+
let qualityReason;
|
|
961
|
+
if (purpose === 'execution-benchmark') {
|
|
962
|
+
qualityStatus = 'not-evaluated';
|
|
963
|
+
qualityReason = {
|
|
964
|
+
code: 'benchmark',
|
|
965
|
+
message: 'This trial measured execution without evaluating output quality.',
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
else if (evaluationMode === 'scoring') {
|
|
969
|
+
const scoringObservations = observations.filter((entry) => entry.kind === 'graph');
|
|
970
|
+
const hasCompleteScore = targetError === undefined &&
|
|
971
|
+
scoringObservations.length === suite.evaluators.length &&
|
|
972
|
+
scoringObservations.every((entry) => entry.status === 'scored' && entry.score !== undefined);
|
|
973
|
+
if (hasCompleteScore) {
|
|
974
|
+
qualityStatus = 'scored';
|
|
975
|
+
qualityReason = { code: 'scores-complete', message: 'All evaluator graphs returned a score for this trial.' };
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
qualityStatus = 'unable-to-evaluate';
|
|
979
|
+
qualityReason = {
|
|
980
|
+
code: 'scores-incomplete',
|
|
981
|
+
message: targetError === undefined
|
|
982
|
+
? 'One or more evaluator graphs did not return a usable score for this trial.'
|
|
983
|
+
: 'The target graph failed, so this trial could not contribute a score.',
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
else if (targetError !== undefined) {
|
|
988
|
+
qualityStatus = 'failed';
|
|
989
|
+
qualityReason = {
|
|
990
|
+
code: 'target-error',
|
|
991
|
+
message: 'The target graph failed before it could produce a valid result.',
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
else if (required.some((entry) => entry.status === 'failed')) {
|
|
995
|
+
qualityStatus = 'failed';
|
|
996
|
+
qualityReason = { code: 'checks-failed', message: 'One or more required quality checks failed.' };
|
|
997
|
+
}
|
|
998
|
+
else if (required.some((entry) => entry.status === 'error')) {
|
|
999
|
+
qualityStatus = 'unable-to-evaluate';
|
|
1000
|
+
qualityReason = {
|
|
1001
|
+
code: 'required-check-error',
|
|
1002
|
+
message: 'A required quality check could not be evaluated.',
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
else if (required.some((entry) => entry.status === 'passed')) {
|
|
1006
|
+
qualityStatus = 'passed';
|
|
1007
|
+
qualityReason = { code: 'checks-passed', message: 'All required per-trial quality checks passed.' };
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
qualityStatus = 'not-evaluated';
|
|
1011
|
+
qualityReason = {
|
|
1012
|
+
code: 'no-trial-quality-checks',
|
|
1013
|
+
message: 'No required per-trial quality check evaluated this result.',
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
const totalMetrics = mergeMetrics(targetMetrics, evaluatorMetrics);
|
|
1017
|
+
return {
|
|
1018
|
+
id: makeId('trial'),
|
|
1019
|
+
caseId: testCase.id,
|
|
1020
|
+
caseName: testCase.name,
|
|
1021
|
+
caseIndex,
|
|
1022
|
+
trialIndex,
|
|
1023
|
+
executionStatus,
|
|
1024
|
+
qualityStatus,
|
|
1025
|
+
qualityReason,
|
|
1026
|
+
inputs,
|
|
1027
|
+
expected,
|
|
1028
|
+
outputs,
|
|
1029
|
+
observations,
|
|
1030
|
+
targetMetrics,
|
|
1031
|
+
evaluatorMetrics,
|
|
1032
|
+
totalMetrics,
|
|
1033
|
+
...(targetError === undefined ? {} : { error: targetError }),
|
|
1034
|
+
...(seed === undefined ? {} : { seed }),
|
|
1035
|
+
...(recording === undefined ? {} : { recording }),
|
|
1036
|
+
...(targetProviderAttempts === undefined ? {} : { targetProviderAttempts }),
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
function percentile(values, percentage) {
|
|
1040
|
+
if (values.length === 0)
|
|
1041
|
+
return 0;
|
|
1042
|
+
const ordered = values.slice().sort((left, right) => left - right);
|
|
1043
|
+
return ordered[Math.min(ordered.length - 1, Math.ceil((percentage / 100) * ordered.length) - 1)];
|
|
1044
|
+
}
|
|
1045
|
+
function meanObservationScore(observations) {
|
|
1046
|
+
let weightedScore = 0;
|
|
1047
|
+
let totalWeight = 0;
|
|
1048
|
+
for (const observation of observations) {
|
|
1049
|
+
if (observation.score === undefined)
|
|
1050
|
+
continue;
|
|
1051
|
+
// Assertions do not currently produce scores. The fallback preserves
|
|
1052
|
+
// compatibility with old recordings and evaluators that predate weights.
|
|
1053
|
+
const weight = observation.scoreWeight ?? 1;
|
|
1054
|
+
weightedScore += observation.score * weight;
|
|
1055
|
+
totalWeight += weight;
|
|
1056
|
+
}
|
|
1057
|
+
return totalWeight === 0 ? undefined : weightedScore / totalWeight;
|
|
1058
|
+
}
|
|
1059
|
+
function meanObservationMetrics(observations) {
|
|
1060
|
+
const totals = {};
|
|
1061
|
+
const counts = {};
|
|
1062
|
+
for (const observation of observations) {
|
|
1063
|
+
for (const [name, value] of Object.entries(observation.metrics ?? {})) {
|
|
1064
|
+
totals[name] = (totals[name] ?? 0) + value;
|
|
1065
|
+
counts[name] = (counts[name] ?? 0) + 1;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return Object.fromEntries(Object.entries(totals).map(([name, total]) => [name, total / counts[name]]));
|
|
1069
|
+
}
|
|
1070
|
+
function aggregate(trials, evaluationMode = 'pass-fail') {
|
|
1071
|
+
const incurred = trials;
|
|
1072
|
+
const settled = trials.filter((trial) => trial.executionStatus !== 'canceled');
|
|
1073
|
+
const completed = trials.filter((trial) => trial.executionStatus === 'completed');
|
|
1074
|
+
const evaluated = completed.filter((trial) => trial.qualityStatus === 'passed' || trial.qualityStatus === 'failed');
|
|
1075
|
+
const passed = evaluated.filter((trial) => trial.qualityStatus === 'passed');
|
|
1076
|
+
const failed = evaluated.filter((trial) => trial.qualityStatus === 'failed');
|
|
1077
|
+
const notEvaluated = completed.filter((trial) => trial.qualityStatus === 'not-evaluated');
|
|
1078
|
+
const unable = completed.filter((trial) => trial.qualityStatus === 'unable-to-evaluate');
|
|
1079
|
+
const errors = trials.filter((trial) => trial.executionStatus === 'error');
|
|
1080
|
+
const canceled = trials.filter((trial) => trial.executionStatus === 'canceled');
|
|
1081
|
+
// Canceled work can have partial observations and accounting. It remains
|
|
1082
|
+
// inspectable in the run, but must not distort completed-run quality or cost
|
|
1083
|
+
// thresholds.
|
|
1084
|
+
const settledObservations = settled.flatMap((trial) => trial.observations);
|
|
1085
|
+
const meanScore = meanObservationScore(settledObservations);
|
|
1086
|
+
const accountingComplete = !incurred.some((trial) => trial.totalMetrics.hasUnknownCost);
|
|
1087
|
+
const totalCostUsd = accountingComplete
|
|
1088
|
+
? incurred.reduce((sum, trial) => sum + (trial.totalMetrics.costUsd ?? 0), 0)
|
|
1089
|
+
: undefined;
|
|
1090
|
+
// Evaluator metrics are per-observation measurements. Aggregating them as
|
|
1091
|
+
// a mean keeps a `0..1` quality score meaningful as trial count changes;
|
|
1092
|
+
// an evaluator that needs a total can return it as an explicit metric.
|
|
1093
|
+
const meanMetrics = meanObservationMetrics(settledObservations);
|
|
1094
|
+
const byCase = new Map();
|
|
1095
|
+
for (const trial of trials)
|
|
1096
|
+
byCase.set(trial.caseId, [...(byCase.get(trial.caseId) ?? []), trial]);
|
|
1097
|
+
const cases = Array.from(byCase.values()).map((caseTrials) => {
|
|
1098
|
+
const caseSettled = caseTrials.filter((trial) => trial.executionStatus !== 'canceled');
|
|
1099
|
+
const caseCompleted = caseSettled.filter((trial) => trial.executionStatus === 'completed');
|
|
1100
|
+
const caseEvaluated = caseCompleted.filter((trial) => trial.qualityStatus === 'passed' || trial.qualityStatus === 'failed');
|
|
1101
|
+
const casePassed = caseEvaluated.filter((trial) => trial.qualityStatus === 'passed');
|
|
1102
|
+
const caseFailed = caseEvaluated.filter((trial) => trial.qualityStatus === 'failed');
|
|
1103
|
+
const caseScored = caseCompleted.filter((trial) => trial.qualityStatus === 'scored');
|
|
1104
|
+
const caseObservations = caseSettled.flatMap((trial) => trial.observations);
|
|
1105
|
+
// A scoring trial has already proved every configured evaluator produced
|
|
1106
|
+
// a score. Average evaluators within a trial first, then average those
|
|
1107
|
+
// trials, so each case gets equal weight regardless of its evaluator set.
|
|
1108
|
+
const caseTrialScores = caseScored
|
|
1109
|
+
.map((trial) => meanObservationScore(trial.observations))
|
|
1110
|
+
.filter((score) => score !== undefined);
|
|
1111
|
+
const caseMeanScore = evaluationMode === 'scoring'
|
|
1112
|
+
? caseTrialScores.length === 0
|
|
1113
|
+
? undefined
|
|
1114
|
+
: caseTrialScores.reduce((sum, score) => sum + score, 0) / caseTrialScores.length
|
|
1115
|
+
: meanObservationScore(caseObservations);
|
|
1116
|
+
return {
|
|
1117
|
+
caseId: caseTrials[0].caseId,
|
|
1118
|
+
caseName: caseTrials[0].caseName,
|
|
1119
|
+
...(caseEvaluated.length === 0 ? {} : { passRate: casePassed.length / caseEvaluated.length }),
|
|
1120
|
+
evaluatedTrialCount: caseEvaluated.length,
|
|
1121
|
+
passedTrialCount: casePassed.length,
|
|
1122
|
+
failedTrialCount: caseFailed.length,
|
|
1123
|
+
notEvaluatedTrialCount: caseCompleted.filter((trial) => trial.qualityStatus === 'not-evaluated').length,
|
|
1124
|
+
unableToEvaluateTrialCount: caseCompleted.filter((trial) => trial.qualityStatus === 'unable-to-evaluate').length,
|
|
1125
|
+
erroredTrialCount: caseTrials.filter((trial) => trial.executionStatus === 'error').length,
|
|
1126
|
+
canceledTrialCount: caseTrials.filter((trial) => trial.executionStatus === 'canceled').length,
|
|
1127
|
+
...(evaluationMode === 'scoring'
|
|
1128
|
+
? {
|
|
1129
|
+
scoredTrialCount: caseScored.length,
|
|
1130
|
+
missingScoreTrialCount: caseTrials.length - caseScored.length,
|
|
1131
|
+
}
|
|
1132
|
+
: {}),
|
|
1133
|
+
...(caseMeanScore === undefined ? {} : { meanScore: caseMeanScore }),
|
|
1134
|
+
metrics: meanObservationMetrics(caseObservations),
|
|
1135
|
+
};
|
|
1136
|
+
});
|
|
1137
|
+
const scoredTrialCount = cases.reduce((sum, testCase) => sum + (testCase.scoredTrialCount ?? 0), 0);
|
|
1138
|
+
const scoringCaseMeans = cases
|
|
1139
|
+
.map((testCase) => testCase.meanScore)
|
|
1140
|
+
.filter((score) => score !== undefined);
|
|
1141
|
+
const scoringMeanScore = scoringCaseMeans.length === 0
|
|
1142
|
+
? undefined
|
|
1143
|
+
: scoringCaseMeans.reduce((sum, score) => sum + score, 0) / scoringCaseMeans.length;
|
|
1144
|
+
const aggregateValue = {
|
|
1145
|
+
trialCount: trials.length,
|
|
1146
|
+
evaluatedTrialCount: evaluated.length,
|
|
1147
|
+
notEvaluatedTrialCount: notEvaluated.length,
|
|
1148
|
+
unableToEvaluateTrialCount: unable.length,
|
|
1149
|
+
passedTrialCount: passed.length,
|
|
1150
|
+
failedTrialCount: failed.length,
|
|
1151
|
+
erroredTrialCount: errors.length,
|
|
1152
|
+
canceledTrialCount: canceled.length,
|
|
1153
|
+
passRate: evaluated.length === 0 ? 0 : passed.length / evaluated.length,
|
|
1154
|
+
...(evaluationMode === 'scoring'
|
|
1155
|
+
? {
|
|
1156
|
+
scoredTrialCount,
|
|
1157
|
+
missingScoreTrialCount: trials.length - scoredTrialCount,
|
|
1158
|
+
...(scoringMeanScore === undefined ? {} : { meanScore: scoringMeanScore }),
|
|
1159
|
+
}
|
|
1160
|
+
: meanScore === undefined
|
|
1161
|
+
? {}
|
|
1162
|
+
: { meanScore }),
|
|
1163
|
+
averageLatencyMs: incurred.length === 0
|
|
1164
|
+
? 0
|
|
1165
|
+
: incurred.reduce((sum, trial) => sum + trial.totalMetrics.durationMs, 0) / incurred.length,
|
|
1166
|
+
p95LatencyMs: percentile(incurred.map((trial) => trial.totalMetrics.durationMs), 95),
|
|
1167
|
+
...(incurred.length === 0 || totalCostUsd === undefined
|
|
1168
|
+
? {}
|
|
1169
|
+
: { totalCostUsd, averageCostUsd: totalCostUsd / incurred.length }),
|
|
1170
|
+
targetErrorRate: settled.length === 0 ? 0 : errors.length / settled.length,
|
|
1171
|
+
evaluatorErrorRate: settled.length === 0
|
|
1172
|
+
? 0
|
|
1173
|
+
: settled.filter((trial) => trial.observations.some((observation) => observation.kind === 'graph' && observation.status === 'error')).length / settled.length,
|
|
1174
|
+
toolFailureRate: settled.reduce((sum, trial) => sum + (trial.totalMetrics.toolCallCount ?? 0), 0) === 0
|
|
1175
|
+
? 0
|
|
1176
|
+
: settled.reduce((sum, trial) => sum + (trial.totalMetrics.toolFailureCount ?? 0), 0) /
|
|
1177
|
+
settled.reduce((sum, trial) => sum + (trial.totalMetrics.toolCallCount ?? 0), 0),
|
|
1178
|
+
metrics: meanMetrics,
|
|
1179
|
+
};
|
|
1180
|
+
return {
|
|
1181
|
+
aggregate: aggregateValue,
|
|
1182
|
+
cases,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
function thresholdValue(aggregateValue, metric) {
|
|
1186
|
+
switch (metric) {
|
|
1187
|
+
case 'pass-rate':
|
|
1188
|
+
return aggregateValue.evaluatedTrialCount === 0 ? undefined : aggregateValue.passRate;
|
|
1189
|
+
case 'mean-score':
|
|
1190
|
+
return aggregateValue.meanScore;
|
|
1191
|
+
case 'target-error-rate':
|
|
1192
|
+
return aggregateValue.targetErrorRate;
|
|
1193
|
+
case 'evaluator-error-rate':
|
|
1194
|
+
return aggregateValue.evaluatorErrorRate;
|
|
1195
|
+
case 'tool-failure-rate':
|
|
1196
|
+
return aggregateValue.toolFailureRate;
|
|
1197
|
+
case 'average-cost':
|
|
1198
|
+
return aggregateValue.averageCostUsd;
|
|
1199
|
+
case 'total-cost':
|
|
1200
|
+
return aggregateValue.totalCostUsd;
|
|
1201
|
+
case 'average-latency-ms':
|
|
1202
|
+
return aggregateValue.averageLatencyMs;
|
|
1203
|
+
case 'p95-latency-ms':
|
|
1204
|
+
return aggregateValue.p95LatencyMs;
|
|
1205
|
+
default:
|
|
1206
|
+
return metric.startsWith('custom:') ? aggregateValue.metrics[metric.slice('custom:'.length)] : undefined;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
function isHigherBetterMetric(metric) {
|
|
1210
|
+
return metric === 'pass-rate' || metric === 'mean-score' || metric.startsWith('custom:');
|
|
1211
|
+
}
|
|
1212
|
+
function baselineCompatible(current, baseline) {
|
|
1213
|
+
return (current.executionMode === baseline.provenance.executionMode &&
|
|
1214
|
+
current.suiteFingerprint === baseline.provenance.suiteFingerprint &&
|
|
1215
|
+
current.datasetFingerprint === baseline.provenance.datasetFingerprint &&
|
|
1216
|
+
current.targetFingerprint === baseline.provenance.targetFingerprint &&
|
|
1217
|
+
canonicalStringify(current.evaluatorFingerprints) === canonicalStringify(baseline.provenance.evaluatorFingerprints));
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Project-level values which can change a graph execution even though no node
|
|
1221
|
+
* or connection changed. Deliberately omit cosmetic metadata (title,
|
|
1222
|
+
* description, path, main-graph selection) and UI graphs: changing those must
|
|
1223
|
+
* not invalidate an otherwise comparable baseline.
|
|
1224
|
+
*/
|
|
1225
|
+
function executionNodeConfiguration(node) {
|
|
1226
|
+
return {
|
|
1227
|
+
id: node.id,
|
|
1228
|
+
type: node.type,
|
|
1229
|
+
title: node.title,
|
|
1230
|
+
data: node.data,
|
|
1231
|
+
variants: node.variants,
|
|
1232
|
+
isSplitRun: node.isSplitRun,
|
|
1233
|
+
isSplitSequential: node.isSplitSequential,
|
|
1234
|
+
splitRunMax: node.splitRunMax,
|
|
1235
|
+
splitRunConcurrency: node.splitRunConcurrency,
|
|
1236
|
+
disabled: node.disabled,
|
|
1237
|
+
isConditional: node.isConditional,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
function executionConnectionConfiguration(connection) {
|
|
1241
|
+
return {
|
|
1242
|
+
outputNodeId: connection.outputNodeId,
|
|
1243
|
+
inputNodeId: connection.inputNodeId,
|
|
1244
|
+
outputId: connection.outputId,
|
|
1245
|
+
inputId: connection.inputId,
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
function executionGraphConfiguration(graph) {
|
|
1249
|
+
return {
|
|
1250
|
+
metadata: {
|
|
1251
|
+
id: graph.metadata?.id,
|
|
1252
|
+
name: graph.metadata?.name,
|
|
1253
|
+
attachedData: graph.metadata?.attachedData,
|
|
1254
|
+
},
|
|
1255
|
+
nodes: graph.nodes.map(executionNodeConfiguration),
|
|
1256
|
+
connections: graph.connections.map(executionConnectionConfiguration),
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
function executionProjectGraphs(project) {
|
|
1260
|
+
return Object.fromEntries(Object.entries(project.graphs).map(([graphId, graph]) => [graphId, executionGraphConfiguration(graph)]));
|
|
1261
|
+
}
|
|
1262
|
+
function executionProjectConfiguration(project) {
|
|
1263
|
+
return {
|
|
1264
|
+
...(project.plugins === undefined ? {} : { plugins: project.plugins }),
|
|
1265
|
+
...(project.nodePrefabs === undefined
|
|
1266
|
+
? {}
|
|
1267
|
+
: {
|
|
1268
|
+
nodePrefabs: Object.fromEntries(Object.entries(project.nodePrefabs).map(([prefabId, prefab]) => [
|
|
1269
|
+
prefabId,
|
|
1270
|
+
{ id: prefab.id, sourceNode: executionNodeConfiguration(prefab.sourceNode) },
|
|
1271
|
+
])),
|
|
1272
|
+
}),
|
|
1273
|
+
...(project.data === undefined ? {} : { data: project.data }),
|
|
1274
|
+
...(project.references === undefined ? {} : { references: project.references }),
|
|
1275
|
+
...(project.metadata.mcpServer === undefined ? {} : { mcpServer: project.metadata.mcpServer }),
|
|
1276
|
+
...(project.metadata.knowledgeStores === undefined ? {} : { knowledgeStores: project.metadata.knowledgeStores }),
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Rivet's live project model intentionally keeps optional fields as own
|
|
1281
|
+
* properties whose value is `undefined` (for example `node.description`).
|
|
1282
|
+
* That is valid executable state, but it is not PortableJson. Evaluation
|
|
1283
|
+
* datasets and graph outputs must retain the stricter contract; provenance
|
|
1284
|
+
* fingerprints instead mirror ordinary JSON serialization for this one
|
|
1285
|
+
* runtime-only boundary so those optional fields neither block a run nor
|
|
1286
|
+
* create a distinct baseline identity.
|
|
1287
|
+
*/
|
|
1288
|
+
function canonicalizeExecutionFingerprintValue(value, path = '$', stack = new Set()) {
|
|
1289
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
1290
|
+
return value;
|
|
1291
|
+
if (typeof value === 'number') {
|
|
1292
|
+
if (!Number.isFinite(value))
|
|
1293
|
+
throw new Error(`${path} must not contain a non-finite number.`);
|
|
1294
|
+
return value;
|
|
1295
|
+
}
|
|
1296
|
+
if (value === undefined)
|
|
1297
|
+
return null;
|
|
1298
|
+
if (typeof value !== 'object')
|
|
1299
|
+
throw new Error(`${path} must be JSON-compatible to fingerprint an evaluation.`);
|
|
1300
|
+
if (stack.has(value))
|
|
1301
|
+
throw new Error(`${path} must not contain a cycle.`);
|
|
1302
|
+
if (Array.isArray(value)) {
|
|
1303
|
+
stack.add(value);
|
|
1304
|
+
const normalized = Array.from(value, (entry, index) => canonicalizeExecutionFingerprintValue(entry, `${path}[${index}]`, stack));
|
|
1305
|
+
stack.delete(value);
|
|
1306
|
+
return normalized;
|
|
1307
|
+
}
|
|
1308
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
|
|
1309
|
+
throw new Error(`${path} must be a plain object to fingerprint an evaluation.`);
|
|
1310
|
+
}
|
|
1311
|
+
stack.add(value);
|
|
1312
|
+
const normalized = Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => entry === undefined ? [] : [[key, canonicalizeExecutionFingerprintValue(entry, `${path}.${key}`, stack)]]));
|
|
1313
|
+
stack.delete(value);
|
|
1314
|
+
return normalized;
|
|
1315
|
+
}
|
|
1316
|
+
function fingerprintExecutionProject(value) {
|
|
1317
|
+
return fingerprint(canonicalizeExecutionFingerprintValue(value));
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* A baseline must follow every graph that a static node configuration can
|
|
1321
|
+
* invoke, not only Subgraph and Loop nodes. Dynamic Call Graph references
|
|
1322
|
+
* deliberately remain part of the owning graph fingerprint, because their
|
|
1323
|
+
* eventual target cannot be known before a trial runs.
|
|
1324
|
+
*/
|
|
1325
|
+
function fingerprintGraphDependency(project, graphId) {
|
|
1326
|
+
const visited = new Set();
|
|
1327
|
+
const graphEntries = Object.entries(project.graphs);
|
|
1328
|
+
const asKnownGraphId = (value) => {
|
|
1329
|
+
return typeof value === 'string' && project.graphs[value] ? value : undefined;
|
|
1330
|
+
};
|
|
1331
|
+
const getStaticChildren = (graph) => {
|
|
1332
|
+
const children = new Set();
|
|
1333
|
+
const autoDelegatedToolNames = [];
|
|
1334
|
+
const add = (value) => {
|
|
1335
|
+
const id = asKnownGraphId(value);
|
|
1336
|
+
if (id)
|
|
1337
|
+
children.add(id);
|
|
1338
|
+
};
|
|
1339
|
+
for (const node of graph.nodes) {
|
|
1340
|
+
const data = node.data;
|
|
1341
|
+
switch (node.type) {
|
|
1342
|
+
case 'subGraph':
|
|
1343
|
+
case 'loopUntil':
|
|
1344
|
+
case 'cron':
|
|
1345
|
+
add(node.type === 'subGraph' ? data.graphId : data.targetGraph);
|
|
1346
|
+
break;
|
|
1347
|
+
case 'delegateFunctionCall': {
|
|
1348
|
+
add(data.unknownHandler);
|
|
1349
|
+
if (data.autoDelegate === true) {
|
|
1350
|
+
continue;
|
|
1351
|
+
}
|
|
1352
|
+
if (Array.isArray(data.handlers)) {
|
|
1353
|
+
for (const handler of data.handlers) {
|
|
1354
|
+
if (handler && typeof handler === 'object' && 'value' in handler) {
|
|
1355
|
+
add(handler.value);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
break;
|
|
1360
|
+
}
|
|
1361
|
+
case 'gptFunction':
|
|
1362
|
+
if (data.useNameInput !== true && typeof data.name === 'string' && data.name) {
|
|
1363
|
+
autoDelegatedToolNames.push(data.name);
|
|
1364
|
+
}
|
|
1365
|
+
break;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
if (graph.nodes.some((node) => node.type === 'delegateFunctionCall' && node.data.autoDelegate === true)) {
|
|
1369
|
+
for (const toolName of autoDelegatedToolNames) {
|
|
1370
|
+
const resolved = findAutoDelegateGraphCandidate(graphEntries, toolName, ([, candidate]) => candidate.metadata?.name);
|
|
1371
|
+
if (resolved)
|
|
1372
|
+
children.add(resolved[0]);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return [...children];
|
|
1376
|
+
};
|
|
1377
|
+
const collect = (id) => {
|
|
1378
|
+
if (visited.has(id))
|
|
1379
|
+
return { graphId: id, cycle: true };
|
|
1380
|
+
visited.add(id);
|
|
1381
|
+
const graph = project.graphs[id];
|
|
1382
|
+
if (!graph)
|
|
1383
|
+
return { graphId: id, missing: true };
|
|
1384
|
+
const dependencies = getStaticChildren(graph).map(collect);
|
|
1385
|
+
return { graph: executionGraphConfiguration(graph), dependencies };
|
|
1386
|
+
};
|
|
1387
|
+
return fingerprintExecutionProject({
|
|
1388
|
+
graphDependency: collect(graphId),
|
|
1389
|
+
projectExecutionConfiguration: executionProjectConfiguration(project),
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
function unavailableThresholdResult(threshold, message) {
|
|
1393
|
+
return {
|
|
1394
|
+
id: threshold.id,
|
|
1395
|
+
metric: threshold.metric,
|
|
1396
|
+
operator: threshold.operator,
|
|
1397
|
+
status: 'unavailable',
|
|
1398
|
+
expectedValue: threshold.value,
|
|
1399
|
+
message,
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
export function evaluateThresholdResults(aggregateValue, thresholds = [], baseline) {
|
|
1403
|
+
return thresholds.map((threshold) => {
|
|
1404
|
+
const actual = thresholdValue(aggregateValue, threshold.metric);
|
|
1405
|
+
if (actual === undefined || !Number.isFinite(actual)) {
|
|
1406
|
+
const message = threshold.metric === 'average-cost' || threshold.metric === 'total-cost'
|
|
1407
|
+
? 'Some provider pricing was unavailable, so this cost requirement could not be evaluated.'
|
|
1408
|
+
: threshold.metric === 'pass-rate'
|
|
1409
|
+
? 'No completed trial produced an evaluated pass or fail result.'
|
|
1410
|
+
: `The metric "${threshold.metric}" was unavailable.`;
|
|
1411
|
+
return unavailableThresholdResult(threshold, message);
|
|
1412
|
+
}
|
|
1413
|
+
if (threshold.operator === 'max-regression') {
|
|
1414
|
+
const isCostMetric = threshold.metric === 'average-cost' || threshold.metric === 'total-cost';
|
|
1415
|
+
if (isCostMetric &&
|
|
1416
|
+
(baseline?.accountingStatus === 'partial' || baseline?.provenance.accountingComplete === false)) {
|
|
1417
|
+
return unavailableThresholdResult(threshold, 'The baseline has partial accounting because some provider pricing was unavailable, so this cost regression could not be evaluated.');
|
|
1418
|
+
}
|
|
1419
|
+
const previous = baseline ? thresholdValue(baseline.aggregate, threshold.metric) : undefined;
|
|
1420
|
+
if (previous === undefined || !Number.isFinite(previous)) {
|
|
1421
|
+
return unavailableThresholdResult(threshold, baseline
|
|
1422
|
+
? `The baseline does not contain a comparable "${threshold.metric}" metric.`
|
|
1423
|
+
: 'A compatible baseline is required for this regression requirement.');
|
|
1424
|
+
}
|
|
1425
|
+
const delta = isHigherBetterMetric(threshold.metric) ? previous - actual : actual - previous;
|
|
1426
|
+
const zeroBaselineRegression = previous === 0 && delta > 0;
|
|
1427
|
+
const regression = previous === 0 ? 0 : delta / Math.abs(previous);
|
|
1428
|
+
const failed = zeroBaselineRegression || regression > threshold.value;
|
|
1429
|
+
return {
|
|
1430
|
+
id: threshold.id,
|
|
1431
|
+
metric: threshold.metric,
|
|
1432
|
+
operator: threshold.operator,
|
|
1433
|
+
status: failed ? 'failed' : 'passed',
|
|
1434
|
+
expectedValue: threshold.value,
|
|
1435
|
+
actualValue: actual,
|
|
1436
|
+
baselineValue: previous,
|
|
1437
|
+
...(zeroBaselineRegression ? {} : { regression }),
|
|
1438
|
+
message: failed
|
|
1439
|
+
? `Regression exceeded the allowed ${formatThresholdResultValue(threshold.metric, threshold.operator, threshold.value)}.`
|
|
1440
|
+
: `Regression stayed within the allowed ${formatThresholdResultValue(threshold.metric, threshold.operator, threshold.value)}.`,
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
const passed = threshold.operator === 'at-least' ? actual >= threshold.value : actual <= threshold.value;
|
|
1444
|
+
return {
|
|
1445
|
+
id: threshold.id,
|
|
1446
|
+
metric: threshold.metric,
|
|
1447
|
+
operator: threshold.operator,
|
|
1448
|
+
status: passed ? 'passed' : 'failed',
|
|
1449
|
+
expectedValue: threshold.value,
|
|
1450
|
+
actualValue: actual,
|
|
1451
|
+
message: passed
|
|
1452
|
+
? `Actual value ${formatThresholdResultValue(threshold.metric, threshold.operator, actual)} satisfied ${threshold.operator} ${formatThresholdResultValue(threshold.metric, threshold.operator, threshold.value)}.`
|
|
1453
|
+
: `Actual value ${formatThresholdResultValue(threshold.metric, threshold.operator, actual)} did not satisfy ${threshold.operator} ${formatThresholdResultValue(threshold.metric, threshold.operator, threshold.value)}.`,
|
|
1454
|
+
};
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
function createProvenance(project, suite, dataset, options) {
|
|
1458
|
+
const purpose = options.purpose ?? 'evaluation';
|
|
1459
|
+
const evaluationMode = getEvaluationSuiteMode(suite);
|
|
1460
|
+
const configuration = suite.configuration;
|
|
1461
|
+
const materialEvaluators = suite.evaluators.map((evaluator) => ({
|
|
1462
|
+
graphId: evaluator.graphId,
|
|
1463
|
+
inputBindings: evaluator.inputBindings?.slice().sort((left, right) => left.graphInputId.localeCompare(right.graphInputId)) ??
|
|
1464
|
+
null,
|
|
1465
|
+
// Normalize defaults so editing a control between its implicit and
|
|
1466
|
+
// explicit default does not invalidate a baseline.
|
|
1467
|
+
scoreWeight: evaluator.scoreWeight ?? 1,
|
|
1468
|
+
...(evaluationMode === 'scoring'
|
|
1469
|
+
? {}
|
|
1470
|
+
: { required: evaluator.required !== false, runOnTargetError: evaluator.runOnTargetError === true }),
|
|
1471
|
+
}));
|
|
1472
|
+
const materialSuiteDefinition = {
|
|
1473
|
+
purpose,
|
|
1474
|
+
evaluationMode,
|
|
1475
|
+
targetGraphId: suite.targetGraphId,
|
|
1476
|
+
datasetId: suite.datasetId,
|
|
1477
|
+
inputBindings: suite.inputBindings
|
|
1478
|
+
.slice()
|
|
1479
|
+
.sort((left, right) => left.graphInputId.localeCompare(right.graphInputId)),
|
|
1480
|
+
configuration: {
|
|
1481
|
+
trialCount: configuration?.trialCount ?? 1,
|
|
1482
|
+
concurrency: configuration?.concurrency ?? DEFAULT_CONCURRENCY,
|
|
1483
|
+
timeoutMs: configuration?.timeoutMs ?? null,
|
|
1484
|
+
seed: configuration?.seed ?? null,
|
|
1485
|
+
seedGraphInputId: configuration?.seedGraphInputId ?? null,
|
|
1486
|
+
},
|
|
1487
|
+
...(purpose !== 'evaluation'
|
|
1488
|
+
? {}
|
|
1489
|
+
: evaluationMode === 'scoring'
|
|
1490
|
+
? { evaluators: materialEvaluators }
|
|
1491
|
+
: {
|
|
1492
|
+
assertions: suite.assertions,
|
|
1493
|
+
evaluators: materialEvaluators,
|
|
1494
|
+
thresholds: suite.thresholds ?? [],
|
|
1495
|
+
}),
|
|
1496
|
+
};
|
|
1497
|
+
return {
|
|
1498
|
+
projectFingerprint: options.projectFingerprint ??
|
|
1499
|
+
fingerprintExecutionProject({
|
|
1500
|
+
projectId: project.metadata.id,
|
|
1501
|
+
graphs: executionProjectGraphs(project),
|
|
1502
|
+
executionConfiguration: executionProjectConfiguration(project),
|
|
1503
|
+
}),
|
|
1504
|
+
// Names, descriptions, tags, and recording-retention choices do not
|
|
1505
|
+
// change an execution result. Quality-only definitions likewise cannot
|
|
1506
|
+
// stale an execution-benchmark comparison because they never ran. Pass/
|
|
1507
|
+
// fail assertions and thresholds are equally dormant in scoring mode.
|
|
1508
|
+
suiteFingerprint: fingerprint(materialSuiteDefinition),
|
|
1509
|
+
datasetFingerprint: fingerprintEvaluationDataset(dataset),
|
|
1510
|
+
targetFingerprint: fingerprintGraphDependency(project, suite.targetGraphId),
|
|
1511
|
+
evaluatorFingerprints: purpose === 'evaluation'
|
|
1512
|
+
? Object.fromEntries(suite.evaluators.map((evaluator) => [evaluator.id, fingerprintGraphDependency(project, evaluator.graphId)]))
|
|
1513
|
+
: {},
|
|
1514
|
+
executionMode: options.executionMode ?? 'unknown',
|
|
1515
|
+
accountingComplete: true,
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
export async function runEvaluationSuite(options) {
|
|
1519
|
+
const suite = resolveSuite(options.evaluationData, options.suiteId);
|
|
1520
|
+
const purpose = options.purpose ?? 'evaluation';
|
|
1521
|
+
const evaluationMode = getEvaluationSuiteMode(suite);
|
|
1522
|
+
if (purpose === 'evaluation' && !hasAuthoritativeEvaluationCriteria(suite)) {
|
|
1523
|
+
throw new Error(evaluationMode === 'scoring'
|
|
1524
|
+
? `Scoring evaluation suite "${suite.name}" has no evaluator graph. Add an evaluator that returns result.score or run an execution benchmark.`
|
|
1525
|
+
: `Evaluation suite "${suite.name}" has no required quality check or threshold. Add a quality criterion or run it as an execution benchmark.`);
|
|
1526
|
+
}
|
|
1527
|
+
validateSuite(options.project, suite, options.dataset, purpose);
|
|
1528
|
+
const runId = options.runId ?? makeId('evaluation');
|
|
1529
|
+
const run = {
|
|
1530
|
+
version: 2,
|
|
1531
|
+
id: runId,
|
|
1532
|
+
projectId: options.project.metadata.id,
|
|
1533
|
+
suiteId: suite.id,
|
|
1534
|
+
suiteName: suite.name,
|
|
1535
|
+
revision: 0,
|
|
1536
|
+
startedAt: new Date().toISOString(),
|
|
1537
|
+
purpose,
|
|
1538
|
+
evaluationMode,
|
|
1539
|
+
executionStatus: 'running',
|
|
1540
|
+
qualityStatus: 'not-evaluated',
|
|
1541
|
+
qualityReason: { code: 'in-progress', message: 'The run is still in progress.' },
|
|
1542
|
+
accountingStatus: 'complete',
|
|
1543
|
+
provenance: createProvenance(options.project, suite, options.dataset, options),
|
|
1544
|
+
trials: [],
|
|
1545
|
+
thresholdResults: [],
|
|
1546
|
+
warnings: [],
|
|
1547
|
+
};
|
|
1548
|
+
const trialCount = Math.max(1, Math.floor(suite.configuration?.trialCount ?? 1));
|
|
1549
|
+
const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(suite.configuration?.concurrency ?? DEFAULT_CONCURRENCY)));
|
|
1550
|
+
const work = options.dataset.cases
|
|
1551
|
+
.filter((testCase) => testCase.enabled !== false)
|
|
1552
|
+
.flatMap((testCase, caseIndex) => Array.from({ length: trialCount }, (_, trialIndex) => ({ testCase, caseIndex, trialIndex })));
|
|
1553
|
+
const publish = () => {
|
|
1554
|
+
run.revision = (run.revision ?? 0) + 1;
|
|
1555
|
+
// Progress callbacks and stores may retain earlier revisions. Publish a
|
|
1556
|
+
// fully detached snapshot so later mutations to warnings, provenance,
|
|
1557
|
+
// metrics, observations, or nested trial data cannot rewrite history in
|
|
1558
|
+
// place behind the consumer's back.
|
|
1559
|
+
options.onUpdate?.(structuredClone(run));
|
|
1560
|
+
};
|
|
1561
|
+
// Publish the running shell before constructing the worker-pool promise:
|
|
1562
|
+
// async workers start immediately, so publishing afterwards can otherwise
|
|
1563
|
+
// let the first graph call race ahead of the run's initial state.
|
|
1564
|
+
publish();
|
|
1565
|
+
const workPool = runEvaluationWorkPool({
|
|
1566
|
+
work,
|
|
1567
|
+
concurrency,
|
|
1568
|
+
signal: options.signal,
|
|
1569
|
+
execute: async (task) => {
|
|
1570
|
+
try {
|
|
1571
|
+
return await runTrial({
|
|
1572
|
+
...task,
|
|
1573
|
+
project: options.project,
|
|
1574
|
+
suite,
|
|
1575
|
+
dataset: options.dataset,
|
|
1576
|
+
purpose,
|
|
1577
|
+
runId,
|
|
1578
|
+
runGraph: options.runGraph,
|
|
1579
|
+
signal: options.signal,
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
catch (error) {
|
|
1583
|
+
const executionStatus = isAbort(error) ? 'canceled' : 'error';
|
|
1584
|
+
const qualityStatus = purpose === 'evaluation' && executionStatus === 'error'
|
|
1585
|
+
? evaluationMode === 'scoring'
|
|
1586
|
+
? 'unable-to-evaluate'
|
|
1587
|
+
: 'failed'
|
|
1588
|
+
: 'not-evaluated';
|
|
1589
|
+
return {
|
|
1590
|
+
id: makeId('trial'),
|
|
1591
|
+
caseId: task.testCase.id,
|
|
1592
|
+
caseName: task.testCase.name,
|
|
1593
|
+
caseIndex: task.caseIndex,
|
|
1594
|
+
trialIndex: task.trialIndex,
|
|
1595
|
+
executionStatus,
|
|
1596
|
+
qualityStatus,
|
|
1597
|
+
qualityReason: executionStatus === 'canceled'
|
|
1598
|
+
? { code: 'canceled', message: 'The evaluation trial was canceled.' }
|
|
1599
|
+
: purpose === 'evaluation'
|
|
1600
|
+
? evaluationMode === 'scoring'
|
|
1601
|
+
? { code: 'scores-incomplete', message: 'The target graph could not produce a score.' }
|
|
1602
|
+
: { code: 'target-error', message: 'The target graph could not be executed.' }
|
|
1603
|
+
: { code: 'benchmark', message: 'This benchmark trial could not be executed.' },
|
|
1604
|
+
inputs: {},
|
|
1605
|
+
expected: {},
|
|
1606
|
+
outputs: {},
|
|
1607
|
+
observations: [],
|
|
1608
|
+
targetMetrics: createEmptyMetrics(),
|
|
1609
|
+
evaluatorMetrics: createEmptyMetrics(),
|
|
1610
|
+
totalMetrics: executionStatus === 'canceled' ? { ...createEmptyMetrics(), hasUnknownCost: true } : createEmptyMetrics(),
|
|
1611
|
+
error: toErrorMessage(error),
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
},
|
|
1615
|
+
onSettled: (_result, _index, currentResults) => {
|
|
1616
|
+
run.trials = currentResults.filter((result) => result !== undefined);
|
|
1617
|
+
publish();
|
|
1618
|
+
},
|
|
1619
|
+
});
|
|
1620
|
+
const results = await workPool;
|
|
1621
|
+
run.trials = results.filter((result) => result !== undefined);
|
|
1622
|
+
run.completedAt = new Date().toISOString();
|
|
1623
|
+
const outcome = aggregate(run.trials, evaluationMode);
|
|
1624
|
+
run.aggregate = outcome.aggregate;
|
|
1625
|
+
run.provenance.accountingComplete = !run.trials.some((trial) => trial.totalMetrics.hasUnknownCost);
|
|
1626
|
+
run.accountingStatus = run.provenance.accountingComplete ? 'complete' : 'partial';
|
|
1627
|
+
if (run.accountingStatus === 'partial') {
|
|
1628
|
+
run.warnings.push('Some provider pricing was unavailable. Cost totals are unavailable, and cost requirements cannot be evaluated.');
|
|
1629
|
+
}
|
|
1630
|
+
if (options.signal?.aborted) {
|
|
1631
|
+
run.executionStatus = 'canceled';
|
|
1632
|
+
run.qualityStatus = 'not-evaluated';
|
|
1633
|
+
run.qualityReason = { code: 'canceled', message: 'The evaluation run was canceled.' };
|
|
1634
|
+
}
|
|
1635
|
+
else if (purpose === 'execution-benchmark') {
|
|
1636
|
+
run.executionStatus = 'completed';
|
|
1637
|
+
run.qualityStatus = 'not-evaluated';
|
|
1638
|
+
run.qualityReason = {
|
|
1639
|
+
code: 'benchmark',
|
|
1640
|
+
message: 'This run measured execution without evaluating output quality.',
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
else if (evaluationMode === 'scoring') {
|
|
1644
|
+
run.executionStatus = 'completed';
|
|
1645
|
+
if ((outcome.aggregate.missingScoreTrialCount ?? run.trials.length) > 0) {
|
|
1646
|
+
run.qualityStatus = 'unable-to-evaluate';
|
|
1647
|
+
run.qualityReason = {
|
|
1648
|
+
code: 'scores-incomplete',
|
|
1649
|
+
message: 'One or more requested trials did not produce a usable score. Available averages are shown with coverage.',
|
|
1650
|
+
};
|
|
1651
|
+
run.warnings.push(`Score coverage is ${outcome.aggregate.scoredTrialCount ?? 0} of ${outcome.aggregate.trialCount} requested trials.`);
|
|
1652
|
+
}
|
|
1653
|
+
else if ((outcome.aggregate.scoredTrialCount ?? 0) > 0) {
|
|
1654
|
+
run.qualityStatus = 'scored';
|
|
1655
|
+
run.qualityReason = {
|
|
1656
|
+
code: 'scores-complete',
|
|
1657
|
+
message: 'Every requested trial produced a score. Overall score is the equal-weight average of case averages.',
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
else {
|
|
1661
|
+
run.qualityStatus = 'unable-to-evaluate';
|
|
1662
|
+
run.qualityReason = { code: 'scores-incomplete', message: 'No requested trial produced a usable score.' };
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
else {
|
|
1666
|
+
const baseline = options.baseline ?? options.evaluationData.baselines.find((candidate) => candidate.suiteId === suite.id);
|
|
1667
|
+
const usableBaseline = baseline && baselineCompatible(run.provenance, baseline) ? baseline : undefined;
|
|
1668
|
+
if (baseline && !usableBaseline)
|
|
1669
|
+
run.warnings.push('The suite baseline is stale because its target, dataset, bindings, or evaluator definition changed.');
|
|
1670
|
+
run.thresholdResults = evaluateThresholdResults(outcome.aggregate, suite.thresholds, usableBaseline);
|
|
1671
|
+
const hasPassRateThreshold = suite.thresholds?.some((threshold) => threshold.metric === 'pass-rate') ?? false;
|
|
1672
|
+
const hasTargetErrorRateThreshold = suite.thresholds?.some((threshold) => threshold.metric === 'target-error-rate') ?? false;
|
|
1673
|
+
// Per-trial checks and target execution errors are strict by default. An
|
|
1674
|
+
// author can deliberately replace either default with its matching
|
|
1675
|
+
// aggregate tolerance, but one kind of threshold must never hide the
|
|
1676
|
+
// other kind of failure.
|
|
1677
|
+
const hasUngovernedCheckFailure = !hasPassRateThreshold &&
|
|
1678
|
+
run.trials.some((trial) => trial.executionStatus === 'completed' && trial.qualityStatus === 'failed');
|
|
1679
|
+
const hasUngovernedTargetError = !hasTargetErrorRateThreshold && run.trials.some((trial) => trial.executionStatus === 'error');
|
|
1680
|
+
const hasThresholdFailure = run.thresholdResults.some((result) => result.status === 'failed');
|
|
1681
|
+
const hasUnavailableEvidence = run.trials.some((trial) => trial.qualityStatus === 'unable-to-evaluate') ||
|
|
1682
|
+
run.thresholdResults.some((result) => result.status === 'unavailable');
|
|
1683
|
+
const hasAuthoritativePass = run.trials.some((trial) => trial.executionStatus === 'completed' && trial.qualityStatus === 'passed') ||
|
|
1684
|
+
run.thresholdResults.some((result) => result.status === 'passed');
|
|
1685
|
+
if (hasUngovernedCheckFailure || hasUngovernedTargetError || hasThresholdFailure) {
|
|
1686
|
+
run.qualityStatus = 'failed';
|
|
1687
|
+
run.qualityReason = {
|
|
1688
|
+
code: hasThresholdFailure ? 'thresholds-failed' : 'checks-failed',
|
|
1689
|
+
message: hasThresholdFailure
|
|
1690
|
+
? 'One or more required aggregate thresholds failed.'
|
|
1691
|
+
: 'One or more target executions or required quality checks failed.',
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
else if (hasUnavailableEvidence) {
|
|
1695
|
+
run.qualityStatus = 'unable-to-evaluate';
|
|
1696
|
+
run.qualityReason = {
|
|
1697
|
+
code: run.thresholdResults.some((result) => result.status === 'unavailable')
|
|
1698
|
+
? 'required-metric-unavailable'
|
|
1699
|
+
: 'required-check-error',
|
|
1700
|
+
message: run.thresholdResults.some((result) => result.status === 'unavailable')
|
|
1701
|
+
? 'A required aggregate metric could not be evaluated.'
|
|
1702
|
+
: 'A required quality check could not be evaluated.',
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
else if (hasAuthoritativePass) {
|
|
1706
|
+
run.qualityStatus = 'passed';
|
|
1707
|
+
run.qualityReason = {
|
|
1708
|
+
code: run.thresholdResults.length > 0 ? 'thresholds-passed' : 'checks-passed',
|
|
1709
|
+
message: run.thresholdResults.length > 0
|
|
1710
|
+
? 'All required quality checks and aggregate thresholds passed.'
|
|
1711
|
+
: 'All required quality checks passed.',
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
else {
|
|
1715
|
+
run.qualityStatus = 'not-evaluated';
|
|
1716
|
+
run.qualityReason = {
|
|
1717
|
+
code: 'no-completed-trials',
|
|
1718
|
+
message: 'No completed trial produced an authoritative quality result.',
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
run.executionStatus = 'completed';
|
|
1722
|
+
}
|
|
1723
|
+
publish();
|
|
1724
|
+
return run;
|
|
1725
|
+
}
|
|
1726
|
+
export async function runEvaluationCases(options) {
|
|
1727
|
+
const permitted = new Set(options.caseIds);
|
|
1728
|
+
return runEvaluationSuite({
|
|
1729
|
+
...options,
|
|
1730
|
+
dataset: { ...options.dataset, cases: options.dataset.cases.filter((testCase) => permitted.has(testCase.id)) },
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
export function summarizeEvaluationRun(run) {
|
|
1734
|
+
return run.aggregate ? aggregate(run.trials, getEvaluationSuiteMode(run)) : undefined;
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Baselines deliberately retain aggregate/provenance only. Raw prompts,
|
|
1738
|
+
* outputs, credentials, and replay artifacts remain in the run store.
|
|
1739
|
+
*/
|
|
1740
|
+
export function createEvaluationBaselineSnapshot(run) {
|
|
1741
|
+
const normalizedRun = normalizeEvaluationRun(run);
|
|
1742
|
+
if (normalizedRun.executionStatus !== 'completed') {
|
|
1743
|
+
throw new Error('Only a completed evaluation run can be promoted to a baseline.');
|
|
1744
|
+
}
|
|
1745
|
+
if (normalizedRun.purpose !== 'execution-benchmark' &&
|
|
1746
|
+
getEvaluationSuiteMode(normalizedRun) === 'scoring' &&
|
|
1747
|
+
normalizedRun.qualityStatus !== 'scored') {
|
|
1748
|
+
throw new Error('A scoring baseline needs a complete score for every requested trial.');
|
|
1749
|
+
}
|
|
1750
|
+
const summary = summarizeEvaluationRun(normalizedRun);
|
|
1751
|
+
if (!summary)
|
|
1752
|
+
throw new Error('Only a completed evaluation run can be promoted to a baseline.');
|
|
1753
|
+
return {
|
|
1754
|
+
id: makeId('baseline'),
|
|
1755
|
+
suiteId: normalizedRun.suiteId,
|
|
1756
|
+
sourceRunId: normalizedRun.id,
|
|
1757
|
+
createdAt: new Date().toISOString(),
|
|
1758
|
+
provenance: normalizedRun.provenance,
|
|
1759
|
+
aggregate: summary.aggregate,
|
|
1760
|
+
purpose: normalizedRun.purpose,
|
|
1761
|
+
evaluationMode: getEvaluationSuiteMode(normalizedRun),
|
|
1762
|
+
qualityStatus: normalizedRun.qualityStatus,
|
|
1763
|
+
qualityReason: normalizedRun.qualityReason,
|
|
1764
|
+
accountingStatus: normalizedRun.accountingStatus,
|
|
1765
|
+
cases: summary.cases,
|
|
1766
|
+
};
|
|
1767
|
+
}
|