praxis-agent 0.67.2 → 0.69.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/README.md +21 -1
- package/dist/build-identity.json +1 -1
- package/dist/cli-runtime.js +32 -3
- package/dist/evals/eval-contract.d.ts +2 -0
- package/dist/evals/held-out-corpus.d.ts +28 -0
- package/dist/evals/held-out-corpus.js +303 -0
- package/dist/evals/held-out-qualification.d.ts +140 -0
- package/dist/evals/held-out-qualification.js +1061 -0
- package/dist/evals/project-eval-runner.d.ts +2 -0
- package/dist/evals/project-eval-runner.js +4 -0
- package/dist/evals/project-eval.d.ts +3 -1
- package/dist/evals/project-eval.js +16 -0
- package/package.json +4 -2
|
@@ -0,0 +1,1061 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { writeFileAtomically } from '../platform/atomic-write.js';
|
|
6
|
+
import { validatePraxisBuildIdentity, } from '../platform/praxis-build-identity.js';
|
|
7
|
+
import { resolveEvalAllowedTools } from './eval-contract.js';
|
|
8
|
+
import { loadHeldOutCorpus } from './held-out-corpus.js';
|
|
9
|
+
import { assertProjectEvalIdentitiesComparable, createProjectEvalIdentity, validateProjectEvalIdentity, } from './project-eval-identity.js';
|
|
10
|
+
import { loadProjectEvalAggregate, } from './project-eval-comparison.js';
|
|
11
|
+
import { createProjectEvalWorkspace, cleanupProjectEvalWorkspace, } from './project-eval-workspace.js';
|
|
12
|
+
/**
|
|
13
|
+
* Qualification orchestration for the immutable held-out corpus.
|
|
14
|
+
*
|
|
15
|
+
* This module owns the qualification contract and evidence envelope; actual
|
|
16
|
+
* case execution remains in the Project Eval runner.
|
|
17
|
+
*/
|
|
18
|
+
export const HELD_OUT_QUALIFICATION_HELP = `Usage: praxis eval qualify [options] <corpus>
|
|
19
|
+
|
|
20
|
+
Qualify an explicitly pinned provider/model against the immutable held-out corpus.
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
|
|
24
|
+
Required:
|
|
25
|
+
--provider <id> Provider identifier
|
|
26
|
+
--profile <id> Provider profile identifier
|
|
27
|
+
--model <id> Model identifier
|
|
28
|
+
--confirm-held-out <id@sha256> Confirm the exact held-out corpus digest
|
|
29
|
+
--run-verification Run the declared case verifiers
|
|
30
|
+
--output-dir <dir> Write qualification artifacts here
|
|
31
|
+
--allow-tools <rules> Grant gated tools; comma-separated and repeatable
|
|
32
|
+
|
|
33
|
+
Optional:
|
|
34
|
+
--baseline <qualification-result.json> Compare against a completed result
|
|
35
|
+
--keep-temp Preserve temporary workspaces
|
|
36
|
+
--json Print exactly one qualification JSON value
|
|
37
|
+
--verbose Print run progress to stderr
|
|
38
|
+
-h, --help Display help`;
|
|
39
|
+
function optionValue(argv, index, option) {
|
|
40
|
+
const value = argv[index + 1];
|
|
41
|
+
if (!value || value.startsWith('-'))
|
|
42
|
+
throw new Error(`${option} requires a value`);
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function listValues(value, option) {
|
|
46
|
+
const values = value.split(',').map((item) => item.trim());
|
|
47
|
+
if (values.some((item) => item.length === 0))
|
|
48
|
+
throw new Error(`${option} contains an empty value`);
|
|
49
|
+
return values;
|
|
50
|
+
}
|
|
51
|
+
function scalar(options, key, option, value) {
|
|
52
|
+
if (options[key] !== undefined)
|
|
53
|
+
throw new Error(`${option} may be specified only once`);
|
|
54
|
+
options[key] = value;
|
|
55
|
+
}
|
|
56
|
+
/** Parse the strict, side-effect-free qualification command line. */
|
|
57
|
+
export function parseHeldOutQualificationOptions(argv) {
|
|
58
|
+
const options = {
|
|
59
|
+
allowTools: [],
|
|
60
|
+
runVerification: false,
|
|
61
|
+
keepTemp: false,
|
|
62
|
+
json: false,
|
|
63
|
+
verbose: false,
|
|
64
|
+
};
|
|
65
|
+
const operands = [];
|
|
66
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
67
|
+
const value = argv[index];
|
|
68
|
+
if (!value)
|
|
69
|
+
continue;
|
|
70
|
+
if (value === '-h' || value === '--help')
|
|
71
|
+
return { ...options, help: true };
|
|
72
|
+
if (value === '--run-verification') {
|
|
73
|
+
if (options.runVerification)
|
|
74
|
+
throw new Error(`${value} may be specified only once`);
|
|
75
|
+
options.runVerification = true;
|
|
76
|
+
}
|
|
77
|
+
else if (value === '--keep-temp') {
|
|
78
|
+
if (options.keepTemp)
|
|
79
|
+
throw new Error(`${value} may be specified only once`);
|
|
80
|
+
options.keepTemp = true;
|
|
81
|
+
}
|
|
82
|
+
else if (value === '--json') {
|
|
83
|
+
if (options.json)
|
|
84
|
+
throw new Error(`${value} may be specified only once`);
|
|
85
|
+
options.json = true;
|
|
86
|
+
}
|
|
87
|
+
else if (value === '--verbose') {
|
|
88
|
+
if (options.verbose)
|
|
89
|
+
throw new Error(`${value} may be specified only once`);
|
|
90
|
+
options.verbose = true;
|
|
91
|
+
}
|
|
92
|
+
else if (value === '--provider' ||
|
|
93
|
+
value === '--profile' ||
|
|
94
|
+
value === '--model' ||
|
|
95
|
+
value === '--confirm-held-out' ||
|
|
96
|
+
value === '--output-dir' ||
|
|
97
|
+
value === '--baseline') {
|
|
98
|
+
const selected = optionValue(argv, index, value);
|
|
99
|
+
index += 1;
|
|
100
|
+
const key = {
|
|
101
|
+
'--provider': 'provider',
|
|
102
|
+
'--profile': 'profile',
|
|
103
|
+
'--model': 'model',
|
|
104
|
+
'--confirm-held-out': 'confirmHeldOut',
|
|
105
|
+
'--output-dir': 'outputDir',
|
|
106
|
+
'--baseline': 'baseline',
|
|
107
|
+
}[value];
|
|
108
|
+
scalar(options, key, value, selected);
|
|
109
|
+
}
|
|
110
|
+
else if (value === '--allow-tools') {
|
|
111
|
+
options.allowTools.push(...listValues(optionValue(argv, index, value), value));
|
|
112
|
+
index += 1;
|
|
113
|
+
}
|
|
114
|
+
else if (value.startsWith('-')) {
|
|
115
|
+
throw new Error(`Unknown eval qualify option: ${value}`);
|
|
116
|
+
}
|
|
117
|
+
else
|
|
118
|
+
operands.push(value);
|
|
119
|
+
}
|
|
120
|
+
if (operands.length !== 1)
|
|
121
|
+
throw new Error('eval qualify requires one corpus');
|
|
122
|
+
const corpus = operands[0];
|
|
123
|
+
if (!corpus)
|
|
124
|
+
throw new Error('eval qualify requires one corpus');
|
|
125
|
+
options.corpus = corpus;
|
|
126
|
+
for (const [option, selected] of [
|
|
127
|
+
['--provider', options.provider],
|
|
128
|
+
['--profile', options.profile],
|
|
129
|
+
['--model', options.model],
|
|
130
|
+
['--confirm-held-out', options.confirmHeldOut],
|
|
131
|
+
['--output-dir', options.outputDir],
|
|
132
|
+
])
|
|
133
|
+
if (!selected)
|
|
134
|
+
throw new Error(`${option} is required`);
|
|
135
|
+
if (!options.runVerification)
|
|
136
|
+
throw new Error('--run-verification is required');
|
|
137
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(options.provider ?? ''))
|
|
138
|
+
throw new Error('--provider is not a safe eval identifier');
|
|
139
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(options.profile ?? ''))
|
|
140
|
+
throw new Error('--profile is not a safe eval identifier');
|
|
141
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/u.test(options.model ?? ''))
|
|
142
|
+
throw new Error('--model is not a safe eval identifier');
|
|
143
|
+
if (!/^praxis-held-out-v1@sha256:[0-9a-f]{64}$/u.test(options.confirmHeldOut ?? ''))
|
|
144
|
+
throw new Error('--confirm-held-out must be <id>@sha256:<64 lowercase hex>');
|
|
145
|
+
return options;
|
|
146
|
+
}
|
|
147
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
|
148
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
149
|
+
const MODEL_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/u;
|
|
150
|
+
const MAX_RESULT_BYTES = 16 * 1024 * 1024;
|
|
151
|
+
const PROJECT_EVAL_ARTIFACTS = [
|
|
152
|
+
'trace.jsonl',
|
|
153
|
+
'workspace-diff.json',
|
|
154
|
+
'verification.json',
|
|
155
|
+
'identity.json',
|
|
156
|
+
'result.json',
|
|
157
|
+
];
|
|
158
|
+
function digest(value) {
|
|
159
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
160
|
+
}
|
|
161
|
+
function deepFreeze(value) {
|
|
162
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
163
|
+
Object.freeze(value);
|
|
164
|
+
for (const child of Object.values(value))
|
|
165
|
+
deepFreeze(child);
|
|
166
|
+
}
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
function compareStrings(left, right) {
|
|
170
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
171
|
+
}
|
|
172
|
+
function canonical(value) {
|
|
173
|
+
if (value === null || typeof value !== 'object')
|
|
174
|
+
return JSON.stringify(value);
|
|
175
|
+
if (Array.isArray(value))
|
|
176
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
177
|
+
return `{${Object.entries(value)
|
|
178
|
+
.sort(([a], [b]) => compareStrings(a, b))
|
|
179
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`)
|
|
180
|
+
.join(',')}}`;
|
|
181
|
+
}
|
|
182
|
+
function assertSafePath(path, label) {
|
|
183
|
+
if (!path || !isAbsolute(path) || path.includes('\0'))
|
|
184
|
+
throw new Error(`${label} must be an absolute path`);
|
|
185
|
+
return resolve(path);
|
|
186
|
+
}
|
|
187
|
+
async function assertNoSymlinkComponents(path, label) {
|
|
188
|
+
const absolute = resolve(path);
|
|
189
|
+
const parts = absolute.split(sep);
|
|
190
|
+
let current = parts[0] === '' ? sep : (parts.shift() ?? '');
|
|
191
|
+
for (const part of parts) {
|
|
192
|
+
if (!part)
|
|
193
|
+
continue;
|
|
194
|
+
current =
|
|
195
|
+
current === sep ? joinPath(current, part) : joinPath(current, part);
|
|
196
|
+
const info = await lstat(current).catch(() => null);
|
|
197
|
+
// macOS exposes /tmp as a platform-owned symlink to /private/tmp. It is
|
|
198
|
+
// safe to accept that fixed system alias while rejecting all user path
|
|
199
|
+
// components.
|
|
200
|
+
if (info?.isSymbolicLink() &&
|
|
201
|
+
current !== tmpdir() &&
|
|
202
|
+
current !== resolve(tmpdir()) &&
|
|
203
|
+
current !== '/tmp' &&
|
|
204
|
+
current !== '/private/tmp' &&
|
|
205
|
+
current !== '/var')
|
|
206
|
+
throw new Error(`${label} contains symlink`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function joinPath(left, right) {
|
|
210
|
+
return left.endsWith(sep) ? `${left}${right}` : `${left}${sep}${right}`;
|
|
211
|
+
}
|
|
212
|
+
async function admitOutputDirectory(path) {
|
|
213
|
+
const output = assertSafePath(path, '--output-dir');
|
|
214
|
+
await assertNoSymlinkComponents(dirname(output), '--output-dir');
|
|
215
|
+
if (await lstat(output).catch(() => null))
|
|
216
|
+
throw new Error('--output-dir must not already exist');
|
|
217
|
+
return output;
|
|
218
|
+
}
|
|
219
|
+
async function admitBaseline(path, output) {
|
|
220
|
+
const baseline = assertSafePath(path, '--baseline');
|
|
221
|
+
await assertNoSymlinkComponents(baseline, '--baseline');
|
|
222
|
+
if (baseline === output || baseline.startsWith(`${output}${sep}`))
|
|
223
|
+
throw new Error('--baseline must not equal or be inside --output-dir');
|
|
224
|
+
const info = await lstat(baseline).catch(() => null);
|
|
225
|
+
if (!info?.isFile() || info.isSymbolicLink())
|
|
226
|
+
throw new Error('--baseline must be a regular file');
|
|
227
|
+
return baseline;
|
|
228
|
+
}
|
|
229
|
+
async function assertProjectEvalArtifacts(aggregate, aggregatePath, expectedOutput) {
|
|
230
|
+
const artifactRoot = dirname(resolve(aggregatePath));
|
|
231
|
+
if (expectedOutput !== undefined &&
|
|
232
|
+
resolve(aggregate.output_dir) !== resolve(expectedOutput))
|
|
233
|
+
throw new Error('Project Eval aggregate output directory drifted');
|
|
234
|
+
for (const run of aggregate.runs) {
|
|
235
|
+
const expectedDirectory = `${run.case}/run-${run.run}`;
|
|
236
|
+
if (run.artifact_dir !== expectedDirectory)
|
|
237
|
+
throw new Error(`Project Eval artifact directory is invalid for ${run.case} run ${run.run}`);
|
|
238
|
+
const directory = resolve(artifactRoot, run.artifact_dir);
|
|
239
|
+
if (!directory.startsWith(`${artifactRoot}${sep}`))
|
|
240
|
+
throw new Error(`Project Eval artifact directory escapes output for ${run.case} run ${run.run}`);
|
|
241
|
+
await assertNoSymlinkComponents(directory, 'Project Eval artifact');
|
|
242
|
+
for (const name of PROJECT_EVAL_ARTIFACTS) {
|
|
243
|
+
const path = join(directory, name);
|
|
244
|
+
const info = await lstat(path).catch(() => null);
|
|
245
|
+
if (!info?.isFile() || info.isSymbolicLink())
|
|
246
|
+
throw new Error(`Project Eval artifact is missing or unsafe for ${run.case} run ${run.run}: ${name}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function median(values) {
|
|
251
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
252
|
+
if (!sorted.length)
|
|
253
|
+
throw new Error('Qualification requires completed runs');
|
|
254
|
+
const middle = Math.floor(sorted.length / 2);
|
|
255
|
+
return sorted.length % 2
|
|
256
|
+
? sorted[middle]
|
|
257
|
+
: (sorted[middle - 1] + sorted[middle]) / 2;
|
|
258
|
+
}
|
|
259
|
+
function p95(values) {
|
|
260
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
261
|
+
if (!sorted.length)
|
|
262
|
+
throw new Error('Qualification requires completed runs');
|
|
263
|
+
return sorted[Math.ceil(sorted.length * 0.95) - 1];
|
|
264
|
+
}
|
|
265
|
+
function runKey(run) {
|
|
266
|
+
return `${run.case}\u0000${run.run}`;
|
|
267
|
+
}
|
|
268
|
+
function expectedPlanDigest(corpus, build, identities) {
|
|
269
|
+
const cases = [...identities.entries()]
|
|
270
|
+
.sort(([a], [b]) => compareStrings(a, b))
|
|
271
|
+
.map(([name, identity]) => ({ case: name, identity }));
|
|
272
|
+
return digest(canonical({
|
|
273
|
+
corpus: {
|
|
274
|
+
id: corpus.id,
|
|
275
|
+
version: corpus.version,
|
|
276
|
+
content_sha256: corpus.contentSha256,
|
|
277
|
+
repetitions: corpus.repetitions,
|
|
278
|
+
},
|
|
279
|
+
build,
|
|
280
|
+
cases,
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
async function preflight(options, dependencies, callerCwd, signal) {
|
|
284
|
+
const corpus = await loadHeldOutCorpus(resolve(callerCwd, options.corpus));
|
|
285
|
+
if (options.confirmHeldOut !== `${corpus.id}@${corpus.contentSha256}`)
|
|
286
|
+
throw new Error('--confirm-held-out does not match the loaded corpus');
|
|
287
|
+
for (const item of corpus.repositories.flatMap((repository) => repository.cases))
|
|
288
|
+
resolveEvalAllowedTools(item.execution.allowedTools, options.allowTools);
|
|
289
|
+
const output = await admitOutputDirectory(resolve(callerCwd, options.outputDir));
|
|
290
|
+
const baseline = options.baseline
|
|
291
|
+
? await admitBaseline(resolve(callerCwd, options.baseline), output)
|
|
292
|
+
: undefined;
|
|
293
|
+
const baselineData = baseline
|
|
294
|
+
? await loadQualificationBaseline(baseline, corpus)
|
|
295
|
+
: undefined;
|
|
296
|
+
const build = await dependencies.loadBuildIdentity();
|
|
297
|
+
const identities = new Map();
|
|
298
|
+
let protocol;
|
|
299
|
+
let endpoint;
|
|
300
|
+
for (const repository of corpus.repositories) {
|
|
301
|
+
for (const item of repository.cases) {
|
|
302
|
+
if (signal?.aborted)
|
|
303
|
+
throw new Error('Held-out qualification interrupted');
|
|
304
|
+
const workspace = await createProjectEvalWorkspace(item.fixture);
|
|
305
|
+
try {
|
|
306
|
+
const allowedTools = resolveEvalAllowedTools(item.execution.allowedTools, options.allowTools);
|
|
307
|
+
const descriptor = await dependencies.runtimeFactory.identify({
|
|
308
|
+
dataPlane: 'native',
|
|
309
|
+
cwd: workspace.cwd,
|
|
310
|
+
configRoot: workspace.config,
|
|
311
|
+
home: workspace.home,
|
|
312
|
+
...(options.provider === undefined
|
|
313
|
+
? {}
|
|
314
|
+
: { provider: options.provider }),
|
|
315
|
+
...(options.profile === undefined
|
|
316
|
+
? {}
|
|
317
|
+
: { providerProfile: options.profile }),
|
|
318
|
+
...(options.model === undefined ? {} : { model: options.model }),
|
|
319
|
+
maxTurns: item.execution.maxTurns,
|
|
320
|
+
pluginDirectories: [],
|
|
321
|
+
allowedTools,
|
|
322
|
+
...(item.execution.appendSystemPrompt
|
|
323
|
+
? { appendSystemPrompt: item.execution.appendSystemPrompt }
|
|
324
|
+
: {}),
|
|
325
|
+
addDirs: [],
|
|
326
|
+
env: item.execution.env,
|
|
327
|
+
});
|
|
328
|
+
if (descriptor.providerId !== options.provider ||
|
|
329
|
+
descriptor.profileId !== options.profile ||
|
|
330
|
+
descriptor.modelId !== options.model)
|
|
331
|
+
throw new Error(`Identity for ${item.name} does not match requested pin`);
|
|
332
|
+
protocol ??= descriptor.protocol;
|
|
333
|
+
endpoint ??= descriptor.endpoint;
|
|
334
|
+
if (protocol !== descriptor.protocol ||
|
|
335
|
+
endpoint !== descriptor.endpoint)
|
|
336
|
+
throw new Error(`Identity for ${item.name} is not comparable with other cases`);
|
|
337
|
+
identities.set(item.name, createProjectEvalIdentity({
|
|
338
|
+
provider: descriptor,
|
|
339
|
+
case: item,
|
|
340
|
+
sourceBefore: workspace.sourceBefore,
|
|
341
|
+
effectiveTools: allowedTools,
|
|
342
|
+
runVerification: true,
|
|
343
|
+
praxisVersion: dependencies.version ?? 'unknown',
|
|
344
|
+
buildIdentity: build,
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
await cleanupProjectEvalWorkspace(workspace.root);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (baselineData)
|
|
353
|
+
for (const [name, baselineIdentity] of baselineData.identities) {
|
|
354
|
+
const identity = identities.get(name);
|
|
355
|
+
if (!identity)
|
|
356
|
+
throw new Error(`Baseline has unexpected case ${name}`);
|
|
357
|
+
assertProjectEvalIdentitiesComparable(identity, baselineIdentity, `Baseline ${name}`);
|
|
358
|
+
}
|
|
359
|
+
const planDigest = expectedPlanDigest(corpus, build, identities);
|
|
360
|
+
return {
|
|
361
|
+
corpus,
|
|
362
|
+
build,
|
|
363
|
+
output,
|
|
364
|
+
...(baseline ? { baseline } : {}),
|
|
365
|
+
identities,
|
|
366
|
+
plan: planDigest,
|
|
367
|
+
protocol: protocol,
|
|
368
|
+
endpoint: identities.values().next().value
|
|
369
|
+
.endpoint_sha256,
|
|
370
|
+
...(baselineData ? { baselineData } : {}),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function toRunSummary(repository, summary) {
|
|
374
|
+
return {
|
|
375
|
+
repository,
|
|
376
|
+
case: summary.case,
|
|
377
|
+
run: summary.run,
|
|
378
|
+
passed: summary.passed,
|
|
379
|
+
safety_passed: summary.safety_passed,
|
|
380
|
+
verifier_satisfied: summary.verification.satisfied,
|
|
381
|
+
turns: summary.turns,
|
|
382
|
+
duration_ms: summary.duration_ms,
|
|
383
|
+
usage_known: summary.usage !== null,
|
|
384
|
+
cost_known: summary.cost_known,
|
|
385
|
+
usage: summary.usage,
|
|
386
|
+
cost_usd: summary.cost_usd,
|
|
387
|
+
identity: summary.identity,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function aggregateRuns(corpus, aggregates) {
|
|
391
|
+
const byTarget = new Map(aggregates.map((aggregate) => [aggregate.target, aggregate]));
|
|
392
|
+
return corpus.repositories.flatMap((repository) => {
|
|
393
|
+
const aggregate = byTarget.get(repository.target);
|
|
394
|
+
if (!aggregate)
|
|
395
|
+
return [];
|
|
396
|
+
const runs = new Map(aggregate.runs.map((run) => [runKey(run), run]));
|
|
397
|
+
return repository.cases.flatMap((item) => Array.from({ length: corpus.repetitions }, (_, index) => {
|
|
398
|
+
const run = runs.get(runKey({ case: item.name, run: index + 1 }));
|
|
399
|
+
return run ? toRunSummary(repository.id, run) : undefined;
|
|
400
|
+
}).filter((run) => run !== undefined));
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function totals(runs) {
|
|
404
|
+
const usageKnown = runs.every((run) => run.usage_known);
|
|
405
|
+
const usageTotals = usageKnown
|
|
406
|
+
? {
|
|
407
|
+
input_tokens: runs.reduce((n, run) => n + (run.usage?.inputTokens ?? 0), 0),
|
|
408
|
+
output_tokens: runs.reduce((n, run) => n + (run.usage?.outputTokens ?? 0), 0),
|
|
409
|
+
cache_read_input_tokens: runs.reduce((n, run) => n + (run.usage?.cacheReadInputTokens ?? 0), 0),
|
|
410
|
+
cache_creation_input_tokens: runs.reduce((n, run) => n + (run.usage?.cacheCreationInputTokens ?? 0), 0),
|
|
411
|
+
web_search_requests: runs.reduce((n, run) => n + (run.usage?.webSearchRequests ?? 0), 0),
|
|
412
|
+
}
|
|
413
|
+
: null;
|
|
414
|
+
const knownCosts = runs.filter((run) => run.cost_known);
|
|
415
|
+
return {
|
|
416
|
+
usageTotals,
|
|
417
|
+
knownCostTotal: knownCosts.length === runs.length
|
|
418
|
+
? knownCosts.reduce((n, run) => n + (run.cost_usd ?? 0), 0)
|
|
419
|
+
: null,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
function caseSummaries(corpus, runs) {
|
|
423
|
+
return corpus.repositories.flatMap((repository) => repository.cases.map((item) => {
|
|
424
|
+
const selected = runs.filter((run) => run.repository === repository.id && run.case === item.name);
|
|
425
|
+
return {
|
|
426
|
+
repository: repository.id,
|
|
427
|
+
case: item.name,
|
|
428
|
+
repetitions: corpus.repetitions,
|
|
429
|
+
passed: selected.filter((run) => run.passed).length,
|
|
430
|
+
failed: selected.filter((run) => !run.passed).length,
|
|
431
|
+
safety_passed: selected.filter((run) => run.safety_passed).length,
|
|
432
|
+
safety_failed: selected.filter((run) => !run.safety_passed).length,
|
|
433
|
+
verifier_satisfied_runs: selected.filter((run) => run.verifier_satisfied).length,
|
|
434
|
+
verifier_unsatisfied_runs: selected.filter((run) => !run.verifier_satisfied).length,
|
|
435
|
+
passed_all: selected.length === corpus.repetitions &&
|
|
436
|
+
selected.every((run) => run.passed),
|
|
437
|
+
safety_passed_all: selected.length === corpus.repetitions &&
|
|
438
|
+
selected.every((run) => run.safety_passed),
|
|
439
|
+
verifier_satisfied: selected.length === corpus.repetitions &&
|
|
440
|
+
selected.every((run) => run.verifier_satisfied),
|
|
441
|
+
};
|
|
442
|
+
}));
|
|
443
|
+
}
|
|
444
|
+
function assertDerivedQualificationEvidence(result, runs) {
|
|
445
|
+
const passed = runs.filter((run) => run.passed).length;
|
|
446
|
+
const safetyPassed = runs.filter((run) => run.safety_passed).length;
|
|
447
|
+
const verifierSatisfied = runs.filter((run) => run.verifier_satisfied).length;
|
|
448
|
+
const usageKnown = runs.filter((run) => run.usage_known).length;
|
|
449
|
+
const costKnown = runs.filter((run) => run.cost_known).length;
|
|
450
|
+
const known = totals(runs);
|
|
451
|
+
if (result.completed_run_count !== runs.length ||
|
|
452
|
+
result.planned_run_count !== runs.length ||
|
|
453
|
+
result.passed !== passed ||
|
|
454
|
+
result.failed !== runs.length - passed ||
|
|
455
|
+
result.safety_passed !== safetyPassed ||
|
|
456
|
+
result.safety_failed !== runs.length - safetyPassed ||
|
|
457
|
+
result.verifier_satisfied_runs !== verifierSatisfied ||
|
|
458
|
+
result.verifier_unsatisfied_runs !== runs.length - verifierSatisfied ||
|
|
459
|
+
result.usage_known_runs !== usageKnown ||
|
|
460
|
+
result.usage_unknown_runs !== runs.length - usageKnown ||
|
|
461
|
+
result.cost_known_runs !== costKnown ||
|
|
462
|
+
result.cost_unknown_runs !== runs.length - costKnown ||
|
|
463
|
+
JSON.stringify(result.usage_totals) !== JSON.stringify(known.usageTotals) ||
|
|
464
|
+
result.known_cost_total_usd !== known.knownCostTotal ||
|
|
465
|
+
result.median_turns !== median(runs.map((run) => run.turns)) ||
|
|
466
|
+
result.p95_turns !== p95(runs.map((run) => run.turns)) ||
|
|
467
|
+
result.median_duration_ms !== median(runs.map((run) => run.duration_ms)) ||
|
|
468
|
+
result.p95_duration_ms !== p95(runs.map((run) => run.duration_ms)))
|
|
469
|
+
throw new Error('Baseline qualification statistics diverge from aggregate evidence');
|
|
470
|
+
}
|
|
471
|
+
async function loadQualificationBaseline(path, corpus) {
|
|
472
|
+
const content = await readFile(path);
|
|
473
|
+
if (content.byteLength > MAX_RESULT_BYTES)
|
|
474
|
+
throw new Error('Baseline qualification result exceeds size limit');
|
|
475
|
+
let raw;
|
|
476
|
+
try {
|
|
477
|
+
raw = JSON.parse(content.toString('utf8'));
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
throw new Error('Invalid baseline qualification JSON');
|
|
481
|
+
}
|
|
482
|
+
const result = validateQualificationResult(raw);
|
|
483
|
+
if (result.corpus.id !== corpus.id ||
|
|
484
|
+
result.corpus.version !== corpus.version ||
|
|
485
|
+
result.corpus.content_sha256 !== corpus.contentSha256 ||
|
|
486
|
+
result.corpus.task_count !== corpus.taskCount ||
|
|
487
|
+
result.corpus.repetitions !== corpus.repetitions ||
|
|
488
|
+
result.corpus.repository_count !== corpus.repositories.length ||
|
|
489
|
+
result.corpus.planned_run_count !== corpus.plannedRunCount ||
|
|
490
|
+
result.planned_run_count !== corpus.plannedRunCount)
|
|
491
|
+
throw new Error('Baseline qualification corpus does not match candidate corpus');
|
|
492
|
+
if (result.qualified !== null || result.optimization_claim_allowed)
|
|
493
|
+
throw new Error('Baseline qualification must be baseline-only evidence');
|
|
494
|
+
if (result.regressions.length !== 0 ||
|
|
495
|
+
Object.keys(result.metric_deltas).length !== 0)
|
|
496
|
+
throw new Error('Baseline qualification must not contain comparison deltas');
|
|
497
|
+
if (result.aggregates.length !== corpus.repositories.length)
|
|
498
|
+
throw new Error('Baseline aggregate references do not match corpus repositories');
|
|
499
|
+
const loadedAggregates = [];
|
|
500
|
+
for (const repository of corpus.repositories) {
|
|
501
|
+
const reference = result.aggregates[corpus.repositories.indexOf(repository)];
|
|
502
|
+
const expectedPath = `repositories/${repository.id}/aggregate-result.json`;
|
|
503
|
+
if (!reference ||
|
|
504
|
+
reference.repository !== repository.id ||
|
|
505
|
+
reference.path !== expectedPath)
|
|
506
|
+
throw new Error(`Baseline aggregate reference is invalid for ${repository.id}`);
|
|
507
|
+
if (!DIGEST.test(reference.sha256) ||
|
|
508
|
+
!DIGEST.test(reference.identity_sha256))
|
|
509
|
+
throw new Error('Baseline aggregate reference digest is invalid');
|
|
510
|
+
const aggregatePath = resolve(dirname(path), reference.path);
|
|
511
|
+
await assertNoSymlinkComponents(aggregatePath, 'Baseline aggregate');
|
|
512
|
+
const bytes = await readFile(aggregatePath).catch(() => null);
|
|
513
|
+
if (!bytes || digest(bytes.toString('utf8')) !== reference.sha256)
|
|
514
|
+
throw new Error(`Baseline aggregate hash mismatch for ${repository.id}`);
|
|
515
|
+
const loaded = await loadProjectEvalAggregate(aggregatePath);
|
|
516
|
+
await assertProjectEvalArtifacts(loaded.aggregate, aggregatePath);
|
|
517
|
+
if (loaded.aggregate.identity_sha256 !== reference.identity_sha256)
|
|
518
|
+
throw new Error(`Baseline aggregate identity mismatch for ${repository.id}`);
|
|
519
|
+
if (loaded.aggregate.target !== repository.target ||
|
|
520
|
+
loaded.aggregate.case_count !== repository.cases.length ||
|
|
521
|
+
loaded.aggregate.planned_run_count !==
|
|
522
|
+
repository.cases.length * corpus.repetitions ||
|
|
523
|
+
loaded.aggregate.partial ||
|
|
524
|
+
loaded.aggregate.interrupted ||
|
|
525
|
+
loaded.aggregate.completed_run_count !==
|
|
526
|
+
repository.cases.length * corpus.repetitions)
|
|
527
|
+
throw new Error(`Baseline aggregate is incomplete for ${repository.id}`);
|
|
528
|
+
const expectedKeys = new Set(repository.cases.flatMap((item) => Array.from({ length: corpus.repetitions }, (_, index) => runKey({ case: item.name, run: index + 1 }))));
|
|
529
|
+
const aggregateKeysSeen = new Set();
|
|
530
|
+
for (const run of loaded.aggregate.runs) {
|
|
531
|
+
const key = runKey(run);
|
|
532
|
+
if (!expectedKeys.has(key) || aggregateKeysSeen.has(key))
|
|
533
|
+
throw new Error(`Baseline aggregate run set is invalid for ${repository.id}`);
|
|
534
|
+
aggregateKeysSeen.add(key);
|
|
535
|
+
}
|
|
536
|
+
if (aggregateKeysSeen.size !== expectedKeys.size)
|
|
537
|
+
throw new Error(`Baseline aggregate run set is incomplete for ${repository.id}`);
|
|
538
|
+
loadedAggregates.push(loaded.aggregate);
|
|
539
|
+
}
|
|
540
|
+
const derivedRuns = aggregateRuns(corpus, loadedAggregates);
|
|
541
|
+
const expectedRunKeys = derivedRuns.map(runKey).sort(compareStrings);
|
|
542
|
+
const actualRunKeys = result.runs.map(runKey).sort(compareStrings);
|
|
543
|
+
if (expectedRunKeys.length !== actualRunKeys.length ||
|
|
544
|
+
expectedRunKeys.some((key, index) => key !== actualRunKeys[index]))
|
|
545
|
+
throw new Error('Baseline qualification runs do not match aggregate runs');
|
|
546
|
+
if (JSON.stringify(result.runs) !== JSON.stringify(derivedRuns))
|
|
547
|
+
throw new Error('Baseline qualification runs diverge from aggregates');
|
|
548
|
+
if (JSON.stringify(result.cases) !==
|
|
549
|
+
JSON.stringify(caseSummaries(corpus, derivedRuns)))
|
|
550
|
+
throw new Error('Baseline case summaries diverge from aggregates');
|
|
551
|
+
assertDerivedQualificationEvidence(result, derivedRuns);
|
|
552
|
+
const baselineIdentities = new Map();
|
|
553
|
+
for (const run of result.runs) {
|
|
554
|
+
const runtime = run.identity.runtime;
|
|
555
|
+
if (runtime.praxis_version !== result.praxis_version ||
|
|
556
|
+
runtime.node_version !== result.node_version ||
|
|
557
|
+
runtime.platform !== result.platform ||
|
|
558
|
+
runtime.architecture !== result.architecture ||
|
|
559
|
+
canonical(runtime.build) !== canonical(result.build))
|
|
560
|
+
throw new Error(`Baseline runtime identity mismatch for ${run.case}`);
|
|
561
|
+
const previous = baselineIdentities.get(run.case);
|
|
562
|
+
if (previous && canonical(previous) !== canonical(run.identity))
|
|
563
|
+
throw new Error(`Baseline has mixed identities for ${run.case}`);
|
|
564
|
+
baselineIdentities.set(run.case, run.identity);
|
|
565
|
+
}
|
|
566
|
+
if (result.plan_sha256 !==
|
|
567
|
+
expectedPlanDigest(corpus, result.build, baselineIdentities))
|
|
568
|
+
throw new Error('Baseline qualification plan does not match its evidence');
|
|
569
|
+
return {
|
|
570
|
+
result,
|
|
571
|
+
sourceSha256: digest(content.toString('utf8')),
|
|
572
|
+
runs: [...derivedRuns],
|
|
573
|
+
identities: baselineIdentities,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function validateQualificationResult(value) {
|
|
577
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
578
|
+
throw new Error('Invalid qualification result');
|
|
579
|
+
const source = value;
|
|
580
|
+
const required = [
|
|
581
|
+
'schema_version',
|
|
582
|
+
'corpus',
|
|
583
|
+
'provider',
|
|
584
|
+
'profile',
|
|
585
|
+
'protocol',
|
|
586
|
+
'model',
|
|
587
|
+
'endpoint_sha256',
|
|
588
|
+
'plan_sha256',
|
|
589
|
+
'praxis_version',
|
|
590
|
+
'build',
|
|
591
|
+
'node_version',
|
|
592
|
+
'platform',
|
|
593
|
+
'architecture',
|
|
594
|
+
'start',
|
|
595
|
+
'duration_ms',
|
|
596
|
+
'aggregates',
|
|
597
|
+
'planned_run_count',
|
|
598
|
+
'completed_run_count',
|
|
599
|
+
'passed',
|
|
600
|
+
'failed',
|
|
601
|
+
'safety_passed',
|
|
602
|
+
'safety_failed',
|
|
603
|
+
'verifier_satisfied_runs',
|
|
604
|
+
'verifier_unsatisfied_runs',
|
|
605
|
+
'usage_known_runs',
|
|
606
|
+
'usage_unknown_runs',
|
|
607
|
+
'cost_known_runs',
|
|
608
|
+
'cost_unknown_runs',
|
|
609
|
+
'usage_totals',
|
|
610
|
+
'known_cost_total_usd',
|
|
611
|
+
'median_turns',
|
|
612
|
+
'p95_turns',
|
|
613
|
+
'median_duration_ms',
|
|
614
|
+
'p95_duration_ms',
|
|
615
|
+
'cases',
|
|
616
|
+
'runs',
|
|
617
|
+
'regressions',
|
|
618
|
+
'metric_deltas',
|
|
619
|
+
'qualified',
|
|
620
|
+
'optimization_claim_allowed',
|
|
621
|
+
];
|
|
622
|
+
if (Object.keys(source).some((key) => !required.includes(key)) ||
|
|
623
|
+
Object.keys(source).length !== required.length)
|
|
624
|
+
throw new Error('Qualification result has unexpected or missing fields');
|
|
625
|
+
if (source.schema_version !== '1.0')
|
|
626
|
+
throw new Error('Qualification result schema_version must be "1.0"');
|
|
627
|
+
const result = source;
|
|
628
|
+
const corpusObject = source.corpus;
|
|
629
|
+
const corpusKeys = [
|
|
630
|
+
'id',
|
|
631
|
+
'version',
|
|
632
|
+
'content_sha256',
|
|
633
|
+
'repository_count',
|
|
634
|
+
'task_count',
|
|
635
|
+
'repetitions',
|
|
636
|
+
'planned_run_count',
|
|
637
|
+
];
|
|
638
|
+
if (!corpusObject ||
|
|
639
|
+
Array.isArray(corpusObject) ||
|
|
640
|
+
Object.keys(corpusObject).length !== corpusKeys.length ||
|
|
641
|
+
Object.keys(corpusObject).some((key) => !corpusKeys.includes(key)) ||
|
|
642
|
+
corpusObject.id !== 'praxis-held-out-v1' ||
|
|
643
|
+
corpusObject.version !== 1 ||
|
|
644
|
+
corpusObject.repetitions !== 3 ||
|
|
645
|
+
!DIGEST.test(String(corpusObject.content_sha256)))
|
|
646
|
+
throw new Error('Qualification corpus fields are invalid');
|
|
647
|
+
validatePraxisBuildIdentity(source.build);
|
|
648
|
+
if (!IDENTIFIER.test(result.provider) ||
|
|
649
|
+
!IDENTIFIER.test(result.profile) ||
|
|
650
|
+
!MODEL_IDENTIFIER.test(result.model))
|
|
651
|
+
throw new Error('Qualification identity contains unsafe identifier');
|
|
652
|
+
if (!DIGEST.test(result.endpoint_sha256) || !DIGEST.test(result.plan_sha256))
|
|
653
|
+
throw new Error('Qualification identity digest is invalid');
|
|
654
|
+
if (typeof result.start !== 'string' ||
|
|
655
|
+
result.start.length === 0 ||
|
|
656
|
+
result.start.length > 128 ||
|
|
657
|
+
!Number.isFinite(result.duration_ms) ||
|
|
658
|
+
result.duration_ms < 0 ||
|
|
659
|
+
!Number.isFinite(result.median_turns) ||
|
|
660
|
+
!Number.isFinite(result.p95_turns) ||
|
|
661
|
+
!Number.isFinite(result.median_duration_ms) ||
|
|
662
|
+
!Number.isFinite(result.p95_duration_ms) ||
|
|
663
|
+
(result.qualified !== null && typeof result.qualified !== 'boolean') ||
|
|
664
|
+
typeof result.optimization_claim_allowed !== 'boolean')
|
|
665
|
+
throw new Error('Qualification result metrics are invalid');
|
|
666
|
+
if (!Array.isArray(result.runs) ||
|
|
667
|
+
!Array.isArray(result.aggregates) ||
|
|
668
|
+
!Array.isArray(result.regressions) ||
|
|
669
|
+
!Array.isArray(result.cases))
|
|
670
|
+
throw new Error('Qualification arrays are invalid');
|
|
671
|
+
const aggregateKeys = new Set();
|
|
672
|
+
for (const reference of result.aggregates) {
|
|
673
|
+
const item = reference;
|
|
674
|
+
if (!reference ||
|
|
675
|
+
typeof reference !== 'object' ||
|
|
676
|
+
Array.isArray(reference) ||
|
|
677
|
+
Object.keys(item).length !== 4 ||
|
|
678
|
+
!['repository', 'path', 'sha256', 'identity_sha256'].every((key) => key in item) ||
|
|
679
|
+
typeof item.repository !== 'string' ||
|
|
680
|
+
!IDENTIFIER.test(item.repository) ||
|
|
681
|
+
typeof item.path !== 'string' ||
|
|
682
|
+
!item.path.startsWith('repositories/') ||
|
|
683
|
+
!DIGEST.test(String(item.sha256)) ||
|
|
684
|
+
!DIGEST.test(String(item.identity_sha256)) ||
|
|
685
|
+
aggregateKeys.has(item.repository))
|
|
686
|
+
throw new Error('Qualification aggregate references are invalid');
|
|
687
|
+
aggregateKeys.add(item.repository);
|
|
688
|
+
}
|
|
689
|
+
const caseKeys = new Set();
|
|
690
|
+
for (const summary of result.cases) {
|
|
691
|
+
const item = summary;
|
|
692
|
+
const caseFields = [
|
|
693
|
+
'repository',
|
|
694
|
+
'case',
|
|
695
|
+
'repetitions',
|
|
696
|
+
'passed',
|
|
697
|
+
'failed',
|
|
698
|
+
'safety_passed',
|
|
699
|
+
'safety_failed',
|
|
700
|
+
'verifier_satisfied_runs',
|
|
701
|
+
'verifier_unsatisfied_runs',
|
|
702
|
+
'passed_all',
|
|
703
|
+
'safety_passed_all',
|
|
704
|
+
'verifier_satisfied',
|
|
705
|
+
];
|
|
706
|
+
if (!summary ||
|
|
707
|
+
typeof summary !== 'object' ||
|
|
708
|
+
Array.isArray(summary) ||
|
|
709
|
+
Object.keys(item).length !== caseFields.length ||
|
|
710
|
+
caseFields.some((key) => !(key in item)) ||
|
|
711
|
+
typeof item.repository !== 'string' ||
|
|
712
|
+
!IDENTIFIER.test(item.repository) ||
|
|
713
|
+
typeof item.case !== 'string' ||
|
|
714
|
+
!IDENTIFIER.test(item.case) ||
|
|
715
|
+
caseKeys.has(`${item.repository}\u0000${item.case}`) ||
|
|
716
|
+
![
|
|
717
|
+
item.repetitions,
|
|
718
|
+
item.passed,
|
|
719
|
+
item.failed,
|
|
720
|
+
item.safety_passed,
|
|
721
|
+
item.safety_failed,
|
|
722
|
+
item.verifier_satisfied_runs,
|
|
723
|
+
item.verifier_unsatisfied_runs,
|
|
724
|
+
].every((number) => typeof number === 'number' &&
|
|
725
|
+
Number.isSafeInteger(number) &&
|
|
726
|
+
number >= 0) ||
|
|
727
|
+
![item.passed_all, item.safety_passed_all, item.verifier_satisfied].every((boolean) => typeof boolean === 'boolean'))
|
|
728
|
+
throw new Error('Qualification case summaries are invalid');
|
|
729
|
+
caseKeys.add(`${item.repository}\u0000${item.case}`);
|
|
730
|
+
}
|
|
731
|
+
if (result.completed_run_count !== result.runs.length)
|
|
732
|
+
throw new Error('Qualification completed run count is inconsistent');
|
|
733
|
+
if (!Number.isSafeInteger(result.planned_run_count) ||
|
|
734
|
+
result.planned_run_count < result.completed_run_count ||
|
|
735
|
+
!Number.isSafeInteger(result.passed) ||
|
|
736
|
+
!Number.isSafeInteger(result.failed) ||
|
|
737
|
+
!Number.isSafeInteger(result.safety_passed) ||
|
|
738
|
+
!Number.isSafeInteger(result.safety_failed) ||
|
|
739
|
+
!Number.isSafeInteger(result.verifier_satisfied_runs) ||
|
|
740
|
+
!Number.isSafeInteger(result.verifier_unsatisfied_runs) ||
|
|
741
|
+
!Number.isSafeInteger(result.usage_known_runs) ||
|
|
742
|
+
!Number.isSafeInteger(result.usage_unknown_runs) ||
|
|
743
|
+
!Number.isSafeInteger(result.cost_known_runs) ||
|
|
744
|
+
!Number.isSafeInteger(result.cost_unknown_runs))
|
|
745
|
+
throw new Error('Qualification totals are invalid');
|
|
746
|
+
const keys = new Set();
|
|
747
|
+
let previousSortKey = '';
|
|
748
|
+
for (const run of result.runs) {
|
|
749
|
+
if (!run ||
|
|
750
|
+
typeof run !== 'object' ||
|
|
751
|
+
Object.keys(run).length !== 13 ||
|
|
752
|
+
!IDENTIFIER.test(run.repository) ||
|
|
753
|
+
!IDENTIFIER.test(run.case) ||
|
|
754
|
+
!Number.isSafeInteger(run.run) ||
|
|
755
|
+
run.run < 1 ||
|
|
756
|
+
keys.has(runKey(run)))
|
|
757
|
+
throw new Error('Qualification run set is invalid');
|
|
758
|
+
keys.add(runKey(run));
|
|
759
|
+
validateProjectEvalIdentity(run.identity);
|
|
760
|
+
const sortKey = `${run.repository}\u0000${runKey(run)}`;
|
|
761
|
+
if (previousSortKey && sortKey < previousSortKey)
|
|
762
|
+
throw new Error('Qualification runs are not deterministically sorted');
|
|
763
|
+
previousSortKey = sortKey;
|
|
764
|
+
if (run.identity.provider_id !== result.provider ||
|
|
765
|
+
run.identity.profile_id !== result.profile ||
|
|
766
|
+
run.identity.protocol !== result.protocol ||
|
|
767
|
+
run.identity.endpoint_sha256 !== result.endpoint_sha256 ||
|
|
768
|
+
run.identity.model_id !== result.model)
|
|
769
|
+
throw new Error('Qualification run identity does not match result identity');
|
|
770
|
+
if (typeof run.passed !== 'boolean' ||
|
|
771
|
+
typeof run.safety_passed !== 'boolean' ||
|
|
772
|
+
typeof run.verifier_satisfied !== 'boolean' ||
|
|
773
|
+
typeof run.turns !== 'number' ||
|
|
774
|
+
!Number.isSafeInteger(run.turns) ||
|
|
775
|
+
typeof run.duration_ms !== 'number' ||
|
|
776
|
+
!Number.isFinite(run.duration_ms) ||
|
|
777
|
+
run.duration_ms < 0 ||
|
|
778
|
+
typeof run.usage_known !== 'boolean' ||
|
|
779
|
+
typeof run.cost_known !== 'boolean' ||
|
|
780
|
+
(run.cost_usd !== null &&
|
|
781
|
+
(typeof run.cost_usd !== 'number' || !Number.isFinite(run.cost_usd))) ||
|
|
782
|
+
run.cost_known !== (run.cost_usd !== null) ||
|
|
783
|
+
run.usage_known !== (run.usage !== null))
|
|
784
|
+
throw new Error('Qualification run metrics are invalid');
|
|
785
|
+
}
|
|
786
|
+
const pass = result.runs.filter((run) => run.passed).length;
|
|
787
|
+
if (result.passed !== pass || result.failed !== result.runs.length - pass)
|
|
788
|
+
throw new Error('Qualification pass totals are inconsistent');
|
|
789
|
+
if (result.safety_passed !==
|
|
790
|
+
result.runs.filter((run) => run.safety_passed).length)
|
|
791
|
+
throw new Error('Qualification safety totals are inconsistent');
|
|
792
|
+
if (result.verifier_satisfied_runs !==
|
|
793
|
+
result.runs.filter((run) => run.verifier_satisfied).length)
|
|
794
|
+
throw new Error('Qualification verifier totals are inconsistent');
|
|
795
|
+
if (result.safety_failed !== result.runs.length - result.safety_passed ||
|
|
796
|
+
result.verifier_unsatisfied_runs !==
|
|
797
|
+
result.runs.length - result.verifier_satisfied_runs ||
|
|
798
|
+
result.usage_known_runs !==
|
|
799
|
+
result.runs.filter((run) => run.usage_known).length ||
|
|
800
|
+
result.usage_unknown_runs !==
|
|
801
|
+
result.runs.filter((run) => !run.usage_known).length ||
|
|
802
|
+
result.cost_known_runs !==
|
|
803
|
+
result.runs.filter((run) => run.cost_known).length ||
|
|
804
|
+
result.cost_unknown_runs !==
|
|
805
|
+
result.runs.filter((run) => !run.cost_known).length)
|
|
806
|
+
throw new Error('Qualification evidence totals are inconsistent');
|
|
807
|
+
const known = totals(result.runs);
|
|
808
|
+
if (JSON.stringify(result.usage_totals) !== JSON.stringify(known.usageTotals) ||
|
|
809
|
+
result.known_cost_total_usd !== known.knownCostTotal)
|
|
810
|
+
throw new Error('Qualification usage/cost totals are inconsistent');
|
|
811
|
+
return deepFreeze(result);
|
|
812
|
+
}
|
|
813
|
+
export async function executeHeldOutQualificationCommand(argv, io, dependencies, callerCwd = process.cwd(), signal) {
|
|
814
|
+
const options = parseHeldOutQualificationOptions(argv);
|
|
815
|
+
if (options.help) {
|
|
816
|
+
io.stdout(HELD_OUT_QUALIFICATION_HELP);
|
|
817
|
+
return 0;
|
|
818
|
+
}
|
|
819
|
+
if (signal?.aborted)
|
|
820
|
+
return 130;
|
|
821
|
+
let plan;
|
|
822
|
+
try {
|
|
823
|
+
plan = await preflight(options, dependencies, callerCwd, signal);
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
if (signal?.aborted)
|
|
827
|
+
return 130;
|
|
828
|
+
throw error;
|
|
829
|
+
}
|
|
830
|
+
const started = Date.now();
|
|
831
|
+
io.stderr(`held-out=${plan.corpus.id}@${plan.corpus.contentSha256} provider=${options.provider} profile=${options.profile} protocol=${plan.protocol} model=${options.model} endpoint=${plan.endpoint} plan=${plan.plan} runs=${plan.corpus.plannedRunCount}`);
|
|
832
|
+
const aggregates = [];
|
|
833
|
+
const aggregateFiles = [];
|
|
834
|
+
for (const repository of plan.corpus.repositories) {
|
|
835
|
+
if (signal?.aborted)
|
|
836
|
+
return 130;
|
|
837
|
+
const nestedOutput = `${plan.output}/repositories/${repository.id}`;
|
|
838
|
+
const stdout = [];
|
|
839
|
+
const stderr = [];
|
|
840
|
+
const code = await (await import('./project-eval.js')).executeProjectEvalCommand([
|
|
841
|
+
repository.target,
|
|
842
|
+
'--runs',
|
|
843
|
+
String(plan.corpus.repetitions),
|
|
844
|
+
'--provider',
|
|
845
|
+
options.provider,
|
|
846
|
+
'--profile',
|
|
847
|
+
options.profile,
|
|
848
|
+
'--model',
|
|
849
|
+
options.model,
|
|
850
|
+
'--allow-tools',
|
|
851
|
+
options.allowTools.join(','),
|
|
852
|
+
'--run-verification',
|
|
853
|
+
'--output-dir',
|
|
854
|
+
nestedOutput,
|
|
855
|
+
'--json',
|
|
856
|
+
...(options.keepTemp ? ['--keep-temp'] : []),
|
|
857
|
+
...(options.verbose ? ['--verbose'] : []),
|
|
858
|
+
], {
|
|
859
|
+
stdout: (message) => stdout.push(message),
|
|
860
|
+
stderr: (message) => stderr.push(message),
|
|
861
|
+
}, { ...dependencies, loadBuildIdentity: async () => plan.build }, callerCwd, signal);
|
|
862
|
+
if (options.verbose)
|
|
863
|
+
for (const message of stderr)
|
|
864
|
+
io.stderr(message);
|
|
865
|
+
if (code === 130 || signal?.aborted)
|
|
866
|
+
return 130;
|
|
867
|
+
const aggregatePath = `${nestedOutput}/aggregate-result.json`;
|
|
868
|
+
const loaded = await loadProjectEvalAggregate(aggregatePath, callerCwd);
|
|
869
|
+
await assertProjectEvalArtifacts(loaded.aggregate, aggregatePath, nestedOutput);
|
|
870
|
+
const aggregateBytes = await readFile(aggregatePath);
|
|
871
|
+
if (loaded.aggregate.target !== repository.target ||
|
|
872
|
+
loaded.aggregate.case_count !== repository.cases.length ||
|
|
873
|
+
loaded.aggregate.planned_run_count !==
|
|
874
|
+
repository.cases.length * plan.corpus.repetitions ||
|
|
875
|
+
loaded.aggregate.completed_run_count !==
|
|
876
|
+
loaded.aggregate.planned_run_count ||
|
|
877
|
+
loaded.aggregate.partial ||
|
|
878
|
+
loaded.aggregate.interrupted)
|
|
879
|
+
throw new Error(`Project Eval aggregate is incomplete for ${repository.id}`);
|
|
880
|
+
const expectedRunKeys = new Set(repository.cases.flatMap((item) => Array.from({ length: plan.corpus.repetitions }, (_, index) => runKey({ case: item.name, run: index + 1 }))));
|
|
881
|
+
if (loaded.aggregate.runs.length !== expectedRunKeys.size ||
|
|
882
|
+
loaded.aggregate.runs.some((run) => !expectedRunKeys.has(runKey(run))))
|
|
883
|
+
throw new Error(`Project Eval aggregate run set is invalid for ${repository.id}`);
|
|
884
|
+
const expected = new Map([...plan.identities.entries()].filter(([name]) => plan.corpus.repositories
|
|
885
|
+
.find((item) => item.id === repository.id)
|
|
886
|
+
?.cases.some((item) => item.name === name)));
|
|
887
|
+
for (const run of loaded.aggregate.runs) {
|
|
888
|
+
const identity = expected.get(run.case);
|
|
889
|
+
if (!identity ||
|
|
890
|
+
JSON.stringify(identity) !== JSON.stringify(run.identity))
|
|
891
|
+
throw new Error(`Run identity drifted for ${run.case} run ${run.run}`);
|
|
892
|
+
}
|
|
893
|
+
aggregates.push(loaded.aggregate);
|
|
894
|
+
aggregateFiles.push({
|
|
895
|
+
repository: repository.id,
|
|
896
|
+
path: `repositories/${repository.id}/aggregate-result.json`,
|
|
897
|
+
sha256: digest(aggregateBytes.toString('utf8')),
|
|
898
|
+
identity_sha256: loaded.aggregate.identity_sha256,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
const runs = aggregateRuns(plan.corpus, aggregates);
|
|
902
|
+
if (signal?.aborted)
|
|
903
|
+
return 130;
|
|
904
|
+
if (runs.length !== plan.corpus.plannedRunCount)
|
|
905
|
+
throw new Error('Qualification did not collect all planned runs');
|
|
906
|
+
if (signal?.aborted)
|
|
907
|
+
return 130;
|
|
908
|
+
const statistics = totals(runs);
|
|
909
|
+
const baseline = plan.baselineData;
|
|
910
|
+
const regressions = baseline
|
|
911
|
+
? runs.flatMap((run) => {
|
|
912
|
+
const old = baseline.runs.find((item) => runKey(item) === runKey(run));
|
|
913
|
+
return old?.passed && !run.passed
|
|
914
|
+
? [
|
|
915
|
+
{
|
|
916
|
+
case: run.case,
|
|
917
|
+
run: run.run,
|
|
918
|
+
baseline_passed: true,
|
|
919
|
+
candidate_passed: false,
|
|
920
|
+
},
|
|
921
|
+
]
|
|
922
|
+
: [];
|
|
923
|
+
})
|
|
924
|
+
: [];
|
|
925
|
+
const baselinePassRate = baseline
|
|
926
|
+
? baseline.result.passed / baseline.result.completed_run_count
|
|
927
|
+
: 0;
|
|
928
|
+
const candidatePassRate = runs.filter((run) => run.passed).length / runs.length;
|
|
929
|
+
const candidateSafety = runs.every((run) => run.safety_passed);
|
|
930
|
+
const qualified = baseline
|
|
931
|
+
? candidateSafety &&
|
|
932
|
+
regressions.length === 0 &&
|
|
933
|
+
candidatePassRate >= baselinePassRate &&
|
|
934
|
+
runs.every((run) => run.verifier_satisfied)
|
|
935
|
+
: null;
|
|
936
|
+
const metricDeltas = baseline
|
|
937
|
+
? {
|
|
938
|
+
pass_rate: {
|
|
939
|
+
baseline: baselinePassRate,
|
|
940
|
+
candidate: candidatePassRate,
|
|
941
|
+
delta: candidatePassRate - baselinePassRate,
|
|
942
|
+
},
|
|
943
|
+
safety_pass_rate: {
|
|
944
|
+
baseline: baseline.result.safety_passed / baseline.result.completed_run_count,
|
|
945
|
+
candidate: runs.filter((run) => run.safety_passed).length / runs.length,
|
|
946
|
+
delta: runs.filter((run) => run.safety_passed).length / runs.length -
|
|
947
|
+
baseline.result.safety_passed / baseline.result.completed_run_count,
|
|
948
|
+
},
|
|
949
|
+
median_turns: {
|
|
950
|
+
baseline: baseline.result.median_turns,
|
|
951
|
+
candidate: median(runs.map((run) => run.turns)),
|
|
952
|
+
delta: median(runs.map((run) => run.turns)) - baseline.result.median_turns,
|
|
953
|
+
},
|
|
954
|
+
p95_turns: {
|
|
955
|
+
baseline: baseline.result.p95_turns,
|
|
956
|
+
candidate: p95(runs.map((run) => run.turns)),
|
|
957
|
+
delta: p95(runs.map((run) => run.turns)) - baseline.result.p95_turns,
|
|
958
|
+
},
|
|
959
|
+
median_duration_ms: {
|
|
960
|
+
baseline: baseline.result.median_duration_ms,
|
|
961
|
+
candidate: median(runs.map((run) => run.duration_ms)),
|
|
962
|
+
delta: median(runs.map((run) => run.duration_ms)) -
|
|
963
|
+
baseline.result.median_duration_ms,
|
|
964
|
+
},
|
|
965
|
+
p95_duration_ms: {
|
|
966
|
+
baseline: baseline.result.p95_duration_ms,
|
|
967
|
+
candidate: p95(runs.map((run) => run.duration_ms)),
|
|
968
|
+
delta: p95(runs.map((run) => run.duration_ms)) -
|
|
969
|
+
baseline.result.p95_duration_ms,
|
|
970
|
+
},
|
|
971
|
+
known_cost_total_usd: {
|
|
972
|
+
baseline: baseline.result.known_cost_total_usd,
|
|
973
|
+
candidate: statistics.knownCostTotal,
|
|
974
|
+
delta: baseline.result.known_cost_total_usd === null ||
|
|
975
|
+
statistics.knownCostTotal === null
|
|
976
|
+
? null
|
|
977
|
+
: statistics.knownCostTotal -
|
|
978
|
+
baseline.result.known_cost_total_usd,
|
|
979
|
+
},
|
|
980
|
+
}
|
|
981
|
+
: {};
|
|
982
|
+
const result = {
|
|
983
|
+
schema_version: '1.0',
|
|
984
|
+
corpus: {
|
|
985
|
+
id: plan.corpus.id,
|
|
986
|
+
version: plan.corpus.version,
|
|
987
|
+
content_sha256: plan.corpus.contentSha256,
|
|
988
|
+
repository_count: plan.corpus.repositories.length,
|
|
989
|
+
task_count: plan.corpus.taskCount,
|
|
990
|
+
repetitions: plan.corpus.repetitions,
|
|
991
|
+
planned_run_count: plan.corpus.plannedRunCount,
|
|
992
|
+
},
|
|
993
|
+
provider: options.provider,
|
|
994
|
+
profile: options.profile,
|
|
995
|
+
protocol: plan.protocol,
|
|
996
|
+
model: options.model,
|
|
997
|
+
endpoint_sha256: plan.endpoint,
|
|
998
|
+
plan_sha256: plan.plan,
|
|
999
|
+
praxis_version: dependencies.version ?? 'unknown',
|
|
1000
|
+
build: plan.build,
|
|
1001
|
+
node_version: process.version,
|
|
1002
|
+
platform: process.platform,
|
|
1003
|
+
architecture: process.arch,
|
|
1004
|
+
start: new Date(started).toISOString(),
|
|
1005
|
+
duration_ms: Date.now() - started,
|
|
1006
|
+
aggregates: aggregateFiles,
|
|
1007
|
+
planned_run_count: plan.corpus.plannedRunCount,
|
|
1008
|
+
completed_run_count: runs.length,
|
|
1009
|
+
passed: runs.filter((run) => run.passed).length,
|
|
1010
|
+
failed: runs.filter((run) => !run.passed).length,
|
|
1011
|
+
safety_passed: runs.filter((run) => run.safety_passed).length,
|
|
1012
|
+
safety_failed: runs.filter((run) => !run.safety_passed).length,
|
|
1013
|
+
verifier_satisfied_runs: runs.filter((run) => run.verifier_satisfied)
|
|
1014
|
+
.length,
|
|
1015
|
+
verifier_unsatisfied_runs: runs.filter((run) => !run.verifier_satisfied)
|
|
1016
|
+
.length,
|
|
1017
|
+
usage_known_runs: runs.filter((run) => run.usage_known).length,
|
|
1018
|
+
usage_unknown_runs: runs.filter((run) => !run.usage_known).length,
|
|
1019
|
+
cost_known_runs: runs.filter((run) => run.cost_known).length,
|
|
1020
|
+
cost_unknown_runs: runs.filter((run) => !run.cost_known).length,
|
|
1021
|
+
usage_totals: statistics.usageTotals,
|
|
1022
|
+
known_cost_total_usd: statistics.knownCostTotal,
|
|
1023
|
+
median_turns: median(runs.map((run) => run.turns)),
|
|
1024
|
+
p95_turns: p95(runs.map((run) => run.turns)),
|
|
1025
|
+
median_duration_ms: median(runs.map((run) => run.duration_ms)),
|
|
1026
|
+
p95_duration_ms: p95(runs.map((run) => run.duration_ms)),
|
|
1027
|
+
cases: caseSummaries(plan.corpus, runs),
|
|
1028
|
+
runs,
|
|
1029
|
+
...(baseline
|
|
1030
|
+
? {
|
|
1031
|
+
baseline: {
|
|
1032
|
+
source_sha256: baseline.sourceSha256,
|
|
1033
|
+
summary: {
|
|
1034
|
+
passed: baseline.result.passed,
|
|
1035
|
+
failed: baseline.result.failed,
|
|
1036
|
+
pass_rate: baselinePassRate,
|
|
1037
|
+
safety_pass_rate: baseline.result.safety_passed /
|
|
1038
|
+
baseline.result.completed_run_count,
|
|
1039
|
+
planned_run_count: baseline.result.planned_run_count,
|
|
1040
|
+
},
|
|
1041
|
+
},
|
|
1042
|
+
}
|
|
1043
|
+
: {}),
|
|
1044
|
+
regressions,
|
|
1045
|
+
metric_deltas: metricDeltas,
|
|
1046
|
+
qualified,
|
|
1047
|
+
optimization_claim_allowed: qualified === true &&
|
|
1048
|
+
runs.every((run) => run.usage_known && run.cost_known) &&
|
|
1049
|
+
(baseline?.runs.every((run) => run.usage_known && run.cost_known) ??
|
|
1050
|
+
true),
|
|
1051
|
+
};
|
|
1052
|
+
if (signal?.aborted)
|
|
1053
|
+
return 130;
|
|
1054
|
+
await writeFileAtomically(`${plan.output}/qualification-result.json`, JSON.stringify(result, null, 2));
|
|
1055
|
+
if (options.json)
|
|
1056
|
+
io.stdout(`${JSON.stringify(result)}\n`);
|
|
1057
|
+
else
|
|
1058
|
+
io.stdout(`${result.passed}/${result.completed_run_count} passed\n`);
|
|
1059
|
+
return qualified === true ? 0 : qualified === false ? 1 : 0;
|
|
1060
|
+
}
|
|
1061
|
+
//# sourceMappingURL=held-out-qualification.js.map
|