praxis-agent 0.64.1 → 0.66.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 CHANGED
@@ -134,10 +134,16 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
134
134
 
135
135
  - **Outcome-driven evaluation** — `praxis eval <target>` runs contained cases
136
136
  in isolated workspaces, requires explicit verifier authorization, and writes
137
- versioned artifacts locally; usage and cost remain explicitly available or
138
- unknown. Separate runs can be compared with `praxis eval compare`; unknown
139
- token/cost evidence produces null deltas, while the gate requires no pass-rate
140
- or safety-rate regression and rejects incomplete safety evidence.
137
+ versioned artifacts with deterministic run identities locally, including the
138
+ Praxis source revision, dirty state, and emitted-runtime digest; usage and cost
139
+ remain explicitly available or unknown. Separate runs can be compared with
140
+ `praxis eval compare` only when provider, model, configuration, tools, prompt,
141
+ corpus, and host-runtime identities match; Praxis version and build provenance
142
+ remain attribution fields so candidate builds may differ. Unknown token/cost
143
+ evidence produces null deltas, while the gate requires no pass-rate or
144
+ safety-rate regression, every candidate verifier to be satisfied, and every
145
+ candidate `high` or `release` task to pass, and rejects incomplete safety
146
+ evidence.
141
147
  - **Local agent runtime** — C+ Quiet Operator responsive TUI with a linear
142
148
  `❯` user / `⏺` assistant conversation, `✻` thinking activity, and `!` shell
143
149
  composer grammar, compact stable tool rows, responsive density,
@@ -401,9 +407,10 @@ normal/low-capability full-frame p95 budgets of `<16.7/<33 ms`.
401
407
  `npm run test:coverage` measures all production code under `src/**` with V8 and
402
408
  enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines,
403
409
  and rejects any production runtime module with zero covered statements (while allowing
404
- type-only modules). `npm run test:fixtures` executes the 71-behavior native contract; 63 behaviors
405
- are qualified and 8 are explicitly excluded. `npm run verify:fixture-contracts`
406
- performs the structural check and is part of `npm run check`.
410
+ type-only modules). `npm run test:fixtures` executes the 74-behavior native contract; 66 behaviors
411
+ are qualified and 8 are explicitly excluded. Schema-v2 risk tiers and executable evidence dimensions
412
+ are enforced fail-closed. `npm run verify:fixture-contracts` performs the structural check and is part
413
+ of `npm run check`.
407
414
  `npm run test:core-completion` is retained as a compatibility alias for
408
415
  `npm run test:fixtures`.
409
416
 
@@ -0,0 +1 @@
1
+ {"schema_version":"1.0","source_revision":"git:8ed6222ee3a981ea0a00bb484ad43e13e278ab5d","source_dirty":false,"artifact_sha256":"sha256:8eb26913c75e9288f8387f5af1b5aefe847942e4c1d35598130cdac53d758524"}
@@ -51,7 +51,7 @@ import { FallbackModelProvider } from './providers/fallback-provider.js';
51
51
  import { ProviderCredentialVault } from './persistence/provider-credential-vault.js';
52
52
  import { parseContextEnvironment, parseProviderEnvironment, } from './providers/environment.js';
53
53
  import { ProviderAuthenticationError, resolveProviderCredential, } from './providers/provider-auth.js';
54
- import { resolveProviderContextWindowTokens, resolveProviderRegistry, } from './providers/provider-registry.js';
54
+ import { resolveProviderContextWindowTokens, resolveProviderRegistry, resolveProviderRuntimeTarget, } from './providers/provider-registry.js';
55
55
  import { ProviderSettingsError, resolveProviderTarget, } from './providers/provider-settings.js';
56
56
  import { ModelPricingRegistry, usageCostUsd } from './core/usage.js';
57
57
  import { LocalToolRegistry } from './tools/local-tools.js';
@@ -73,6 +73,7 @@ import { addClaudeMarketplace, disableAllNativePlugins, installClaudeMarketplace
73
73
  import { executeClaudePluginEvalCommand, PLUGIN_EVAL_HELP, } from './plugins/claude-plugin-eval.js';
74
74
  import { executeProjectEvalCommand, PROJECT_EVAL_HELP, } from './evals/project-eval.js';
75
75
  import { PROJECT_EVAL_COMPARE_HELP } from './evals/project-eval-comparison.js';
76
+ import { loadPraxisBuildIdentity } from './platform/praxis-build-identity.js';
76
77
  import { CLAUDE_PLUGIN_PRUNE_HELP, CLAUDE_PLUGIN_TAG_HELP, executeClaudePluginPrune, planClaudePluginPrune, tagClaudePlugin, } from './plugins/claude-plugin-maintenance.js';
77
78
  import { formatDoctorReport, runDoctor } from './maintenance/doctor.js';
78
79
  import { runSelfUpdate, } from './maintenance/self-update.js';
@@ -2358,6 +2359,27 @@ const defaultPluginEvalRuntimeFactory = {
2358
2359
  };
2359
2360
  },
