@skill-harness/core 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/regate.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { parseVerdict, detectMisfire } from "./grade.js";
4
4
  import { evaluateNeedleGates, hasNeedleGates } from "./seeded.js";
5
5
  import { evaluateTraceGates } from "./trace-gates.js";
6
6
  import { mergeTraces, deserializeTrace } from "./execution-trace.js";
7
+ import { deserializeTrajectoryEvents, evaluateTrajectoryGates } from "./trajectory-gates.js";
7
8
  import { judgeOneRep } from "./regrade.js";
8
- import { readResults, writeResults, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, findTraceFiles, tracePath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, } from "./results.js";
9
+ import { readResults, writeResults, diffPath, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, findTraceFiles, findTrajectoryFiles, tracePath, trajectoryPath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, } from "./results.js";
9
10
  import { outcomesToResult } from "./reps.js";
10
11
  import { appendJournal } from "./journal.js";
11
12
  import { gatesDigest, GATES_PREFIX } from "./sources.js";
@@ -88,30 +89,39 @@ export async function regateRun(opts) {
88
89
  const s = specById.get(rec.id);
89
90
  const needles = hasNeedleGates(s ?? {});
90
91
  const traceGated = Boolean(s?.traceAssert);
91
- if (!s || (!needles && !traceGated))
92
+ const trajectoryGated = Boolean(s?.trajectoryAssert);
93
+ if (!s || (!needles && !traceGated && !trajectoryGated))
92
94
  continue; // nothing for regate to re-decide
93
95
  if (s.assert?.vitest || s.assert?.post_test) {
94
96
  blocked.push(`${s.id}: declares ${s.assert.vitest ? "assert.vitest" : "assert.post_test"}, which needs the workspace — ` +
95
97
  `no saved artifact can stand in for it, so this scenario needs a re-run`);
96
98
  continue;
97
99
  }
98
- if (needles && findDiffFiles(opts.runDir, s.id, mode).length === 0) {
99
- blocked.push(`${s.id}: no staged-diff artifact on disk (\`.diff.txt\` is gitignored — regate needs the run dir that produced it)`);
100
+ const expectedReps = rec.reps ?? 1;
101
+ const expectedSuffixes = expectedReps === 1 ? [undefined] : Array.from({ length: expectedReps }, (_, index) => index);
102
+ const complete = (paths, expectedPaths) => paths.length === expectedPaths.length && expectedPaths.every((path) => paths.includes(basename(path)));
103
+ if (needles && !complete(findDiffFiles(opts.runDir, s.id, mode), expectedSuffixes.map((rep) => diffPath(opts.runDir, s.id, mode, rep)))) {
104
+ blocked.push(`${s.id}: staged-diff artifacts are incomplete for ${expectedReps} recorded rep(s) — regate refuses a partial repetition set`);
100
105
  continue;
101
106
  }
102
107
  // A trace gate is only re-decidable from a saved trace. A run recorded before
103
108
  // traces existed has none, and saying so is the whole point — pretending
104
109
  // regate can answer would report a verdict derived from no evidence.
105
- if (traceGated && findTraceFiles(opts.runDir, s.id, mode).length === 0) {
106
- blocked.push(`${s.id}: declares assert.trace but this run saved no \`.trace.jsonl\` artifact ` +
107
- `(it predates trace capture, or the trace was not persisted) — it needs a re-run`);
110
+ if (traceGated && !complete(findTraceFiles(opts.runDir, s.id, mode), expectedSuffixes.map((rep) => tracePath(opts.runDir, s.id, mode, rep)))) {
111
+ blocked.push(`${s.id}: assert.trace artifacts are incomplete for ${expectedReps} recorded rep(s) ` +
112
+ `(traces are gitignored, or capture failed) — it needs a re-run`);
113
+ continue;
114
+ }
115
+ if (trajectoryGated && !complete(findTrajectoryFiles(opts.runDir, s.id, mode), expectedSuffixes.map((rep) => trajectoryPath(opts.runDir, s.id, mode, rep)))) {
116
+ blocked.push(`${s.id}: assert.trajectory artifacts are incomplete for ${expectedReps} recorded rep(s) ` +
117
+ `(events are gitignored, or required native evidence was missing) — it needs a re-run`);
108
118
  continue;
109
119
  }
110
120
  targets.push(s);
111
121
  }
112
122
  if (targets.length === 0) {
113
123
  throw new Error(`nothing to regate in ${opts.runDir}` +
114
- (blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff_contains/diff_excludes or assert.trace"));
124
+ (blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff_contains/diff_excludes, assert.trace, or assert.trajectory"));
115
125
  }
116
126
  const changes = [];
117
127
  let judgeCalls = 0;
@@ -124,17 +134,21 @@ export async function regateRun(opts) {
124
134
  }
125
135
  const diffFiles = findDiffFiles(opts.runDir, scenario.id, mode);
126
136
  const traceFiles = findTraceFiles(opts.runDir, scenario.id, mode);
127
- // Reps come from whichever artifact this scenario actually has. A trace-only
128
- // scenario has no `.diff.txt` at all, so iterating diffs would silently
129
- // regate nothing and report success.
137
+ const eventFiles = findTrajectoryFiles(opts.runDir, scenario.id, mode);
138
+ // Reps come from whichever artifact this scenario actually has. A trajectory-only
139
+ // scenario has neither diff nor trace, so falling back only once would regate
140
+ // nothing and report success.
130
141
  const repKeys = diffFiles.length > 0
131
142
  ? diffFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: f }))
132
- : traceFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: undefined }));
143
+ : traceFiles.length > 0
144
+ ? traceFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: undefined }))
145
+ : eventFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: undefined }));
133
146
  const outcomes = [];
