@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/adapters/types.d.ts +10 -0
- package/dist/canary.d.ts +44 -0
- package/dist/canary.js +123 -0
- package/dist/defaults.d.ts +30 -0
- package/dist/defaults.js +34 -0
- package/dist/discover.d.ts +7 -0
- package/dist/discover.js +13 -5
- package/dist/downgrade.d.ts +41 -0
- package/dist/downgrade.js +100 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/journal.d.ts +28 -0
- package/dist/judge-policy.d.ts +42 -0
- package/dist/judge-policy.js +61 -0
- package/dist/lift.d.ts +13 -0
- package/dist/lift.js +13 -8
- package/dist/lint.js +37 -8
- package/dist/regate.d.ts +57 -0
- package/dist/regate.js +199 -0
- package/dist/regrade.d.ts +34 -11
- package/dist/regrade.js +62 -21
- package/dist/report.d.ts +6 -5
- package/dist/report.js +5 -4
- package/dist/rescore.d.ts +6 -0
- package/dist/rescore.js +40 -5
- package/dist/results.d.ts +88 -0
- package/dist/results.js +56 -0
- package/dist/run.d.ts +7 -0
- package/dist/run.js +61 -8
- package/dist/seeded.d.ts +18 -0
- package/dist/seeded.js +40 -15
- package/dist/sources.d.ts +67 -6
- package/dist/sources.js +185 -26
- package/dist/trends.d.ts +23 -9
- package/dist/trends.js +44 -35
- package/dist/version.d.ts +1 -0
- package/dist/version.js +22 -0
- package/dist/workspace.js +32 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
256
|
-
|
|
260
|
+
else if (isScoredMode(r.mode))
|
|
261
|
+
skillOn = r;
|
|
257
262
|
}
|
|
258
|
-
if (!red || !
|
|
263
|
+
if (!red || !skillOn)
|
|
259
264
|
continue;
|
|
260
|
-
lifts.push({ ...computeLift(red,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!(
|
|
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
|
}
|
package/dist/regate.d.ts
ADDED
|
@@ -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
|
|
30
|
-
* re-run. Rewrites the judge-raw artifact per rep, emits per-rep
|
|
31
|
-
* (+ misfire-flag) journal events, and returns the aggregated
|
|
32
|
-
* (override/note empty; the caller merges any prior override +
|
|
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
|
|
51
|
-
* harness re-run. Targets are the run's RECORDED scenarios
|
|
52
|
-
* the spec for a run with no prior results.yaml), so re-grading
|
|
53
|
-
* whole results.yaml consistently with what the run actually recorded.
|
|
54
|
-
* target must still exist in the spec (for its checklist) AND have a
|
|
55
|
-
* transcript on disk; anything missing fails fast before spending
|
|
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
|
|
21
|
-
* re-run. Rewrites the judge-raw artifact per rep, emits per-rep
|
|
22
|
-
* (+ misfire-flag) journal events, and returns the aggregated
|
|
23
|
-
* (override/note empty; the caller merges any prior override +
|
|
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
|
|
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
|
|
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
|
|
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
|
|
44
|
-
* harness re-run. Targets are the run's RECORDED scenarios
|
|
45
|
-
* the spec for a run with no prior results.yaml), so re-grading
|
|
46
|
-
* whole results.yaml consistently with what the run actually recorded.
|
|
47
|
-
* target must still exist in the spec (for its checklist) AND have a
|
|
48
|
-
* transcript on disk; anything missing fails fast before spending
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
26
|
-
* green
|
|
27
|
-
* a zero lift, so the report must not render a 0
|
|
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
|
|
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
|
|
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;
|