2360
2361
  };
2362
+ const defaultProjectEvalRuntimeFactory = {
2363
+ create: (options) => defaultPluginEvalRuntimeFactory.create(options),
2364
+ identify: async (options) => {
2365
+ const environment = process.env;
2366
+ const target = await resolveProviderRuntimeTarget({
2367
+ configRoot: options.configRoot,
2368
+ cwd: options.cwd,
2369
+ environment,
2370
+ ...(options.model === undefined ? {} : { model: options.model }),
2371
+ includeSettings: true,
2372
+ includeProjectSettings: false,
2373
+ });
2374
+ return {
2375
+ providerId: target.providerId,
2376
+ profileId: target.profileId,
2377
+ protocol: target.protocol,
2378
+ endpoint: target.baseUrl,
2379
+ modelId: target.modelId,
2380
+ };
2381
+ },
2382
+ };
2361
2383
  const defaultPluginEvalJudge = {
2362
2384
  vote: async ({ criteria, focus, baseline, model, signal }) => {
2363
2385
  const environment = process.env;
@@ -2600,7 +2622,8 @@ export function createDefaultDependencies(entrypoint = fileURLToPath(import.meta
2600
2622
  judge: defaultPluginEvalJudge,
2601
2623
  },
2602
2624
  projectEval: {
2603
- runtimeFactory: defaultPluginEvalRuntimeFactory,
2625
+ runtimeFactory: defaultProjectEvalRuntimeFactory,
2626
+ loadBuildIdentity: () => loadPraxisBuildIdentity(),
2604
2627
  version: VERSION,
2605
2628
  configRoot: resolveDataPlaneRoot(),
2606
2629
  },
@@ -27,6 +27,17 @@ export interface EvalRuntimeFactoryOptions {
27
27
  export interface EvalRuntimeFactory {
28
28
  create(options: EvalRuntimeFactoryOptions): Promise<EvalRuntime>;
29
29
  }
30
+ export type EvalRuntimeFactoryIdentityOptions = Omit<EvalRuntimeFactoryOptions, 'eventSink'>;
31
+ export interface EvalRuntimeIdentityDescriptor {
32
+ providerId: string;
33
+ profileId: string;
34
+ protocol: string;
35
+ endpoint: string;
36
+ modelId: string;
37
+ }
38
+ export interface IdentifiedEvalRuntimeFactory extends EvalRuntimeFactory {
39
+ identify(options: EvalRuntimeFactoryIdentityOptions): Promise<EvalRuntimeIdentityDescriptor>;
40
+ }
30
41
  export interface EvalTraceEvent {
31
42
  type: string;
32
43
  tool?: string;
@@ -1,4 +1,5 @@
1
1
  import type { ProjectEvalAggregate } from './project-eval.js';
2
+ import { type ProjectEvalRisk } from './project-eval-schema.js';
2
3
  export declare const PROJECT_EVAL_COMPARE_HELP = "Usage: praxis eval compare [options]\n\nCompare two completed project evaluation aggregate artifacts.\n\nOptions:\n --baseline <aggregate-result.json> Baseline aggregate artifact\n --baseline-name <name> Name shown for the baseline\n --candidate <aggregate-result.json> Candidate aggregate artifact\n --candidate-name <name> Name shown for the candidate\n --output-dir <dir> Write comparison-result.json here\n --json Print exactly one comparison JSON value\n -h, --help Display help";
3
4
  export interface ProjectEvalCompareOptions {
4
5
  baseline?: string;
@@ -20,18 +21,20 @@ export interface ProjectEvalComparisonMetric<T = number | null> {
20
21
  delta: T;
21
22
  }
22
23
  export interface ProjectEvalComparisonResult {
23
- schema_version: '1.0';
24
+ schema_version: '1.2';
24
25
  baseline: {
25
26
  name: string;
26
27
  source_path: string;
27
28
  version: string;
28
29
  model: string | null;
30
+ identity_sha256: `sha256:${string}`;
29
31
  };
30
32
  candidate: {
31
33
  name: string;
32
34
  source_path: string;
33
35
  version: string;
34
36
  model: string | null;
37
+ identity_sha256: `sha256:${string}`;
35
38
  };
36
39
  comparable_run_count: number;
37
40
  passed: boolean;
@@ -43,6 +46,8 @@ export interface ProjectEvalComparisonResult {
43
46
  }[];
44
47
  metrics: {
45
48
  pass_rate: ProjectEvalComparisonMetric<number>;
49
+ verification_pass_rate: ProjectEvalComparisonMetric<number | null>;
50
+ task_risk_pass_rate: Record<ProjectEvalRisk, ProjectEvalComparisonMetric<number | null>>;
46
51
  safety_pass_rate: ProjectEvalComparisonMetric<number | null>;
47
52
  average_turns: ProjectEvalComparisonMetric<number>;
48
53
  input_tokens: ProjectEvalComparisonMetric<number | null>;
@@ -1,6 +1,9 @@
1
1
  import { mkdir, lstat, readFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { writeFileAtomically } from '../platform/atomic-write.js';
4
+ import { assertProjectEvalIdentitiesComparable, validateProjectEvalAggregateIdentity, validateProjectEvalIdentity, } from './project-eval-identity.js';
5
+ import { PROJECT_EVAL_SAFETY_CHECKS } from './project-eval-runner.js';
6
+ import { PROJECT_EVAL_MAX_ITEMS, PROJECT_EVAL_RISKS, } from './project-eval-schema.js';
4
7
  const MAX_AGGREGATE_BYTES = 8 * 1024 * 1024;
5
8
  const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
6
9
  export const PROJECT_EVAL_COMPARE_HELP = `Usage: praxis eval compare [options]
@@ -106,12 +109,6 @@ function objectField(value, path) {
106
109
  fail(path, 'expected an object');
107
110
  return value;
108
111
  }
109
- function optionalGroup(value, keys, path) {
110
- const present = keys.filter((key) => value[key] !== undefined);
111
- if (present.length !== 0 && present.length !== keys.length)
112
- fail(path, `fields must be all present or all absent: ${keys.join(', ')}`);
113
- return present.length === keys.length;
114
- }
115
112
  function usageField(value, path) {
116
113
  if (value === null)
117
114
  return null;
@@ -130,17 +127,117 @@ function usageField(value, path) {
130
127
  fail(path, 'inputTokens and outputTokens are required');
131
128
  return usage;
132
129
  }
130
+ const FIXED_CHECKS = [
131
+ 'runtime',
132
+ 'termination',
133
+ ...PROJECT_EVAL_SAFETY_CHECKS,
134
+ 'expected-paths',
135
+ ];
136
+ const MAX_COMPACT_CHECKS = FIXED_CHECKS.length + 2 * PROJECT_EVAL_MAX_ITEMS;
137
+ function checkEvidence(value, path) {
138
+ if (!Array.isArray(value) || value.length > MAX_COMPACT_CHECKS)
139
+ fail(path, 'expected a bounded array');
140
+ const names = new Set();
141
+ const checks = value.map((item, index) => {
142
+ const check = objectField(item, `${path}[${index}]`);
143
+ const unknown = Object.keys(check).find((key) => !['name', 'passed'].includes(key));
144
+ if (unknown)
145
+ fail(`${path}[${index}]`, `unknown field: ${unknown}`);
146
+ const name = stringField(check.name, `${path}[${index}].name`);
147
+ boolField(check.passed, `${path}[${index}].passed`);
148
+ if (names.has(name))
149
+ fail(path, 'check names must be unique');
150
+ names.add(name);
151
+ const fixed = FIXED_CHECKS.includes(name);
152
+ const dynamic = name === 'graders' ||
153
+ /^verifier:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(name) ||
154
+ /^grader:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(name);
155
+ if (!fixed && !dynamic)
156
+ fail(`${path}[${index}].name`, 'unsupported check');
157
+ return { name, passed: check.passed };
158
+ });
159
+ for (const name of FIXED_CHECKS)
160
+ if (!names.has(name))
161
+ fail(path, `missing required check: ${name}`);
162
+ return checks;
163
+ }
164
+ function verificationEvidence(value, path) {
165
+ const evidence = objectField(value, path);
166
+ const unknownEvidence = Object.keys(evidence).find((key) => !['outcomes', 'satisfied'].includes(key));
167
+ if (unknownEvidence)
168
+ fail(path, `unknown field: ${unknownEvidence}`);
169
+ const raw = evidence.outcomes;
170
+ if (!Array.isArray(raw) || raw.length > 256)
171
+ fail(`${path}.outcomes`, 'expected a bounded array');
172
+ const names = new Set();
173
+ const outcomes = raw.map((item, index) => {
174
+ const outcome = objectField(item, `${path}.outcomes[${index}]`);
175
+ const unknown = Object.keys(outcome).find((key) => !['name', 'required', 'expect', 'status'].includes(key));
176
+ if (unknown)
177
+ fail(`${path}.outcomes[${index}]`, `unknown field: ${unknown}`);
178
+ const name = stringField(outcome.name, `${path}.outcomes[${index}].name`);
179
+ if (!IDENTIFIER.test(name))
180
+ fail(`${path}.outcomes[${index}].name`, 'unsafe verifier name');
181
+ if (names.has(name))
182
+ fail(`${path}.outcomes`, 'verifier names must be unique');
183
+ names.add(name);
184
+ if (outcome.required !== true)
185
+ fail(`${path}.outcomes[${index}].required`, 'must be true');
186
+ if (outcome.expect !== 'pass')
187
+ fail(`${path}.outcomes[${index}].expect`, 'must be pass');
188
+ if (outcome.status !== 'passed' &&
189
+ outcome.status !== 'failed' &&
190
+ outcome.status !== 'not_run')
191
+ fail(`${path}.outcomes[${index}].status`, 'invalid verifier status');
192
+ return {
193
+ name,
194
+ required: true,
195
+ expect: 'pass',
196
+ status: outcome.status,
197
+ };
198
+ });
199
+ boolField(evidence.satisfied, `${path}.satisfied`);
200
+ const satisfied = outcomes.every((outcome) => outcome.status === 'passed');
201
+ if (evidence.satisfied !== satisfied)
202
+ fail(`${path}.satisfied`, 'does not match verifier statuses');
203
+ return { outcomes, satisfied };
204
+ }
133
205
  function validateRun(value, index) {
134
206
  const path = `runs[${index}]`;
135
207
  const run = objectField(value, path);
208
+ if (run.schema_version !== '1.2')
209
+ fail(`${path}.schema_version`, 'must be "1.2"');
136
210
  stringField(run.case, `${path}.case`);
137
211
  if (!IDENTIFIER.test(run.case))
138
212
  fail(`${path}.case`, 'unsafe case name');
139
213
  const runNumber = numberField(run.run, `${path}.run`, true);
140
214
  if (runNumber < 1)
141
215
  fail(`${path}.run`, 'must be positive');
142
- stringField(run.model, `${path}.model`, true);
216
+ const model = stringField(run.model, `${path}.model`);
217
+ const identity = validateProjectEvalIdentity(run.identity);
218
+ if (model !== identity.model_id)
219
+ fail(`${path}.model`, 'does not match identity.model_id');
220
+ if (!PROJECT_EVAL_RISKS.includes(run.risk))
221
+ fail(`${path}.risk`, 'invalid risk');
222
+ const checks = checkEvidence(run.checks, `${path}.checks`);
223
+ const verification = verificationEvidence(run.verification, `${path}.verification`);
224
+ const verifierChecks = new Map(checks
225
+ .filter(({ name }) => name.startsWith('verifier:'))
226
+ .map((check) => [check.name.slice('verifier:'.length), check.passed]));
227
+ if (verifierChecks.size !== verification.outcomes.length)
228
+ fail(`${path}.checks`, 'verifier checks do not match outcomes');
229
+ for (const outcome of verification.outcomes) {
230
+ const checkPassed = verifierChecks.get(outcome.name);
231
+ if (checkPassed === undefined)
232
+ fail(`${path}.verification`, 'missing verifier check');
233
+ if (checkPassed !== (outcome.status === 'passed'))
234
+ fail(`${path}.verification`, 'verifier check does not match status');
235
+ }
236
+ const recomputedPassed = checks.every((check) => check.passed);
237
+ const safetyPassed = PROJECT_EVAL_SAFETY_CHECKS.every((name) => checks.find((check) => check.name === name)?.passed === true);
143
238
  boolField(run.passed, `${path}.passed`);
239
+ if (run.passed !== recomputedPassed)
240
+ fail(`${path}.passed`, 'does not match compact checks');
144
241
  if (run.score !== 0 && run.score !== 1)
145
242
  fail(`${path}.score`, 'must be 0 or 1');
146
243
  if (run.score !== (run.passed ? 1 : 0))
@@ -158,16 +255,24 @@ function validateRun(value, index) {
158
255
  fail(`${path}.termination`, 'invalid termination');
159
256
  stringField(run.error, `${path}.error`, true);
160
257
  stringField(run.artifact_dir, `${path}.artifact_dir`);
161
- const evidenceKnown = optionalGroup(run, ['safety_passed', 'permission_decisions', 'tool_errors', 'retries'], path);
162
- if (evidenceKnown) {
163
- boolField(run.safety_passed, `${path}.safety_passed`);
164
- const permissions = objectField(run.permission_decisions, `${path}.permission_decisions`);
165
- for (const key of ['allow', 'ask', 'deny'])
166
- numberField(permissions[key], `${path}.permission_decisions.${key}`, true);
167
- numberField(run.tool_errors, `${path}.tool_errors`, true);
168
- numberField(run.retries, `${path}.retries`, true);
169
- }
170
- return run;
258
+ boolField(run.safety_passed, `${path}.safety_passed`);
259
+ if (run.safety_passed !== safetyPassed)
260
+ fail(`${path}.safety_passed`, 'does not match safety checks');
261
+ const permissions = objectField(run.permission_decisions, `${path}.permission_decisions`);
262
+ for (const key of ['allow', 'ask', 'deny'])
263
+ numberField(permissions[key], `${path}.permission_decisions.${key}`, true);
264
+ numberField(run.tool_errors, `${path}.tool_errors`, true);
265
+ numberField(run.retries, `${path}.retries`, true);
266
+ const terminationCheck = checks.find((check) => check.name === 'termination');
267
+ if (terminationCheck?.passed !== (run.termination === null))
268
+ fail(`${path}.checks`, 'termination check does not match termination');
269
+ return {
270
+ ...run,
271
+ model,
272
+ identity,
273
+ checks,
274
+ verification,
275
+ };
171
276
  }
172
277
  export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cwd()) {
173
278
  const sourcePath = resolve(callerCwd, inputPath);
@@ -190,8 +295,8 @@ export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cw
190
295
  }
191
296
  const aggregate = objectField(value, 'root');
192
297
  const data = aggregate;
193
- if (aggregate.schema_version !== '1.0')
194
- fail('schema_version', 'must be "1.0"');
298
+ if (aggregate.schema_version !== '1.2')
299
+ fail('schema_version', 'must be "1.2"; legacy "1.1" aggregates are unsupported');
195
300
  stringField(aggregate.version, 'version');
196
301
  stringField(aggregate.start, 'start');
197
302
  numberField(aggregate.duration_ms, 'duration_ms');
@@ -230,6 +335,14 @@ export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cw
230
335
  if (!Array.isArray(aggregate.runs) || aggregate.runs.length > 100000)
231
336
  fail('runs', 'expected a bounded array');
232
337
  const runs = aggregate.runs.map(validateRun);
338
+ for (const [index, run] of runs.entries())
339
+ if (run.identity.runtime.praxis_version !== aggregate.version)
340
+ fail(`runs[${index}].identity.runtime.praxis_version`, 'must match aggregate version');
341
+ const expectedAggregateModel = runs.length > 0 && runs.every((run) => run.model === runs[0]?.model)
342
+ ? (runs[0]?.model ?? null)
343
+ : null;
344
+ if (aggregate.model !== expectedAggregateModel)
345
+ fail('model', 'does not match completed run identity models');
233
346
  if (data.run_count !== runs.length)
234
347
  fail('run_count', 'does not match runs length');
235
348
  if (data.completed_run_count > data.planned_run_count ||
@@ -285,6 +398,11 @@ export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cw
285
398
  keys.add(key);
286
399
  caseNames.add(run.case);
287
400
  }
401
+ validateProjectEvalAggregateIdentity(aggregate.identity_sha256, runs.map((run) => ({
402
+ case: run.case,
403
+ run: run.run,
404
+ identity_sha256: run.identity.identity_sha256,
405
+ })));
288
406
  if (!data.partial && caseNames.size !== data.case_count)
289
407
  fail('case_count', 'does not match completed run cases');
290
408
  if (data.interrupted && !data.partial)
@@ -294,64 +412,95 @@ export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cw
294
412
  if (data.partial !==
295
413
  (data.interrupted || data.run_count < data.planned_run_count))
296
414
  fail('partial', 'does not match interrupted/completed run state');
297
- const aggregateEvidenceKnown = optionalGroup(aggregate, [
298
- 'safety_passed',
299
- 'safety_failed',
300
- 'permission_decisions',
301
- 'tool_errors',
302
- 'retries',
303
- 'terminations',
304
- ], 'root evidence');
305
- const runEvidenceKnown = runs.every((run) => typeof run.safety_passed === 'boolean' &&
306
- typeof run.tool_errors === 'number' &&
307
- typeof run.retries === 'number' &&
308
- run.permission_decisions !== undefined);
309
- const runEvidenceAbsent = runs.every((run) => run.safety_passed === undefined &&
310
- run.tool_errors === undefined &&
311
- run.retries === undefined &&
312
- run.permission_decisions === undefined);
313
- if ((aggregateEvidenceKnown && !runEvidenceKnown) ||
314
- (!aggregateEvidenceKnown && !runEvidenceAbsent))
315
- fail('runs', 'run evidence must match aggregate evidence availability');
316
- const safetyKnown = aggregateEvidenceKnown && runEvidenceKnown;
317
- if (aggregateEvidenceKnown) {
318
- numberField(aggregate.safety_passed, 'safety_passed', true);
319
- numberField(aggregate.safety_failed, 'safety_failed', true);
320
- const permissions = objectField(aggregate.permission_decisions, 'permission_decisions');
321
- for (const key of ['allow', 'ask', 'deny'])
322
- numberField(permissions[key], `permission_decisions.${key}`, true);
323
- numberField(aggregate.tool_errors, 'tool_errors', true);
324
- numberField(aggregate.retries, 'retries', true);
325
- if (aggregate.safety_passed !==
326
- runs.filter((run) => run.safety_passed).length ||
327
- aggregate.safety_failed !==
328
- runs.filter((run) => !run.safety_passed).length)
329
- fail('safety_passed', 'does not match run safety evidence');
330
- const expectedPermissions = {
331
- allow: runs.reduce((total, run) => total + run.permission_decisions.allow, 0),
332
- ask: runs.reduce((total, run) => total + run.permission_decisions.ask, 0),
333
- deny: runs.reduce((total, run) => total + run.permission_decisions.deny, 0),
334
- };
335
- if (permissions.allow !== expectedPermissions.allow ||
336
- permissions.ask !== expectedPermissions.ask ||
337
- permissions.deny !== expectedPermissions.deny)
338
- fail('permission_decisions', 'does not match run evidence');
339
- if (aggregate.tool_errors !==
340
- runs.reduce((total, run) => total + run.tool_errors, 0) ||
341
- aggregate.retries !== runs.reduce((total, run) => total + run.retries, 0))
342
- fail('tool_errors', 'does not match run evidence');
343
- const terminations = objectField(aggregate.terminations, 'terminations');
344
- for (const key of ['completed', 'timeout', 'interrupted'])
345
- numberField(terminations[key], `terminations.${key}`, true);
346
- const expectedTerminations = {
347
- completed: runs.filter((run) => run.termination === null).length,
348
- timeout: runs.filter((run) => run.termination === 'timeout').length,
349
- interrupted: runs.filter((run) => run.termination === 'interrupted')
350
- .length,
351
- };
352
- for (const key of Object.keys(expectedTerminations))
353
- if (terminations[key] !== expectedTerminations[key])
354
- fail(`terminations.${key}`, 'does not match runs');
415
+ const safetyKnown = true;
416
+ numberField(aggregate.safety_passed, 'safety_passed', true);
417
+ numberField(aggregate.safety_failed, 'safety_failed', true);
418
+ const permissions = objectField(aggregate.permission_decisions, 'permission_decisions');
419
+ for (const key of ['allow', 'ask', 'deny'])
420
+ numberField(permissions[key], `permission_decisions.${key}`, true);
421
+ numberField(aggregate.tool_errors, 'tool_errors', true);
422
+ numberField(aggregate.retries, 'retries', true);
423
+ if (aggregate.safety_passed !==
424
+ runs.filter((run) => run.safety_passed).length ||
425
+ aggregate.safety_failed !== runs.filter((run) => !run.safety_passed).length)
426
+ fail('safety_passed', 'does not match run safety evidence');
427
+ const expectedPermissions = {
428
+ allow: runs.reduce((total, run) => total + run.permission_decisions.allow, 0),
429
+ ask: runs.reduce((total, run) => total + run.permission_decisions.ask, 0),
430
+ deny: runs.reduce((total, run) => total + run.permission_decisions.deny, 0),
431
+ };
432
+ if (permissions.allow !== expectedPermissions.allow ||
433
+ permissions.ask !== expectedPermissions.ask ||
434
+ permissions.deny !== expectedPermissions.deny)
435
+ fail('permission_decisions', 'does not match run evidence');
436
+ if (aggregate.tool_errors !==
437
+ runs.reduce((total, run) => total + run.tool_errors, 0) ||
438
+ aggregate.retries !== runs.reduce((total, run) => total + run.retries, 0))
439
+ fail('tool_errors', 'does not match run evidence');
440
+ const terminations = objectField(aggregate.terminations, 'terminations');
441
+ for (const key of ['completed', 'timeout', 'interrupted'])
442
+ numberField(terminations[key], `terminations.${key}`, true);
443
+ const expectedTerminations = {
444
+ completed: runs.filter((run) => run.termination === null).length,
445
+ timeout: runs.filter((run) => run.termination === 'timeout').length,
446
+ interrupted: runs.filter((run) => run.termination === 'interrupted').length,
447
+ };
448
+ for (const key of Object.keys(expectedTerminations))
449
+ if (terminations[key] !== expectedTerminations[key])
450
+ fail(`terminations.${key}`, 'does not match runs');
451
+ const verificationTotals = objectField(aggregate.verification_totals, 'verification_totals');
452
+ const unknownVerificationTotal = Object.keys(verificationTotals).find((key) => ![
453
+ 'declared',
454
+ 'passed',
455
+ 'failed',
456
+ 'not_run',
457
+ 'satisfied_runs',
458
+ 'unsatisfied_runs',
459
+ ].includes(key));
460
+ if (unknownVerificationTotal)
461
+ fail('verification_totals', `unknown field: ${unknownVerificationTotal}`);
462
+ for (const key of [
463
+ 'declared',
464
+ 'passed',
465
+ 'failed',
466
+ 'not_run',
467
+ 'satisfied_runs',
468
+ 'unsatisfied_runs',
469
+ ])
470
+ numberField(verificationTotals[key], `verification_totals.${key}`, true);
471
+ const expectedVerificationTotals = {
472
+ declared: runs.reduce((total, run) => total + run.verification.outcomes.length, 0),
473
+ passed: runs.reduce((total, run) => total +
474
+ run.verification.outcomes.filter(({ status }) => status === 'passed')
475
+ .length, 0),
476
+ failed: runs.reduce((total, run) => total +
477
+ run.verification.outcomes.filter(({ status }) => status === 'failed')
478
+ .length, 0),
479
+ not_run: runs.reduce((total, run) => total +
480
+ run.verification.outcomes.filter(({ status }) => status === 'not_run')
481
+ .length, 0),
482
+ satisfied_runs: runs.filter((run) => run.verification.satisfied).length,
483
+ unsatisfied_runs: runs.filter((run) => !run.verification.satisfied).length,
484
+ };
485
+ for (const key of Object.keys(expectedVerificationTotals))
486
+ if (verificationTotals[key] !== expectedVerificationTotals[key])
487
+ fail(`verification_totals.${key}`, 'does not match run verification evidence');
488
+ const riskTiers = objectField(aggregate.risk_tiers, 'risk_tiers');
489
+ const unknownRisk = Object.keys(riskTiers).find((key) => !PROJECT_EVAL_RISKS.includes(key));
490
+ if (unknownRisk)
491
+ fail('risk_tiers', `unknown field: ${unknownRisk}`);
492
+ for (const risk of PROJECT_EVAL_RISKS) {
493
+ const tier = objectField(riskTiers[risk], `risk_tiers.${risk}`);
494
+ const unknownTier = Object.keys(tier).find((key) => !['runs', 'passed', 'failed'].includes(key));
495
+ if (unknownTier)
496
+ fail(`risk_tiers.${risk}`, `unknown field: ${unknownTier}`);
497
+ for (const key of ['runs', 'passed', 'failed'])
498
+ numberField(tier[key], `risk_tiers.${risk}.${key}`, true);
499
+ const riskRuns = runs.filter((run) => run.risk === risk);
500
+ if (tier.runs !== riskRuns.length ||
501
+ tier.passed !== riskRuns.filter((run) => run.passed).length ||
502
+ tier.failed !== riskRuns.filter((run) => !run.passed).length)
503
+ fail(`risk_tiers.${risk}`, 'does not match run risk evidence');
355
504
  }
356
505
  return {
357
506
  aggregate: { ...aggregate, runs },
@@ -393,6 +542,13 @@ export function compareProjectEvalAggregates(baseline, candidate, baselineName,
393
542
  throw new Error('Aggregates have different comparable run sets');
394
543
  if (leftRuns.length === 0)
395
544
  throw new Error('Comparison requires at least one completed run');
545
+ for (let index = 0; index < leftRuns.length; index += 1) {
546
+ const leftRun = leftRuns[index];
547
+ const rightRun = rightRuns[index];
548
+ if (!leftRun || !rightRun)
549
+ continue;
550
+ assertProjectEvalIdentitiesComparable(leftRun.identity, rightRun.identity, `Identity mismatch for (${leftRun.case}, ${leftRun.run})`);
551
+ }
396
552
  const regressions = rightRuns.flatMap((run, index) => leftRuns[index]?.passed && !run.passed
397
553
  ? [
398
554
  {
@@ -410,6 +566,21 @@ export function compareProjectEvalAggregates(baseline, candidate, baselineName,
410
566
  const rightSafety = safetyKnown
411
567
  ? rightRuns.filter((run) => run.safety_passed).length / rightRuns.length
412
568
  : null;
569
+ const verificationRate = (runs) => {
570
+ const declared = runs.reduce((total, run) => total + run.verification.outcomes.length, 0);
571
+ if (declared === 0)
572
+ return null;
573
+ const passed = runs.reduce((total, run) => total +
574
+ run.verification.outcomes.filter(({ status }) => status === 'passed')
575
+ .length, 0);
576
+ return passed / declared;
577
+ };
578
+ const taskRiskRate = (runs, risk) => {
579
+ const selected = runs.filter((run) => run.risk === risk);
580
+ return selected.length === 0
581
+ ? null
582
+ : selected.filter((run) => run.passed).length / selected.length;
583
+ };
413
584
  const terms = (runs, kind) => runs.filter((run) => kind === 'completed'
414
585
  ? run.termination === null
415
586
  : run.termination === kind).length;
@@ -418,27 +589,38 @@ export function compareProjectEvalAggregates(baseline, candidate, baselineName,
418
589
  ? metric(leftRuns.reduce((n, r) => n + r.permission_decisions[name], 0), rightRuns.reduce((n, r) => n + r.permission_decisions[name], 0))
419
590
  : nullableMetric(null, null);
420
591
  const result = {
421
- schema_version: '1.0',
592
+ schema_version: '1.2',
422
593
  baseline: {
423
594
  name: baselineName,
424
595
  source_path: baseline.sourcePath,
425
596
  version: left.version,
426
597
  model: left.model,
598
+ identity_sha256: left.identity_sha256,
427
599
  },
428
600
  candidate: {
429
601
  name: candidateName,
430
602
  source_path: candidate.sourcePath,
431
603
  version: right.version,
432
604
  model: right.model,
605
+ identity_sha256: right.identity_sha256,
433
606
  },
434
607
  comparable_run_count: leftRuns.length,
435
608
  passed: regressions.length === 0 &&
436
609
  right.pass_rate >= left.pass_rate &&
437
610
  safetyKnown &&
438
- (rightSafety ?? 0) >= (leftSafety ?? 0),
611
+ (rightSafety ?? 0) >= (leftSafety ?? 0) &&
612
+ rightRuns.every((run) => run.verification.satisfied) &&
613
+ rightRuns
614
+ .filter((run) => run.risk === 'high' || run.risk === 'release')
615
+ .every((run) => run.passed),
439
616
  regressions,
440
617
  metrics: {
441
618
  pass_rate: metric(left.pass_rate, right.pass_rate),
619
+ verification_pass_rate: nullableMetric(verificationRate(leftRuns), verificationRate(rightRuns)),
620
+ task_risk_pass_rate: Object.fromEntries(PROJECT_EVAL_RISKS.map((risk) => [
621
+ risk,
622
+ nullableMetric(taskRiskRate(leftRuns, risk), taskRiskRate(rightRuns, risk)),
623
+ ])),
442
624
  safety_pass_rate: nullableMetric(leftSafety, rightSafety),
443
625
  average_turns: metric(avg(leftRuns.map((r) => r.turns)), avg(rightRuns.map((r) => r.turns))),
444
626
  input_tokens: token('input_tokens'),
@@ -0,0 +1,54 @@
1
+ import type { EvalRuntimeIdentityDescriptor } from './eval-contract.js';
2
+ import type { ProjectEvalCase } from './project-eval-schema.js';
3
+ import type { FileManifest } from './project-eval-workspace.js';
4
+ import { type PraxisBuildIdentity } from '../platform/praxis-build-identity.js';
5
+ export declare const PROJECT_EVAL_IDENTITY_SCHEMA_VERSION: "1.1";
6
+ export type ProjectEvalIdentitySchemaVersion = typeof PROJECT_EVAL_IDENTITY_SCHEMA_VERSION;
7
+ export type IdentityDigest = `sha256:${string}`;
8
+ export interface ProjectEvalRuntimeIdentity {
9
+ engine: 'praxis';
10
+ praxis_version: string;
11
+ node_version: string;
12
+ platform: string;
13
+ architecture: string;
14
+ build: PraxisBuildIdentity;
15
+ runtime_sha256: IdentityDigest;
16
+ }
17
+ export interface ProjectEvalIdentity {
18
+ schema_version: ProjectEvalIdentitySchemaVersion;
19
+ provider_id: string;
20
+ profile_id: string;
21
+ protocol: string;
22
+ endpoint_sha256: IdentityDigest;
23
+ model_id: string;
24
+ configuration_sha256: IdentityDigest;
25
+ tools_sha256: IdentityDigest;
26
+ prompt_sha256: IdentityDigest;
27
+ corpus_sha256: IdentityDigest;
28
+ runtime: ProjectEvalRuntimeIdentity;
29
+ identity_sha256: IdentityDigest;
30
+ }
31
+ export interface CreateProjectEvalIdentityInput {
32
+ provider: EvalRuntimeIdentityDescriptor;
33
+ case: ProjectEvalCase;
34
+ sourceBefore: FileManifest;
35
+ effectiveTools: readonly string[];
36
+ runVerification: boolean;
37
+ praxisVersion: string;
38
+ nodeVersion?: string;
39
+ platform?: string;
40
+ architecture?: string;
41
+ buildIdentity: PraxisBuildIdentity;
42
+ }
43
+ export interface AggregateIdentityRun {
44
+ case: string;
45
+ run: number;
46
+ identity_sha256: IdentityDigest;
47
+ }
48
+ export declare function createProjectEvalIdentity(input: CreateProjectEvalIdentityInput): ProjectEvalIdentity;
49
+ export declare function validateProjectEvalIdentity(value: unknown): ProjectEvalIdentity;
50
+ export declare function computeProjectEvalAggregateIdentity(runs: readonly AggregateIdentityRun[]): IdentityDigest;
51
+ export declare function validateProjectEvalAggregateIdentity(value: unknown, runs: readonly AggregateIdentityRun[]): IdentityDigest;
52
+ export declare function firstProjectEvalIdentityMismatch(left: ProjectEvalIdentity, right: ProjectEvalIdentity): string | null;
53
+ export declare function assertProjectEvalIdentitiesComparable(left: ProjectEvalIdentity, right: ProjectEvalIdentity, context?: string): void;
54
+ //# sourceMappingURL=project-eval-identity.d.ts.map