134
147
  // Per scenario, not run-wide: with several regated scenarios, a global counter
135
148
  // would report every change as "re-judged" because some other scenario was.
136
149
  let judgedHere = 0;
137
150
  let gateFailedHere = false;
151
+ let gateErroredHere = false;
138
152
  for (const { rep, diffFile } of repKeys) {
139
153
  const diff = diffFile ? readFileSync(join(opts.runDir, diffFile), "utf8") : "";
140
154
  const needleGate = diffFile ? evaluateNeedleGates(scenario, diff) : { lines: [], failure: null };
@@ -166,14 +180,69 @@ export async function regateRun(opts) {
166
180
  }
167
181
  else {
168
182
  const g = evaluateTraceGates(scenario.traceAssert, merged);
169
- objective = { status: g.status, trace_version: merged.trace_version, trace_sha256: merged.trace_sha256, assertions: g.assertions };
170
- if (g.status === "FAIL" || g.status === "ERROR") {
171
- const bad = g.assertions.filter((x) => x.status === g.status).map((x) => x.detail);
183
+ const expectedHash = rec.objective?.rep_trace_sha256?.[rep ?? 0] ?? (rec.reps === undefined ? rec.objective?.trace_sha256 : undefined);
184
+ const digestMismatch = expectedHash !== undefined && expectedHash !== merged.trace_sha256;
185
+ const integrityAssertion = digestMismatch
186
+ ? [{ kind: "trace_evidence", status: "ERROR", detail: "saved trace no longer matches the hash recorded by the run" }]
187
+ : [];
188
+ objective = { status: digestMismatch ? "ERROR" : g.status, trace_version: merged.trace_version, trace_sha256: merged.trace_sha256, assertions: [...integrityAssertion, ...g.assertions] };
189
+ if (g.status === "FAIL" || g.status === "ERROR" || digestMismatch) {
190
+ const bad = [...integrityAssertion.map((x) => x.detail), ...g.assertions.filter((x) => x.status === g.status && g.status !== "PASS").map((x) => x.detail)];
172
191
  traceFailure = `objective: ${bad.join("; ")}`;
173
192
  }
174
193
  }
175
194
  }
176
- const gate = { lines: needleGate.lines, failure: needleGate.failure ?? traceFailure };
195
+ // Adapter-neutral trajectory gate, replayed from the saved normalized
196
+ // event artifact. Native ledgers are deliberately not re-read here: the
197
+ // artifact is the immutable evidence this run actually captured.
198
+ let trajectoryFailure = null;
199
+ if (scenario.trajectoryAssert) {
200
+ const ep = trajectoryPath(opts.runDir, scenario.id, mode, rep);
201
+ const events = existsSync(ep) ? deserializeTrajectoryEvents(readFileSync(ep, "utf8")) : null;
202
+ // The normalized artifact contains only events that were successfully captured.
203
+ // Source-read/normalization failures are persisted on the original objective;
204
+ // dropping them here would let a partial ledger become PASS on replay.
205
+ const priorEvidenceErrors = (rec.objective?.assertions ?? [])
206
+ .filter((result) => result.kind === "trajectory_evidence" && result.status === "ERROR");
207
+ if (!events) {
208
+ trajectoryFailure = "objective: saved normalized events are missing, malformed, or from an unsupported version";
209
+ objective = {
210
+ ...(objective ?? { assertions: [] }),
211
+ status: "ERROR",
212
+ assertions: [...(objective?.assertions ?? []), { kind: "trajectory_evidence", status: "ERROR", detail: trajectoryFailure }],
213
+ };
214
+ }
215
+ else {
216
+ const g = evaluateTrajectoryGates(scenario.trajectoryAssert, events);
217
+ const expectedHash = rec.objective?.rep_events_sha256?.[rep ?? 0] ?? (rec.reps === undefined ? rec.objective?.events_sha256 : undefined);
218
+ const digestMismatch = expectedHash !== undefined && expectedHash !== g.events_sha256;
219
+ const integrityAssertion = digestMismatch
220
+ ? [{ kind: "trajectory_evidence", status: "ERROR", detail: "saved normalized events no longer match the hash recorded by the run" }]
221
+ : [];
222
+ const priorStatus = objective?.status ?? "PASS";
223
+ const status = priorStatus === "ERROR" || g.status === "ERROR" || priorEvidenceErrors.length || digestMismatch
224
+ ? "ERROR"
225
+ : priorStatus === "FAIL" || g.status === "FAIL"
226
+ ? "FAIL"
227
+ : "PASS";
228
+ objective = {
229
+ ...(objective ?? {}),
230
+ status,
231
+ trajectory_version: g.event_version,
232
+ events_sha256: g.events_sha256,
233
+ assertions: [...(objective?.assertions ?? []), ...priorEvidenceErrors, ...integrityAssertion, ...g.assertions],
234
+ };
235
+ if (g.status !== "PASS" || priorEvidenceErrors.length || digestMismatch) {
236
+ const details = [
237
+ ...priorEvidenceErrors.map((result) => result.detail),
238
+ ...integrityAssertion.map((result) => result.detail),
239
+ ...g.assertions.filter((result) => result.status === g.status && g.status !== "PASS").map((result) => result.detail),
240
+ ];
241
+ trajectoryFailure = `objective: ${details.join("; ")}`;
242
+ }
243
+ }
244
+ }
245
+ const gate = { lines: needleGate.lines, failure: needleGate.failure ?? traceFailure ?? trajectoryFailure };
177
246
  const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
178
247
  const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
179
248
  // Two sources, because the two gate kinds record their prior state
@@ -189,8 +258,11 @@ export async function regateRun(opts) {
189
258
  if (existsSync(tPath))
190
259
  rewriteTranscript(tPath, gate.lines);
191
260
  if (gate.failure) {
192
- gateFailedHere = true;
193
- outcomes.push({ verdict: "FAIL", reason: gate.failure, suspect: false, objective });
261
+ if (objective?.status === "ERROR")
262
+ gateErroredHere = true;
263
+ else
264
+ gateFailedHere = true;
265
+ outcomes.push({ verdict: objective?.status === "ERROR" ? "ERROR" : "FAIL", reason: gate.failure, suspect: false, objective });
194
266
  continue;
195
267
  }
196
268
  if (!oldGateFailed) {
@@ -205,13 +277,14 @@ export async function regateRun(opts) {
205
277
  outcomes.push({ ...(await judgeOneRep({
206
278
  runDir: opts.runDir, spec: opts.spec, scenario, transcript,
207
279
  adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
208
- mode, rep, now,
280
+ mode, rep, now, rejudge: true,
209
281
  })), objective });
210
282
  judgeCalls++;
211
283
  judgedHere++;
212
284
  }
213
285
  const threshold = effectiveThreshold(rec, scenario);
214
286
  const next = outcomesToResult(scenario.id, outcomes, outcomes.length, threshold);
287
+ next.metrics = mergeScenarioMetrics(rec.metrics, next.metrics);
215
288
  // Overrides and their notes survive: a regate re-decides the gate, and an author
216
289
  // override is a statement about the judge, not about the needle.
217
290
  // `regate` re-evaluates gates from saved artifacts and asks no judge anything:
@@ -222,7 +295,7 @@ export async function regateRun(opts) {
222
295
  if (to !== rec.judge_verdict) {
223
296
  changes.push({
224
297
  id: scenario.id, from: rec.judge_verdict, to,
225
- gate: gateFailedHere ? "fail" : "pass",
298
+ gate: gateErroredHere ? "error" : gateFailedHere ? "fail" : "pass",
226
299
  judged: judgedHere > 0,
227
300
  });
228
301
  }
package/dist/regrade.d.ts CHANGED
@@ -32,6 +32,8 @@ export interface RegradeOptions {
32
32
  * with "nothing to re-grade", which is how ten scorable runs stayed ungraded.
33
33
  */
34
34
  mode?: string;
35
+ /** Recorded repetition count; when supplied, missing/extra rep artifacts fail before judge calls. */
36
+ expectedReps?: number;
35
37
  now?: () => string;
36
38
  }
37
39
  /** Judge one saved transcript: writes the judge-raw artifact, emits a `judge-verdict` journal event (plus `misfire-flag` when the verdict is suspect), and returns the outcome. */
@@ -46,6 +48,8 @@ export declare function judgeOneRep(opts: {
46
48
  mode: string;
47
49
  rep: number | undefined;
48
50
  now: () => string;
51
+ /** True when this call revisits a saved subject transcript. */
52
+ rejudge?: boolean;
49
53
  }): Promise<RepOutcome>;
50
54
  /**
51
55
  * Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
package/dist/regrade.js CHANGED
@@ -1,7 +1,7 @@
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, } from "./results.js";
4
+ import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, } 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";
@@ -36,6 +36,7 @@ export function refreshRubricHashes(recorded, spec, judgedIds) {
36
36
  /** Judge one saved transcript: writes the judge-raw artifact, emits a `judge-verdict` journal event (plus `misfire-flag` when the verdict is suspect), and returns the outcome. */
37
37
  export async function judgeOneRep(opts) {
38
38
  const { runDir, spec, scenario, transcript, adapter, judge, specDir, mode, rep, now } = opts;
39
+ const startedAt = performance.now();
39
40
  const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
40
41
  const g = await judgeInWorkspace(adapter, judge, prompt, specDir);
41
42
  writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), g.raw, "utf8");
@@ -43,7 +44,14 @@ export async function judgeOneRep(opts) {
43
44
  appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: g.verdict, reason: g.reason, suspect: g.suspect, ...repField });
44
45
  if (g.suspect)
45
46
  appendJournal(runDir, { event: "misfire-flag", ts: now(), id: scenario.id, reason: g.reason, ...repField });
46
- return { verdict: g.verdict, reason: g.reason, suspect: g.suspect };
47
+ return {
48
+ verdict: g.verdict, reason: g.reason, suspect: g.suspect,
49
+ metrics: {
50
+ wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
51
+ judge_calls: 1,
52
+ judge_rejudge_calls: opts.rejudge ? 1 : 0,
53
+ },
54
+ };
47
55
  }
48
56
  /**
49
57
  * Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
@@ -58,14 +66,20 @@ export async function regradeScenario(opts) {
58
66
  const files = findTranscriptFiles(opts.runDir, opts.scenario.id, mode);
59
67
  if (files.length === 0)
60
68
  throw new Error(`no ${mode} transcripts for ${opts.scenario.id} in ${opts.runDir}`);
61
- const repCount = files.length;
69
+ const expected = opts.expectedReps ?? files.length;
70
+ const expectedIndices = expected === 1 ? [null] : Array.from({ length: expected }, (_, index) => index);
71
+ const actualIndices = files.map((file) => repIndexOf(file)).sort((a, b) => (a ?? -1) - (b ?? -1));
72
+ if (files.length !== expected || JSON.stringify(actualIndices) !== JSON.stringify(expectedIndices)) {
73
+ throw new Error(`${opts.scenario.id}: transcript artifacts are incomplete for ${expected} recorded rep(s) — re-run instead of grading a smaller repetition set`);
74
+ }
75
+ const repCount = expected;
62
76
  const outcomes = [];
63
77
  for (const file of files) {
64
78
  const rep = repIndexOf(file) ?? undefined;
65
79
  const transcript = readFileSync(join(opts.runDir, file), "utf8");
66
80
  outcomes.push(await judgeOneRep({
67
81
  runDir: opts.runDir, spec: opts.spec, scenario: opts.scenario, transcript,
68
- adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now,
82
+ adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now, rejudge: true,
69
83
  }));
70
84
  }
71
85
  return outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
@@ -120,7 +134,15 @@ export async function regradeRun(opts) {
120
134
  return prev;
121
135
  }
122
136
  }
123
- const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id, mode).length === 0);
137
+ const completeTranscripts = (record) => {
138
+ const expected = record.reps ?? 1;
139
+ const files = findTranscriptFiles(runDir, record.id, mode);
140
+ const expectedIndices = expected === 1 ? [null] : Array.from({ length: expected }, (_, index) => index);
141
+ const actualIndices = files.map((file) => repIndexOf(file)).sort((a, b) => (a ?? -1) - (b ?? -1));
142
+ return files.length === expected && JSON.stringify(actualIndices) === JSON.stringify(expectedIndices);
143
+ };
144
+ const recordedById = new Map(recorded.map((record) => [record.id, record]));
145
+ const missing = targets.filter((id) => !specById.has(id) || !completeTranscripts(recordedById.get(id)));
124
146
  if (missing.length === targets.length) {
125
147
  throw new Error(`no ${mode} transcripts in ${runDir} — nothing to re-grade`);
126
148
  }
@@ -140,8 +162,10 @@ export async function regradeRun(opts) {
140
162
  const threshold = effectiveThreshold(prevScenario, scenario);
141
163
  const rr = await regradeScenario({
142
164
  runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
165
+ expectedReps: prevScenario?.reps ?? 1,
143
166
  });
144
167
  const carry = overrides.get(id);
168
+ rr.metrics = mergeScenarioMetrics(carry?.metrics, rr.metrics);
145
169
  // `grade` re-judges the saved transcript. It does not re-evaluate trace gates
146
170
  // (that is `regate`), so `objective` still describes this run; and it replaced
147
171
  // the judgments a prior adjudication described, so that panel must go.
package/dist/report.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ShipBar } from "./spec.js";
2
2
  import { type ResultsFile } from "./results.js";
3
3
  import { type Lift } from "./lift.js";
4
+ import { type AggregateMetrics } from "./comparison.js";
4
5
  export interface RunColumn {
5
6
  index: number;
6
7
  label: string;
@@ -8,8 +9,10 @@ export interface RunColumn {
8
9
  runDir: string;
9
10
  timestamp: string;
10
11
  mode: string;
12
+ partial: boolean;
11
13
  grade: ResultsFile["effective_grade"];
12
14
  judge: ResultsFile["judge"];
15
+ metrics: AggregateMetrics;
13
16
  cells: Record<string, {
14
17
  judge_verdict: string;
15
18
  judge_reason: string;
@@ -30,6 +33,7 @@ export interface RunColumn {
30
33
  passes?: number;
31
34
  clean?: number;
32
35
  flakiness?: number;
36
+ metrics?: import("./results.js").ScenarioMetrics;
33
37
  override: string | null;
34
38
  note: string;
35
39
  /**
@@ -92,11 +96,13 @@ export declare function publicView(data: ReportData): {
92
96
  tag: string;
93
97
  timestamp: string;
94
98
  mode: string;
99
+ partial: boolean;
95
100
  grade: import("./results.js").GradeSummary;
96
101
  judge: {
97
102
  provider: string;
98
103
  model: string;
99
104
  };
105
+ metrics: AggregateMetrics;
100
106
  cells: Record<string, {
101
107
  judge_verdict: string;
102
108
  judge_reason: string;
@@ -117,6 +123,7 @@ export declare function publicView(data: ReportData): {
117
123
  passes?: number;
118
124
  clean?: number;
119
125
  flakiness?: number;
126
+ metrics?: import("./results.js").ScenarioMetrics;
120
127
  override: string | null;
121
128
  note: string;
122
129
  /**
package/dist/report.js CHANGED
@@ -4,6 +4,7 @@ import { loadSpec } from "./spec.js";
4
4
  import { readResults } from "./results.js";
5
5
  import { collectLift, liftHeadline } from "./lift.js";
6
6
  import { boundaryCells, collectStability, stabilityNote } from "./stability.js";
7
+ import { aggregateMetrics } from "./comparison.js";
7
8
  /** Most-recent run dir (by name, which is an ISO-ish slug) under a model-tag dir. */
8
9
  function latestRunDir(tagDir) {
9
10
  if (!statSync(tagDir).isDirectory())
@@ -83,6 +84,7 @@ export function collectReport(skillDir) {
83
84
  passes: s.passes,
84
85
  clean: s.clean,
85
86
  flakiness: s.flakiness,
87
+ metrics: s.metrics,
86
88
  override: s.override,
87
89
  note: s.note,
88
90
  };
@@ -105,8 +107,10 @@ export function collectReport(skillDir) {
105
107
  runDir,
106
108
  timestamp: r.timestamp,
107
109
  mode: r.mode,
110
+ partial: r.partial === true,
108
111
  grade: r.effective_grade,
109
112
  judge: r.judge,
113
+ metrics: aggregateMetrics(r.scenarios),
110
114
  cells,
111
115
  ...(lift ? { lift, liftHeadline: liftHeadline(lift) } : {}),
112
116
  });
@@ -127,8 +131,10 @@ export function publicView(data) {
127
131
  tag: c.tag,
128
132
  timestamp: c.timestamp,
129
133
  mode: c.mode,
134
+ partial: c.partial,
130
135
  grade: c.grade,
131
136
  judge: c.judge,
137
+ metrics: c.metrics,
132
138
  cells: c.cells,
133
139
  ...(c.lift ? { lift: c.lift, liftHeadline: c.liftHeadline } : {}),
134
140
  })),
