@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
@@ -0,0 +1,12 @@
1
+ /** Canonical final validator: invocation evidence plus panel-board completeness. */
2
+ export declare function validateQualificationRunnerSpool(spoolDir: string): {
3
+ derived: {
4
+ panels: number;
5
+ cells: number;
6
+ };
7
+ ok: true;
8
+ configuration_sha256: string;
9
+ invocations: number;
10
+ accounting: import("./qualification-store.js").QualificationAccountingReport;
11
+ terminal: number;
12
+ };
@@ -0,0 +1,8 @@
1
+ import { validateQualificationPanelOutputs } from "./qualification-panel-store.js";
2
+ import { validateQualificationBaseSpool } from "./qualification-runner.js";
3
+ /** Canonical final validator: invocation evidence plus panel-board completeness. */
4
+ export function validateQualificationRunnerSpool(spoolDir) {
5
+ const base = validateQualificationBaseSpool(spoolDir);
6
+ return { ...base, derived: validateQualificationPanelOutputs(spoolDir) };
7
+ }
8
+ //# sourceMappingURL=qualification-validation.js.map
package/dist/regate.js CHANGED
@@ -6,7 +6,7 @@ import { evaluateTraceGates } from "./trace-gates.js";
6
6
  import { mergeTraces, deserializeTrace } from "./execution-trace.js";
7
7
  import { deserializeTrajectoryEvents, evaluateTrajectoryGates } from "./trajectory-gates.js";
8
8
  import { judgeOneRep } from "./regrade.js";
9
- import { readResults, writeResults, diffPath, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, findTraceFiles, findTrajectoryFiles, tracePath, trajectoryPath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, } from "./results.js";
9
+ import { readResults, writeResults, diffPath, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, findTraceFiles, findTrajectoryFiles, tracePath, trajectoryPath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, deliveryStatusForObservations, } from "./results.js";
10
10
  import { outcomesToResult } from "./reps.js";
11
11
  import { appendJournal } from "./journal.js";
12
12
  import { gatesDigest, GATES_PREFIX } from "./sources.js";
@@ -222,9 +222,11 @@ export async function regateRun(opts) {
222
222
  const priorStatus = objective?.status ?? "PASS";
223
223
  const status = priorStatus === "ERROR" || g.status === "ERROR" || priorEvidenceErrors.length || digestMismatch
224
224
  ? "ERROR"
225
- : priorStatus === "FAIL" || g.status === "FAIL"
226
- ? "FAIL"
227
- : "PASS";
225
+ : priorStatus === "NOT-MEASURED"
226
+ ? "NOT-MEASURED"
227
+ : priorStatus === "FAIL" || g.status === "FAIL"
228
+ ? "FAIL"
229
+ : "PASS";
228
230
  objective = {
229
231
  ...(objective ?? {}),
230
232
  status,
@@ -242,6 +244,21 @@ export async function regateRun(opts) {
242
244
  }
243
245
  }
244
246
  }
247
+ // Prompt delivery was observed during the subject invocation and cannot be
248
+ // replayed by regate. Carry its objective finding while refreshing only
249
+ // trace/trajectory/needle assertions.
250
+ const retainedDelivery = rec.objective?.assertions.find(assertion => assertion.kind === "skill_delivered");
251
+ const repObservations = prev.subject_invocations?.filter(observation => observation.scenario_id === scenario.id && observation.repetition === (rep ?? 0)) ?? [];
252
+ const delivery = prev.schema === 3
253
+ ? { kind: "skill_delivered", status: deliveryStatusForObservations(repObservations), detail: `${repObservations.length} retained provider request observation(s)` }
254
+ : retainedDelivery;
255
+ if (delivery) {
256
+ const prior = objective?.status ?? "PASS";
257
+ const status = delivery.status === "ERROR" || prior === "ERROR" ? "ERROR" : delivery.status === "NOT-MEASURED" ? "NOT-MEASURED" : prior === "FAIL" ? "FAIL" : "PASS";
258
+ objective = { ...(objective ?? {}), status, assertions: [delivery, ...(objective?.assertions ?? [])] };
259
+ if (delivery.status !== "PASS")
260
+ traceFailure = `objective: ${delivery.detail}`;
261
+ }
245
262
  const gate = { lines: needleGate.lines, failure: needleGate.failure ?? traceFailure ?? trajectoryFailure };
246
263
  const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
247
264
  const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
