@skill-harness/core 0.11.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 (77) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/types.d.ts +19 -0
  3. package/dist/adjudication.d.ts +9 -1
  4. package/dist/adjudication.js +41 -33
  5. package/dist/arms.d.ts +1 -1
  6. package/dist/capture-trace-types.d.ts +2 -0
  7. package/dist/command-cost.d.ts +9 -0
  8. package/dist/command-cost.js +23 -0
  9. package/dist/comparison.d.ts +1 -0
  10. package/dist/comparison.js +9 -3
  11. package/dist/execution-trace.js +9 -1
  12. package/dist/grade.d.ts +2 -0
  13. package/dist/grade.js +2 -1
  14. package/dist/index.d.ts +12 -0
  15. package/dist/index.js +12 -0
  16. package/dist/journal.d.ts +1 -1
  17. package/dist/judge-agreement.d.ts +19 -0
  18. package/dist/judge-agreement.js +31 -0
  19. package/dist/judge-policy.js +5 -3
  20. package/dist/lift.js +1 -1
  21. package/dist/lint.js +1 -1
  22. package/dist/prompt-normalization.d.ts +6 -0
  23. package/dist/prompt-normalization.js +14 -0
  24. package/dist/qualification-capture.d.ts +44 -0
  25. package/dist/qualification-capture.js +163 -0
  26. package/dist/qualification-config.d.ts +233 -0
  27. package/dist/qualification-config.js +694 -0
  28. package/dist/qualification-lock.d.ts +15 -0
  29. package/dist/qualification-lock.js +87 -0
  30. package/dist/qualification-oauth-directory.d.ts +79 -0
  31. package/dist/qualification-oauth-directory.js +444 -0
  32. package/dist/qualification-panel-store.d.ts +14 -0
  33. package/dist/qualification-panel-store.js +188 -0
  34. package/dist/qualification-panels.d.ts +99 -0
  35. package/dist/qualification-panels.js +91 -0
  36. package/dist/qualification-process.d.ts +13 -0
  37. package/dist/qualification-process.js +188 -0
  38. package/dist/qualification-runner.d.ts +203 -0
  39. package/dist/qualification-runner.js +2037 -0
  40. package/dist/qualification-store.d.ts +187 -0
  41. package/dist/qualification-store.js +833 -0
  42. package/dist/qualification-validation.d.ts +12 -0
  43. package/dist/qualification-validation.js +8 -0
  44. package/dist/regate.js +42 -10
  45. package/dist/regrade.d.ts +3 -1
  46. package/dist/regrade.js +37 -5
  47. package/dist/reps.d.ts +4 -2
  48. package/dist/reps.js +13 -4
  49. package/dist/rescore.js +2 -1
  50. package/dist/results.d.ts +53 -7
  51. package/dist/results.js +210 -7
  52. package/dist/run.js +66 -17
  53. package/dist/score.d.ts +4 -3
  54. package/dist/score.js +13 -4
  55. package/dist/screen.d.ts +31 -0
  56. package/dist/screen.js +105 -0
  57. package/dist/seeded.d.ts +2 -1
  58. package/dist/seeded.js +1 -0
  59. package/dist/sources.d.ts +7 -1
  60. package/dist/sources.js +13 -5
  61. package/dist/spec.d.ts +1 -1
  62. package/dist/spec.js +1 -1
  63. package/dist/stability.js +1 -1
  64. package/dist/trajectory-gates.d.ts +14 -2
  65. package/dist/trajectory-gates.js +26 -6
  66. package/dist/vote-panel.d.ts +33 -0
  67. package/dist/vote-panel.js +41 -0
  68. package/dist/workspace.js +4 -1
  69. package/package.json +3 -3
  70. package/schemas/qualification-cell-result-v1.schema.json +32 -0
  71. package/schemas/qualification-config-v1.schema.json +244 -0
  72. package/schemas/qualification-invocation-request-v1.schema.json +59 -0
  73. package/schemas/qualification-judge-panel-result-v1.schema.json +42 -0
  74. package/schemas/results-v2.schema.json +19 -21
  75. package/schemas/results-v3.schema.json +654 -0
  76. package/schemas/specification-v1.schema.json +1 -1
  77. 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.
