@skill-harness/core 0.3.2 → 0.5.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/lift.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { readResults, effectiveVerdicts } from "./results.js";
3
+ import { readResults, effectiveVerdicts, isScoredMode } from "./results.js";
4
4
  import { loadSpec } from "./spec.js";
5
5
  function aggregationShape(s) {
6
6
  const reps = s.reps ?? 1;
@@ -98,6 +98,7 @@ export function computeLift(red, green, opts = {}) {
98
98
  return {
99
99
  tag: "",
100
100
  model: green.model,
101
+ mode: green.mode,
101
102
  redTimestamp: red.timestamp,
102
103
  greenTimestamp: green.timestamp,
103
104
  compared: Object.keys(cells).length,
@@ -193,7 +194,7 @@ function isDir(p) {
193
194
  }
194
195
  /**
195
196
  * Per model-tag under <skillDir>/tests/results/, pair the most recent red run
196
- * with the most recent green run and compute the lift.
197
+ * with the most recent skill-delivered run (green or force) and compute the lift.
197
198
  *
198
199
  * Deliberately derived on read rather than persisted into results.yaml: a lift
199
200
  * is a fact about a *pair* of runs, so caching it inside one run's file would go
@@ -237,9 +238,13 @@ export function collectLift(skillDir) {
237
238
  .filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
238
239
  .sort(); // timestamp-slug names ⇒ chronological ascending
239
240
  // Mode is only knowable after reading results.yaml, so every run in the tag
240
- // is read; last-wins gives the most recent of each mode.
241
+ // is read; last-wins gives the most recent baseline and the most recent
242
+ // skill-delivered run. The skill side is whichever scored mode ran most
243
+ // recently — a corpus that moved from green to force delivery should see its
244
+ // lift follow, and the baseline it is measured against is the same either way
245
+ // (`--no-skills` in both).
241
246
  let red;
242
- let green;
247
+ let skillOn;
243
248
  for (const rd of runDirs) {
244
249
  let r;
245
250
  try {
@@ -252,12 +257,12 @@ export function collectLift(skillDir) {
252
257
  }
253
258
  if (r.mode === "red")
254
259
  red = r;
255
- else if (r.mode === "green")
256
- green = r;
260
+ else if (isScoredMode(r.mode))
261
+ skillOn = r;
257
262
  }
258
- if (!red || !green)
263
+ if (!red || !skillOn)
259
264
  continue;
260
- lifts.push({ ...computeLift(red, green, { modeInsensitive }), tag });
265
+ lifts.push({ ...computeLift(red, skillOn, { modeInsensitive }), tag });
261
266
  }
262
267
  return lifts;
263
268
  }
package/dist/lint.js CHANGED
@@ -2,8 +2,9 @@ import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
3
  import yaml from "js-yaml";
4
4
  import { loadSpec, SpecError } from "./spec.js";
5
- import { readResults, finalizeResults, findTranscriptFiles, resultsPath } from "./results.js";
6
- import { currentHashFor, describeSourceKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, UNREADABLE } from "./sources.js";
5
+ import { readResults, finalizeResults, findTranscriptFiles, resultsPath, scoreContextFor } from "./results.js";
6
+ import { currentHashFor, describeSourceKey, remedyForKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, STIMULUS_PREFIX, UNREADABLE } from "./sources.js";
7
+ import { downgradeWarning } from "./downgrade.js";
7
8
  import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
8
9
  /** True if `p` exists and is a directory. Never throws (TOCTOU-safe: a race or dangling
9
10
  * symlink between the check and the stat is treated as "not a directory", not an error). */
@@ -41,6 +42,16 @@ export function lintSkill(skillDir) {
41
42
  return [{ skill: basename(skillDir), code: "spec", message }];
42
43
  }
43
44
  const skill = spec.skill;
45
+ // A lint from an older tool than the records it is checking cannot be trusted to
46
+ // check them: a 0.1.0 binary reported 38 spurious `consistency` findings against
47
+ // partial runs that 0.3.x handles correctly, and it cannot read source-hash key
48
+ // kinds added after it shipped. Reported as a finding (so CI sees it) rather than
49
+ // thrown, because refusing to lint is worse than linting with a caveat, and it is
50
+ // one of the few ways this situation announces itself at all.
51
+ const stale = downgradeWarning(skillDir);
52
+ if (stale) {
53
+ findings.push({ skill, code: "consistency", message: stale.replace(/^warning: /, "") });
54
+ }
44
55
  // ship_bar sanity
45
56
  if (spec.ship_bar.total < 1) {
46
57
  findings.push({ skill, code: "ship_bar", message: "ship_bar.total must be >= 1" });
@@ -152,10 +163,17 @@ export function lintSkill(skillDir) {
152
163
  // can actually re-score. Override/transcript rules below still apply.
153
164
  const specIds = new Set(spec.scenarios.map((sc) => sc.id));
154
165
  const sameSet = r.scenarios.length === specIds.size && r.scenarios.every((sc) => specIds.has(sc.id));
155
- const ctx = r.mode === "green" && !r.partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
166
+ const ctx = scoreContextFor(r, spec);
156
167
  const recomputed = !sameSet ? null : finalizeResults({ skill: r.skill, harness: r.harness, model: r.model, judge: r.judge, timestamp: r.timestamp, label: r.label, mode: r.mode, partial: r.partial, source_hashes: r.source_hashes, scenarios: r.scenarios }, ctx).effective_grade;
157
168
  if (recomputed && JSON.stringify(recomputed) !== JSON.stringify(r.effective_grade)) {
158
- findings.push({ skill, code: "consistency", message: `results.yaml effective_grade is stale in ${runDir} (recompute differs)` });
169
+ // The remedy is named because this finding now has a benign, expected cause
170
+ // as well as a suspicious one: a force run recorded before 0.5.0 carries a
171
+ // "not scored" placeholder, and today's policy scores it (see SCORED_MODES).
172
+ // `rescore` is free and offline, so the fix is never a reason to re-measure.
173
+ findings.push({
174
+ skill, code: "consistency",
175
+ message: `results.yaml effective_grade is stale in ${runDir} (recompute differs) — re-apply the current scoring policy: rescore (free, offline)`,
176
+ });
159
177
  }
160
178
  for (const s of r.scenarios) {
161
179
  if (s.override != null) {
@@ -198,7 +216,7 @@ export function lintSkill(skillDir) {
198
216
  continue; // predates source_hashes → silent
199
217
  {
200
218
  const newest = full.runDir;
201
- const ctx = { skillDir, specDir, scenarios: spec.scenarios };
219
+ const ctx = { skillDir, specDir, scenarios: spec.scenarios, judgePersona: spec.judge_persona };
202
220
  for (const [key, recorded] of Object.entries(hashes)) {
203
221
  const what = describeSourceKey(key);
204
222
  const scenario = scenarioIdForKey(key, spec.scenarios);
@@ -218,7 +236,12 @@ export function lintSkill(skillDir) {
218
236
  findings.push({ skill, scenario, code: "stale", message: `${what} no longer exists but the newest ${basename(tagDir)} run measured it (${newest})` });
219
237
  }
220
238
  else if (current !== recorded) {
221
- findings.push({ skill, scenario, code: "stale", message: `${what} changed since the newest ${basename(tagDir)} run (${newest}) results are stale; re-run before publishing` });
239
+ // The remedy is per key kind, and it is the whole point of the split: a
240
+ // rubric edit is re-gradeable from saved transcripts, a policy edit is a
241
+ // free rescore, a needle edit is a free regate. Only stimulus drift costs
242
+ // model spend. One message saying "re-run" for all four is what made
243
+ // correcting a known-bad rubric expensive enough to skip.
244
+ findings.push({ skill, scenario, code: "stale", message: `${what} changed since the newest ${basename(tagDir)} run (${newest}) — results are stale; ${remedyForKey(key)}` });
222
245
  }
223
246
  }
224
247
  // Coverage: a scenario the spec defines that the newest full run never
@@ -227,9 +250,15 @@ export function lintSkill(skillDir) {
227
250
  // 100%/SHIP scorecard survives an arbitrary spec rewrite reporting zero
228
251
  // findings. Gated on the run having recorded scenario keys at all, so runs
229
252
  // predating the key kind stay silent like every other pre-existing run.
230
- if (Object.keys(hashes).some((k) => k.startsWith(SCENARIO_PREFIX))) {
253
+ // Either key kind counts as "this run recorded per-scenario hashes": 0.4.0+ runs
254
+ // carry `stimulus:<id>`, older ones the combined `scenario:<id>`. Checking only
255
+ // the legacy prefix would silently drop coverage checking for every new run.
256
+ const scenarioKeyPrefix = Object.keys(hashes).some((k) => k.startsWith(STIMULUS_PREFIX))
257
+ ? STIMULUS_PREFIX
258
+ : SCENARIO_PREFIX;
259
+ if (Object.keys(hashes).some((k) => k.startsWith(scenarioKeyPrefix))) {
231
260
  for (const s of spec.scenarios) {
232
- if (!(SCENARIO_PREFIX + s.id in hashes)) {
261
+ if (!(scenarioKeyPrefix + s.id in hashes)) {
233
262
  findings.push({ skill, scenario: s.id, code: "stale", message: `the newest ${basename(tagDir)} run (${newest}) did not measure scenario \`${s.id}\` — the published result covers a different scenario set than the spec; re-run before publishing` });
234
263
  }
235
264
  }
@@ -0,0 +1,57 @@
1
+ import type { Spec } from "./spec.js";
2
+ import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
3
+ import { type ResultsFile } from "./results.js";
4
+ import type { Verdict } from "./score.js";
5
+ export interface RegateOptions {
6
+ runDir: string;
7
+ spec: Spec;
8
+ specDir: string;
9
+ adapter: HarnessAdapter;
10
+ judge: ModelRef;
11
+ now?: () => string;
12
+ }
13
+ export interface RegateChange {
14
+ id: string;
15
+ from: Verdict;
16
+ to: Verdict;
17
+ /** What the corrected needles say about this scenario now. */
18
+ gate: "pass" | "fail";
19
+ /** Whether re-deciding it required a judge call (only a gate-FAIL → gate-pass flip does). */
20
+ judged: boolean;
21
+ }
22
+ export interface RegateResult {
23
+ results: ResultsFile;
24
+ changes: RegateChange[];
25
+ /** Judge calls actually made, for the cost line the CLI prints. */
26
+ judgeCalls: number;
27
+ }
28
+ /**
29
+ * Re-evaluate needle gates against a run's **saved staged diffs** and re-decide the
30
+ * verdicts they determined — without re-running the model.
31
+ *
32
+ * `diff_contains` / `diff_excludes` are pure functions of the diff, and since
33
+ * `f6a5f6c` every seeded rep persists its diff as a run artifact. So the defect class
34
+ * "the gate was wrong, the behavior wasn't" — hit three times in the reference corpus
35
+ * (a context needle, a baseline-satisfied needle, a filename needle) — no longer costs
36
+ * a re-run. Measured on the C2 needle fix: **9 judge calls instead of 81
37
+ * rep-executions across three models.**
38
+ *
39
+ * Per rep, exactly one of four things happens:
40
+ *
41
+ * | old gate | new gate | outcome | cost |
42
+ * |---|---|---|---|
43
+ * | fail | fail | FAIL, with the corrected reason | free |
44
+ * | pass | fail | FAIL — the gate is objective and it says no | free |
45
+ * | pass | pass | the rep's saved judgement, re-parsed from its judge-raw artifact | free |
46
+ * | fail | pass | judged now: the judge never saw this rep, because the gate blocked it | 1 judge call |
47
+ *
48
+ * That third row is what keeps this cheap without guessing: a rep the judge already
49
+ * saw has its verdict on disk, so regate re-reads it rather than re-asking.
50
+ *
51
+ * **Limits, deliberately hard failures rather than partial work:** `assert.vitest` and
52
+ * `assert.post_test` need the workspace and cannot be re-evaluated from any artifact,
53
+ * so a scenario carrying either is not regatable. Diffs and judge-raw files are
54
+ * gitignored, so this works for whoever holds the run dirs — the repo owner, or CI that
55
+ * just ran — which is exactly the situation it is needed in.
56
+ */
57
+ export declare function regateRun(opts: RegateOptions): Promise<RegateResult>;
package/dist/regate.js ADDED
@@ -0,0 +1,199 @@
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseVerdict, detectMisfire } from "./grade.js";
4
+ import { evaluateNeedleGates, hasNeedleGates } from "./seeded.js";
5
+ import { judgeOneRep } from "./regrade.js";
6
+ import { readResults, writeResults, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, effectiveThreshold, scoreContextFor, } from "./results.js";
7
+ import { outcomesToResult } from "./reps.js";
8
+ import { appendJournal } from "./journal.js";
9
+ import { gatesDigest, GATES_PREFIX } from "./sources.js";
10
+ /** Marker in a saved transcript that a needle gate reported a failure. */
11
+ const GATE_FAILED_RE = /: (MISSING|PRESENT)$/m;
12
+ const TRAILER = "=== SEEDED GATES ===";
13
+ const DIFF_HEADER = "=== STAGED DIFF ===";
14
+ /**
15
+ * Rebuild a transcript with a fresh gates trailer, preserving the model's turns and
16
+ * the embedded diff exactly.
17
+ *
18
+ * The trailer is harness-generated annotation appended *after* the model's output, so
19
+ * regenerating it corrects our own note rather than falsifying a transcript. The old
20
+ * file is still kept beside the new one (`.pre-regate.txt`) so the audit trail never
21
+ * depends on the reader accepting that distinction.
22
+ */
23
+ function rewriteTranscript(path, gateLines) {
24
+ const original = readFileSync(path, "utf8");
25
+ const trailerAt = original.indexOf(TRAILER);
26
+ if (trailerAt === -1)
27
+ return; // no trailer to correct (non-seeded shape); leave it alone
28
+ const diffAt = original.indexOf(DIFF_HEADER);
29
+ const head = original.slice(0, trailerAt);
30
+ const tail = diffAt === -1 ? "" : original.slice(diffAt);
31
+ renameSync(path, path.replace(/\.txt$/, ".pre-regate.txt"));
32
+ writeFileSync(path, `${head}${TRAILER}\n${gateLines.join("\n")}\n\n${tail}`, "utf8");
33
+ }
34
+ /** Recover a rep's judge verdict from its saved judge-raw artifact — free, and exact. */
35
+ function verdictFromSavedJudgement(runDir, id, mode, rep) {
36
+ const path = judgeRawPath(runDir, id, mode, rep);
37
+ if (!existsSync(path))
38
+ return null;
39
+ const raw = readFileSync(path, "utf8");
40
+ const parsed = parseVerdict(raw);
41
+ return { verdict: parsed.verdict, reason: parsed.reason, suspect: detectMisfire(raw, parsed.verdict) };
42
+ }
43
+ /**
44
+ * Re-evaluate needle gates against a run's **saved staged diffs** and re-decide the
45
+ * verdicts they determined — without re-running the model.
46
+ *
47
+ * `diff_contains` / `diff_excludes` are pure functions of the diff, and since
48
+ * `f6a5f6c` every seeded rep persists its diff as a run artifact. So the defect class
49
+ * "the gate was wrong, the behavior wasn't" — hit three times in the reference corpus
50
+ * (a context needle, a baseline-satisfied needle, a filename needle) — no longer costs
51
+ * a re-run. Measured on the C2 needle fix: **9 judge calls instead of 81
52
+ * rep-executions across three models.**
53
+ *
54
+ * Per rep, exactly one of four things happens:
55
+ *
56
+ * | old gate | new gate | outcome | cost |
57
+ * |---|---|---|---|
58
+ * | fail | fail | FAIL, with the corrected reason | free |
59
+ * | pass | fail | FAIL — the gate is objective and it says no | free |
60
+ * | pass | pass | the rep's saved judgement, re-parsed from its judge-raw artifact | free |
61
+ * | fail | pass | judged now: the judge never saw this rep, because the gate blocked it | 1 judge call |
62
+ *
63
+ * That third row is what keeps this cheap without guessing: a rep the judge already
64
+ * saw has its verdict on disk, so regate re-reads it rather than re-asking.
65
+ *
66
+ * **Limits, deliberately hard failures rather than partial work:** `assert.vitest` and
67
+ * `assert.post_test` need the workspace and cannot be re-evaluated from any artifact,
68
+ * so a scenario carrying either is not regatable. Diffs and judge-raw files are
69
+ * gitignored, so this works for whoever holds the run dirs — the repo owner, or CI that
70
+ * just ran — which is exactly the situation it is needed in.
71
+ */
72
+ export async function regateRun(opts) {
73
+ const now = opts.now ?? (() => new Date().toISOString());
74
+ const prev = readResults(opts.runDir);
75
+ // The run's own mode names its artifacts (`<id>.<mode>[.rep<k>].diff.txt`). Read
76
+ // from the record rather than assumed green: force runs are scored measurements
77
+ // too, and looking for green artifacts under a force run finds nothing at all.
78
+ const mode = prev.mode;
79
+ const specById = new Map(opts.spec.scenarios.map((s) => [s.id, s]));
80
+ // Why a scenario cannot be regated, collected rather than thrown one at a time: a
81
+ // mixed spec (needles here, vitest there) should regate what it can, and only a run
82
+ // with nothing regatable is an error worth refusing.
83
+ const blocked = [];
84
+ const targets = [];
85
+ for (const rec of prev.scenarios) {
86
+ const s = specById.get(rec.id);
87
+ if (!s || !hasNeedleGates(s))
88
+ continue; // nothing for regate to re-decide
89
+ if (s.assert?.vitest || s.assert?.post_test) {
90
+ blocked.push(`${s.id}: declares ${s.assert.vitest ? "assert.vitest" : "assert.post_test"}, which needs the workspace — ` +
91
+ `no saved artifact can stand in for it, so this scenario needs a re-run`);
92
+ continue;
93
+ }
94
+ if (findDiffFiles(opts.runDir, s.id, mode).length === 0) {
95
+ blocked.push(`${s.id}: no staged-diff artifact on disk (\`.diff.txt\` is gitignored — regate needs the run dir that produced it)`);
96
+ continue;
97
+ }
98
+ targets.push(s);
99
+ }
100
+ if (targets.length === 0) {
101
+ throw new Error(`nothing to regate in ${opts.runDir}` +
102
+ (blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff_contains/diff_excludes"));
103
+ }
104
+ const changes = [];
105
+ let judgeCalls = 0;
106
+ const scenarios = [];
107
+ for (const rec of prev.scenarios) {
108
+ const scenario = targets.find((s) => s.id === rec.id);
109
+ if (!scenario) {
110
+ scenarios.push(rec); // untouched: not regatable, or no gates
111
+ continue;
112
+ }
113
+ const diffFiles = findDiffFiles(opts.runDir, scenario.id, mode);
114
+ const outcomes = [];
115
+ // Per scenario, not run-wide: with several regated scenarios, a global counter
116
+ // would report every change as "re-judged" because some other scenario was.
117
+ let judgedHere = 0;
118
+ let gateFailedHere = false;
119
+ for (const file of diffFiles) {
120
+ const rep = repIndexOf(file) ?? undefined;
121
+ const diff = readFileSync(join(opts.runDir, file), "utf8");
122
+ const gate = evaluateNeedleGates(scenario, diff);
123
+ const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
124
+ const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
125
+ const oldGateFailed = GATE_FAILED_RE.test(before.slice(before.indexOf(TRAILER)));
126
+ // The trailer is regenerated whatever the outcome: leaving a stale
127
+ // `MISSING` note beside a corrected verdict would misinform the next reader
128
+ // (and the next judge, which reads this transcript).
129
+ if (existsSync(tPath))
130
+ rewriteTranscript(tPath, gate.lines);
131
+ if (gate.failure) {
132
+ gateFailedHere = true;
133
+ outcomes.push({ verdict: "FAIL", reason: gate.failure, suspect: false });
134
+ continue;
135
+ }
136
+ if (!oldGateFailed) {
137
+ // The judge already saw this rep. Its verdict is on disk — re-read it rather
138
+ // than paying to ask the same question again.
139
+ const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, mode, rep);
140
+ outcomes.push(saved ?? { verdict: rec.judge_verdict, reason: rec.judge_reason, suspect: rec.suspect });
141
+ continue;
142
+ }
143
+ // The gate blocked this rep before, so no judgement of it exists anywhere.
144
+ const transcript = readFileSync(tPath, "utf8");
145
+ outcomes.push(await judgeOneRep({
146
+ runDir: opts.runDir, spec: opts.spec, scenario, transcript,
147
+ adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
148
+ mode, rep, now,
149
+ }));
150
+ judgeCalls++;
151
+ judgedHere++;
152
+ }
153
+ const threshold = effectiveThreshold(rec, scenario);
154
+ const next = outcomesToResult(scenario.id, outcomes, outcomes.length, threshold);
155
+ // Overrides and their notes survive: a regate re-decides the gate, and an author
156
+ // override is a statement about the judge, not about the needle.
157
+ scenarios.push({ ...next, override: rec.override, note: rec.note });
158
+ const to = next.judge_verdict;
159
+ if (to !== rec.judge_verdict) {
160
+ changes.push({
161
+ id: scenario.id, from: rec.judge_verdict, to,
162
+ gate: gateFailedHere ? "fail" : "pass",
163
+ judged: judgedHere > 0,
164
+ });
165
+ }
166
+ }
167
+ const ctx = scoreContextFor(prev, opts.spec);
168
+ const results = writeResults(opts.runDir, {
169
+ skill: prev.skill, harness: prev.harness, model: prev.model,
170
+ // Carried verbatim: a regate re-reads saved diffs, it never re-runs the harness.
171
+ harness_cli_version: prev.harness_cli_version, delivery_canary: prev.delivery_canary,
172
+ judge: { provider: opts.judge.provider, model: opts.judge.model },
173
+ timestamp: prev.timestamp, label: prev.label, mode: prev.mode, partial: prev.partial,
174
+ // Only the `gates:` keys of the scenarios actually re-evaluated. Stimulus, rubric
175
+ // and policy were not re-decided here, so their hashes stay exactly as recorded.
176
+ source_hashes: refreshGateHashes(prev.source_hashes, targets),
177
+ scenarios,
178
+ }, ctx);
179
+ appendJournal(opts.runDir, {
180
+ event: "regate", ts: now(),
181
+ scenarios: targets.map((s) => s.id),
182
+ changed: changes.map((c) => `${c.id}: ${c.from}->${c.to} (gate ${c.gate}${c.judged ? ", re-judged" : ""})`),
183
+ judge_calls: judgeCalls,
184
+ ...(blocked.length > 0 ? { skipped: blocked } : {}),
185
+ });
186
+ return { results, changes, judgeCalls };
187
+ }
188
+ function refreshGateHashes(recorded, regated) {
189
+ if (!recorded)
190
+ return undefined;
191
+ const next = { ...recorded };
192
+ for (const s of regated) {
193
+ const digest = gatesDigest(s);
194
+ if (digest !== null && GATES_PREFIX + s.id in next)
195
+ next[GATES_PREFIX + s.id] = digest;
196
+ }
197
+ return next;
198
+ }
199
+ //# sourceMappingURL=regate.js.map
package/dist/regrade.d.ts CHANGED
@@ -2,6 +2,19 @@ import type { Spec, Scenario } from "./spec.js";
2
2
  import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
3
3
  import { type ScenarioResult, type ResultsFile } from "./results.js";
4
4
  import { type RepOutcome } from "./reps.js";
5
+ /**
6
+ * Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
7
+ * re-grade actually judged under (plus the persona, which applies to all of them).
8
+ *
9
+ * Everything else is preserved deliberately: the transcripts were produced by the old
10
+ * stimulus, so a stimulus hash must stay stale until someone re-runs. `--suspect-only`
11
+ * is why this takes an id list rather than refreshing every rubric key — a re-grade
12
+ * that touched two scenarios must not certify the rubric of the twelve it skipped.
13
+ *
14
+ * No hashes recorded (a pre-`source_hashes` run) stays that way: inventing hashes for
15
+ * a run that never recorded any would claim a coverage it does not have.
16
+ */
17
+ export declare function refreshRubricHashes(recorded: Record<string, string> | undefined, spec: Spec, judgedIds: string[]): Record<string, string> | undefined;
5
18
  export interface RegradeOptions {
6
19
  runDir: string;
7
20
  spec: Spec;
@@ -10,6 +23,15 @@ export interface RegradeOptions {
10
23
  judge: ModelRef;
11
24
  specDir: string;
12
25
  threshold: number;
26
+ /**
27
+ * Which mode's saved transcripts to re-judge — the run's own mode, since
28
+ * transcript filenames are `<id>.<mode>[.rep<k>].txt`.
29
+ *
30
+ * Defaults to `green` for callers that predate force being a scored mode. A
31
+ * force-mode run whose transcripts were looked up as green found none and failed
32
+ * with "nothing to re-grade", which is how ten scorable runs stayed ungraded.
33
+ */
34
+ mode?: string;
13
35
  now?: () => string;
14
36
  }
15
37
  /** 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. */
@@ -26,10 +48,11 @@ export declare function judgeOneRep(opts: {
26
48
  now: () => string;
27
49
  }): Promise<RepOutcome>;
28
50
  /**
29
- * Re-judge a scenario's saved GREEN transcript(s) with `judge` — no harness
30
- * re-run. Rewrites the judge-raw artifact per rep, emits per-rep judge-verdict
31
- * (+ misfire-flag) journal events, and returns the aggregated ScenarioResult
32
- * (override/note empty; the caller merges any prior override + persists).
51
+ * Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
52
+ * harness re-run. Rewrites the judge-raw artifact per rep, emits per-rep
53
+ * judge-verdict (+ misfire-flag) journal events, and returns the aggregated
54
+ * ScenarioResult (override/note empty; the caller merges any prior override +
55
+ * persists).
33
56
  */
34
57
  export declare function regradeScenario(opts: RegradeOptions): Promise<ScenarioResult>;
35
58
  export interface RegradeRunOptions {
@@ -47,13 +70,13 @@ export interface RegradeRunOptions {
47
70
  onlySuspect?: boolean;
48
71
  }
49
72
  /**
50
- * Re-judge every green-transcript scenario in a run dir with `judge` no
51
- * harness re-run. Targets are the run's RECORDED scenarios (falling back to
52
- * the spec for a run with no prior results.yaml), so re-grading rewrites the
53
- * whole results.yaml consistently with what the run actually recorded. Each
54
- * target must still exist in the spec (for its checklist) AND have a green
55
- * transcript on disk; anything missing fails fast before spending any judge
56
- * calls. Preserves each prior scenario's override/note, rewrites
73
+ * Re-judge every scenario in a run dir that has a transcript for the run's own
74
+ * mode, with `judge` — no harness re-run. Targets are the run's RECORDED scenarios
75
+ * (falling back to the spec for a run with no prior results.yaml), so re-grading
76
+ * rewrites the whole results.yaml consistently with what the run actually recorded.
77
+ * Each target must still exist in the spec (for its checklist) AND have a
78
+ * transcript on disk for that mode; anything missing fails fast before spending
79
+ * any judge calls. Preserves each prior scenario's override/note, rewrites
57
80
  * results.yaml, emits the `score` journal event, and returns the new
58
81
  * ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
59
82
  */
package/dist/regrade.js CHANGED
@@ -1,9 +1,38 @@
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, } from "./results.js";
4
+ import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, } from "./results.js";
5
5
  import { outcomesToResult } from "./reps.js";
6
6
  import { appendJournal } from "./journal.js";
7
+ import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
8
+ /**
9
+ * Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
10
+ * re-grade actually judged under (plus the persona, which applies to all of them).
11
+ *
12
+ * Everything else is preserved deliberately: the transcripts were produced by the old
13
+ * stimulus, so a stimulus hash must stay stale until someone re-runs. `--suspect-only`
14
+ * is why this takes an id list rather than refreshing every rubric key — a re-grade
15
+ * that touched two scenarios must not certify the rubric of the twelve it skipped.
16
+ *
17
+ * No hashes recorded (a pre-`source_hashes` run) stays that way: inventing hashes for
18
+ * a run that never recorded any would claim a coverage it does not have.
19
+ */
20
+ export function refreshRubricHashes(recorded, spec, judgedIds) {
21
+ if (!recorded)
22
+ return undefined;
23
+ const next = { ...recorded };
24
+ const specById = new Map(spec.scenarios.map((s) => [s.id, s]));
25
+ for (const id of judgedIds) {
26
+ const s = specById.get(id);
27
+ // Only refresh a key the run already carried: adding one for a scenario whose
28
+ // rubric was never hashed would fabricate coverage.
29
+ if (s && RUBRIC_PREFIX + id in next)
30
+ next[RUBRIC_PREFIX + id] = rubricDigest(s);
31
+ }
32
+ if (PERSONA_KEY in next)
33
+ next[PERSONA_KEY] = personaDigest(spec.judge_persona);
34
+ return next;
35
+ }
7
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. */
8
37
  export async function judgeOneRep(opts) {
9
38
  const { runDir, spec, scenario, transcript, adapter, judge, specDir, mode, rep, now } = opts;
@@ -17,16 +46,18 @@ export async function judgeOneRep(opts) {
17
46
  return { verdict: g.verdict, reason: g.reason, suspect: g.suspect };
18
47
  }
19
48
  /**
20
- * Re-judge a scenario's saved GREEN transcript(s) with `judge` — no harness
21
- * re-run. Rewrites the judge-raw artifact per rep, emits per-rep judge-verdict
22
- * (+ misfire-flag) journal events, and returns the aggregated ScenarioResult
23
- * (override/note empty; the caller merges any prior override + persists).
49
+ * Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
50
+ * harness re-run. Rewrites the judge-raw artifact per rep, emits per-rep
51
+ * judge-verdict (+ misfire-flag) journal events, and returns the aggregated
52
+ * ScenarioResult (override/note empty; the caller merges any prior override +
53
+ * persists).
24
54
  */
25
55
  export async function regradeScenario(opts) {
26
56
  const now = opts.now ?? (() => new Date().toISOString());
27
- const files = findTranscriptFiles(opts.runDir, opts.scenario.id, "green");
57
+ const mode = opts.mode ?? "green";
58
+ const files = findTranscriptFiles(opts.runDir, opts.scenario.id, mode);
28
59
  if (files.length === 0)
29
- throw new Error(`no green transcripts for ${opts.scenario.id} in ${opts.runDir}`);
60
+ throw new Error(`no ${mode} transcripts for ${opts.scenario.id} in ${opts.runDir}`);
30
61
  const repCount = files.length;
31
62
  const outcomes = [];
32
63
  for (const file of files) {
@@ -34,19 +65,19 @@ export async function regradeScenario(opts) {
34
65
  const transcript = readFileSync(join(opts.runDir, file), "utf8");
35
66
  outcomes.push(await judgeOneRep({
36
67
  runDir: opts.runDir, spec: opts.spec, scenario: opts.scenario, transcript,
37
- adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode: "green", rep, now,
68
+ adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now,
38
69
  }));
39
70
  }
40
71
  return outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
41
72
  }
42
73
  /**
43
- * Re-judge every green-transcript scenario in a run dir with `judge` no
44
- * harness re-run. Targets are the run's RECORDED scenarios (falling back to
45
- * the spec for a run with no prior results.yaml), so re-grading rewrites the
46
- * whole results.yaml consistently with what the run actually recorded. Each
47
- * target must still exist in the spec (for its checklist) AND have a green
48
- * transcript on disk; anything missing fails fast before spending any judge
49
- * calls. Preserves each prior scenario's override/note, rewrites
74
+ * Re-judge every scenario in a run dir that has a transcript for the run's own
75
+ * mode, with `judge` — no harness re-run. Targets are the run's RECORDED scenarios
76
+ * (falling back to the spec for a run with no prior results.yaml), so re-grading
77
+ * rewrites the whole results.yaml consistently with what the run actually recorded.
78
+ * Each target must still exist in the spec (for its checklist) AND have a
79
+ * transcript on disk for that mode; anything missing fails fast before spending
80
+ * any judge calls. Preserves each prior scenario's override/note, rewrites
50
81
  * results.yaml, emits the `score` journal event, and returns the new
51
82
  * ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
52
83
  */
@@ -78,9 +109,9 @@ export async function regradeRun(opts) {
78
109
  return prev;
79
110
  }
80
111
  }
81
- const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id, "green").length === 0);
112
+ const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id, mode).length === 0);
82
113
  if (missing.length === targets.length) {
83
- throw new Error(`no green transcripts in ${runDir} — nothing to re-grade`);
114
+ throw new Error(`no ${mode} transcripts in ${runDir} — nothing to re-grade`);
84
115
  }
85
116
  if (missing.length > 0) {
86
117
  throw new Error(`cannot re-grade ${missing.join(", ")} in ${runDir} (transcript missing or scenario no longer in the spec) — re-run instead of grading`);
@@ -97,24 +128,34 @@ export async function regradeRun(opts) {
97
128
  const prevScenario = prev?.scenarios.find((s) => s.id === id);
98
129
  const threshold = effectiveThreshold(prevScenario, scenario);
99
130
  const rr = await regradeScenario({
100
- runDir, spec, scenario, adapter, judge, specDir, threshold, now,
131
+ runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
101
132
  });
102
133
  const carry = overrides.get(id);
103
134
  scenarioResults.push({ ...rr, override: carry?.override ?? null, note: carry?.note ?? "" });
104
135
  }
105
- const ctx = mode === "green" && !prev?.partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
136
+ const ctx = scoreContextFor({ mode, partial: prev?.partial }, spec);
106
137
  const results = writeResults(runDir, {
107
138
  skill: spec.skill,
108
139
  harness: prev?.harness ?? "pi",
140
+ // The harness CLI that produced these transcripts, carried verbatim: a re-grade
141
+ // re-asks the judge, it does not re-deliver the skill, so stamping today's pi
142
+ // here would credit the old transcripts to a version that never ran them.
143
+ harness_cli_version: prev?.harness_cli_version,
144
+ delivery_canary: prev?.delivery_canary,
109
145
  model: prev?.model ?? "unknown",
110
146
  judge: { provider: judge.provider, model: judge.model },
111
147
  timestamp: prev?.timestamp ?? now(),
112
148
  label: prev?.label ?? null,
113
149
  mode,
114
150
  // A re-grade judges the SAVED transcripts, which were produced by the OLD text —
115
- // the recorded hashes stay, keeping an honestly-stale run honestly stale.
151
+ // the recorded **stimulus** hashes stay, keeping an honestly-stale run honestly
152
+ // stale. The rubric hashes are a different matter: this re-grade applied the
153
+ // CURRENT checklist and persona to those transcripts, so "the verdicts reflect
154
+ // today's rubric" is now a true statement about the record, and the hashes should
155
+ // say so. Doctrine narrowed 0.4.0, from "recorded hashes stay" to "recorded
156
+ // *stimulus* hashes stay" — see refreshRubricHashes.
116
157
  partial: prev?.partial,
117
- source_hashes: prev?.source_hashes,
158
+ source_hashes: refreshRubricHashes(prev?.source_hashes, spec, targets),
118
159
  scenarios: scenarioResults,
119
160
  }, ctx);
120
161
  const g = results.effective_grade;
package/dist/report.d.ts CHANGED
@@ -22,13 +22,14 @@ export interface RunColumn {
22
22
  note: string;
23
23
  }>;
24
24
  /**
25
- * Red-vs-green lift for this model, when the tag has both a red baseline and a
26
- * green run. Undefined means "never measured" — which is not the same claim as
27
- * a zero lift, so the report must not render a 0 for it.
25
+ * Baseline-vs-skill lift for this model, when the tag has both a red baseline and
26
+ * a skill-delivered run (green or force). Undefined means "never measured" —
27
+ * which is not the same claim as a zero lift, so the report must not render a 0
28
+ * for it.
28
29
  *
29
- * Only set when THIS column is the green run the lift was computed from (see
30
+ * Only set when THIS column is the skill-side run the lift was computed from (see
30
31
  * collectReport): the review UI recomputes lift from the column's live cells,
31
- * which is only valid if those cells are the green side of the comparison.
32
+ * which is only valid if those cells are the skill side of the comparison.
32
33
  */
33
34
  lift?: Lift;
34
35
  liftHeadline?: string;