@@ -250,7 +267,7 @@ export async function regateRun(opts) {
250
267
  // trace gate leaves an `objective` block on the result. Reading only the
251
268
  // trailer meant a trace gate flipping to PASS never triggered the re-judge
252
269
  // it needs, leaving a stale FAIL verdict beside a PASS objective.
253
- const oldObjectiveFailed = rec.objective?.status === "FAIL" || rec.objective?.status === "ERROR";
270
+ const oldObjectiveFailed = rec.objective?.status === "FAIL" || rec.objective?.status === "ERROR" || rec.objective?.status === "NOT-MEASURED";
254
271
  const oldGateFailed = GATE_FAILED_RE.test(before.slice(before.indexOf(TRAILER))) || oldObjectiveFailed;
255
272
  // The trailer is regenerated whatever the outcome: leaving a stale
256
273
  // `MISSING` note beside a corrected verdict would misinform the next reader
@@ -258,11 +275,11 @@ export async function regateRun(opts) {
258
275
  if (existsSync(tPath))
259
276
  rewriteTranscript(tPath, gate.lines);
260
277
  if (gate.failure) {
261
- if (objective?.status === "ERROR")
278
+ if (objective?.status === "ERROR" || objective?.status === "NOT-MEASURED")
262
279
  gateErroredHere = true;
263
280
  else
264
281
  gateFailedHere = true;
265
- outcomes.push({ verdict: objective?.status === "ERROR" ? "ERROR" : "FAIL", reason: gate.failure, suspect: false, objective });
282
+ outcomes.push({ verdict: objective?.status === "ERROR" ? "ERROR" : objective?.status === "NOT-MEASURED" ? "NOT-MEASURED" : "FAIL", reason: gate.failure, suspect: false, objective });
266
283
  continue;
267
284
  }
268
285
  if (!oldGateFailed) {
@@ -284,12 +301,26 @@ export async function regateRun(opts) {
284
301
  }
285
302
  const threshold = effectiveThreshold(rec, scenario);
286
303
  const next = outcomesToResult(scenario.id, outcomes, outcomes.length, threshold);
304
+ if (prev.schema === 3) {
305
+ next.criterion_count = scenario.checklist.length;
306
+ next.rep_judgments = outcomes.map((outcome, repetition) => {
307
+ const fresh = next.rep_judgments?.find(panel => panel.repetition === repetition);
308
+ const prior = rec.rep_judgments?.find(panel => panel.repetition === repetition);
309
+ const judgments = fresh?.judgments.length ? fresh.judgments : prior?.judgments ?? [];
310
+ return {
311
+ repetition,
312
+ judgments,
313
+ recorded_verdict: outcome.verdict,
314
+ ...(outcome.objective ? { objective: outcome.objective } : {}),
315
+ };
316
+ });
317
+ }
287
318
  next.metrics = mergeScenarioMetrics(rec.metrics, next.metrics);
288
319
  // Overrides and their notes survive: a regate re-decides the gate, and an author
289
320
  // override is a statement about the judge, not about the needle.
290
- // `regate` re-evaluates gates from saved artifacts and asks no judge anything:
291
- // `objective` is freshly recomputed above, and the recorded judge panel still
292
- // describes the current judgments.
321
+ // `regate` re-evaluates gates from saved artifacts. It may judge a previously
322
+ // blocked fail→pass rep above; carrying the recorded panel still preserves any
323
+ // earlier multi-judge decision for judgments that were not replaced.
293
324
  scenarios.push(rebuildScenarioResult(next, rec, { objective: "fresh", adjudication: "carry" }));
294
325
  const to = next.judge_verdict;
295
326
  if (to !== rec.judge_verdict) {
@@ -302,9 +333,17 @@ export async function regateRun(opts) {
302
333
  }
303
334
  const ctx = scoreContextFor(prev, opts.spec);
304
335
  const results = writeResults(opts.runDir, {
336
+ schema: prev.schema, subject_invocations: prev.subject_invocations,
305
337
  skill: prev.skill, harness: prev.harness, model: prev.model,
306
338
  // Carried verbatim: a regate re-reads saved diffs, it never re-runs the harness.
307
339
  harness_cli_version: prev.harness_cli_version, delivery_canary: prev.delivery_canary,
340
+ // The arm is provenance of the MEASUREMENT, not of this rewrite, and it is the
341
+ // only record that a `+<arm>` run actually delegated: rebuilding the draft
342
+ // field-by-field without it silently deleted `definitions`/`ledger_events`
343
+ // from any arm run that was ever re-graded, leaving a record
344
+ // indistinguishable from a vacuous arm. Same reason `harness_cli_version`,
345
+ // `delivery_canary` and `source_hashes` are carried here.
346
+ arm: prev.arm,
308
347
  judge: { provider: opts.judge.provider, model: opts.judge.model },
309
348
  timestamp: prev.timestamp, label: prev.label, mode: prev.mode, partial: prev.partial,
310
349
  // Only the `gates:` keys of the scenarios actually re-evaluated. Stimulus, rubric
package/dist/regrade.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Spec, Scenario } from "./spec.js";
2
2
  import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
3
- import { type ScenarioResult, type ResultsFile } from "./results.js";
3
+ import { type Judgment, type ScenarioResult, type ResultsFile } from "./results.js";
4
4
  import { type RepOutcome } from "./reps.js";
5
5
  /**
6
6
  * Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
@@ -84,4 +84,6 @@ export interface RegradeRunOptions {
84
84
  * results.yaml, emits the `score` journal event, and returns the new
85
85
  * ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
86
86
  */
87
+ /** Preserve prior panel votes and append a full-cell grade without writing invalid one-vote history. */
88
+ export declare function appendJudgeHistory(prior: ScenarioResult | undefined, priorJudge: ModelRef | undefined, fresh: Omit<Judgment, "ordinal">): Judgment[] | undefined;
87
89
  export declare function regradeRun(opts: RegradeRunOptions): Promise<ResultsFile>;
package/dist/regrade.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { buildJudgePrompt, judgeInWorkspace } from "./grade.js";
4
- import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, } from "./results.js";
4
+ import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, carryRepObjectives, completeCriterionVotes, } from "./results.js";
5
5
  import { outcomesToResult } from "./reps.js";
6
6
  import { appendJournal } from "./journal.js";
7
7
  import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
8
+ import { providerFailureFromTranscript } from "./provider-failure.js";
8
9
  /**
9
10
  * Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
10
11
  * re-grade actually judged under (plus the persona, which applies to all of them).
@@ -37,15 +38,32 @@ export function refreshRubricHashes(recorded, spec, judgedIds) {
37
38
  export async function judgeOneRep(opts) {
38
39
  const { runDir, spec, scenario, transcript, adapter, judge, specDir, mode, rep, now } = opts;
39
40
  const startedAt = performance.now();
41
+ const repField = rep === undefined ? {} : { rep };
42
+ // A provider outage recorded in the transcript (either by `run()`'s marker or,
43
+ // since the fix for I2, by `runStructured` too) must stay ERROR through a
44
+ // re-judge. Without this check, `grade`/`regrade` reads the saved transcript
45
+ // with no infrastructure-failure guard at all — the one `run.ts` applies BEFORE
46
+ // ever calling this function — and re-judges "provider never ran the request"
47
+ // into a model verdict, spending a judge call to turn infrastructure noise into
48
+ // a false FAIL.
49
+ const providerFailure = providerFailureFromTranscript(transcript);
50
+ if (providerFailure) {
51
+ const reason = `provider failure — ${providerFailure}`;
52
+ appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: "ERROR", reason, suspect: false, ...repField });
53
+ return {
54
+ verdict: "ERROR", reason, suspect: false,
55
+ metrics: { wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)), judge_calls: 0, judge_rejudge_calls: 0 },
56
+ };
57
+ }
40
58
  const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
41
59
  const g = await judgeInWorkspace(adapter, judge, prompt, specDir);
42
60
  writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), g.raw, "utf8");
43
- const repField = rep === undefined ? {} : { rep };
44
61
  appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: g.verdict, reason: g.reason, suspect: g.suspect, ...repField });
45
62
  if (g.suspect)
46
63
  appendJournal(runDir, { event: "misfire-flag", ts: now(), id: scenario.id, reason: g.reason, ...repField });
47
64
  return {
48
65
  verdict: g.verdict, reason: g.reason, suspect: g.suspect,
66
+ judgment: { ordinal: 1, judge: { ...judge }, verdict: g.verdict, reason: g.reason, suspect: g.suspect, criteria: completeCriterionVotes(g.criteria, scenario.checklist.length) },
49
67
  metrics: {
50
68
  wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
51
69
  judge_calls: 1,
@@ -95,6 +113,22 @@ export async function regradeScenario(opts) {
95
113
  * results.yaml, emits the `score` journal event, and returns the new
96
114
  * ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
97
115
  */
116
+ /** Preserve prior panel votes and append a full-cell grade without writing invalid one-vote history. */
117
+ export function appendJudgeHistory(prior, priorJudge, fresh) {
118
+ const history = prior?.judge_history ?? prior?.adjudication?.judgments ?? (prior && priorJudge ? [{
119
+ ordinal: 1,
120
+ judge: priorJudge,
121
+ verdict: prior.judge_verdict ?? "JUDGE-AMBIGUOUS",
122
+ reason: prior.judge_reason ?? "prior grade",
123
+ suspect: prior.suspect ?? true,
124
+ criteria: prior.rep_judgments?.find(panel => panel.repetition === 0)?.judgments[0]?.criteria ?? [],
125
+ }] : []);
126
+ const next = [
127
+ ...history,
128
+ { ...fresh, criteria: fresh.criteria ?? [], ordinal: history.length + 1 },
129
+ ].slice(-3).map((judgment, index) => ({ ...judgment, ordinal: index + 1 }));
130
+ return next.length >= 2 ? next : undefined;
131
+ }
98
132
  export async function regradeRun(opts) {
99
133
  const { runDir, spec, adapter, judge, specDir } = opts;
100
134
  const now = opts.now ?? (() => new Date().toISOString());
@@ -129,10 +163,16 @@ export async function regradeRun(opts) {
129
163
  targets = prev.scenarios
130
164
  .filter((s) => s.suspect || s.judge_verdict === "JUDGE-AMBIGUOUS")
131
165
  .map((s) => s.id);
132
- if (targets.length === 0) {
133
- // Nothing untrustworthy — a no-op, not an error. Return the file as-is.
134
- return prev;
135
- }
166
+ }
167
+ // Schema-3 delivery is a prerequisite for behavioral judging. A later `grade`
168
+ // cannot turn an undelivered or unauthenticated transcript into product evidence.
169
+ if (prev?.schema === 3) {
170
+ const blocked = new Set(prev.scenarios.filter(s => s.objective?.assertions.some(a => a.kind === "skill_delivered" && a.status !== "PASS")).map(s => s.id));
171
+ targets = targets.filter(id => !blocked.has(id));
172
+ }
173
+ if (targets.length === 0 && prev) {
174
+ // Nothing judgeable — a no-op, preserving both evidence and judge identity.
175
+ return prev;
136
176
  }
137
177
  const completeTranscripts = (record) => {
138
178
  const expected = record.reps ?? 1;
@@ -165,7 +205,14 @@ export async function regradeRun(opts) {
165
205
  expectedReps: prevScenario?.reps ?? 1,
166
206
  });
167
207
  const carry = overrides.get(id);
208
+ if (prev?.schema === 3)
209
+ rr.criterion_count = scenario.checklist.length;
168
210
  rr.metrics = mergeScenarioMetrics(carry?.metrics, rr.metrics);
211
+ rr.rep_judgments = carryRepObjectives(rr.rep_judgments, carry?.rep_judgments);
212
+ rr.judge_history = appendJudgeHistory(carry, prev?.judge, {
213
+ judge, verdict: rr.judge_verdict, reason: rr.judge_reason, suspect: rr.suspect,
214
+ criteria: rr.rep_judgments?.find(panel => panel.repetition === 0)?.judgments[0]?.criteria,
215
+ });
169
216
  // `grade` re-judges the saved transcript. It does not re-evaluate trace gates
170
217
  // (that is `regate`), so `objective` still describes this run; and it replaced
171
218
  // the judgments a prior adjudication described, so that panel must go.
@@ -173,6 +220,8 @@ export async function regradeRun(opts) {
173
220
  }
174
221
  const ctx = scoreContextFor({ mode, partial: prev?.partial }, spec);
175
222
  const results = writeResults(runDir, {
223
+ schema: prev?.schema,
224
+ subject_invocations: prev?.subject_invocations,
176
225
  skill: spec.skill,
177
226
  harness: prev?.harness ?? "pi",
178
227
  // The harness CLI that produced these transcripts, carried verbatim: a re-grade
@@ -180,6 +229,13 @@ export async function regradeRun(opts) {
180
229
  // here would credit the old transcripts to a version that never ran them.
181
230
  harness_cli_version: prev?.harness_cli_version,
182
231
  delivery_canary: prev?.delivery_canary,
232
+ // The arm is provenance of the MEASUREMENT, not of this rewrite, and it is the
233
+ // only record that a `+<arm>` run actually delegated: rebuilding the draft
234
+ // field-by-field without it silently deleted `definitions`/`ledger_events`
235
+ // from any arm run that was ever re-graded, leaving a record
236
+ // indistinguishable from a vacuous arm. Same reason `harness_cli_version`,
237
+ // `delivery_canary` and `source_hashes` are carried here.
238
+ arm: prev?.arm,
183
239
  model: prev?.model ?? "unknown",
184
240
  judge: { provider: judge.provider, model: judge.model },
185
241
  timestamp: prev?.timestamp ?? now(),
package/dist/reps.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Verdict } from "./score.js";
2
- import type { ScenarioResult, ObjectiveResult } from "./results.js";
2
+ import type { ScenarioResult, ObjectiveResult, Judgment, SubjectInvocationObservation } from "./results.js";
3
3
  import type { TraceMetrics } from "./capture-trace-types.js";
4
4
  /** One rep's outcome (subject run + judge). */
5
5
  export interface RepOutcome {
@@ -8,6 +8,8 @@ export interface RepOutcome {
8
8
  suspect: boolean;
9
9
  /** Present only when the scenario declared structured objective assertions. */
10
10
  objective?: ObjectiveResult;
11
+ judgment?: Judgment;
12
+ subject_invocations?: SubjectInvocationObservation[];
11
13
  metrics?: {
12
14
  wall_time_ms: number;
13
15
  judge_calls: number;
@@ -22,7 +24,7 @@ export interface RepOutcome {
22
24
  * pass-threshold aggregation: an objective assertion is a statement about what
23
25
  * the model DID, so one rep that called a forbidden tool is a real finding, not
24
26
  * a minority draw to be voted away. ERROR dominates (missing evidence is never a
25
- * pass), then FAIL, then PASS. The retained assertion detail comes from the
27
+ * pass), then NOT-MEASURED (delivery absent), then FAIL, then PASS. The retained assertion detail comes from the
26
28
  * first non-passing rep, since that is the one worth reading.
27
29
  */
28
30
  export declare function aggregateObjective(outcomes: RepOutcome[]): ObjectiveResult | undefined;
package/dist/reps.js CHANGED
@@ -5,14 +5,14 @@
5
5
  * pass-threshold aggregation: an objective assertion is a statement about what
6
6
  * the model DID, so one rep that called a forbidden tool is a real finding, not
7
7
  * a minority draw to be voted away. ERROR dominates (missing evidence is never a
8
- * pass), then FAIL, then PASS. The retained assertion detail comes from the
8
+ * pass), then NOT-MEASURED (delivery absent), then FAIL, then PASS. The retained assertion detail comes from the
9
9
  * first non-passing rep, since that is the one worth reading.
10
10
  */
11
11
  export function aggregateObjective(outcomes) {
12
12
  const present = outcomes.map((o) => o.objective).filter((o) => o !== undefined);
13
13
  if (present.length === 0)
14
14
  return undefined;
15
- const picked = present.find((o) => o.status === "ERROR") ?? present.find((o) => o.status === "FAIL") ?? present[0];
15
+ const picked = present.find((o) => o.status === "ERROR") ?? present.find((o) => o.status === "NOT-MEASURED") ?? present.find((o) => o.status === "FAIL") ?? present[0];
16
16
  if (present.length === 1)
17
17
  return picked;
18
18
  const eventHashes = present.map((objective) => objective.events_sha256);
@@ -41,6 +41,10 @@ export function aggregateReps(outcomes, threshold) {
41
41
  if (errored > 0) {
42
42
  return { verdict: "ERROR", reason: `${errored}/${reps} reps errored — infrastructure, not behavior`, passes, reps, clean: clean.length, flakiness: 0, suspect: false };
43
43
  }
44
+ const notMeasured = outcomes.filter((o) => o.verdict === "NOT-MEASURED").length;
45
+ if (notMeasured > 0) {
46
+ return { verdict: "NOT-MEASURED", reason: `${notMeasured}/${reps} reps not measured — skill delivery was not established`, passes, reps, clean: clean.length - notMeasured, flakiness: 0, suspect: false };
47
+ }
44
48
  if (clean.length * 2 < reps) {
45
49
  // majority of reps misfired → untrustworthy
46
50
  return { verdict: "FAIL", reason: `${reps - clean.length}/${reps} reps misfired — re-judge`, passes, reps, clean: clean.length, flakiness: 0, suspect: true };
@@ -65,15 +69,17 @@ export function outcomesToResult(id, outcomes, repCount, threshold) {
65
69
  const objectiveField = objective ? { objective } : {};
66
70
  const metrics = aggregateMetrics(outcomes);
67
71
  const metricsField = metrics ? { metrics } : {};
72
+ const repJudgments = outcomes.map((outcome, repetition) => ({ repetition, judgments: outcome.judgment ? [outcome.judgment] : [], recorded_verdict: outcome.verdict, ...(outcome.objective ? { objective: outcome.objective } : {}) }));
73
+ const repJudgmentField = outcomes.some(outcome => outcome.judgment) ? { rep_judgments: repJudgments } : {};
68
74
  if (repCount === 1) {
69
75
  const o = outcomes[0];
70
- return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, ...metricsField, override: null, note: "", ...objectiveField };
76
+ return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, ...metricsField, override: null, note: "", ...objectiveField, ...repJudgmentField };
71
77
  }
72
78
  const agg = aggregateReps(outcomes, threshold);
73
79
  return {
74
80
  id, judge_verdict: agg.verdict, judge_reason: agg.reason, suspect: agg.suspect,
75
81
  reps: agg.reps, passes: agg.passes, clean: agg.clean, flakiness: agg.flakiness,
76
- pass_threshold: threshold, ...metricsField, override: null, note: "", ...objectiveField,
82
+ pass_threshold: threshold, ...metricsField, override: null, note: "", ...objectiveField, ...repJudgmentField,
77
83
  };
78
84
  }
79
85
  function aggregateMetrics(outcomes) {
@@ -97,6 +103,9 @@ function aggregateMetrics(outcomes) {
97
103
  cache_read_tokens: subjects.reduce((sum, metrics) => sum + metrics.cache_read_tokens, 0),
98
104
  cache_write_tokens: subjects.reduce((sum, metrics) => sum + metrics.cache_write_tokens, 0),
99
105
  subject_cost_usd: subjects.reduce((sum, metrics) => sum + metrics.cost_usd, 0),
106
+ cost_source: subjects.every((metrics) => metrics.cost_source === subjects[0].cost_source)
107
+ ? subjects[0].cost_source
108
+ : "unreported",
100
109
  tool_calls: subjects.reduce((sum, metrics) => sum + metrics.tool_calls, 0),
101
110
  delegated_children: subjects.reduce((sum, metrics) => sum + metrics.delegated_children, 0),
102
111
  max_concurrency: Math.max(...subjects.map((metrics) => metrics.max_concurrency)),
package/dist/rescore.js CHANGED
@@ -52,7 +52,7 @@ export function rescoreRun(opts) {
52
52
  // no rate to re-apply, or an untrustworthy verdict → carry verbatim
53
53
  if (!scenario || s.reps === undefined || s.clean === undefined || s.passes === undefined)
54
54
  return s;
55
- if (s.judge_verdict === "ERROR" || s.judge_verdict === "JUDGE-AMBIGUOUS")
55
+ if (s.judge_verdict === "ERROR" || s.judge_verdict === "NOT-MEASURED" || s.judge_verdict === "JUDGE-AMBIGUOUS")
56
56
  return s;
57
57
  if (s.clean === 0)
58
58
  return s;
@@ -78,6 +78,7 @@ export function rescoreRun(opts) {
78
78
  });
79
79
  const ctx = scoreContextFor(prev, opts.spec);
80
80
  const results = writeResults(opts.runDir, {
81
+ schema: prev.schema, subject_invocations: prev.subject_invocations,
81
82
  skill: prev.skill, harness: prev.harness, model: prev.model, judge: prev.judge,
82
83
  timestamp: prev.timestamp, label: prev.label, mode: prev.mode,
83
84
  partial: prev.partial,
@@ -85,6 +86,13 @@ export function rescoreRun(opts) {
85
86
  // threshold to reps the recorded harness already produced.
86
87
  harness_cli_version: prev.harness_cli_version,
87
88
  delivery_canary: prev.delivery_canary,
89
+ // The arm is provenance of the MEASUREMENT, not of this rewrite, and it is the
90
+ // only record that a `+<arm>` run actually delegated: rebuilding the draft
91
+ // field-by-field without it silently deleted `definitions`/`ledger_events`
92
+ // from any arm run that was ever re-graded, leaving a record
93
+ // indistinguishable from a vacuous arm. Same reason `harness_cli_version`,
94
+ // `delivery_canary` and `source_hashes` are carried here.
95
+ arm: prev.arm,
88
96
  // A rescore re-applies the CURRENT policy (thresholds, critical set) to the
89
97
  // recorded reps, so `policy:` drift is genuinely resolved by having run this —
90
98
  // that is what makes `rescore` the honest remedy lint names for it. Stimulus,
package/dist/results.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type ModelRef } from "./adapters/types.js";
2
2
  import { type ScenarioVerdict } from "./score.js";
3
3
  import type { Verdict } from "./score.js";
4
4
  import type { ShipBar, Scenario } from "./spec.js";
5
+ import type { CostSource } from "./capture-trace-types.js";
5
6
  export interface ScenarioMetrics {
6
7
  wall_time_ms: number;
7
8
  judge_calls: number;
@@ -13,14 +14,36 @@ export interface ScenarioMetrics {
13
14
  cache_read_tokens?: number;
14
15
  cache_write_tokens?: number;
15
16
  subject_cost_usd?: number;
17
+ cost_source?: CostSource;
16
18
  tool_calls?: number;
17
19
  delegated_children?: number;
18
20
  max_concurrency?: number;
19
21
  }
20
22
  /** Merge bookkeeping from a later judge/gate pass without duplicating subject usage. */
21
23
  export declare function mergeScenarioMetrics(prior: ScenarioMetrics | undefined, fresh: ScenarioMetrics | undefined): ScenarioMetrics | undefined;
24
+ export interface CriterionVote {
25
+ index: number;
26
+ verdict: "PASS" | "FAIL" | "ERROR";
27
+ reason: string;
28
+ }
29
+ export interface RepJudgmentPanel {
30
+ repetition: number;
31
+ judgments: Judgment[];
32
+ recorded_verdict: Verdict;
33
+ /** Per-repetition objective result; needed because scenario.objective is a strict aggregate. */
34
+ objective?: ObjectiveResult;
35
+ }
36
+ export declare function carryRepObjectives(fresh: RepJudgmentPanel[] | undefined, prior: RepJudgmentPanel[] | undefined): RepJudgmentPanel[] | undefined;
37
+ export interface SubjectInvocationObservation {
38
+ scenario_id: string;
39
+ repetition: number;
40
+ attempt?: number;
41
+ prompt: import("./adapters/types.js").PromptProvenance;
42
+ }
22
43
  export interface ScenarioResult {
23
44
  id: string;
45
+ /** Numbered rubric items expected on every schema-3 judgment. */
46
+ criterion_count?: number;
24
47
  judge_verdict: Verdict;
25
48
  judge_reason: string;
26
49
  suspect: boolean;
@@ -49,6 +72,10 @@ export interface ScenarioResult {
49
72
  * actually blocks SHIP; this field is the audit trail behind that flag.
50
73
  */
51
74
  adjudication?: AdjudicationResult;
75
+ /** Latest three full-cell grades; enables offline agreement reports with distinct judges. */
76
+ judge_history?: Judgment[];
77
+ /** Initial and panel judge members, by repetition, sufficient to recompute the recorded verdict. */
78
+ rep_judgments?: RepJudgmentPanel[];
52
79
  }
53
80
  /** One judge's answer for a cell, kept verbatim however the collapse turned out. */
54
81
  export interface Judgment {
@@ -62,8 +89,12 @@ export interface Judgment {
62
89
  reason: string;
63
90
  /** The judge misfired — recorded, never counted as a clean vote. */
64
91
  suspect: boolean;
92
+ /** Every numbered rubric-item vote parsed from this exact judge response. Required in schema v3. */
93
+ criteria?: CriterionVote[];
65
94
  }
66
95
  export interface AdjudicationResult {
96
+ /** The saved transcript repetition judged by every panel member (first rep by policy). */
97
+ repetition?: number;
67
98
  state: "confirmed" | "tie_broken" | "unresolved";
68
99
  /** Why the cell was re-judged. */
69
100
  trigger: "ambiguous" | "contradictory" | "non_unanimous" | "ship_deciding";
@@ -74,8 +105,8 @@ export interface AdjudicationResult {
74
105
  }
75
106
  /** Objective gate outcome for one scenario cell. */
76
107
  export interface ObjectiveResult {
77
- /** ERROR means the evidence was missing or malformed — never a pass. */
78
- status: "PASS" | "FAIL" | "ERROR";
108
+ /** ERROR is malformed evidence; NOT-MEASURED is a known-undelivered experiment. */
109
+ status: "PASS" | "FAIL" | "ERROR" | "NOT-MEASURED";
79
110
  trace_version?: number;
80
111
  trace_sha256?: string;
81
112
  trajectory_version?: string;
@@ -85,7 +116,7 @@ export interface ObjectiveResult {
85
116
  rep_trace_sha256?: string[];
86
117
  assertions: {
87
118
  kind: string;
88
- status: "PASS" | "FAIL" | "ERROR";
119
+ status: "PASS" | "FAIL" | "ERROR" | "NOT-MEASURED";
89
120
  detail: string;
90
121
  }[];
91
122
  }
@@ -98,7 +129,7 @@ export interface GradeSummary {
98
129
  note: string;
99
130
  }
100
131
  export interface ResultsFile {
101
- schema: 2;
132
+ schema: 2 | 3;
102
133
  /**
103
134
  * The harness version that wrote this file — provenance for the numbers in it.
104
135
  *
@@ -157,6 +188,39 @@ export interface ResultsFile {
157
188
  timestamp: string;
158
189
  label: string | null;
159
190
  mode: string;
191
+ /**
192
+ * The arm this run was measured under, absent for the control.
193
+ *
194
+ * Recorded rather than inferred from the tag: the tag says which arm, this says
195
+ * what the arm actually loaded — a run whose extension list or definition count
196
+ * differed from today's `arms.yaml` is not comparable, and only the record can
197
+ * show that.
198
+ *
199
+ * `ledger_events` is the arm's delivery proof, `--canary`'s equivalent for this
200
+ * axis: the pi-daddy ledger itself is gitignored (`*.jsonl`), so without a
201
+ * count here a vacuous arm run (extension loaded, nothing ever delegated)
202
+ * commits a record indistinguishable from one that actually exercised
203
+ * delegation. 0 is a reportable outcome, not an absent field.
204
+ *
205
+ * `env` is the arm's declared env for the subject process (`PI_GRANTS_GRANT`,
206
+ * `PI_GRANTS_MAX_DEPTH`, …) — recorded because it IS the experimental
207
+ * condition, not decoration around it. Without it, changing `PI_GRANTS_MAX_DEPTH`
208
+ * from 1 to 3 and re-running lands both runs in the same `+<arm>` tag with
209
+ * byte-identical `arm` records, and `stability` reads a verdict flip between two
210
+ * different conditions as one lineage being bimodal — the exact misreading the
211
+ * tag placement exists to prevent. Recorded as DECLARED, with `<run-dir>` and
212
+ * `<workspace>` unsubstituted: the substituted form differs every run for the same condition,
213
+ * which would make two identical conditions look like two.
214
+ *
215
+ * Optional only for records written before it was added; every writer emits it.
216
+ */
217
+ arm?: {
218
+ name: string;
219
+ extensions: string[];
220
+ definitions: number;
221
+ ledger_events: number;
222
+ env?: Record<string, string>;
223
+ };
160
224
  /** True for an `--only`-filtered run: a scenario subset, never ship-graded, never a release run. */
161
225
  partial?: boolean;
162
226
  /**
@@ -171,6 +235,8 @@ export interface ResultsFile {
171
235
  source_hashes?: Record<string, string>;
172
236
  effective_grade: GradeSummary;
173
237
  scenarios: ScenarioResult[];
238
+ /** Adapter-computed model-visible prompt observations. Required for schema v3. */
239
+ subject_invocations?: SubjectInvocationObservation[];
174
240
  }
175
241
  /**
176
242
  * The run modes whose results carry a real grade: the ones where the skill under
@@ -222,13 +288,28 @@ export declare function scoreContextFor(run: {
222
288
  /** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
223
289
  export declare function effectiveThreshold(prevScenario: ScenarioResult | undefined, scenario: Scenario): number;
224
290
  /** Everything a caller may set. The grade is computed, never supplied. */
225
- export type ResultsDraft = Omit<ResultsFile, "schema" | "effective_grade">;
291
+ export type ResultsDraft = Omit<ResultsFile, "schema" | "effective_grade"> & {
292
+ schema?: 2 | 3;
293
+ };
226
294
  export interface ScoreContext {
227
295
  shipBar: ShipBar;
228
296
  critical: string[];
229
297
  }
230
- /** <skillDir>/tests/results/<harness>-<model-slug>/<timestamp-slug>/ */
231
- export declare function runDirFor(skillDir: string, harness: string, model: ModelRef, timestamp: string): string;
298
+ /**
299
+ * `<skillDir>/tests/results/<harness>-<model-slug>[+<arm>]/<timestamp-slug>/`
300
+ *
301
+ * The arm is part of the run's IDENTITY, not of the spec's digest. `lint` and
302
+ * `stability` both key on this tag, so two arms become separate lineages that can
303
+ * never be misread as one lineage flipping run-over-run — and no
304
+ * `specification.yaml` byte moves, so every committed run stays valid.
305
+ *
306
+ * `+` is deliberate and is appended OUTSIDE `modelSlug`, whose character class
307
+ * (`[^A-Za-z0-9._-]`) cannot emit one. So the separator can never occur inside a
308
+ * slug and the tag stays unambiguously splittable back into model and arm. The
309
+ * control arm appends nothing, so its paths are byte-identical to before arms
310
+ * existed.
311
+ */
312
+ export declare function runDirFor(skillDir: string, harness: string, model: ModelRef, timestamp: string, armName?: string): string;
232
313
  /** Path of a transcript file within a run dir. A rep index (for --reps N>1) is suffixed. */
233
314
  export declare function transcriptPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
234
315
  export declare function reportPath(runDir: string): string;
@@ -270,6 +351,17 @@ export declare function writeResults(runDir: string, draft: ResultsDraft, ctx: S
270
351
  export declare function migrateResults(raw: unknown): ResultsFile;
271
352
  /** Read results.yaml from a run dir, migrating schema-1 files in memory. */
272
353
  export declare function readResults(runDir: string): ResultsFile;
354
+ /** Parse rubric-item votes for retention only; verdict semantics remain in grade.ts. */
355
+ export declare function parseCriterionVotes(raw: string): CriterionVote[];
356
+ export declare function completeCriterionVotes(votes: CriterionVote[], expected: number): CriterionVote[];
357
+ export declare function deliveryStatusForObservations(observations: SubjectInvocationObservation[]): "PASS" | "NOT-MEASURED" | "ERROR";
358
+ export declare function recomputeRecordedPanels(results: ResultsFile): Array<{
359
+ scenario_id: string;
360
+ repetition: number;
361
+ verdict: Verdict;
362
+ }>;
363
+ /** Runtime v2/v3 validator. v2 remains permissive; v3 fails closed on observation gaps. */
364
+ export declare function validateResults(raw: unknown): ResultsFile;
273
365
  /** Pure: return a copy with override + note applied to one scenario. */
274
366
  export declare function applyOverride(results: ResultsFile, scenarioId: string, override: Verdict | null, note: string): ResultsFile;
275
367
  /**