package/dist/reps.d.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import type { Verdict } from "./score.js";
2
2
  import type { ScenarioResult, ObjectiveResult } from "./results.js";
3
+ import type { TraceMetrics } from "./capture-trace-types.js";
3
4
  /** One rep's outcome (subject run + judge). */
4
5
  export interface RepOutcome {
5
6
  verdict: Verdict;
6
7
  reason: string;
7
8
  suspect: boolean;
8
- /** Present only when the scenario declared `assert.trace`. */
9
+ /** Present only when the scenario declared structured objective assertions. */
9
10
  objective?: ObjectiveResult;
11
+ metrics?: {
12
+ wall_time_ms: number;
13
+ judge_calls: number;
14
+ judge_rejudge_calls: number;
15
+ subject?: TraceMetrics;
16
+ };
10
17
  }
11
18
  /**
12
19
  * Collapse per-rep objective results.
package/dist/reps.js CHANGED
@@ -12,13 +12,16 @@ 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 errored = present.find((o) => o.status === "ERROR");
16
- if (errored)
17
- return errored;
18
- const failed = present.find((o) => o.status === "FAIL");
19
- if (failed)
20
- return failed;
21
- return present[0];
15
+ const picked = present.find((o) => o.status === "ERROR") ?? present.find((o) => o.status === "FAIL") ?? present[0];
16
+ if (present.length === 1)
17
+ return picked;
18
+ const eventHashes = present.map((objective) => objective.events_sha256);
19
+ const traceHashes = present.map((objective) => objective.trace_sha256);
20
+ return {
21
+ ...picked,
22
+ ...(eventHashes.every((hash) => typeof hash === "string") ? { rep_events_sha256: eventHashes } : {}),
23
+ ...(traceHashes.every((hash) => typeof hash === "string") ? { rep_trace_sha256: traceHashes } : {}),
24
+ };
22
25
  }
23
26
  /**
24
27
  * Collapse N rep outcomes into one scenario verdict. A rep is "clean" when its
@@ -31,14 +34,17 @@ export function aggregateReps(outcomes, threshold) {
31
34
  const reps = outcomes.length;
32
35
  const clean = outcomes.filter((o) => !o.suspect);
33
36
  const passes = clean.filter((o) => o.verdict === "PASS").length;
37
+ const errored = outcomes.filter((o) => o.verdict === "ERROR").length;
38
+ // Infrastructure/judge/tooling errors are not behavioral failures, and they
39
+ // cannot be voted into a pass by successful siblings. Preserve the third
40
+ // state so a release blocks for missing evidence without blaming the skill.
41
+ if (errored > 0) {
42
+ return { verdict: "ERROR", reason: `${errored}/${reps} reps errored — infrastructure, not behavior`, passes, reps, clean: clean.length, flakiness: 0, suspect: false };
43
+ }
34
44
  if (clean.length * 2 < reps) {
35
45
  // majority of reps misfired → untrustworthy
36
46
  return { verdict: "FAIL", reason: `${reps - clean.length}/${reps} reps misfired — re-judge`, passes, reps, clean: clean.length, flakiness: 0, suspect: true };
37
47
  }
38
- const errored = clean.filter((o) => o.verdict === "ERROR").length;
39
- if (clean.length > 0 && errored === clean.length) {
40
- return { verdict: "ERROR", reason: `${errored}/${reps} reps errored`, passes: 0, reps, clean: clean.length, flakiness: 0, suspect: false };
41
- }
42
48
  const passRate = passes / clean.length;
43
49
  const verdict = passRate >= threshold ? "PASS" : "FAIL";
44
50
  const flakiness = 1 - Math.abs(2 * passRate - 1);
@@ -57,15 +63,43 @@ export function outcomesToResult(id, outcomes, repCount, threshold) {
57
63
  // produce a result byte-identical to one from before this field existed.
58
64
  const objective = aggregateObjective(outcomes);
59
65
  const objectiveField = objective ? { objective } : {};
66
+ const metrics = aggregateMetrics(outcomes);
67
+ const metricsField = metrics ? { metrics } : {};
60
68
  if (repCount === 1) {
61
69
  const o = outcomes[0];
62
- return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, override: null, note: "", ...objectiveField };
70
+ return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, ...metricsField, override: null, note: "", ...objectiveField };
63
71
  }
64
72
  const agg = aggregateReps(outcomes, threshold);
65
73
  return {
66
74
  id, judge_verdict: agg.verdict, judge_reason: agg.reason, suspect: agg.suspect,
67
75
  reps: agg.reps, passes: agg.passes, clean: agg.clean, flakiness: agg.flakiness,
68
- pass_threshold: threshold, override: null, note: "", ...objectiveField,
76
+ pass_threshold: threshold, ...metricsField, override: null, note: "", ...objectiveField,
77
+ };
78
+ }
79
+ function aggregateMetrics(outcomes) {
80
+ const present = outcomes.map((outcome) => outcome.metrics).filter((metrics) => metrics !== undefined);
81
+ if (present.length === 0)
82
+ return undefined;
83
+ const subjects = present.map((metrics) => metrics.subject).filter((metrics) => metrics !== undefined);
84
+ const base = {
85
+ wall_time_ms: present.reduce((sum, metrics) => sum + metrics.wall_time_ms, 0),
86
+ judge_calls: present.reduce((sum, metrics) => sum + metrics.judge_calls, 0),
87
+ judge_rejudge_calls: present.reduce((sum, metrics) => sum + metrics.judge_rejudge_calls, 0),
88
+ subject_metrics_reps: subjects.length,
89
+ total_reps: outcomes.length,
90
+ };
91
+ if (subjects.length === 0)
92
+ return base;
93
+ return {
94
+ ...base,
95
+ input_tokens: subjects.reduce((sum, metrics) => sum + metrics.input_tokens, 0),
96
+ output_tokens: subjects.reduce((sum, metrics) => sum + metrics.output_tokens, 0),
97
+ cache_read_tokens: subjects.reduce((sum, metrics) => sum + metrics.cache_read_tokens, 0),
98
+ cache_write_tokens: subjects.reduce((sum, metrics) => sum + metrics.cache_write_tokens, 0),
99
+ subject_cost_usd: subjects.reduce((sum, metrics) => sum + metrics.cost_usd, 0),
100
+ tool_calls: subjects.reduce((sum, metrics) => sum + metrics.tool_calls, 0),
101
+ delegated_children: subjects.reduce((sum, metrics) => sum + metrics.delegated_children, 0),
102
+ max_concurrency: Math.max(...subjects.map((metrics) => metrics.max_concurrency)),
69
103
  };
70
104
  }
71
105
  //# sourceMappingURL=reps.js.map
package/dist/rescore.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { readResults, writeResults, scoreContextFor, rebuildScenarioResult } from "./results.js";
3
+ import { readResults, writeResults, scoreContextFor, rebuildScenarioResult, effectiveThreshold } from "./results.js";
4
4
  import { appendJournal } from "./journal.js";
5
5
  import { policyDigest, POLICY_PREFIX } from "./sources.js";
6
6
  /**
@@ -56,7 +56,7 @@ export function rescoreRun(opts) {
56
56
  return s;
57
57
  if (s.clean === 0)
58
58
  return s;
59
- const toThreshold = scenario.passThreshold ?? 0.5;
59
+ const toThreshold = effectiveThreshold(undefined, scenario);
60
60
  const fromThreshold = s.pass_threshold ?? toThreshold;
61
61
  if (toThreshold === fromThreshold)
62
62
  return s;
package/dist/results.d.ts CHANGED
@@ -2,6 +2,23 @@ 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
+ export interface ScenarioMetrics {
6
+ wall_time_ms: number;
7
+ judge_calls: number;
8
+ judge_rejudge_calls: number;
9
+ subject_metrics_reps: number;
10
+ total_reps: number;
11
+ input_tokens?: number;
12
+ output_tokens?: number;
13
+ cache_read_tokens?: number;
14
+ cache_write_tokens?: number;
15
+ subject_cost_usd?: number;
16
+ tool_calls?: number;
17
+ delegated_children?: number;
18
+ max_concurrency?: number;
19
+ }
20
+ /** Merge bookkeeping from a later judge/gate pass without duplicating subject usage. */
21
+ export declare function mergeScenarioMetrics(prior: ScenarioMetrics | undefined, fresh: ScenarioMetrics | undefined): ScenarioMetrics | undefined;
5
22
  export interface ScenarioResult {
6
23
  id: string;
7
24
  judge_verdict: Verdict;
@@ -14,6 +31,8 @@ export interface ScenarioResult {
14
31
  clean?: number;
15
32
  flakiness?: number;
16
33
  pass_threshold?: number;
34
+ /** Cost/latency counters. Separate from verdicts: cheaper failure is still failure. */
35
+ metrics?: ScenarioMetrics;
17
36
  /**
18
37
  * Objective trace-gate evidence. ADDITIVE and optional.
19
38
  *
@@ -59,6 +78,11 @@ export interface ObjectiveResult {
59
78
  status: "PASS" | "FAIL" | "ERROR";
60
79
  trace_version?: number;
61
80
  trace_sha256?: string;
81
+ trajectory_version?: string;
82
+ events_sha256?: string;
83
+ /** Per-repetition hashes retained when an aggregate has more than one rep. */
84
+ rep_events_sha256?: string[];
85
+ rep_trace_sha256?: string[];
62
86
  assertions: {
63
87
  kind: string;
64
88
  status: "PASS" | "FAIL" | "ERROR";
@@ -327,6 +351,10 @@ export declare function rebuildScenarioResult(fresh: ScenarioResult, prior: Scen
327
351
  * traces append without a wrapper.
328
352
  */
329
353
  export declare function tracePath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
354
+ /** Saved adapter-neutral workflow events for one scenario rep. */
355
+ export declare function trajectoryPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
356
+ /** A scenario's normalized-event files, sorted (plain first, then numeric rep). */
357
+ export declare function findTrajectoryFiles(runDir: string, scenarioId: string, mode?: string): string[];
330
358
  /** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
331
359
  export declare function findDiffFiles(runDir: string, scenarioId: string, mode?: string): string[];
332
360
  /** A scenario's execution-trace files, sorted (plain first, then numeric rep). Mode-scoped when given. */