@skill-harness/core 0.1.2 → 0.3.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 +6 -0
- package/dist/discover.d.ts +5 -1
- package/dist/discover.js +9 -1
- package/dist/grade.d.ts +14 -1
- package/dist/grade.js +74 -11
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/journal.d.ts +14 -0
- package/dist/lift.d.ts +69 -0
- package/dist/lift.js +163 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +168 -4
- package/dist/regrade.d.ts +6 -0
- package/dist/regrade.js +25 -3
- package/dist/report.d.ts +14 -0
- package/dist/report.js +16 -1
- package/dist/rescore.d.ts +34 -0
- package/dist/rescore.js +66 -0
- package/dist/results.d.ts +41 -9
- package/dist/results.js +58 -14
- package/dist/run.d.ts +25 -1
- package/dist/run.js +115 -22
- package/dist/scaffold.d.ts +28 -0
- package/dist/scaffold.js +188 -0
- package/dist/score.d.ts +5 -1
- package/dist/seeded.d.ts +83 -3
- package/dist/seeded.js +315 -15
- package/dist/sources.d.ts +115 -0
- package/dist/sources.js +253 -0
- package/dist/spec.d.ts +17 -0
- package/dist/spec.js +63 -0
- package/dist/util/env.d.ts +32 -0
- package/dist/util/env.js +67 -0
- package/dist/workspace.d.ts +33 -1
- package/dist/workspace.js +125 -6
- package/package.json +1 -1
package/dist/lint.js
CHANGED
|
@@ -3,6 +3,8 @@ 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
5
|
import { readResults, finalizeResults, findTranscriptFiles, resultsPath } from "./results.js";
|
|
6
|
+
import { currentHashFor, describeSourceKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, UNREADABLE } from "./sources.js";
|
|
7
|
+
import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
|
|
6
8
|
/** True if `p` exists and is a directory. Never throws (TOCTOU-safe: a race or dangling
|
|
7
9
|
* symlink between the check and the stat is treated as "not a directory", not an error). */
|
|
8
10
|
function isDir(p) {
|
|
@@ -13,6 +15,14 @@ function isDir(p) {
|
|
|
13
15
|
return false;
|
|
14
16
|
}
|
|
15
17
|
}
|
|
18
|
+
function isFile(p) {
|
|
19
|
+
try {
|
|
20
|
+
return statSync(p).isFile();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
16
26
|
/**
|
|
17
27
|
* Validate one skill's spec + fixtures statically (and results-consistency when
|
|
18
28
|
* committed results exist — see the consistency block). Never throws: a bad spec
|
|
@@ -56,12 +66,68 @@ export function lintSkill(skillDir) {
|
|
|
56
66
|
// relative to the spec's dir, matching workspace.ts resolve(specDir, fixture) where specDir = <skillDir>/tests.
|
|
57
67
|
const specDir = dirname(specPath);
|
|
58
68
|
for (const s of spec.scenarios) {
|
|
59
|
-
const fx =
|
|
69
|
+
const fx = effectiveFixture(s); // shared with sources.ts so hashing and linting can't drift
|
|
60
70
|
if (fx) {
|
|
61
71
|
const abs = isAbsolute(fx) ? fx : resolve(specDir, fx);
|
|
62
72
|
if (!isDir(abs)) {
|
|
63
73
|
findings.push({ skill, scenario: s.id, code: "fixture", message: `fixture not found: ${fx}` });
|
|
64
74
|
}
|
|
75
|
+
else {
|
|
76
|
+
// A mistyped fixture marker (`_uncommited/`) is rejected at run time by
|
|
77
|
+
// createWorkspace — but only once a run has been started, where it surfaces as
|
|
78
|
+
// a scenario FAIL among real results. lint is free, offline and runs in CI, so
|
|
79
|
+
// it is where an author should meet this. The set flagged here is exactly the
|
|
80
|
+
// set workspace.ts refuses, so lint can never bless a fixture the runtime then
|
|
81
|
+
// rejects.
|
|
82
|
+
// try/catch because lintSkill's contract is "never throws": isDir() proves the
|
|
83
|
+
// path stats, not that it can be read, and an EACCES on readdir would escape
|
|
84
|
+
// to cli.ts, which replaces this skill's ENTIRE finding list with one
|
|
85
|
+
// lint-error — silently discarding the staleness and consistency checks below.
|
|
86
|
+
let markers = [];
|
|
87
|
+
try {
|
|
88
|
+
markers = unknownMarkerDirs(abs);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
findings.push({
|
|
92
|
+
skill, scenario: s.id, code: "fixture",
|
|
93
|
+
message: `fixture ${fx} could not be read: ${e instanceof Error ? e.message : String(e)}`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
for (const dir of markers) {
|
|
97
|
+
const guess = suggestMarker(dir);
|
|
98
|
+
findings.push({
|
|
99
|
+
skill,
|
|
100
|
+
scenario: s.id,
|
|
101
|
+
code: "fixture-marker",
|
|
102
|
+
message: `fixture ${fx} has unknown top-level marker directory \`${dir}/\`` +
|
|
103
|
+
(guess ? ` — did you mean \`${guess}/\`?` : "") +
|
|
104
|
+
` Known markers are ${MARKERS.map((m) => `\`${m}/\``).join(" and ")};` +
|
|
105
|
+
` rename it, or move it deeper if it is ordinary content.`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// assert.post_test must exist. A post-test that isn't there fails its scenario at
|
|
112
|
+
// run time with a "spec error" message, but that costs a full model run to discover
|
|
113
|
+
// — and lint is the free, offline gate that exists to catch it first.
|
|
114
|
+
for (const s of spec.scenarios) {
|
|
115
|
+
const pt = s.assert?.post_test;
|
|
116
|
+
if (!pt)
|
|
117
|
+
continue;
|
|
118
|
+
const abs = isAbsolute(pt) ? pt : resolve(specDir, pt);
|
|
119
|
+
if (!isFile(abs)) {
|
|
120
|
+
findings.push({ skill, scenario: s.id, code: "fixture", message: `assert.post_test not found: ${pt}` });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// system_prompt_file must exist — an agent-file scenario silently falling back to
|
|
124
|
+
// skill activation would measure the wrong artifact entirely.
|
|
125
|
+
for (const s of spec.scenarios) {
|
|
126
|
+
if (!s.systemPromptFile)
|
|
127
|
+
continue;
|
|
128
|
+
const abs = isAbsolute(s.systemPromptFile) ? s.systemPromptFile : resolve(specDir, s.systemPromptFile);
|
|
129
|
+
if (!isFile(abs)) {
|
|
130
|
+
findings.push({ skill, scenario: s.id, code: "fixture", message: `system_prompt_file not found: ${s.systemPromptFile}` });
|
|
65
131
|
}
|
|
66
132
|
}
|
|
67
133
|
// results-consistency — only for committed results.yaml (skipped silently otherwise).
|
|
@@ -78,9 +144,17 @@ export function lintSkill(skillDir) {
|
|
|
78
144
|
if (raw?.schema !== 2)
|
|
79
145
|
continue; // schema-1 intentionally skipped — no finding
|
|
80
146
|
const r = readResults(runDir);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
147
|
+
// A run whose scenario set no longer matches the spec predates a spec reshape
|
|
148
|
+
// (scenarios added/removed). Its grade was computed against the OLD ship bar and
|
|
149
|
+
// cannot be meaningfully recomputed against the new one — recomputing would flag
|
|
150
|
+
// every historical run each time a spec grows. Staleness (source_hashes) is the
|
|
151
|
+
// mechanism that says "re-run"; consistency only polices runs the current spec
|
|
152
|
+
// can actually re-score. Override/transcript rules below still apply.
|
|
153
|
+
const specIds = new Set(spec.scenarios.map((sc) => sc.id));
|
|
154
|
+
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;
|
|
156
|
+
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
|
+
if (recomputed && JSON.stringify(recomputed) !== JSON.stringify(r.effective_grade)) {
|
|
84
158
|
findings.push({ skill, code: "consistency", message: `results.yaml effective_grade is stale in ${runDir} (recompute differs)` });
|
|
85
159
|
}
|
|
86
160
|
for (const s of r.scenarios) {
|
|
@@ -96,8 +170,98 @@ export function lintSkill(skillDir) {
|
|
|
96
170
|
findings.push({ skill, code: "consistency", message: `results.yaml unreadable or malformed in ${runDir}: ${e instanceof Error ? e.message : String(e)}` });
|
|
97
171
|
}
|
|
98
172
|
}
|
|
173
|
+
// staleness — the newest FULL (non-partial) run per model tag recorded sha256 hashes of
|
|
174
|
+
// every source it measured: SKILL.md, agent files, each scenario's definition and each
|
|
175
|
+
// fixture tree. If any has changed since, the committed result describes inputs that no
|
|
176
|
+
// longer exist — exactly how three regressions hid behind a 100%-SHIP table for four
|
|
177
|
+
// weeks, and how a swapped fixture went unreported by a "7 skills, 0 findings" lint.
|
|
178
|
+
// Runs predating source_hashes (or predating a given key kind) are skipped silently — no
|
|
179
|
+
// retroactive noise; partial runs never count as coverage.
|
|
180
|
+
for (const tagDir of enumerateTagDirs(resultsRoot)) {
|
|
181
|
+
// Newest FULL run: partial (--only) runs are iteration artifacts and never count as
|
|
182
|
+
// coverage — a fresh partial must not silence a stale full run underneath it.
|
|
183
|
+
let full = null;
|
|
184
|
+
for (const runDir of runDirsNewestFirst(tagDir)) {
|
|
185
|
+
try {
|
|
186
|
+
const r = readResults(runDir);
|
|
187
|
+
if (r.partial)
|
|
188
|
+
continue;
|
|
189
|
+
full = { runDir, r };
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
break;
|
|
194
|
+
} // unreadable → the consistency block already reports it
|
|
195
|
+
}
|
|
196
|
+
const hashes = full?.r.source_hashes;
|
|
197
|
+
if (!full || !hashes)
|
|
198
|
+
continue; // predates source_hashes → silent
|
|
199
|
+
{
|
|
200
|
+
const newest = full.runDir;
|
|
201
|
+
const ctx = { skillDir, specDir, scenarios: spec.scenarios };
|
|
202
|
+
for (const [key, recorded] of Object.entries(hashes)) {
|
|
203
|
+
const what = describeSourceKey(key);
|
|
204
|
+
const scenario = scenarioIdForKey(key, spec.scenarios);
|
|
205
|
+
// The run itself failed to hash this source, so it was never verified and
|
|
206
|
+
// no comparison here can establish anything about it.
|
|
207
|
+
if (recorded === UNREADABLE) {
|
|
208
|
+
findings.push({ skill, scenario, code: "stale", message: `${what} could not be read when the newest ${basename(tagDir)} run was recorded (${newest}) — that source was never verified; re-run` });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const current = currentHashFor(key, ctx);
|
|
212
|
+
// undefined = not comparable: a scenario the spec no longer has (a reshape,
|
|
213
|
+
// per the scenario-set check above), or a key kind written by a newer
|
|
214
|
+
// skill-harness than this one.
|
|
215
|
+
if (current === undefined)
|
|
216
|
+
continue;
|
|
217
|
+
if (current === null) {
|
|
218
|
+
findings.push({ skill, scenario, code: "stale", message: `${what} no longer exists but the newest ${basename(tagDir)} run measured it (${newest})` });
|
|
219
|
+
}
|
|
220
|
+
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` });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Coverage: a scenario the spec defines that the newest full run never
|
|
225
|
+
// measured. Without this, RENAMING a scenario is invisible — the old key
|
|
226
|
+
// resolves to "not comparable" and the new one was never recorded — so a
|
|
227
|
+
// 100%/SHIP scorecard survives an arbitrary spec rewrite reporting zero
|
|
228
|
+
// findings. Gated on the run having recorded scenario keys at all, so runs
|
|
229
|
+
// predating the key kind stay silent like every other pre-existing run.
|
|
230
|
+
if (Object.keys(hashes).some((k) => k.startsWith(SCENARIO_PREFIX))) {
|
|
231
|
+
for (const s of spec.scenarios) {
|
|
232
|
+
if (!(SCENARIO_PREFIX + s.id in hashes)) {
|
|
233
|
+
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
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
99
239
|
return findings;
|
|
100
240
|
}
|
|
241
|
+
/** Model-tag dirs under tests/results (each holds timestamped run dirs). */
|
|
242
|
+
function enumerateTagDirs(resultsRoot) {
|
|
243
|
+
if (!existsSync(resultsRoot))
|
|
244
|
+
return [];
|
|
245
|
+
try {
|
|
246
|
+
return readdirSync(resultsRoot).map((t) => join(resultsRoot, t)).filter(isDir);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Timestamped run dirs holding a results.yaml, newest first (ISO slugs sort lexicographically). */
|
|
253
|
+
function runDirsNewestFirst(tagDir) {
|
|
254
|
+
let timestamps;
|
|
255
|
+
try {
|
|
256
|
+
timestamps = readdirSync(tagDir);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
return timestamps.sort().reverse()
|
|
262
|
+
.map((ts) => join(tagDir, ts))
|
|
263
|
+
.filter((d) => isDir(d) && existsSync(join(d, "results.yaml")));
|
|
264
|
+
}
|
|
101
265
|
/**
|
|
102
266
|
* All committed run dirs under a skill's tests/results (<tag>/<timestamp>/results.yaml).
|
|
103
267
|
* Empty if none. Never throws: unreadable/dangling entries (e.g. a broken symlink, or a
|
package/dist/regrade.d.ts
CHANGED
|
@@ -39,6 +39,12 @@ export interface RegradeRunOptions {
|
|
|
39
39
|
judge: ModelRef;
|
|
40
40
|
specDir: string;
|
|
41
41
|
now?: () => string;
|
|
42
|
+
/**
|
|
43
|
+
* Re-judge ONLY scenarios whose stored verdict is untrustworthy — suspect (misfire) or
|
|
44
|
+
* JUDGE-AMBIGUOUS. Everything else is carried verbatim: their transcripts were judged
|
|
45
|
+
* cleanly, so spending judge calls on them buys nothing.
|
|
46
|
+
*/
|
|
47
|
+
onlySuspect?: boolean;
|
|
42
48
|
}
|
|
43
49
|
/**
|
|
44
50
|
* Re-judge every green-transcript scenario in a run dir with `judge` — no
|
package/dist/regrade.js
CHANGED
|
@@ -65,7 +65,19 @@ export async function regradeRun(opts) {
|
|
|
65
65
|
// a recorded verdict or shrink the grade denominator. Fail fast, before
|
|
66
66
|
// spending any judge calls.
|
|
67
67
|
const specById = new Map(spec.scenarios.map((s) => [s.id, s]));
|
|
68
|
-
const
|
|
68
|
+
const recorded = prev?.scenarios ?? spec.scenarios.map((s) => ({ id: s.id }));
|
|
69
|
+
let targets = recorded.map((s) => s.id);
|
|
70
|
+
if (opts.onlySuspect) {
|
|
71
|
+
if (!prev)
|
|
72
|
+
throw new Error(`--suspect-only needs a prior results.yaml in ${runDir}`);
|
|
73
|
+
targets = prev.scenarios
|
|
74
|
+
.filter((s) => s.suspect || s.judge_verdict === "JUDGE-AMBIGUOUS")
|
|
75
|
+
.map((s) => s.id);
|
|
76
|
+
if (targets.length === 0) {
|
|
77
|
+
// Nothing untrustworthy — a no-op, not an error. Return the file as-is.
|
|
78
|
+
return prev;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
69
81
|
const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id, "green").length === 0);
|
|
70
82
|
if (missing.length === targets.length) {
|
|
71
83
|
throw new Error(`no green transcripts in ${runDir} — nothing to re-grade`);
|
|
@@ -73,8 +85,14 @@ export async function regradeRun(opts) {
|
|
|
73
85
|
if (missing.length > 0) {
|
|
74
86
|
throw new Error(`cannot re-grade ${missing.join(", ")} in ${runDir} (transcript missing or scenario no longer in the spec) — re-run instead of grading`);
|
|
75
87
|
}
|
|
88
|
+
const targetSet = new Set(targets);
|
|
76
89
|
const scenarioResults = [];
|
|
77
|
-
for (const
|
|
90
|
+
for (const rec of recorded) {
|
|
91
|
+
const id = rec.id;
|
|
92
|
+
if (!targetSet.has(id)) {
|
|
93
|
+
scenarioResults.push(rec); // clean verdict, carried verbatim (onlySuspect)
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
78
96
|
const scenario = specById.get(id); // guaranteed present by the guard above
|
|
79
97
|
const prevScenario = prev?.scenarios.find((s) => s.id === id);
|
|
80
98
|
const threshold = effectiveThreshold(prevScenario, scenario);
|
|
@@ -84,7 +102,7 @@ export async function regradeRun(opts) {
|
|
|
84
102
|
const carry = overrides.get(id);
|
|
85
103
|
scenarioResults.push({ ...rr, override: carry?.override ?? null, note: carry?.note ?? "" });
|
|
86
104
|
}
|
|
87
|
-
const ctx = mode === "green" ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
|
|
105
|
+
const ctx = mode === "green" && !prev?.partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
|
|
88
106
|
const results = writeResults(runDir, {
|
|
89
107
|
skill: spec.skill,
|
|
90
108
|
harness: prev?.harness ?? "pi",
|
|
@@ -93,6 +111,10 @@ export async function regradeRun(opts) {
|
|
|
93
111
|
timestamp: prev?.timestamp ?? now(),
|
|
94
112
|
label: prev?.label ?? null,
|
|
95
113
|
mode,
|
|
114
|
+
// 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.
|
|
116
|
+
partial: prev?.partial,
|
|
117
|
+
source_hashes: prev?.source_hashes,
|
|
96
118
|
scenarios: scenarioResults,
|
|
97
119
|
}, ctx);
|
|
98
120
|
const g = results.effective_grade;
|
package/dist/report.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ShipBar } from "./spec.js";
|
|
2
2
|
import { type ResultsFile } from "./results.js";
|
|
3
|
+
import { type Lift } from "./lift.js";
|
|
3
4
|
export interface RunColumn {
|
|
4
5
|
index: number;
|
|
5
6
|
label: string;
|
|
@@ -20,6 +21,17 @@ export interface RunColumn {
|
|
|
20
21
|
override: string | null;
|
|
21
22
|
note: string;
|
|
22
23
|
}>;
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* Only set when THIS column is the green run the lift was computed from (see
|
|
30
|
+
* 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
|
+
*/
|
|
33
|
+
lift?: Lift;
|
|
34
|
+
liftHeadline?: string;
|
|
23
35
|
}
|
|
24
36
|
export interface ReportData {
|
|
25
37
|
skill: string;
|
|
@@ -48,6 +60,8 @@ export declare function publicView(data: ReportData): {
|
|
|
48
60
|
critical: boolean;
|
|
49
61
|
}[];
|
|
50
62
|
columns: {
|
|
63
|
+
lift?: Lift | undefined;
|
|
64
|
+
liftHeadline?: string | undefined;
|
|
51
65
|
index: number;
|
|
52
66
|
label: string;
|
|
53
67
|
tag: string;
|
package/dist/report.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loadSpec } from "./spec.js";
|
|
4
4
|
import { readResults } from "./results.js";
|
|
5
|
+
import { collectLift, liftHeadline } from "./lift.js";
|
|
5
6
|
/** Most-recent run dir (by name, which is an ISO-ish slug) under a model-tag dir. */
|
|
6
7
|
function latestRunDir(tagDir) {
|
|
7
8
|
if (!statSync(tagDir).isDirectory())
|
|
@@ -21,6 +22,8 @@ export function collectReport(skillDir) {
|
|
|
21
22
|
const spec = loadSpec(specPath);
|
|
22
23
|
const scenarios = spec.scenarios.map((s) => ({ id: s.id, title: s.title, critical: s.critical }));
|
|
23
24
|
const resultsRoot = join(skillDir, "tests", "results");
|
|
25
|
+
// Lift is keyed by model tag, the same key columns are built from.
|
|
26
|
+
const liftByTag = new Map(collectLift(skillDir).map((l) => [l.tag, l]));
|
|
24
27
|
const columns = [];
|
|
25
28
|
if (existsSync(resultsRoot)) {
|
|
26
29
|
const tags = readdirSync(resultsRoot)
|
|
@@ -46,16 +49,27 @@ export function collectReport(skillDir) {
|
|
|
46
49
|
note: s.note,
|
|
47
50
|
};
|
|
48
51
|
}
|
|
52
|
+
const tag = tagDir.split("/").pop();
|
|
53
|
+
// A column is the tag's LATEST run, which is not necessarily the green one —
|
|
54
|
+
// record a red baseline after a green run and the newest run in the tag is
|
|
55
|
+
// red. The review UI recomputes lift from `cells` (so author overrides move
|
|
56
|
+
// it live), so attaching a lift to a column whose cells are the RED run
|
|
57
|
+
// would have it compare red against red and report "no effect" for a skill
|
|
58
|
+
// that in fact gained every scenario. Attach only when this column IS the
|
|
59
|
+
// green side of the comparison.
|
|
60
|
+
const tagLift = liftByTag.get(tag);
|
|
61
|
+
const lift = tagLift && tagLift.greenTimestamp === r.timestamp ? tagLift : undefined;
|
|
49
62
|
columns.push({
|
|
50
63
|
index: columns.length,
|
|
51
64
|
label: r.model,
|
|
52
|
-
tag
|
|
65
|
+
tag,
|
|
53
66
|
runDir,
|
|
54
67
|
timestamp: r.timestamp,
|
|
55
68
|
mode: r.mode,
|
|
56
69
|
grade: r.effective_grade,
|
|
57
70
|
judge: r.judge,
|
|
58
71
|
cells,
|
|
72
|
+
...(lift ? { lift, liftHeadline: liftHeadline(lift) } : {}),
|
|
59
73
|
});
|
|
60
74
|
}
|
|
61
75
|
}
|
|
@@ -77,6 +91,7 @@ export function publicView(data) {
|
|
|
77
91
|
grade: c.grade,
|
|
78
92
|
judge: c.judge,
|
|
79
93
|
cells: c.cells,
|
|
94
|
+
...(c.lift ? { lift: c.lift, liftHeadline: c.liftHeadline } : {}),
|
|
80
95
|
})),
|
|
81
96
|
};
|
|
82
97
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Spec } from "./spec.js";
|
|
2
|
+
import { type ResultsFile, type ScenarioResult } from "./results.js";
|
|
3
|
+
export interface RescoreOptions {
|
|
4
|
+
runDir: string;
|
|
5
|
+
spec: Spec;
|
|
6
|
+
now?: () => string;
|
|
7
|
+
}
|
|
8
|
+
export interface RescoreChange {
|
|
9
|
+
id: string;
|
|
10
|
+
from: ScenarioResult["judge_verdict"];
|
|
11
|
+
to: ScenarioResult["judge_verdict"];
|
|
12
|
+
passes: number;
|
|
13
|
+
clean: number;
|
|
14
|
+
fromThreshold: number;
|
|
15
|
+
toThreshold: number;
|
|
16
|
+
}
|
|
17
|
+
export interface RescoreResult {
|
|
18
|
+
results: ResultsFile;
|
|
19
|
+
changes: RescoreChange[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Re-score a run against the CURRENT spec's pass thresholds — no model calls, no judge
|
|
23
|
+
* calls. Reps are the raw measurement (`passes` of `clean`); a threshold is *policy*.
|
|
24
|
+
* When the policy changes, the honest move is to recompute the old measurements under it
|
|
25
|
+
* rather than reconcile two numbers in prose — and to record what moved.
|
|
26
|
+
*
|
|
27
|
+
* Only reps-bearing scenarios can be re-scored: a single-rep verdict has no rate to
|
|
28
|
+
* re-apply a threshold to, and ERROR/JUDGE-AMBIGUOUS carry no trustworthy rate at all —
|
|
29
|
+
* both are carried verbatim. Overrides, notes, and suspect flags are preserved: this
|
|
30
|
+
* changes the collapse rule, nothing about what the judge said.
|
|
31
|
+
*/
|
|
32
|
+
export declare function rescoreRun(opts: RescoreOptions): RescoreResult;
|
|
33
|
+
/** Locate the spec for a run dir: results/<tag>/<ts> → ../../../specification.yaml */
|
|
34
|
+
export declare function specPathForRunDir(runDir: string): string;
|
package/dist/rescore.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readResults, writeResults } from "./results.js";
|
|
4
|
+
import { appendJournal } from "./journal.js";
|
|
5
|
+
/**
|
|
6
|
+
* Re-score a run against the CURRENT spec's pass thresholds — no model calls, no judge
|
|
7
|
+
* calls. Reps are the raw measurement (`passes` of `clean`); a threshold is *policy*.
|
|
8
|
+
* When the policy changes, the honest move is to recompute the old measurements under it
|
|
9
|
+
* rather than reconcile two numbers in prose — and to record what moved.
|
|
10
|
+
*
|
|
11
|
+
* Only reps-bearing scenarios can be re-scored: a single-rep verdict has no rate to
|
|
12
|
+
* re-apply a threshold to, and ERROR/JUDGE-AMBIGUOUS carry no trustworthy rate at all —
|
|
13
|
+
* both are carried verbatim. Overrides, notes, and suspect flags are preserved: this
|
|
14
|
+
* changes the collapse rule, nothing about what the judge said.
|
|
15
|
+
*/
|
|
16
|
+
export function rescoreRun(opts) {
|
|
17
|
+
const now = opts.now ?? (() => new Date().toISOString());
|
|
18
|
+
const prev = readResults(opts.runDir);
|
|
19
|
+
if (!prev)
|
|
20
|
+
throw new Error(`no results.yaml in ${opts.runDir}`);
|
|
21
|
+
const specById = new Map(opts.spec.scenarios.map((s) => [s.id, s]));
|
|
22
|
+
const changes = [];
|
|
23
|
+
const scenarios = prev.scenarios.map((s) => {
|
|
24
|
+
const scenario = specById.get(s.id);
|
|
25
|
+
// no rate to re-apply, or an untrustworthy verdict → carry verbatim
|
|
26
|
+
if (!scenario || s.reps === undefined || s.clean === undefined || s.passes === undefined)
|
|
27
|
+
return s;
|
|
28
|
+
if (s.judge_verdict === "ERROR" || s.judge_verdict === "JUDGE-AMBIGUOUS")
|
|
29
|
+
return s;
|
|
30
|
+
if (s.clean === 0)
|
|
31
|
+
return s;
|
|
32
|
+
const toThreshold = scenario.passThreshold ?? 0.5;
|
|
33
|
+
const fromThreshold = s.pass_threshold ?? toThreshold;
|
|
34
|
+
if (toThreshold === fromThreshold)
|
|
35
|
+
return s;
|
|
36
|
+
const rate = s.passes / s.clean;
|
|
37
|
+
const verdict = rate >= toThreshold ? "PASS" : "FAIL";
|
|
38
|
+
if (verdict !== s.judge_verdict) {
|
|
39
|
+
changes.push({ id: s.id, from: s.judge_verdict, to: verdict, passes: s.passes, clean: s.clean, fromThreshold, toThreshold });
|
|
40
|
+
}
|
|
41
|
+
return { ...s, judge_verdict: verdict, pass_threshold: toThreshold };
|
|
42
|
+
});
|
|
43
|
+
const ctx = prev.mode === "green" && !prev.partial
|
|
44
|
+
? { shipBar: opts.spec.ship_bar, critical: opts.spec.critical }
|
|
45
|
+
: null;
|
|
46
|
+
const results = writeResults(opts.runDir, {
|
|
47
|
+
skill: prev.skill, harness: prev.harness, model: prev.model, judge: prev.judge,
|
|
48
|
+
timestamp: prev.timestamp, label: prev.label, mode: prev.mode,
|
|
49
|
+
partial: prev.partial, source_hashes: prev.source_hashes, scenarios,
|
|
50
|
+
}, ctx);
|
|
51
|
+
appendJournal(opts.runDir, {
|
|
52
|
+
event: "rescore", ts: now(),
|
|
53
|
+
changed: changes.map((c) => `${c.id}: ${c.from}->${c.to} (${c.passes}/${c.clean} @ ${c.toThreshold})`),
|
|
54
|
+
passed: results.effective_grade.passed, total: results.effective_grade.total,
|
|
55
|
+
pct: results.effective_grade.pct, ship: results.effective_grade.ship,
|
|
56
|
+
});
|
|
57
|
+
return { results, changes };
|
|
58
|
+
}
|
|
59
|
+
/** Locate the spec for a run dir: results/<tag>/<ts> → ../../../specification.yaml */
|
|
60
|
+
export function specPathForRunDir(runDir) {
|
|
61
|
+
const p = join(runDir, "..", "..", "..", "specification.yaml");
|
|
62
|
+
if (!existsSync(p))
|
|
63
|
+
throw new Error(`no specification.yaml above ${runDir}`);
|
|
64
|
+
return p;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=rescore.js.map
|
package/dist/results.d.ts
CHANGED
|
@@ -35,6 +35,18 @@ export interface ResultsFile {
|
|
|
35
35
|
timestamp: string;
|
|
36
36
|
label: string | null;
|
|
37
37
|
mode: string;
|
|
38
|
+
/** True for an `--only`-filtered run: a scenario subset, never ship-graded, never a release run. */
|
|
39
|
+
partial?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* sha256 of every source this run measured: SKILL.md, each distinct
|
|
42
|
+
* system_prompt_file, each scenario's definition (`scenario:<id>`) and each
|
|
43
|
+
* fixture tree (`fixture:<path>`). Lint compares the newest run's hashes against
|
|
44
|
+
* the current sources — a mismatch means the published result describes inputs
|
|
45
|
+
* that no longer exist (the stale-scorecard class this field exists to kill).
|
|
46
|
+
* See sources.ts for the key scheme; runs recorded before a key kind existed
|
|
47
|
+
* simply don't carry it, and are never retroactively flagged.
|
|
48
|
+
*/
|
|
49
|
+
source_hashes?: Record<string, string>;
|
|
38
50
|
effective_grade: GradeSummary;
|
|
39
51
|
scenarios: ScenarioResult[];
|
|
40
52
|
}
|
|
@@ -74,7 +86,7 @@ export declare function applyOverride(results: ResultsFile, scenarioId: string,
|
|
|
74
86
|
* `!…` preservation lines added by preserveTranscript.
|
|
75
87
|
*/
|
|
76
88
|
export declare function ensureResultsGitignore(resultsRoot: string): void;
|
|
77
|
-
/** The rep index embedded in a transcript/judge-raw filename (`.rep<k>.`), or null for a plain (non-rep) file. */
|
|
89
|
+
/** The rep index embedded in a transcript / judge-raw / staged-diff filename (`.rep<k>.`), or null for a plain (non-rep) file. */
|
|
78
90
|
export declare function repIndexOf(filename: string): number | null;
|
|
79
91
|
/**
|
|
80
92
|
* ALL transcript files for a scenario in a run dir, sorted deterministically:
|
|
@@ -86,23 +98,43 @@ export declare function repIndexOf(filename: string): number | null;
|
|
|
86
98
|
* `<id>.<mode>.rep<k>.txt`) — e.g. to detect a green-only condition without
|
|
87
99
|
* false positives from a red/force transcript of the same scenario. Omitted,
|
|
88
100
|
* behavior is unchanged: any `<id>.*.txt` regardless of mode, excluding this
|
|
89
|
-
* scenario's
|
|
101
|
+
* scenario's sibling artifacts, which share the `.txt` extension deliberately
|
|
102
|
+
* (so `results/.gitignore`'s `*.txt` covers them all): judge-raw output
|
|
103
|
+
* (`<id>.*.judge.txt` — see judgeRawPath) and the staged diff
|
|
104
|
+
* (`<id>.*.diff.txt` — see diffPath).
|
|
90
105
|
*/
|
|
91
106
|
export declare function findTranscriptFiles(runDir: string, scenarioId: string, mode?: string): string[];
|
|
92
107
|
/** Path of a scenario's raw judge-output artifact within a run dir (rep-suffixed for reps). */
|
|
93
108
|
export declare function judgeRawPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
|
|
94
109
|
/** A scenario's raw judge-output files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
|
95
110
|
export declare function findJudgeRawFiles(runDir: string, scenarioId: string, mode?: string): string[];
|
|
111
|
+
/**
|
|
112
|
+
* Path of a seeded scenario's staged-diff artifact within a run dir (rep-suffixed
|
|
113
|
+
* for reps).
|
|
114
|
+
*
|
|
115
|
+
* The diff is the only record of what the model actually *did* — the workspace is
|
|
116
|
+
* torn down after every rep, so without this a seeded verdict cannot be audited
|
|
117
|
+
* after the fact. Named `<id>.<mode>[.rep<k>].diff.txt` so it sorts beside its
|
|
118
|
+
* transcript and is covered by the `*.txt` rule in results/.gitignore: diffs are
|
|
119
|
+
* generated evidence, ignored like transcripts, not committed like results.yaml.
|
|
120
|
+
*/
|
|
121
|
+
export declare function diffPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
|
|
122
|
+
/** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
|
123
|
+
export declare function findDiffFiles(runDir: string, scenarioId: string, mode?: string): string[];
|
|
96
124
|
/** A single representative transcript file for a scenario in a run dir. Null if none. */
|
|
97
125
|
export declare function findTranscriptFile(runDir: string, scenarioId: string): string | null;
|
|
98
126
|
/**
|
|
99
|
-
* Un-gitignore ALL of a scenario's transcript
|
|
100
|
-
* (audit trail for an override — a --reps run has one
|
|
101
|
-
*
|
|
102
|
-
*
|
|
127
|
+
* Un-gitignore ALL of a scenario's transcript, judge-raw AND staged-diff
|
|
128
|
+
* artifact files (audit trail for an override — a --reps run has one of each
|
|
129
|
+
* per rep, and every rep that drove the verdict must survive a commit, not just
|
|
130
|
+
* an arbitrary one).
|
|
103
131
|
* Appends `!<tag>/<ts>/<id>.<mode>[.rep<k>].txt` (and the matching
|
|
104
|
-
* `.judge.txt`) to results/.gitignore for each, once. The path
|
|
105
|
-
* separators so the negation matches on Windows too (git ignore
|
|
106
|
-
* always forward-slashed).
|
|
132
|
+
* `.judge.txt` / `.diff.txt`) to results/.gitignore for each, once. The path
|
|
133
|
+
* uses POSIX separators so the negation matches on Windows too (git ignore
|
|
134
|
+
* patterns are always forward-slashed).
|
|
135
|
+
*
|
|
136
|
+
* The diff belongs here for the same reason the judge-raw output does: an
|
|
137
|
+
* override says the judge got it wrong, and on a seeded scenario the evidence
|
|
138
|
+
* for that claim is the code the model wrote.
|
|
107
139
|
*/
|
|
108
140
|
export declare function preserveTranscript(resultsRoot: string, runDir: string, scenarioId: string): void;
|