@@ -159,6 +169,8 @@ function objectiveVerdict(s) {
159
169
  return undefined;
160
170
  if (s.objective.status === "ERROR")
161
171
  return "ERROR";
172
+ if (s.objective.status === "NOT-MEASURED")
173
+ return "NOT-MEASURED";
162
174
  if (s.objective.status === "FAIL")
163
175
  return "FAIL";
164
176
  return undefined;
@@ -179,8 +191,9 @@ export function finalizeResults(draft, ctx) {
179
191
  const why = draft.partial ? "partial run (--only) — not scored" : `mode=${draft.mode} (not scored)`;
180
192
  effective_grade = { passed: 0, total: 0, pct: 0, letter: "-", ship: false, note: why };
181
193
  }
194
+ const schema = draft.schema ?? (draft.subject_invocations ? 3 : 2);
182
195
  return {
183
- schema: 2,
196
+ schema,
184
197
  // Stamped here, the single place every writer passes through, so `run`,
185
198
  // `grade`, `rescore` and the review UI's override save all record which tool
186
199
  // produced the record they leave behind.
@@ -201,11 +214,13 @@ export function finalizeResults(draft, ctx) {
201
214
  ...(draft.source_hashes ? { source_hashes: draft.source_hashes } : {}),
202
215
  effective_grade,
203
216
  scenarios: draft.scenarios,
217
+ ...(draft.subject_invocations ? { subject_invocations: draft.subject_invocations } : {}),
204
218
  };
205
219
  }
206
220
  /** Finalize + persist results.yaml (creating the run dir). Returns what was written. */
207
221
  export function writeResults(runDir, draft, ctx) {
208
222
  const results = finalizeResults(draft, ctx);
223
+ validateResults(results);
209
224
  mkdirSync(runDir, { recursive: true });
210
225
  writeFileSync(resultsPath(runDir), yaml.dump(results, { lineWidth: 100 }), "utf8");
211
226
  return results;
@@ -217,8 +232,8 @@ export function migrateResults(raw) {
217
232
  throw new Error("empty or invalid results.yaml");
218
233
  }
219
234
  const o = raw;
220
- if (o.schema === 2)
221
- return raw;
235
+ if (o.schema === 2 || o.schema === 3)
236
+ return validateResults(raw);
222
237
  const v1 = raw;
223
238
  const modeMatch = /^mode=(\w+)/.exec(v1.grade?.note ?? "");
224
239
  return {
@@ -250,6 +265,191 @@ export function readResults(runDir) {
250
265
  const text = readFileSync(resultsPath(runDir), "utf8");
251
266
  return migrateResults(yaml.load(text));
252
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
+ }
253
453
  /** Pure: return a copy with override + note applied to one scenario. */
254
454
  export function applyOverride(results, scenarioId, override, note) {
255
455
  if (override !== null && note.trim() === "") {
@@ -399,7 +599,7 @@ export function diffPath(runDir, scenarioId, mode, rep) {
399
599
  export function rebuildScenarioResult(fresh, prior, policy) {
400
600
  // Exhaustive destructure. Do not replace with a spread: the spread is what
401
601
  // allowed a new field to pass through unconsidered in the first place.
402
- 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;
403
603
  const _exhaustive = rest;
404
604
  void _exhaustive;
405
605
  void _freshOverride;
@@ -411,7 +611,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
411
611
  };
412
612
  const objective = pick(policy.objective, freshObjective, prior?.objective);
413
613
  const pickedAdjudication = pick(policy.adjudication, freshAdjudication, prior?.adjudication);
414
- 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" ||
415
615
  (pass_threshold === 1 && (reps ?? 1) > 1 && judge_verdict !== "PASS")));
416
616
  // A settled cell-level panel cannot overrule newly evaluated objective gates,
417
617
  // nor can one transcript erase a failed repetition under an all-clean policy.
@@ -423,8 +623,8 @@ export function rebuildScenarioResult(fresh, prior, policy) {
423
623
  // Taking `suspect` from `fresh` while carrying the block therefore published a
424
624
  // record that said `state: "unresolved"` and scored as a clean SHIP. `regate`
425
625
  // did exactly that: it rebuilds verdict and suspect from the saved first-wave
426
- // judge file, so a free, offline command silently resolved a disagreement in
427
- // 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
428
628
  // unresolved disagreement must not resolve itself.
429
629
  const unresolved = adjudication?.state === "unresolved";
430
630
  // A settled adjudication outranks a re-read of the first-wave judge file for
@@ -440,6 +640,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
440
640
  // corpus with a pure-noise diff.
441
641
  return {
442
642
  id,
643
+ ...((freshCriterionCount ?? prior?.criterion_count) === undefined ? {} : { criterion_count: freshCriterionCount ?? prior.criterion_count }),
443
644
  judge_verdict: settled ?? judge_verdict,
444
645
  judge_reason,
445
646
  suspect: suspect || unresolved,
@@ -459,6 +660,8 @@ export function rebuildScenarioResult(fresh, prior, policy) {
459
660
  // existed.
460
661
  ...(objective ? { objective } : {}),
461
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 } : {}),
462
665
  };
463
666
  }
464
667
  /**
package/dist/run.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { sourceHashes } from "./sources.js";
3
+ import { sourceHashes, PROMPT_NORMALIZATION_SOURCE_KEY, PROMPT_NORMALIZATION_SOURCE_DIGEST } from "./sources.js";
4
4
  import { judgeResemblesSubject } from "./grade.js";
5
5
  import { NONE_ARM, seedArmDefinitions } from "./arms.js";
6
- import { runDirFor, transcriptPath, diffPath, tracePath, trajectoryPath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, } from "./results.js";
6
+ import { runDirFor, transcriptPath, diffPath, tracePath, trajectoryPath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, deliveryStatusForObservations, } from "./results.js";
7
7
  import { appendJournal } from "./journal.js";
8
8
  import { liftHeadline } from "./lift.js";
9
9
  import { runSeeded } from "./seeded.js";
@@ -139,13 +139,20 @@ export async function runSkillModel(opts) {
139
139
  }
140
140
  });
141
141
  const flat = await runPool(tasks, opts.concurrency ?? 1);
142
+ const subjectInvocations = flat.flatMap(outcome => outcome.subject_invocations ?? []);
142
143
  const grouped = scenarios.map(() => []);
143
144
  flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
144
145
  const scenarioResults = scenarios.map((scenario, si) => {
145
146
  const threshold = scenario.critical
146
147
  ? effectiveThreshold(undefined, scenario)
147
148
  : scenario.passThreshold ?? opts.passThreshold ?? 0.5;
148
- return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
149
+ const result = outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
150
+ if (adapter.observesPrompts)
151
+ result.criterion_count = scenario.checklist.length;
152
+ if (adapter.observesPrompts && !result.rep_judgments) {
153
+ result.rep_judgments = grouped[si].map((outcome, repetition) => ({ repetition, judgments: [], recorded_verdict: outcome.verdict, ...(outcome.objective ? { objective: outcome.objective } : {}) }));
154
+ }
155
+ return result;
149
156
  });
150
157
  const ctx = scoreContextFor({ mode, partial }, spec);
151
158
  const results = writeResults(runDir, {
@@ -161,8 +168,12 @@ export async function runSkillModel(opts) {
161
168
  ...(partial ? { partial: true } : {}),
162
169
  // Only the scenarios this run actually measured: a --only run must not claim
163
170
  // coverage of scenarios it skipped.
164
- source_hashes: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona }),
171
+ source_hashes: {
172
+ ...sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona }),
173
+ ...(adapter.observesPrompts ? { [PROMPT_NORMALIZATION_SOURCE_KEY]: PROMPT_NORMALIZATION_SOURCE_DIGEST } : {}),
174
+ },
165
175
  scenarios: scenarioResults,
176
+ ...(adapter.observesPrompts ? { schema: 3, subject_invocations: subjectInvocations } : {}),
166
177
  ...(arm.name === NONE_ARM.name ? {} : {
167
178
  arm: {
168
179
  name: arm.name,
@@ -210,11 +221,12 @@ async function runRep(scenario, rep, repCount, ctx) {
210
221
  const repField = repCount > 1 ? { rep } : {};
211
222
  const arm = ctx.arm ?? NONE_ARM;
212
223
  const skillsRoot = ctx.skillsRoot ?? dirname(ctx.skillDir);
213
- // `<run-dir>` in an arm's env values is a real path (temp dirs on this box
214
- // routinely contain characters a regex would treat specially), so this is a
215
- // plain split/join, not a `.replace(/<run-dir>/g, …)`.
216
- const armEnv = Object.keys(arm.env).length
217
- ? Object.fromEntries(Object.entries(arm.env).map(([k, v]) => [k, v.split("<run-dir>").join(runDir)]))
224
+ // Arm paths are per-run/per-workspace values. Plain split/join avoids treating
225
+ // temp-path characters as regex syntax.
226
+ const armEnvFor = (workspace) => Object.keys(arm.env).length
227
+ ? Object.fromEntries(Object.entries(arm.env).map(([k, v]) => [k, v
228
+ .split("<run-dir>").join(runDir)
229
+ .split("<workspace>").join(workspace)]))
218
230
  : undefined;
219
231
  if (rep === 0) {
220
232
  log(` ${scenario.id} (${scenario.title})${repCount > 1 ? ` ×${repCount}` : ""} …`);
@@ -228,6 +240,7 @@ async function runRep(scenario, rep, repCount, ctx) {
228
240
  // produces no diff, and writing an empty artifact there would misreport "the
229
241
  // model changed nothing" for a rep that never ran.
230
242
  let stagedDiff = null;
243
+ const subjectInvocations = [];
231
244
  try {
232
245
  try {
233
246
  ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
@@ -284,6 +297,7 @@ async function runRep(scenario, rep, repCount, ctx) {
284
297
  // it happens again the verdict is ERROR — never a judged FAIL on an empty reply.
285
298
  // An adapter that THREW is the same class of event and takes the same retry.
286
299
  for (let attempt = 0; attempt < 2; attempt++) {
300
+ const observe = (prompt) => subjectInvocations.push({ scenario_id: scenario.id, repetition: rep, attempt, prompt });
287
301
  if (attempt > 0) {
288
302
  const why = adapterFailure ? `adapter failed (${adapterFailure})` : "empty response";
289
303
  appendJournal(runDir, { event: "empty-response-retry", ts: now(), id: scenario.id, attempt, reason: why, ...repField });
@@ -322,7 +336,8 @@ async function runRep(scenario, rep, repCount, ctx) {
322
336
  // builds the RunReq — the arm's extensions and env (with `<run-dir>`
323
337
  // already substituted) both must reach pi.
324
338
  armExtensions: arm.extensions,
325
- ...(armEnv ? { armEnv } : {}),
339
+ ...(armEnvFor(ws.cwd) ? { armEnv: armEnvFor(ws.cwd) } : {}),
340
+ ...(ctx.adapter.observesPrompts ? { onPromptObservation: observe } : {}),
326
341
  });
327
342
  transcript = r.transcript;
328
343
  gatePrefix = r.gateFailure;
@@ -347,7 +362,8 @@ async function runRep(scenario, rep, repCount, ctx) {
347
362
  ...arm.extensions,
348
363
  ],
349
364
  eventSources: scenario.eventSources,
350
- ...(armEnv ? { armEnv } : {}),
365
+ ...(armEnvFor(ws.cwd) ? { armEnv: armEnvFor(ws.cwd) } : {}),
366
+ ...(ctx.adapter.observesPrompts ? { onPromptObservation: observe } : {}),
351
367
  };
352
368
  if (useStructured) {
353
369
  const structured = await ctx.adapter.runStructured({ ...req, scenarioId: scenario.id, rep });
@@ -441,6 +457,16 @@ async function runRep(scenario, rep, repCount, ctx) {
441
457
  });
442
458
  }
443
459
  }
460
+ // Delivery is an objective gate on every Pi subject invocation. It is derived
461
+ // from captured provider payload bytes, never from mode/argv supplied by a caller.
462
+ let deliveryObjective;
463
+ if (ctx.adapter.observesPrompts) {
464
+ const statuses = subjectInvocations.map(observation => observation.prompt.status);
465
+ const status = deliveryStatusForObservations(subjectInvocations);
466
+ deliveryObjective = { status, assertions: [{ kind: "skill_delivered", status, detail: statuses.length === 0 ? "no model-visible prompt observation was retained" : `${statuses.length} provider request(s): ${statuses.join(", ")}` }] };
467
+ if (status !== "PASS")
468
+ gatePrefix = `objective: skill_delivered ${status.toLowerCase()} — ${deliveryObjective.assertions[0].detail}`;
469
+ }
444
470
  // Objective evidence is persisted for every rep, pass or fail — a failing gate
445
471
  // is exactly when someone wants to read what the model actually did.
446
472
  // `!adapterFailure`: a rep whose adapter threw never ran, so there is nothing
@@ -448,7 +474,7 @@ async function runRep(scenario, rep, repCount, ctx) {
448
474
  // which is the symptom — it names the missing artifact instead of the crash or
449
475
  // timeout that caused it, and it is that text, not the real reason, that would
450
476
  // reach the results record. The verdict is ERROR either way.
451
- let objective;
477
+ let objective = deliveryObjective;
452
478
  if ((scenario.traceAssert || scenario.trajectoryAssert) && !adapterFailure) {
453
479
  const assertionResults = [];
454
480
  let status = "PASS";
@@ -494,6 +520,11 @@ async function runRep(scenario, rep, repCount, ctx) {
494
520
  trajectoryMeta = { trajectory_version: gate.event_version, events_sha256: gate.events_sha256 };
495
521
  }
496
522
  }
523
+ if (deliveryObjective) {
524
+ if (deliveryObjective.status === "ERROR" || (deliveryObjective.status === "NOT-MEASURED" && status !== "ERROR") || (deliveryObjective.status === "FAIL" && status === "PASS"))
525
+ status = deliveryObjective.status;
526
+ assertionResults.unshift(...deliveryObjective.assertions);
527
+ }
497
528
  objective = { status, ...traceMeta, ...trajectoryMeta, assertions: assertionResults };
498
529
  if (status !== "PASS") {
499
530
  const details = assertionResults.filter((result) => result.status === status).map((result) => result.detail);
@@ -507,12 +538,18 @@ async function runRep(scenario, rep, repCount, ctx) {
507
538
  let verdict;
508
539
  let reason;
509
540
  let suspect = false;
541
+ let judgment;
510
542
  let judgeCalls = 0;
511
543
  if (objective?.status === "ERROR") {
512
544
  verdict = "ERROR";
513
545
  reason = gatePrefix ?? "objective evidence missing";
514
546
  appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
515
547
  }
548
+ else if (objective?.status === "NOT-MEASURED") {
549
+ verdict = "NOT-MEASURED";
550
+ reason = gatePrefix ?? "skill delivery was not established";
551
+ appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
552
+ }
516
553
  else if (infrastructureFailure) {
517
554
  verdict = "ERROR";
518
555
  reason = infrastructureFailure;
@@ -536,13 +573,14 @@ async function runRep(scenario, rep, repCount, ctx) {
536
573
  });
537
574
  verdict = o.verdict;
538
575
  reason = o.reason;
539
- suspect = o.suspect; // judgeOneRep already journaled (verdict + misfire)
576
+ suspect = o.suspect;
577
+ judgment = o.judgment; // judgeOneRep already journaled (verdict + misfire)
540
578
  judgeCalls = 1;
541
579
  }
542
580
  log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
543
581
  const subject = mergeTraces(traces)?.metrics;
544
582
  return {
545
- verdict, reason, suspect, objective,
583
+ verdict, reason, suspect, objective, judgment, subject_invocations: subjectInvocations,
546
584
  metrics: {
547
585
  wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
548
586
  judge_calls: judgeCalls,
@@ -577,8 +615,8 @@ export function formatScorecard(summary, lift, stability) {
577
615
  const lines = [];
578
616
  lines.push(`── ${results.skill} · ${results.harness} · ${results.model} ──`);
579
617
  for (const s of results.scenarios) {
580
- const v = s.override ?? (s.objective?.status === "ERROR" ? "ERROR" : s.objective?.status === "FAIL" ? "FAIL" : s.judge_verdict);
581
- const mark = v === "PASS" ? "✓" : v === "FAIL" ? "✗" : "?";
618
+ const v = s.override ?? (s.objective?.status === "ERROR" ? "ERROR" : s.objective?.status === "NOT-MEASURED" ? "NOT-MEASURED" : s.objective?.status === "FAIL" ? "FAIL" : s.judge_verdict);
619
+ const mark = v === "PASS" ? "✓" : v === "FAIL" ? "✗" : v === "NOT-MEASURED" ? "∅" : "?";
582
620
  const ov = s.override ? " (override)" : "";
583
621
  const susp = s.suspect ? " ⚠suspect" : "";
584
622
  const misfired = s.clean !== undefined && s.reps !== undefined && s.clean < s.reps ? ` · ${s.reps - s.clean} misfired` : "";
@@ -594,7 +632,18 @@ export function formatScorecard(summary, lift, stability) {
594
632
  ? "subject tokens unavailable"
595
633
  : `subject tokens ${metrics.input_tokens} in / ${metrics.output_tokens ?? 0} out / ${metrics.cache_read_tokens ?? 0} cache-read`;
596
634
  const tools = metrics.tool_calls === null ? "tool calls unavailable" : `${metrics.tool_calls} tool call(s), ${metrics.delegated_children ?? 0} delegated, max concurrency ${metrics.max_concurrency ?? 0}`;
597
- lines.push(` COST: ${tokens} (${metrics.subject_metrics_reps}/${metrics.total_reps} reps reported) · ${metrics.judge_calls} judge + ${metrics.judge_rejudge_calls} re-judge call(s) · ${metrics.wall_time_ms}ms · ${tools}`);
635
+ const cost = metrics.cost_source === "subscription"
636
+ ? " · subscription ($0 marginal cost recorded)"
637
+ : metrics.cost_source === "unreported"
638
+ ? metrics.input_tokens !== null && metrics.input_tokens > 0
639
+ ? " · WARNING: subject tokens were used but subject cost was not reported"
640
+ : " · subject cost unavailable"
641
+ : metrics.input_tokens !== null && metrics.input_tokens > 0 && metrics.subject_cost_usd === 0
642
+ ? " · WARNING: subject tokens were used but the provider reported $0 cost"
643
+ : metrics.subject_cost_usd === null
644
+ ? " · subject cost unavailable"
645
+ : ` · $${metrics.subject_cost_usd.toFixed(6)} provider-reported`;
646
+ lines.push(` COST: ${tokens} (${metrics.subject_metrics_reps}/${metrics.total_reps} reps reported)${cost} · ${metrics.judge_calls} judge + ${metrics.judge_rejudge_calls} re-judge call(s) · ${metrics.wall_time_ms}ms · ${tools}`);
598
647
  }
599
648
  // Lift is a statement about a skill-delivered run (green or force). On a red run
600
649
  // the caller may still have a lift in hand (a scored run exists in the same tag),
package/dist/score.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { ShipBar } from "./spec.js";
3
3
  * "JUDGE-AMBIGUOUS": the judge emitted conflicting verdicts for one transcript. It is
4
4
  * never a pass and never silently resolved — it marks the run for a rejudge.
5
5
  */
6
- export type Verdict = "PASS" | "FAIL" | "ERROR" | "JUDGE-AMBIGUOUS";
6
+ export type Verdict = "PASS" | "FAIL" | "ERROR" | "NOT-MEASURED" | "JUDGE-AMBIGUOUS";
7
7
  export interface ScenarioVerdict {
8
8
  id: string;
9
9
  verdict: Verdict;
@@ -23,13 +23,14 @@ export interface ScoreResult {
23
23
  bSeriesFails: number;
24
24
  suspectCount: number;
25
25
  errorCount: number;
26
+ notMeasuredCount: number;
26
27
  note: string;
27
28
  }
28
29
  export declare function letterFor(pct: number): string;
29
30
  /**
30
31
  * Score a set of green-mode verdicts against the ship bar. A scenario PASSes only
31
- * on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS are excluded as
32
- * infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
32
+ * on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS and
33
+ * NOT-MEASURED are excluded as infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
33
34
  * unresolved judge misfire) is excluded from both `passed` and `total` — it is
34
35
  * untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
35
36
  * until an author override resolves it. SHIP otherwise requires: enough total
package/dist/score.js CHANGED
@@ -11,8 +11,8 @@ export function letterFor(pct) {
11
11
  }
12
12
  /**
13
13
  * Score a set of green-mode verdicts against the ship bar. A scenario PASSes only
14
- * on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS are excluded as
15
- * infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
14
+ * on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS and
15
+ * NOT-MEASURED are excluded as infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
16
16
  * unresolved judge misfire) is excluded from both `passed` and `total` — it is
17
17
  * untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
18
18
  * until an author override resolves it. SHIP otherwise requires: enough total
@@ -27,11 +27,16 @@ export function score(verdicts, input) {
27
27
  let bSeriesFails = 0;
28
28
  let suspectCount = 0;
29
29
  let errorCount = 0;
30
+ let notMeasuredCount = 0;
30
31
  for (const v of verdicts) {
31
32
  if (v.suspect) {
32
33
  suspectCount++;
33
34
  continue; // untrustworthy: neither pass nor fail
34
35
  }
36
+ if (v.verdict === "NOT-MEASURED") {
37
+ notMeasuredCount++;
38
+ continue; // known-undelivered experiment: neither behavioral pass nor fail
39
+ }
35
40
  if (v.verdict === "ERROR" || v.verdict === "JUDGE-AMBIGUOUS") {
36
41
  errorCount++;
37
42
  continue; // infrastructure/indeterminate: neither behavioral pass nor fail
@@ -55,7 +60,8 @@ export function score(verdicts, input) {
55
60
  (!shipBar.no_critical_fail || criticalFails === 0) &&
56
61
  bSeriesFails === 0 &&
57
62
  suspectCount === 0 &&
58
- errorCount === 0;
63
+ errorCount === 0 &&
64
+ notMeasuredCount === 0;
59
65
  let note = "";
60
66
  if (!validBar) {
61
67
  note = "invalid ship bar: total/min_pass must be positive integers with min_pass <= total";
@@ -66,12 +72,15 @@ export function score(verdicts, input) {
66
72
  else if (errorCount > 0) {
67
73
  note = `${errorCount} infrastructure error${errorCount === 1 ? "" : "s"}: retry/repair evidence`;
68
74
  }
75
+ else if (notMeasuredCount > 0) {
76
+ note = `${notMeasuredCount} not measured: skill delivery was not established`;
77
+ }
69
78
  else if (criticalFails > 0) {
70
79
  note = `gated: ${criticalFails} critical fail${criticalFails === 1 ? "" : "s"}`;
71
80
  }
72
81
  else if (bSeriesFails > 0) {
73
82
  note = `gated: ${bSeriesFails} B-series fail${bSeriesFails === 1 ? "" : "s"}`;
74
83
  }
75
- return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, errorCount, note };
84
+ return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, errorCount, notMeasuredCount, note };
76
85
  }
77
86
  //# sourceMappingURL=score.js.map
@@ -0,0 +1,31 @@
1
+ import { type ResultsFile } from "./results.js";
2
+ export type ScreenClassification = "CEILING" | "FLOOR" | "INFORMATIVE" | "UNKNOWN";
3
+ export interface ScreenRate {
4
+ passes: number;
5
+ n: number;
6
+ }
7
+ export interface ScreenScenario {
8
+ skill: string;
9
+ model: string;
10
+ id: string;
11
+ control: ScreenRate;
12
+ treatment: ScreenRate;
13
+ not_measured: number;
14
+ classification: ScreenClassification;
15
+ }
16
+ export interface ScreenCriterion {
17
+ skill: string;
18
+ model: string;
19
+ scenario_id: string;
20
+ criterion: number;
21
+ failures: number;
22
+ n: number;
23
+ fail_rate: number;
24
+ }
25
+ export interface ScreenReport {
26
+ scenarios: ScreenScenario[];
27
+ criteria: ScreenCriterion[];
28
+ }
29
+ /** Pure/offline: derive only from v3 retained invocation and vote evidence. */
30
+ export declare function screenResults(results: ResultsFile[]): ScreenReport;
31
+ export declare function formatScreen(report: ScreenReport): string;