@skill-harness/core 0.10.0 → 0.12.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.
Files changed (81) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/types.d.ts +30 -0
  3. package/dist/adjudication.d.ts +9 -1
  4. package/dist/adjudication.js +41 -33
  5. package/dist/arms.d.ts +51 -0
  6. package/dist/arms.js +255 -0
  7. package/dist/capture-trace-types.d.ts +2 -0
  8. package/dist/command-cost.d.ts +9 -0
  9. package/dist/command-cost.js +23 -0
  10. package/dist/comparison.d.ts +1 -0
  11. package/dist/comparison.js +9 -3
  12. package/dist/execution-trace.js +9 -1
  13. package/dist/grade.d.ts +2 -0
  14. package/dist/grade.js +2 -1
  15. package/dist/index.d.ts +14 -0
  16. package/dist/index.js +14 -0
  17. package/dist/journal.d.ts +2 -1
  18. package/dist/judge-agreement.d.ts +19 -0
  19. package/dist/judge-agreement.js +31 -0
  20. package/dist/judge-policy.js +5 -3
  21. package/dist/lift.js +1 -1
  22. package/dist/lint.js +1 -1
  23. package/dist/prompt-normalization.d.ts +6 -0
  24. package/dist/prompt-normalization.js +14 -0
  25. package/dist/provider-failure.d.ts +56 -0
  26. package/dist/provider-failure.js +98 -0
  27. package/dist/qualification-capture.d.ts +44 -0
  28. package/dist/qualification-capture.js +163 -0
  29. package/dist/qualification-config.d.ts +233 -0
  30. package/dist/qualification-config.js +694 -0
  31. package/dist/qualification-lock.d.ts +15 -0
  32. package/dist/qualification-lock.js +87 -0
  33. package/dist/qualification-oauth-directory.d.ts +79 -0
  34. package/dist/qualification-oauth-directory.js +444 -0
  35. package/dist/qualification-panel-store.d.ts +14 -0
  36. package/dist/qualification-panel-store.js +188 -0
  37. package/dist/qualification-panels.d.ts +99 -0
  38. package/dist/qualification-panels.js +91 -0
  39. package/dist/qualification-process.d.ts +13 -0
  40. package/dist/qualification-process.js +188 -0
  41. package/dist/qualification-runner.d.ts +203 -0
  42. package/dist/qualification-runner.js +2037 -0
  43. package/dist/qualification-store.d.ts +187 -0
  44. package/dist/qualification-store.js +833 -0
  45. package/dist/qualification-validation.d.ts +12 -0
  46. package/dist/qualification-validation.js +8 -0
  47. package/dist/regate.js +49 -10
  48. package/dist/regrade.d.ts +3 -1
  49. package/dist/regrade.js +62 -6
  50. package/dist/reps.d.ts +4 -2
  51. package/dist/reps.js +13 -4
  52. package/dist/rescore.js +9 -1
  53. package/dist/results.d.ts +99 -7
  54. package/dist/results.js +228 -10
  55. package/dist/run.d.ts +14 -0
  56. package/dist/run.js +260 -57
  57. package/dist/score.d.ts +4 -3
  58. package/dist/score.js +13 -4
  59. package/dist/screen.d.ts +31 -0
  60. package/dist/screen.js +105 -0
  61. package/dist/seeded.d.ts +12 -1
  62. package/dist/seeded.js +9 -2
  63. package/dist/sources.d.ts +7 -1
  64. package/dist/sources.js +13 -5
  65. package/dist/spec.d.ts +1 -1
  66. package/dist/spec.js +1 -1
  67. package/dist/stability.js +1 -1
  68. package/dist/trajectory-gates.d.ts +14 -2
  69. package/dist/trajectory-gates.js +26 -6
  70. package/dist/vote-panel.d.ts +33 -0
  71. package/dist/vote-panel.js +41 -0
  72. package/dist/workspace.js +34 -3
  73. package/package.json +3 -3
  74. package/schemas/qualification-cell-result-v1.schema.json +32 -0
  75. package/schemas/qualification-config-v1.schema.json +244 -0
  76. package/schemas/qualification-invocation-request-v1.schema.json +59 -0
  77. package/schemas/qualification-judge-panel-result-v1.schema.json +42 -0
  78. package/schemas/results-v2.schema.json +19 -21
  79. package/schemas/results-v3.schema.json +654 -0
  80. package/schemas/specification-v1.schema.json +1 -1
  81. package/schemas/trajectory-event-v1.schema.json +20 -1
