@skill-harness/core 0.3.1 → 0.4.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/defaults.d.ts +30 -0
- package/dist/defaults.js +34 -0
- package/dist/downgrade.d.ts +41 -0
- package/dist/downgrade.js +100 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/journal.d.ts +14 -0
- package/dist/judge-policy.d.ts +42 -0
- package/dist/judge-policy.js +61 -0
- package/dist/lift.d.ts +19 -0
- package/dist/lift.js +64 -2
- package/dist/lint.js +27 -5
- package/dist/regate.d.ts +57 -0
- package/dist/regate.js +195 -0
- package/dist/regrade.d.ts +13 -0
- package/dist/regrade.js +36 -2
- package/dist/rescore.js +28 -1
- package/dist/results.d.ts +10 -0
- package/dist/results.js +5 -0
- package/dist/run.js +1 -1
- 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/version.d.ts +1 -0
- package/dist/version.js +22 -0
- package/dist/workspace.js +32 -1
- package/package.json +1 -1
package/dist/regate.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
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, } 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, rep) {
|
|
36
|
+
const path = judgeRawPath(runDir, id, "green", 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
|
+
const specById = new Map(opts.spec.scenarios.map((s) => [s.id, s]));
|
|
76
|
+
// Why a scenario cannot be regated, collected rather than thrown one at a time: a
|
|
77
|
+
// mixed spec (needles here, vitest there) should regate what it can, and only a run
|
|
78
|
+
// with nothing regatable is an error worth refusing.
|
|
79
|
+
const blocked = [];
|
|
80
|
+
const targets = [];
|
|
81
|
+
for (const rec of prev.scenarios) {
|
|
82
|
+
const s = specById.get(rec.id);
|
|
83
|
+
if (!s || !hasNeedleGates(s))
|
|
84
|
+
continue; // nothing for regate to re-decide
|
|
85
|
+
if (s.assert?.vitest || s.assert?.post_test) {
|
|
86
|
+
blocked.push(`${s.id}: declares ${s.assert.vitest ? "assert.vitest" : "assert.post_test"}, which needs the workspace — ` +
|
|
87
|
+
`no saved artifact can stand in for it, so this scenario needs a re-run`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (findDiffFiles(opts.runDir, s.id, "green").length === 0) {
|
|
91
|
+
blocked.push(`${s.id}: no staged-diff artifact on disk (\`.diff.txt\` is gitignored — regate needs the run dir that produced it)`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
targets.push(s);
|
|
95
|
+
}
|
|
96
|
+
if (targets.length === 0) {
|
|
97
|
+
throw new Error(`nothing to regate in ${opts.runDir}` +
|
|
98
|
+
(blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff_contains/diff_excludes"));
|
|
99
|
+
}
|
|
100
|
+
const changes = [];
|
|
101
|
+
let judgeCalls = 0;
|
|
102
|
+
const scenarios = [];
|
|
103
|
+
for (const rec of prev.scenarios) {
|
|
104
|
+
const scenario = targets.find((s) => s.id === rec.id);
|
|
105
|
+
if (!scenario) {
|
|
106
|
+
scenarios.push(rec); // untouched: not regatable, or no gates
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const diffFiles = findDiffFiles(opts.runDir, scenario.id, "green");
|
|
110
|
+
const outcomes = [];
|
|
111
|
+
// Per scenario, not run-wide: with several regated scenarios, a global counter
|
|
112
|
+
// would report every change as "re-judged" because some other scenario was.
|
|
113
|
+
let judgedHere = 0;
|
|
114
|
+
let gateFailedHere = false;
|
|
115
|
+
for (const file of diffFiles) {
|
|
116
|
+
const rep = repIndexOf(file) ?? undefined;
|
|
117
|
+
const diff = readFileSync(join(opts.runDir, file), "utf8");
|
|
118
|
+
const gate = evaluateNeedleGates(scenario, diff);
|
|
119
|
+
const tPath = transcriptPath(opts.runDir, scenario.id, "green", rep);
|
|
120
|
+
const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
|
|
121
|
+
const oldGateFailed = GATE_FAILED_RE.test(before.slice(before.indexOf(TRAILER)));
|
|
122
|
+
// The trailer is regenerated whatever the outcome: leaving a stale
|
|
123
|
+
// `MISSING` note beside a corrected verdict would misinform the next reader
|
|
124
|
+
// (and the next judge, which reads this transcript).
|
|
125
|
+
if (existsSync(tPath))
|
|
126
|
+
rewriteTranscript(tPath, gate.lines);
|
|
127
|
+
if (gate.failure) {
|
|
128
|
+
gateFailedHere = true;
|
|
129
|
+
outcomes.push({ verdict: "FAIL", reason: gate.failure, suspect: false });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!oldGateFailed) {
|
|
133
|
+
// The judge already saw this rep. Its verdict is on disk — re-read it rather
|
|
134
|
+
// than paying to ask the same question again.
|
|
135
|
+
const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, rep);
|
|
136
|
+
outcomes.push(saved ?? { verdict: rec.judge_verdict, reason: rec.judge_reason, suspect: rec.suspect });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
// The gate blocked this rep before, so no judgement of it exists anywhere.
|
|
140
|
+
const transcript = readFileSync(tPath, "utf8");
|
|
141
|
+
outcomes.push(await judgeOneRep({
|
|
142
|
+
runDir: opts.runDir, spec: opts.spec, scenario, transcript,
|
|
143
|
+
adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
|
|
144
|
+
mode: "green", rep, now,
|
|
145
|
+
}));
|
|
146
|
+
judgeCalls++;
|
|
147
|
+
judgedHere++;
|
|
148
|
+
}
|
|
149
|
+
const threshold = effectiveThreshold(rec, scenario);
|
|
150
|
+
const next = outcomesToResult(scenario.id, outcomes, outcomes.length, threshold);
|
|
151
|
+
// Overrides and their notes survive: a regate re-decides the gate, and an author
|
|
152
|
+
// override is a statement about the judge, not about the needle.
|
|
153
|
+
scenarios.push({ ...next, override: rec.override, note: rec.note });
|
|
154
|
+
const to = next.judge_verdict;
|
|
155
|
+
if (to !== rec.judge_verdict) {
|
|
156
|
+
changes.push({
|
|
157
|
+
id: scenario.id, from: rec.judge_verdict, to,
|
|
158
|
+
gate: gateFailedHere ? "fail" : "pass",
|
|
159
|
+
judged: judgedHere > 0,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const ctx = prev.mode === "green" && !prev.partial
|
|
164
|
+
? { shipBar: opts.spec.ship_bar, critical: opts.spec.critical }
|
|
165
|
+
: null;
|
|
166
|
+
const results = writeResults(opts.runDir, {
|
|
167
|
+
skill: prev.skill, harness: prev.harness, model: prev.model,
|
|
168
|
+
judge: { provider: opts.judge.provider, model: opts.judge.model },
|
|
169
|
+
timestamp: prev.timestamp, label: prev.label, mode: prev.mode, partial: prev.partial,
|
|
170
|
+
// Only the `gates:` keys of the scenarios actually re-evaluated. Stimulus, rubric
|
|
171
|
+
// and policy were not re-decided here, so their hashes stay exactly as recorded.
|
|
172
|
+
source_hashes: refreshGateHashes(prev.source_hashes, targets),
|
|
173
|
+
scenarios,
|
|
174
|
+
}, ctx);
|
|
175
|
+
appendJournal(opts.runDir, {
|
|
176
|
+
event: "regate", ts: now(),
|
|
177
|
+
scenarios: targets.map((s) => s.id),
|
|
178
|
+
changed: changes.map((c) => `${c.id}: ${c.from}->${c.to} (gate ${c.gate}${c.judged ? ", re-judged" : ""})`),
|
|
179
|
+
judge_calls: judgeCalls,
|
|
180
|
+
...(blocked.length > 0 ? { skipped: blocked } : {}),
|
|
181
|
+
});
|
|
182
|
+
return { results, changes, judgeCalls };
|
|
183
|
+
}
|
|
184
|
+
function refreshGateHashes(recorded, regated) {
|
|
185
|
+
if (!recorded)
|
|
186
|
+
return undefined;
|
|
187
|
+
const next = { ...recorded };
|
|
188
|
+
for (const s of regated) {
|
|
189
|
+
const digest = gatesDigest(s);
|
|
190
|
+
if (digest !== null && GATES_PREFIX + s.id in next)
|
|
191
|
+
next[GATES_PREFIX + s.id] = digest;
|
|
192
|
+
}
|
|
193
|
+
return next;
|
|
194
|
+
}
|
|
195
|
+
//# 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;
|
package/dist/regrade.js
CHANGED
|
@@ -4,6 +4,35 @@ import { buildJudgePrompt, judgeInWorkspace } from "./grade.js";
|
|
|
4
4
|
import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, } 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;
|
|
@@ -112,9 +141,14 @@ export async function regradeRun(opts) {
|
|
|
112
141
|
label: prev?.label ?? null,
|
|
113
142
|
mode,
|
|
114
143
|
// 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
|
|
144
|
+
// the recorded **stimulus** hashes stay, keeping an honestly-stale run honestly
|
|
145
|
+
// stale. The rubric hashes are a different matter: this re-grade applied the
|
|
146
|
+
// CURRENT checklist and persona to those transcripts, so "the verdicts reflect
|
|
147
|
+
// today's rubric" is now a true statement about the record, and the hashes should
|
|
148
|
+
// say so. Doctrine narrowed 0.4.0, from "recorded hashes stay" to "recorded
|
|
149
|
+
// *stimulus* hashes stay" — see refreshRubricHashes.
|
|
116
150
|
partial: prev?.partial,
|
|
117
|
-
source_hashes: prev?.source_hashes,
|
|
151
|
+
source_hashes: refreshRubricHashes(prev?.source_hashes, spec, targets),
|
|
118
152
|
scenarios: scenarioResults,
|
|
119
153
|
}, ctx);
|
|
120
154
|
const g = results.effective_grade;
|
package/dist/rescore.js
CHANGED
|
@@ -2,6 +2,27 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readResults, writeResults } from "./results.js";
|
|
4
4
|
import { appendJournal } from "./journal.js";
|
|
5
|
+
import { policyDigest, POLICY_PREFIX } from "./sources.js";
|
|
6
|
+
/**
|
|
7
|
+
* Refresh the `policy:` keys a run recorded, since a rescore has just re-applied the
|
|
8
|
+
* current policy to every rep it holds.
|
|
9
|
+
*
|
|
10
|
+
* Unlike a re-grade, this needs no id list: `rescoreRun` walks every recorded
|
|
11
|
+
* scenario, and the ones it carries verbatim (single-rep, ERROR, JUDGE-AMBIGUOUS) are
|
|
12
|
+
* carried *because the current threshold cannot change their verdict* — they are
|
|
13
|
+
* scored under today's policy too. Only keys the run already carried are touched, so
|
|
14
|
+
* this never fabricates coverage.
|
|
15
|
+
*/
|
|
16
|
+
function refreshPolicyHashes(recorded, spec) {
|
|
17
|
+
if (!recorded)
|
|
18
|
+
return undefined;
|
|
19
|
+
const next = { ...recorded };
|
|
20
|
+
for (const s of spec.scenarios) {
|
|
21
|
+
if (POLICY_PREFIX + s.id in next)
|
|
22
|
+
next[POLICY_PREFIX + s.id] = policyDigest(s);
|
|
23
|
+
}
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
5
26
|
/**
|
|
6
27
|
* Re-score a run against the CURRENT spec's pass thresholds — no model calls, no judge
|
|
7
28
|
* calls. Reps are the raw measurement (`passes` of `clean`); a threshold is *policy*.
|
|
@@ -46,7 +67,13 @@ export function rescoreRun(opts) {
|
|
|
46
67
|
const results = writeResults(opts.runDir, {
|
|
47
68
|
skill: prev.skill, harness: prev.harness, model: prev.model, judge: prev.judge,
|
|
48
69
|
timestamp: prev.timestamp, label: prev.label, mode: prev.mode,
|
|
49
|
-
partial: prev.partial,
|
|
70
|
+
partial: prev.partial,
|
|
71
|
+
// A rescore re-applies the CURRENT policy (thresholds, critical set) to the
|
|
72
|
+
// recorded reps, so `policy:` drift is genuinely resolved by having run this —
|
|
73
|
+
// that is what makes `rescore` the honest remedy lint names for it. Stimulus,
|
|
74
|
+
// rubric and gate hashes are untouched: none of them was re-evaluated here.
|
|
75
|
+
source_hashes: refreshPolicyHashes(prev.source_hashes, opts.spec),
|
|
76
|
+
scenarios,
|
|
50
77
|
}, ctx);
|
|
51
78
|
appendJournal(opts.runDir, {
|
|
52
79
|
event: "rescore", ts: now(),
|
package/dist/results.d.ts
CHANGED
|
@@ -25,6 +25,16 @@ export interface GradeSummary {
|
|
|
25
25
|
}
|
|
26
26
|
export interface ResultsFile {
|
|
27
27
|
schema: 2;
|
|
28
|
+
/**
|
|
29
|
+
* The harness version that wrote this file — provenance for the numbers in it.
|
|
30
|
+
*
|
|
31
|
+
* Optional because runs recorded before the field existed do not have one, and
|
|
32
|
+
* inventing a version for them would fabricate provenance. Not part of `schema`:
|
|
33
|
+
* 0.2.1 → 0.3.0 kept `schema: 2` while changing what a verdict means, which is
|
|
34
|
+
* exactly the drift `schema` cannot express. Written by `finalizeResults`, so no
|
|
35
|
+
* writer can forget it.
|
|
36
|
+
*/
|
|
37
|
+
harness_version?: string;
|
|
28
38
|
skill: string;
|
|
29
39
|
harness: string;
|
|
30
40
|
model: string;
|
package/dist/results.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join, relative, sep } from "node:path";
|
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
4
|
import { modelSlug } from "./adapters/types.js";
|
|
5
5
|
import { score } from "./score.js";
|
|
6
|
+
import { HARNESS_VERSION } from "./version.js";
|
|
6
7
|
/** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
|
|
7
8
|
export function effectiveThreshold(prevScenario, scenario) {
|
|
8
9
|
return prevScenario?.pass_threshold ?? scenario.passThreshold ?? 0.5;
|
|
@@ -51,6 +52,10 @@ export function finalizeResults(draft, ctx) {
|
|
|
51
52
|
}
|
|
52
53
|
return {
|
|
53
54
|
schema: 2,
|
|
55
|
+
// Stamped here, the single place every writer passes through, so `run`,
|
|
56
|
+
// `grade`, `rescore` and the review UI's override save all record which tool
|
|
57
|
+
// produced the record they leave behind.
|
|
58
|
+
harness_version: HARNESS_VERSION,
|
|
54
59
|
skill: draft.skill,
|
|
55
60
|
harness: draft.harness,
|
|
56
61
|
model: draft.model,
|
package/dist/run.js
CHANGED
|
@@ -73,7 +73,7 @@ export async function runSkillModel(opts) {
|
|
|
73
73
|
...(partial ? { partial: true } : {}),
|
|
74
74
|
// Only the scenarios this run actually measured: a --only run must not claim
|
|
75
75
|
// coverage of scenarios it skipped.
|
|
76
|
-
source_hashes: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios }),
|
|
76
|
+
source_hashes: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona }),
|
|
77
77
|
scenarios: scenarioResults,
|
|
78
78
|
}, ctx);
|
|
79
79
|
if (ctx) {
|
package/dist/seeded.d.ts
CHANGED
|
@@ -58,6 +58,24 @@ export interface SeededOutcome {
|
|
|
58
58
|
* guessing from prefixes.
|
|
59
59
|
*/
|
|
60
60
|
export declare function changedLines(diff: string): string;
|
|
61
|
+
/** Whether a scenario declares any needle gate at all. */
|
|
62
|
+
export declare function hasNeedleGates(scenario: Scenario): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Evaluate `diff_contains` / `diff_excludes` against a staged diff: the trailer lines
|
|
65
|
+
* to report, and the first failure (null when every needle is satisfied).
|
|
66
|
+
*
|
|
67
|
+
* A **pure function of the diff**, which is what makes `regate` possible — the saved
|
|
68
|
+
* `.diff.txt` artifact holds everything these gates need, so correcting a needle never
|
|
69
|
+
* requires re-running the model. Shared with `regate` deliberately: two copies of this
|
|
70
|
+
* loop would let a regated verdict disagree with what a fresh run would have produced,
|
|
71
|
+
* which is the same drift the fixture-marker check refuses between lint and runtime.
|
|
72
|
+
*
|
|
73
|
+
* Both gates read the CHANGED lines only, never context — see `changedLines`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function evaluateNeedleGates(scenario: Scenario, diff: string): {
|
|
76
|
+
lines: string[];
|
|
77
|
+
failure: string | null;
|
|
78
|
+
};
|
|
61
79
|
/**
|
|
62
80
|
* Cut a diff to a byte budget on a line boundary, appending an explicit marker
|
|
63
81
|
* naming how much was dropped.
|
package/dist/seeded.js
CHANGED
|
@@ -56,6 +56,42 @@ export function changedLines(diff) {
|
|
|
56
56
|
}
|
|
57
57
|
return out.join("\n");
|
|
58
58
|
}
|
|
59
|
+
/** Whether a scenario declares any needle gate at all. */
|
|
60
|
+
export function hasNeedleGates(scenario) {
|
|
61
|
+
return (scenario.assert?.diff_contains?.length ?? 0) > 0 || (scenario.assert?.diff_excludes?.length ?? 0) > 0;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Evaluate `diff_contains` / `diff_excludes` against a staged diff: the trailer lines
|
|
65
|
+
* to report, and the first failure (null when every needle is satisfied).
|
|
66
|
+
*
|
|
67
|
+
* A **pure function of the diff**, which is what makes `regate` possible — the saved
|
|
68
|
+
* `.diff.txt` artifact holds everything these gates need, so correcting a needle never
|
|
69
|
+
* requires re-running the model. Shared with `regate` deliberately: two copies of this
|
|
70
|
+
* loop would let a regated verdict disagree with what a fresh run would have produced,
|
|
71
|
+
* which is the same drift the fixture-marker check refuses between lint and runtime.
|
|
72
|
+
*
|
|
73
|
+
* Both gates read the CHANGED lines only, never context — see `changedLines`.
|
|
74
|
+
*/
|
|
75
|
+
export function evaluateNeedleGates(scenario, diff) {
|
|
76
|
+
const changed = changedLines(diff);
|
|
77
|
+
const lines = [];
|
|
78
|
+
let failure = null;
|
|
79
|
+
for (const needle of scenario.assert?.diff_contains ?? []) {
|
|
80
|
+
const ok = changed.includes(needle);
|
|
81
|
+
lines.push(` diff_contains ${JSON.stringify(needle)}: ${ok ? "OK" : "MISSING"}`);
|
|
82
|
+
if (!ok && !failure)
|
|
83
|
+
failure = `staged diff missing ${JSON.stringify(needle)}`;
|
|
84
|
+
}
|
|
85
|
+
// Scope discipline, stated as a fact about the diff rather than inferred from
|
|
86
|
+
// whether the model remembered to say "I left lastIndex alone".
|
|
87
|
+
for (const needle of scenario.assert?.diff_excludes ?? []) {
|
|
88
|
+
const ok = !changed.includes(needle);
|
|
89
|
+
lines.push(` diff_excludes ${JSON.stringify(needle)}: ${ok ? "OK" : "PRESENT"}`);
|
|
90
|
+
if (!ok && !failure)
|
|
91
|
+
failure = `staged diff touches forbidden ${JSON.stringify(needle)}`;
|
|
92
|
+
}
|
|
93
|
+
return { lines, failure };
|
|
94
|
+
}
|
|
59
95
|
/**
|
|
60
96
|
* Byte cap on the diff copy embedded in the judged transcript. The judge prompt
|
|
61
97
|
* is a single request, so an unbounded diff (a fixture-wide refactor, a
|
|
@@ -167,21 +203,10 @@ export async function runSeeded(scenario, opts) {
|
|
|
167
203
|
// either token. Every published A4 result recorded that as an objective pass.
|
|
168
204
|
// Read against changed lines it means what the checklist means: the model
|
|
169
205
|
// returned a Result.
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (!ok && !gateFailure)
|
|
175
|
-
gateFailure = `staged diff missing ${JSON.stringify(needle)}`;
|
|
176
|
-
}
|
|
177
|
-
// Scope discipline, stated as a fact about the diff rather than inferred from
|
|
178
|
-
// whether the model remembered to say "I left lastIndex alone".
|
|
179
|
-
for (const needle of scenario.assert?.diff_excludes ?? []) {
|
|
180
|
-
const ok = !changed.includes(needle);
|
|
181
|
-
parts.push(` diff_excludes ${JSON.stringify(needle)}: ${ok ? "OK" : "PRESENT"}`);
|
|
182
|
-
if (!ok && !gateFailure)
|
|
183
|
-
gateFailure = `staged diff touches forbidden ${JSON.stringify(needle)}`;
|
|
184
|
-
}
|
|
206
|
+
const needles = evaluateNeedleGates(scenario, diff);
|
|
207
|
+
parts.push(...needles.lines);
|
|
208
|
+
if (needles.failure && !gateFailure)
|
|
209
|
+
gateFailure = needles.failure;
|
|
185
210
|
if (scenario.assert?.vitest) {
|
|
186
211
|
const v = await runVitest([], repo);
|
|
187
212
|
// code === null means exec SIGKILLed it at the timeout. That is infrastructure,
|
package/dist/sources.d.ts
CHANGED
|
@@ -37,8 +37,44 @@ import type { Scenario } from "./spec.js";
|
|
|
37
37
|
* reindenting the YAML or reordering scenarios is correctly a no-op, while
|
|
38
38
|
* changing a single checklist word is correctly a change.
|
|
39
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* The pre-0.4.0 combined key: one digest over a scenario's stimulus, rubric, policy
|
|
42
|
+
* and gates together. Still read (runs recorded with it must keep comparing), never
|
|
43
|
+
* written. See `scenarioDigest`.
|
|
44
|
+
*/
|
|
40
45
|
export declare const SCENARIO_PREFIX = "scenario:";
|
|
41
46
|
export declare const FIXTURE_PREFIX = "fixture:";
|
|
47
|
+
/**
|
|
48
|
+
* The split: three (four, with gates) digests per scenario, each mapped to the
|
|
49
|
+
* cheapest tool that can honestly restore freshness.
|
|
50
|
+
*
|
|
51
|
+
* | key | contents | drift means | remedy |
|
|
52
|
+
* |---|---|---|---|
|
|
53
|
+
* | `stimulus:<id>` | mode, turns, workspace, remote, agent-file path, fixture path, `assert.vitest`, `post_test` path | the transcripts answer a different question | `run` (model + judge) |
|
|
54
|
+
* | `rubric:<id>` | title, checklist | transcripts fine, verdicts wrong | `grade` (judge only) |
|
|
55
|
+
* | `policy:<id>` | critical, reps, pass_threshold | only the scoring moved | `rescore` (free) |
|
|
56
|
+
* | `gates:<id>` | `diff_contains`, `diff_excludes` | needle wrong, behavior fine | `regate` (free; judges only flipped reps) |
|
|
57
|
+
* | `rubric:__persona` | spec-level `judge_persona` | every verdict in the skill | `grade` per model |
|
|
58
|
+
*
|
|
59
|
+
* Why this matters more than it looks: with one key, lint had exactly one remedy for
|
|
60
|
+
* any drift — "re-run" — so **correcting a rubric cost model spend**. Measured on the
|
|
61
|
+
* reference corpus, two parked branches (one needle, one checklist rewrite) demanded
|
|
62
|
+
* 135 rep-executions to restore freshness while producing zero new information about
|
|
63
|
+
* the models. A gate that charges that much to fix a known-bad rubric is pressure to
|
|
64
|
+
* leave the rubric in place, which inverts the point of having a gate.
|
|
65
|
+
*
|
|
66
|
+
* The strictness is unchanged: every edit still marks something stale. Only the price
|
|
67
|
+
* of getting back to fresh changed.
|
|
68
|
+
*/
|
|
69
|
+
export declare const STIMULUS_PREFIX = "stimulus:";
|
|
70
|
+
export declare const RUBRIC_PREFIX = "rubric:";
|
|
71
|
+
export declare const POLICY_PREFIX = "policy:";
|
|
72
|
+
export declare const GATES_PREFIX = "gates:";
|
|
73
|
+
/**
|
|
74
|
+
* The spec-level rubric key. `__persona` cannot collide with a scenario id: ids are
|
|
75
|
+
* validated as `[A-Za-z][A-Za-z0-9_-]*`, so none can begin with an underscore.
|
|
76
|
+
*/
|
|
77
|
+
export declare const PERSONA_KEY = "rubric:__persona";
|
|
42
78
|
/**
|
|
43
79
|
* Recorded in place of a hash when a source existed but could not be read.
|
|
44
80
|
*
|
|
@@ -65,14 +101,24 @@ export declare function fileSha256(path: string): string | null;
|
|
|
65
101
|
* normalised so a Linux-recorded hash still matches on Windows.
|
|
66
102
|
*/
|
|
67
103
|
export declare function dirSha256(dir: string): string | null;
|
|
104
|
+
export declare function stimulusDigest(s: Scenario): string;
|
|
105
|
+
export declare function rubricDigest(s: Scenario): string;
|
|
106
|
+
export declare function policyDigest(s: Scenario): string;
|
|
107
|
+
/** Null when the scenario declares no needle gates — no key is recorded for it. */
|
|
108
|
+
export declare function gatesDigest(s: Scenario): string | null;
|
|
109
|
+
/** The spec-level judge persona, which is rubric for every scenario at once. */
|
|
110
|
+
export declare function personaDigest(persona: string): string;
|
|
68
111
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
112
|
+
* The pre-0.4.0 combined digest: everything about a scenario in one hash.
|
|
113
|
+
*
|
|
114
|
+
* **Read-only now** — `sourceHashes` writes the four split keys instead. Kept because
|
|
115
|
+
* `lint` must still compare runs that recorded `scenario:<id>`, and those runs are
|
|
116
|
+
* every scorecard published before 0.4.0. Deleting it would turn "no findings" into
|
|
117
|
+
* "no comparison" for the entire existing corpus, silently.
|
|
71
118
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* believes was tested.
|
|
119
|
+
* Its bytes must therefore never change again: this is a stored-hash format, not an
|
|
120
|
+
* implementation detail. The facet digests take new fields; this one is frozen at the
|
|
121
|
+
* 0.3.x field set, which is why it does not go through `facets()`.
|
|
76
122
|
*/
|
|
77
123
|
export declare function scenarioDigest(s: Scenario): string;
|
|
78
124
|
/**
|
|
@@ -89,6 +135,13 @@ export interface SourceContext {
|
|
|
89
135
|
skillDir: string;
|
|
90
136
|
specDir: string;
|
|
91
137
|
scenarios: Scenario[];
|
|
138
|
+
/**
|
|
139
|
+
* The spec's `judge_persona`. Required rather than optional: an optional field that
|
|
140
|
+
* silently disables the `rubric:__persona` comparison is the blind-spot shape this
|
|
141
|
+
* module exists to prevent, and making it required lets the compiler find every
|
|
142
|
+
* caller instead of leaving one quietly un-checked.
|
|
143
|
+
*/
|
|
144
|
+
judgePersona: string;
|
|
92
145
|
}
|
|
93
146
|
/**
|
|
94
147
|
* Hash every source this run measures: SKILL.md, each distinct
|
|
@@ -111,5 +164,13 @@ export declare function sourceHashes(ctx: SourceContext): Record<string, string>
|
|
|
111
164
|
export declare function currentHashFor(key: string, ctx: SourceContext): string | null | undefined;
|
|
112
165
|
/** Human label for a recorded key, used in lint messages. */
|
|
113
166
|
export declare function describeSourceKey(key: string): string;
|
|
167
|
+
/**
|
|
168
|
+
* The cheapest command that honestly restores freshness for this key kind.
|
|
169
|
+
*
|
|
170
|
+
* This string is the feature. Before the split, lint's only remedy was "re-run", so a
|
|
171
|
+
* one-word checklist fix cost a full model pass — pressure to leave a known-bad rubric
|
|
172
|
+
* in place. Naming the actual remedy is what converts that into a free command.
|
|
173
|
+
*/
|
|
174
|
+
export declare function remedyForKey(key: string): string;
|
|
114
175
|
/** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
|
|
115
176
|
export declare function scenarioIdForKey(key: string, scenarios: Scenario[]): string | undefined;
|