package/dist/results.js CHANGED
@@ -4,6 +4,7 @@ import yaml from "js-yaml";
4
4
  import { modelSlug } from "./adapters/types.js";
5
5
  import { score } from "./score.js";
6
6
  import { HARNESS_VERSION } from "./version.js";
7
+ import { collapseVotePanel } from "./vote-panel.js";
7
8
  /** Merge bookkeeping from a later judge/gate pass without duplicating subject usage. */
8
9
  export function mergeScenarioMetrics(prior, fresh) {
9
10
  if (!prior)
@@ -22,11 +23,20 @@ export function mergeScenarioMetrics(prior, fresh) {
22
23
  ...(subject.cache_read_tokens === undefined ? {} : { cache_read_tokens: subject.cache_read_tokens }),
23
24
  ...(subject.cache_write_tokens === undefined ? {} : { cache_write_tokens: subject.cache_write_tokens }),
24
25
  ...(subject.subject_cost_usd === undefined ? {} : { subject_cost_usd: subject.subject_cost_usd }),
26
+ ...(subject.cost_source === undefined ? {} : { cost_source: subject.cost_source }),
25
27
  ...(subject.tool_calls === undefined ? {} : { tool_calls: subject.tool_calls }),
26
28
  ...(subject.delegated_children === undefined ? {} : { delegated_children: subject.delegated_children }),
27
29
  ...(subject.max_concurrency === undefined ? {} : { max_concurrency: subject.max_concurrency }),
28
30
  };
29
31
  }
32
+ export function carryRepObjectives(fresh, prior) {
33
+ if (!fresh)
34
+ return prior;
35
+ return fresh.map(panel => {
36
+ const objective = prior?.find(candidate => candidate.repetition === panel.repetition)?.objective;
37
+ return objective ? { ...panel, objective } : panel;
38
+ });
39
+ }
30
40
  /**
31
41
  * The run modes whose results carry a real grade: the ones where the skill under
32
42
  * test was actually delivered to the model.
@@ -87,9 +97,23 @@ export function effectiveThreshold(prevScenario, scenario) {
87
97
  function timestampSlug(iso) {
88
98
  return iso.replace(/[:.]/g, "-");
89
99
  }
90
- /** <skillDir>/tests/results/<harness>-<model-slug>/<timestamp-slug>/ */
91
- export function runDirFor(skillDir, harness, model, timestamp) {
92
- return join(skillDir, "tests", "results", `${harness}-${modelSlug(model)}`, timestampSlug(timestamp));
100
+ /**
101
+ * `<skillDir>/tests/results/<harness>-<model-slug>[+<arm>]/<timestamp-slug>/`
102
+ *
103
+ * The arm is part of the run's IDENTITY, not of the spec's digest. `lint` and
104
+ * `stability` both key on this tag, so two arms become separate lineages that can
105
+ * never be misread as one lineage flipping run-over-run — and no
106
+ * `specification.yaml` byte moves, so every committed run stays valid.
107
+ *
108
+ * `+` is deliberate and is appended OUTSIDE `modelSlug`, whose character class
109
+ * (`[^A-Za-z0-9._-]`) cannot emit one. So the separator can never occur inside a
110
+ * slug and the tag stays unambiguously splittable back into model and arm. The
111
+ * control arm appends nothing, so its paths are byte-identical to before arms
112
+ * existed.
113
+ */
114
+ export function runDirFor(skillDir, harness, model, timestamp, armName) {
115
+ const arm = armName && armName !== "none" ? `+${armName}` : "";
116
+ return join(skillDir, "tests", "results", `${harness}-${modelSlug(model)}${arm}`, timestampSlug(timestamp));
93
117
  }
94
118
  /** Path of a transcript file within a run dir. A rep index (for --reps N>1) is suffixed. */
95
119
  export function transcriptPath(runDir, scenarioId, mode, rep) {
@@ -145,6 +169,8 @@ function objectiveVerdict(s) {
145
169
  return undefined;
146
170
  if (s.objective.status === "ERROR")
147
171
  return "ERROR";
172
+ if (s.objective.status === "NOT-MEASURED")
173
+ return "NOT-MEASURED";
148
174
  if (s.objective.status === "FAIL")
149
175
  return "FAIL";
150
176
  return undefined;
@@ -165,8 +191,9 @@ export function finalizeResults(draft, ctx) {
165
191
  const why = draft.partial ? "partial run (--only) — not scored" : `mode=${draft.mode} (not scored)`;
166
192
  effective_grade = { passed: 0, total: 0, pct: 0, letter: "-", ship: false, note: why };
167
193
  }
194
+ const schema = draft.schema ?? (draft.subject_invocations ? 3 : 2);
168
195
  return {
169
- schema: 2,
196
+ schema,
170
197
  // Stamped here, the single place every writer passes through, so `run`,
171
198
  // `grade`, `rescore` and the review UI's override save all record which tool
172
199
  // produced the record they leave behind.
@@ -182,15 +209,18 @@ export function finalizeResults(draft, ctx) {
182
209
  timestamp: draft.timestamp,
183
210
  label: draft.label,
184
211
  mode: draft.mode,
212
+ ...(draft.arm ? { arm: draft.arm } : {}),
185
213
  ...(draft.partial ? { partial: true } : {}),
186
214
  ...(draft.source_hashes ? { source_hashes: draft.source_hashes } : {}),
187
215
  effective_grade,
188
216
  scenarios: draft.scenarios,
217
+ ...(draft.subject_invocations ? { subject_invocations: draft.subject_invocations } : {}),
189
218
  };
190
219
  }
191
220
  /** Finalize + persist results.yaml (creating the run dir). Returns what was written. */
192
221
  export function writeResults(runDir, draft, ctx) {
193
222
  const results = finalizeResults(draft, ctx);
223
+ validateResults(results);
194
224
  mkdirSync(runDir, { recursive: true });
195
225
  writeFileSync(resultsPath(runDir), yaml.dump(results, { lineWidth: 100 }), "utf8");
196
226
  return results;
@@ -202,8 +232,8 @@ export function migrateResults(raw) {
202
232
  throw new Error("empty or invalid results.yaml");
203
233
  }
204
234
  const o = raw;
205
- if (o.schema === 2)
206
- return raw;
235
+ if (o.schema === 2 || o.schema === 3)
236
+ return validateResults(raw);
207
237
  const v1 = raw;
208
238
  const modeMatch = /^mode=(\w+)/.exec(v1.grade?.note ?? "");
209
239
  return {
@@ -235,6 +265,191 @@ export function readResults(runDir) {
235
265
  const text = readFileSync(resultsPath(runDir), "utf8");
236
266
  return migrateResults(yaml.load(text));
237
267
  }
268
+ const CRITERION_RE = /^\s*(\d+)[.)]\s*\**\s*(PASS|FAIL)\b\**\s*(.*)$/gim;
269
+ /** Parse rubric-item votes for retention only; verdict semantics remain in grade.ts. */
270
+ export function parseCriterionVotes(raw) {
271
+ return [...raw.matchAll(CRITERION_RE)].map((match) => ({
272
+ index: Number(match[1]), verdict: match[2].toUpperCase(),
273
+ reason: match[3].trim().replace(/^[-—:]\s*/, ""),
274
+ }));
275
+ }
276
+ export function completeCriterionVotes(votes, expected) {
277
+ return Array.from({ length: expected }, (_, offset) => votes.find(vote => vote.index === offset + 1) ?? {
278
+ index: offset + 1, verdict: "ERROR", reason: "judge emitted no parseable vote for this criterion",
279
+ });
280
+ }
281
+ export function deliveryStatusForObservations(observations) {
282
+ if (observations.length === 0)
283
+ return "ERROR";
284
+ const terminalAttempt = Math.max(...observations.map(observation => observation.attempt ?? 0));
285
+ const terminal = observations.filter(observation => (observation.attempt ?? 0) === terminalAttempt);
286
+ if (terminal.some(observation => observation.prompt.status === "ERROR"))
287
+ return "ERROR";
288
+ const mechanism = terminal[0].prompt.mechanism;
289
+ if (terminal.some(observation => observation.prompt.mechanism !== mechanism))
290
+ return "ERROR";
291
+ const counts = terminal.map(observation => observation.prompt.contract_occurrences);
292
+ if (mechanism === "none")
293
+ return counts.every(count => count === 0) ? "PASS" : "NOT-MEASURED";
294
+ if (mechanism === "pi-skill") {
295
+ const firstDelivered = counts.indexOf(1);
296
+ return firstDelivered >= 0 && counts.slice(0, firstDelivered).every(count => count === 0) && counts.slice(firstDelivered).every(count => count === 1) ? "PASS" : "NOT-MEASURED";
297
+ }
298
+ return counts.every(count => count === 1) ? "PASS" : "NOT-MEASURED";
299
+ }
300
+ export function recomputeRecordedPanels(results) {
301
+ const out = [];
302
+ for (const scenario of results.scenarios)
303
+ for (const panel of scenario.rep_judgments ?? []) {
304
+ const clean = panel.judgments.filter(j => !j.suspect && (j.verdict === "PASS" || j.verdict === "FAIL"));
305
+ let verdict = panel.recorded_verdict;
306
+ if (clean.length === 1)
307
+ verdict = clean[0].verdict;
308
+ else if (clean.length >= 2)
309
+ verdict = collapseVotePanel(panel.judgments).verdict ?? "JUDGE-AMBIGUOUS";
310
+ out.push({ scenario_id: scenario.id, repetition: panel.repetition, verdict });
311
+ }
312
+ return out;
313
+ }
314
+ /** Runtime v2/v3 validator. v2 remains permissive; v3 fails closed on observation gaps. */
315
+ export function validateResults(raw) {
316
+ if (!raw || typeof raw !== "object")
317
+ throw new Error("invalid results");
318
+ const result = raw;
319
+ if (result.schema !== 2 && result.schema !== 3)
320
+ throw new Error(`unsupported results schema ${String(raw.schema)}`);
321
+ if (!Array.isArray(result.scenarios))
322
+ throw new Error("results scenarios missing");
323
+ if (result.schema === 2)
324
+ return result;
325
+ if (!Array.isArray(result.subject_invocations))
326
+ throw new Error("schema v3 delivery observations missing");
327
+ for (const observation of result.subject_invocations) {
328
+ const p = observation?.prompt;
329
+ if (!p || !["PASS", "NOT-MEASURED", "ERROR"].includes(p.status))
330
+ throw new Error("delivery status missing");
331
+ if (p.normalization_rule !== "cwd-line-v1")
332
+ throw new Error("unknown prompt normalization rule");
333
+ if (!/^[a-f0-9]{64}$/i.test(p.raw_sha256) || !/^[a-f0-9]{64}$/i.test(p.normalized_sha256) || !/^[a-f0-9]{64}$/i.test(p.contract_sha256))
334
+ throw new Error("invalid prompt provenance digest");
335
+ if (!Number.isInteger(p.bytes) || p.bytes < 0 || !Number.isInteger(p.contract_bytes) || p.contract_bytes < 0)
336
+ throw new Error("invalid prompt provenance byte length");
337
+ if (!Number.isInteger(p.contract_occurrences) || p.contract_occurrences < 0)
338
+ throw new Error("invalid delivery occurrence count");
339
+ const expected = p.mechanism === "none" ? 0 : 1;
340
+ const computed = p.contract_occurrences === expected ? "PASS" : "NOT-MEASURED";
341
+ if (p.status !== "ERROR" && p.status !== computed)
342
+ throw new Error("delivery status contradicts occurrence count");
343
+ }
344
+ const scenarioIds = new Set(result.scenarios.map(scenario => scenario.id));
345
+ const repsByScenario = new Map(result.scenarios.map(scenario => [scenario.id, scenario.reps ?? 1]));
346
+ if (result.subject_invocations.some(observation => !scenarioIds.has(observation.scenario_id) || !Number.isInteger(observation.repetition) || observation.repetition < 0 || observation.repetition >= (repsByScenario.get(observation.scenario_id) ?? 0)))
347
+ throw new Error("schema v3 contains orphan or out-of-range subject invocation observation");
348
+ const recomputed = recomputeRecordedPanels(result);
349
+ for (const scenario of result.scenarios) {
350
+ const reps = scenario.reps ?? 1;
351
+ if (!Array.isArray(scenario.rep_judgments) || scenario.rep_judgments.length !== reps)
352
+ throw new Error(`schema v3 repetition judgments missing for ${scenario.id}`);
353
+ const panelRepetitions = scenario.rep_judgments.map(panel => panel.repetition).sort((a, b) => a - b);
354
+ if (panelRepetitions.some((repetition, index) => repetition !== index))
355
+ throw new Error(`schema v3 repetition judgments duplicate, missing, or out of range for ${scenario.id}`);
356
+ for (let repetition = 0; repetition < reps; repetition++) {
357
+ const observations = result.subject_invocations.filter(o => o.scenario_id === scenario.id && o.repetition === repetition);
358
+ // A pre-provider infrastructure failure legitimately has zero requests;
359
+ // its per-repetition skill_delivered objective must remain explicit ERROR.
360
+ const attempts = [...new Set(observations.map(observation => observation.attempt ?? 0))].sort((a, b) => a - b);
361
+ if (attempts.some((attempt, index) => !Number.isInteger(attempt) || attempt !== index))
362
+ throw new Error(`delivery attempts are duplicate, missing, or out of range for ${scenario.id}#${repetition}`);
363
+ for (const attempt of attempts) {
364
+ const indexes = observations.filter(observation => (observation.attempt ?? 0) === attempt).map(observation => observation.prompt.request_index).sort((a, b) => a - b);
365
+ if (indexes.some((requestIndex, index) => !Number.isInteger(requestIndex) || requestIndex !== index))
366
+ throw new Error(`prompt request indexes are duplicate, missing, or out of range for ${scenario.id}#${repetition} attempt ${attempt}`);
367
+ }
368
+ }
369
+ const repStatuses = Array.from({ length: reps }, (_, repetition) => deliveryStatusForObservations(result.subject_invocations.filter(o => o.scenario_id === scenario.id && o.repetition === repetition)));
370
+ const expectedStatus = repStatuses.includes("ERROR") ? "ERROR" : repStatuses.includes("NOT-MEASURED") ? "NOT-MEASURED" : "PASS";
371
+ const gate = scenario.objective?.assertions.find(assertion => assertion.kind === "skill_delivered");
372
+ if (!gate || gate.status !== expectedStatus)
373
+ throw new Error(`skill_delivered gate missing or inconsistent for ${scenario.id}`);
374
+ if (!Number.isInteger(scenario.criterion_count) || scenario.criterion_count < 1)
375
+ throw new Error(`schema v3 criterion count missing for ${scenario.id}`);
376
+ const assertCriteria = (judgment) => {
377
+ if (!Array.isArray(judgment.criteria))
378
+ throw new Error("schema v3 criterion votes missing");
379
+ const indexes = judgment.criteria.map(vote => vote.index).sort((a, b) => a - b);
380
+ if (indexes.length !== scenario.criterion_count || indexes.some((index, offset) => index !== offset + 1))
381
+ throw new Error(`schema v3 criterion votes are not complete contiguous indexes for ${scenario.id}`);
382
+ };
383
+ for (const panel of scenario.rep_judgments) {
384
+ for (const judgment of panel.judgments)
385
+ assertCriteria(judgment);
386
+ const cleanJudgments = panel.judgments.filter(judgment => !judgment.suspect && (judgment.verdict === "PASS" || judgment.verdict === "FAIL"));
387
+ const objectiveBehavioralFail = panel.objective?.status === "FAIL" && panel.recorded_verdict === "FAIL";
388
+ if ((panel.recorded_verdict === "PASS" || panel.recorded_verdict === "FAIL") && cleanJudgments.length === 0 && !objectiveBehavioralFail)
389
+ throw new Error(`recorded behavioral verdict is unsupported by a clean judgment for ${scenario.id}#${panel.repetition}`);
390
+ const actual = recomputed.find(x => x.scenario_id === scenario.id && x.repetition === panel.repetition);
391
+ if (panel.judgments.length > 0 && actual.verdict !== panel.recorded_verdict)
392
+ throw new Error(`recorded panel verdict diverges from recomputed votes for ${scenario.id}#${panel.repetition}`);
393
+ if (!panel.objective)
394
+ throw new Error(`schema v3 per-repetition objective missing for ${scenario.id}#${panel.repetition}`);
395
+ const observations = result.subject_invocations.filter(o => o.scenario_id === scenario.id && o.repetition === panel.repetition);
396
+ const deliveryStatus = deliveryStatusForObservations(observations);
397
+ const delivery = panel.objective.assertions.find(assertion => assertion.kind === "skill_delivered");
398
+ if (!delivery || delivery.status !== deliveryStatus)
399
+ throw new Error(`per-repetition skill_delivered gate missing or inconsistent for ${scenario.id}#${panel.repetition}`);
400
+ if (deliveryStatus === "ERROR" && panel.objective.status !== "ERROR")
401
+ throw new Error(`per-repetition objective weakens delivery ERROR for ${scenario.id}#${panel.repetition}`);
402
+ if (deliveryStatus === "NOT-MEASURED" && !["ERROR", "NOT-MEASURED"].includes(panel.objective.status))
403
+ throw new Error(`per-repetition objective weakens delivery NOT-MEASURED for ${scenario.id}#${panel.repetition}`);
404
+ const objectiveVerdict = panel.objective.status === "ERROR" ? "ERROR" : panel.objective.status === "NOT-MEASURED" ? "NOT-MEASURED" : panel.objective.status === "FAIL" ? "FAIL" : undefined;
405
+ if (objectiveVerdict && panel.recorded_verdict !== objectiveVerdict)
406
+ throw new Error(`recorded panel verdict weakens objective evidence for ${scenario.id}#${panel.repetition}`);
407
+ }
408
+ const objectiveStatuses = scenario.rep_judgments.map(panel => panel.objective.status);
409
+ const aggregateObjectiveStatus = objectiveStatuses.includes("ERROR") ? "ERROR" : objectiveStatuses.includes("NOT-MEASURED") ? "NOT-MEASURED" : objectiveStatuses.includes("FAIL") ? "FAIL" : "PASS";
410
+ if (!scenario.objective || scenario.objective.status !== aggregateObjectiveStatus)
411
+ throw new Error(`scenario objective diverges from per-repetition objectives for ${scenario.id}`);
412
+ for (const judgment of [...(scenario.judge_history ?? []), ...(scenario.adjudication?.judgments ?? [])])
413
+ assertCriteria(judgment);
414
+ let adjudicatedVerdict;
415
+ if (scenario.adjudication) {
416
+ if (!Number.isInteger(scenario.adjudication.repetition) || scenario.adjudication.repetition < 0 || scenario.adjudication.repetition >= reps)
417
+ throw new Error(`schema v3 adjudication repetition missing or out of range for ${scenario.id}`);
418
+ const collapsed = collapseVotePanel(scenario.adjudication.judgments);
419
+ const boundedCriticalAggregate = (scenario.reps ?? 1) > 1 && scenario.pass_threshold === 1 && scenario.judge_verdict !== "PASS" && collapsed.verdict === "PASS" && scenario.adjudication.state === "unresolved" && scenario.adjudication.verdict === undefined;
420
+ if (!boundedCriticalAggregate && (scenario.adjudication.state !== collapsed.state || scenario.adjudication.verdict !== collapsed.verdict))
421
+ throw new Error(`recorded adjudication state/verdict diverges from recomputed votes for ${scenario.id}`);
422
+ if (scenario.adjudication.state !== "unresolved")
423
+ adjudicatedVerdict = scenario.adjudication.verdict;
424
+ }
425
+ const panels = scenario.rep_judgments;
426
+ const errorCount = panels.filter(panel => panel.recorded_verdict === "ERROR").length;
427
+ const notMeasuredCount = panels.filter(panel => panel.recorded_verdict === "NOT-MEASURED").length;
428
+ const cleanPanels = panels.filter(panel => !(panel.judgments[0]?.suspect ?? false));
429
+ const passes = cleanPanels.filter(panel => panel.recorded_verdict === "PASS").length;
430
+ let aggregateVerdict;
431
+ let aggregateSuspect = false;
432
+ if (panels.length === 1) {
433
+ aggregateVerdict = panels[0].recorded_verdict;
434
+ aggregateSuspect = panels[0].judgments[0]?.suspect ?? false;
435
+ }
436
+ else if (errorCount > 0)
437
+ aggregateVerdict = "ERROR";
438
+ else if (notMeasuredCount > 0)
439
+ aggregateVerdict = "NOT-MEASURED";
440
+ else if (cleanPanels.length * 2 < panels.length) {
441
+ aggregateVerdict = "FAIL";
442
+ aggregateSuspect = true;
443
+ }
444
+ else
445
+ aggregateVerdict = passes / cleanPanels.length >= (scenario.pass_threshold ?? 0.5) ? "PASS" : "FAIL";
446
+ const expectedVerdict = adjudicatedVerdict ?? aggregateVerdict;
447
+ const expectedSuspect = scenario.adjudication ? scenario.adjudication.state === "unresolved" : aggregateSuspect;
448
+ if (scenario.judge_verdict !== expectedVerdict || scenario.suspect !== expectedSuspect)
449
+ throw new Error(`schema v3 scenario verdict/suspect diverges from repetition aggregate for ${scenario.id}`);
450
+ }
451
+ return result;
452
+ }
238
453
  /** Pure: return a copy with override + note applied to one scenario. */
239
454
  export function applyOverride(results, scenarioId, override, note) {
240
455
  if (override !== null && note.trim() === "") {
@@ -384,7 +599,7 @@ export function diffPath(runDir, scenarioId, mode, rep) {
384
599
  export function rebuildScenarioResult(fresh, prior, policy) {
385
600
  // Exhaustive destructure. Do not replace with a spread: the spread is what
386
601
  // allowed a new field to pass through unconsidered in the first place.
387
- const { id, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, metrics: freshMetrics, objective: freshObjective, adjudication: freshAdjudication, ...rest } = fresh;
602
+ const { id, criterion_count: freshCriterionCount, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, metrics: freshMetrics, objective: freshObjective, adjudication: freshAdjudication, judge_history: freshJudgeHistory, rep_judgments: freshRepJudgments, ...rest } = fresh;
388
603
  const _exhaustive = rest;
389
604
  void _exhaustive;
390
605
  void _freshOverride;
@@ -396,7 +611,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
396
611
  };
397
612
  const objective = pick(policy.objective, freshObjective, prior?.objective);
398
613
  const pickedAdjudication = pick(policy.adjudication, freshAdjudication, prior?.adjudication);
399
- const conflictsWithFreshEvidence = Boolean(pickedAdjudication?.verdict && (objective?.status === "FAIL" || objective?.status === "ERROR" ||
614
+ const conflictsWithFreshEvidence = Boolean(pickedAdjudication?.verdict && (objective?.status === "FAIL" || objective?.status === "ERROR" || objective?.status === "NOT-MEASURED" ||
400
615
  (pass_threshold === 1 && (reps ?? 1) > 1 && judge_verdict !== "PASS")));
401
616
  // A settled cell-level panel cannot overrule newly evaluated objective gates,
402
617
  // nor can one transcript erase a failed repetition under an all-clean policy.
@@ -408,8 +623,8 @@ export function rebuildScenarioResult(fresh, prior, policy) {
408
623
  // Taking `suspect` from `fresh` while carrying the block therefore published a
409
624
  // record that said `state: "unresolved"` and scored as a clean SHIP. `regate`
410
625
  // did exactly that: it rebuilds verdict and suspect from the saved first-wave
411
- // judge file, so a free, offline command silently resolved a disagreement in
412
- // favour of shipping — inverting this module's stated invariant that an
626
+ // judge file, so a gate-only rewrite silently resolved a disagreement in favour
627
+ // of shipping — inverting this module's stated invariant that an
413
628
  // unresolved disagreement must not resolve itself.
414
629
  const unresolved = adjudication?.state === "unresolved";
415
630
  // A settled adjudication outranks a re-read of the first-wave judge file for
@@ -425,6 +640,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
425
640
  // corpus with a pure-noise diff.
426
641
  return {
427
642
  id,
643
+ ...((freshCriterionCount ?? prior?.criterion_count) === undefined ? {} : { criterion_count: freshCriterionCount ?? prior.criterion_count }),
428
644
  judge_verdict: settled ?? judge_verdict,
429
645
  judge_reason,
430
646
  suspect: suspect || unresolved,
@@ -444,6 +660,8 @@ export function rebuildScenarioResult(fresh, prior, policy) {
444
660
  // existed.
445
661
  ...(objective ? { objective } : {}),
446
662
  ...(adjudication ? { adjudication } : {}),
663
+ ...((freshJudgeHistory ?? prior?.judge_history) ? { judge_history: freshJudgeHistory ?? prior.judge_history } : {}),
664
+ ...((freshRepJudgments ?? prior?.rep_judgments) ? { rep_judgments: freshRepJudgments ?? prior.rep_judgments } : {}),
447
665
  };
448
666
  }
449
667
  /**
package/dist/run.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Spec } from "./spec.js";
2
2
  import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
3
+ import { type Arm } from "./arms.js";
3
4
  import { type ResultsFile } from "./results.js";
4
5
  import { type Lift } from "./lift.js";
5
6
  import { type ScenarioStability } from "./stability.js";
@@ -32,6 +33,19 @@ export interface RunOptions {
32
33
  * there) is already refused by the adapter for free.
33
34
  */
34
35
  canary?: boolean;
36
+ /**
37
+ * Take the structured path even when no scenario declares a trace or trajectory
38
+ * assertion. The subject half of `ScenarioMetrics` — tokens, `subject_cost_usd` —
39
+ * is only ever populated from traces, so without this a run records no cost or
40
+ * token data at all, which is why the reference corpus has none.
41
+ */
42
+ structured?: boolean;
43
+ /** The arm this run is measured under. Absent means the control (`none`). */
44
+ arm?: Arm;
45
+ /** Skills root — `seed_skills` paths resolve against it. Defaults to the skill's parent. */
46
+ skillsRoot?: string;
47
+ /** Injectable ambient skill root, for tests. Production reads `~/.pi/agent/skills`. */
48
+ ambientSkillsDir?: string;
35
49
  }
36
50
  export interface RunSummary {
37
51
  runDir: string;