@skill-harness/core 0.5.0 → 0.7.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 +37 -0
- package/dist/adjudication.d.ts +210 -0
- package/dist/adjudication.js +392 -0
- package/dist/affected.d.ts +88 -0
- package/dist/affected.js +222 -0
- package/dist/capture-trace-types.d.ts +228 -0
- package/dist/capture-trace-types.js +23 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +344 -0
- package/dist/execution-trace.d.ts +61 -0
- package/dist/execution-trace.js +299 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/instruction-coverage.d.ts +106 -0
- package/dist/instruction-coverage.js +253 -0
- package/dist/journal.d.ts +17 -0
- package/dist/lint.d.ts +16 -1
- package/dist/lint.js +52 -0
- package/dist/regate.js +80 -17
- package/dist/regrade.js +17 -3
- package/dist/report.d.ts +48 -0
- package/dist/report.js +39 -1
- package/dist/reps.d.ts +14 -1
- package/dist/reps.js +28 -2
- package/dist/rescore.js +11 -2
- package/dist/results.d.ts +128 -6
- package/dist/results.js +155 -6
- package/dist/run.d.ts +9 -1
- package/dist/run.js +129 -9
- package/dist/seeded.d.ts +11 -0
- package/dist/seeded.js +31 -7
- package/dist/sources.d.ts +26 -0
- package/dist/sources.js +82 -3
- package/dist/spec-write.d.ts +62 -0
- package/dist/spec-write.js +106 -0
- package/dist/spec.d.ts +29 -0
- package/dist/spec.js +55 -0
- package/dist/stability.d.ts +144 -0
- package/dist/stability.js +232 -0
- package/dist/trace-gates.d.ts +133 -0
- package/dist/trace-gates.js +519 -0
- package/dist/trends.d.ts +28 -0
- package/dist/trends.js +76 -61
- package/dist/workspace.d.ts +36 -0
- package/dist/workspace.js +61 -0
- package/package.json +1 -1
package/dist/trends.js
CHANGED
|
@@ -11,6 +11,67 @@ function isDir(p) {
|
|
|
11
11
|
return false;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Walk `<skillDir>/tests/results/` and group every SCORED run by model tag × delivery
|
|
16
|
+
* mode, chronologically (timestamp-slug dir names sort correctly).
|
|
17
|
+
*
|
|
18
|
+
* The single history reader: `collectTrends` renders it, `collectStability` derives
|
|
19
|
+
* run-over-run flips from it. Two walkers over the same tree is how "which runs count"
|
|
20
|
+
* drifts — the mistake that had force runs excluded from scoring in seven places at
|
|
21
|
+
* once (see SCORED_MODES).
|
|
22
|
+
*
|
|
23
|
+
* Red runs are excluded: a baseline has no grade, and pairing it with anything would
|
|
24
|
+
* compare a skill-off run to a skill-on one. Green and force are never pooled into one
|
|
25
|
+
* group — placement moves verdicts, so a green run and a force run of the same scenario
|
|
26
|
+
* are two measurements, not two samples.
|
|
27
|
+
*
|
|
28
|
+
* A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic write) is
|
|
29
|
+
* logged via `console.warn`, skipped, and counted in `skipped` — never thrown, because
|
|
30
|
+
* one torn file must not take down a whole read-only view.
|
|
31
|
+
*/
|
|
32
|
+
export function collectScoredRuns(skillDir) {
|
|
33
|
+
const resultsRoot = join(skillDir, "tests", "results");
|
|
34
|
+
if (!existsSync(resultsRoot))
|
|
35
|
+
return [];
|
|
36
|
+
const groups = [];
|
|
37
|
+
const tags = readdirSync(resultsRoot)
|
|
38
|
+
.filter((n) => isDir(join(resultsRoot, n)))
|
|
39
|
+
.sort();
|
|
40
|
+
for (const tag of tags) {
|
|
41
|
+
const tagDir = join(resultsRoot, tag);
|
|
42
|
+
const runDirs = readdirSync(tagDir)
|
|
43
|
+
.map((n) => join(tagDir, n))
|
|
44
|
+
.filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
|
|
45
|
+
.sort(); // timestamp-slug dir names ⇒ chronological ascending
|
|
46
|
+
if (runDirs.length === 0)
|
|
47
|
+
continue;
|
|
48
|
+
// Every candidate run is read: a run's mode is not knowable from its dir name, so
|
|
49
|
+
// filtering has to happen after the read. Bucketed by mode in first-seen order.
|
|
50
|
+
const byMode = new Map();
|
|
51
|
+
let skipped = 0;
|
|
52
|
+
for (const rd of runDirs) {
|
|
53
|
+
let r;
|
|
54
|
+
try {
|
|
55
|
+
r = readResults(rd);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
console.warn(`skill-harness: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
|
|
59
|
+
skipped++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!isScoredMode(r.mode))
|
|
63
|
+
continue; // baseline — deliberate exclusion, not a skip
|
|
64
|
+
(byMode.get(r.mode) ?? byMode.set(r.mode, []).get(r.mode)).push(r);
|
|
65
|
+
}
|
|
66
|
+
for (const [mode, runs] of byMode) {
|
|
67
|
+
// `skipped` is per tag (an unreadable run has no knowable mode), so a tag with
|
|
68
|
+
// two series reports the same count on both — the alternative is attributing a
|
|
69
|
+
// parse failure to a mode nobody could read.
|
|
70
|
+
groups.push({ tag, mode, model: runs[runs.length - 1].model, runs, skipped });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return groups;
|
|
74
|
+
}
|
|
14
75
|
/**
|
|
15
76
|
* Per model-tag, read the full run history (not just the latest) from
|
|
16
77
|
* <skillDir>/tests/results/, chronologically (timestamp-slug dir names sort
|
|
@@ -42,69 +103,23 @@ export function collectTrends(skillDir, limit = 20) {
|
|
|
42
103
|
const specPath = join(skillDir, "tests", "specification.yaml");
|
|
43
104
|
const spec = loadSpec(specPath);
|
|
44
105
|
const scenarios = spec.scenarios.map((s) => ({ id: s.id, title: s.title, critical: s.critical }));
|
|
45
|
-
const resultsRoot = join(skillDir, "tests", "results");
|
|
46
106
|
const models = [];
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
for (const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// after the slice would let red runs consume window slots, undercounting
|
|
62
|
-
// the history even when more exists. Bucketed by mode, in first-seen
|
|
63
|
-
// order, so each delivery epoch gets its own series and its own window.
|
|
64
|
-
const byMode = new Map();
|
|
65
|
-
let skipped = 0;
|
|
66
|
-
for (const rd of runDirs) {
|
|
67
|
-
let r;
|
|
68
|
-
try {
|
|
69
|
-
r = readResults(rd);
|
|
70
|
-
}
|
|
71
|
-
catch (e) {
|
|
72
|
-
// A corrupt/truncated results.yaml must not take down the whole
|
|
73
|
-
// trends view — skip that run, but surface the failure.
|
|
74
|
-
console.warn(`skill-harness trends: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
|
|
75
|
-
skipped++;
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
if (!isScoredMode(r.mode))
|
|
79
|
-
continue; // baseline — deliberate exclusion, not a skip
|
|
80
|
-
(byMode.get(r.mode) ?? byMode.set(r.mode, []).get(r.mode)).push(r);
|
|
81
|
-
}
|
|
82
|
-
if (byMode.size === 0)
|
|
83
|
-
continue;
|
|
84
|
-
for (const [mode, scoredRuns] of byMode) {
|
|
85
|
-
const truncated = scoredRuns.length > limit;
|
|
86
|
-
const kept = scoredRuns.slice(-limit); // most recent `limit`, newest last
|
|
87
|
-
const runs = [];
|
|
88
|
-
let model = "";
|
|
89
|
-
for (const r of kept) {
|
|
90
|
-
// effectiveVerdicts is the single source of truth for the
|
|
91
|
-
// override-aware verdict/suspect rule (suspect = s.suspect &&
|
|
92
|
-
// s.override == null — an override resolves the misfire); zip in
|
|
93
|
-
// flakiness from the matching ScenarioResult.
|
|
94
|
-
const verdicts = effectiveVerdicts(r.scenarios);
|
|
95
|
-
const cells = {};
|
|
96
|
-
r.scenarios.forEach((s, i) => {
|
|
97
|
-
cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
|
|
98
|
-
});
|
|
99
|
-
runs.push({ timestamp: r.timestamp, label: r.label, grade: r.effective_grade, cells });
|
|
100
|
-
model = r.model; // last successfully-read run (kept is ascending) wins
|
|
101
|
-
}
|
|
102
|
-
// `skipped` is per tag (an unreadable run has no knowable mode), so a tag with
|
|
103
|
-
// two series reports the same count on both — the alternative is attributing a
|
|
104
|
-
// parse failure to a mode nobody could read.
|
|
105
|
-
models.push({ model, tag, mode, runs, truncated, skipped });
|
|
106
|
-
}
|
|
107
|
+
for (const group of collectScoredRuns(skillDir)) {
|
|
108
|
+
const truncated = group.runs.length > limit;
|
|
109
|
+
const kept = group.runs.slice(-limit); // most recent `limit`, newest last
|
|
110
|
+
const runs = [];
|
|
111
|
+
for (const r of kept) {
|
|
112
|
+
// effectiveVerdicts is the single source of truth for the override-aware
|
|
113
|
+
// verdict/suspect rule (suspect = s.suspect && s.override == null — an override
|
|
114
|
+
// resolves the misfire); zip in flakiness from the matching ScenarioResult.
|
|
115
|
+
const verdicts = effectiveVerdicts(r.scenarios);
|
|
116
|
+
const cells = {};
|
|
117
|
+
r.scenarios.forEach((s, i) => {
|
|
118
|
+
cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
|
|
119
|
+
});
|
|
120
|
+
runs.push({ timestamp: r.timestamp, label: r.label, grade: r.effective_grade, cells });
|
|
107
121
|
}
|
|
122
|
+
models.push({ model: group.model, tag: group.tag, mode: group.mode, runs, truncated, skipped: group.skipped });
|
|
108
123
|
}
|
|
109
124
|
return { skill: spec.skill, scenarios, models };
|
|
110
125
|
}
|
package/dist/workspace.d.ts
CHANGED
|
@@ -47,3 +47,39 @@ export declare function createWorkspace(kind: WorkspaceKind, opts: {
|
|
|
47
47
|
specDir: string;
|
|
48
48
|
remote?: boolean;
|
|
49
49
|
}): Workspace;
|
|
50
|
+
/**
|
|
51
|
+
* A content snapshot of every file in a workspace: relative path → sha256.
|
|
52
|
+
*
|
|
53
|
+
* Taken immediately before the model runs, and compared after. Three reasons it
|
|
54
|
+
* is a content walk rather than the obvious `git diff`:
|
|
55
|
+
*
|
|
56
|
+
* 1. **`git add -A` honours `.gitignore`.** The canonical assertion this feature
|
|
57
|
+
* exists for is `unchanged_paths: [".env"]`, and `.env` is the canonical
|
|
58
|
+
* gitignored file. Overwriting it produced an empty diff, which read as
|
|
59
|
+
* "observed, nothing changed" — a safety gate reporting green on precisely
|
|
60
|
+
* the file class that motivated it. It also covers `TOOL_ARTIFACTS`, which
|
|
61
|
+
* the harness itself writes into `.git/info/exclude`.
|
|
62
|
+
* 2. **The baseline commit is not the pre-run state.** `createWorkspace` applies
|
|
63
|
+
* a fixture's `_staged/` and `_uncommitted/` trees AFTER `gitBaseline`, so
|
|
64
|
+
* those files are already dirty before the model does anything. Diffing
|
|
65
|
+
* against the baseline blamed the model for the fixture's own contents.
|
|
66
|
+
* 3. **The harness writes to the workspace too** — `runSeeded` copies the
|
|
67
|
+
* post-test in. A snapshot taken after setup contains it, so it cancels out
|
|
68
|
+
* instead of being attributed to the model.
|
|
69
|
+
*/
|
|
70
|
+
export type PathSnapshot = Map<string, string>;
|
|
71
|
+
/** Snapshot a workspace, or null when there is no workspace to look at. */
|
|
72
|
+
export declare function snapshotPaths(cwd: string | undefined, kind: WorkspaceKind): PathSnapshot | null;
|
|
73
|
+
/**
|
|
74
|
+
* Paths whose content changed between two snapshots — added, removed, modified.
|
|
75
|
+
*
|
|
76
|
+
* The ONLY evidence `assert.trace.unchanged_paths` can honestly rest on. A tool
|
|
77
|
+
* trace proves which tool was called with which arguments; it cannot prove what
|
|
78
|
+
* that tool then did to the filesystem, so a path policy has to be checked
|
|
79
|
+
* against the filesystem.
|
|
80
|
+
*
|
|
81
|
+
* Returns null when either snapshot is missing — the caller must treat that as
|
|
82
|
+
* MISSING EVIDENCE, never as "nothing changed". An empty array means
|
|
83
|
+
* observed-and-nothing-changed; null means we could not look.
|
|
84
|
+
*/
|
|
85
|
+
export declare function diffSnapshots(before: PathSnapshot | null, after: PathSnapshot | null): string[] | null;
|
package/dist/workspace.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { isAbsolute, join, resolve } from "node:path";
|
|
5
6
|
const GIT_TIMEOUT_MS = 30_000;
|
|
@@ -189,4 +190,64 @@ export function createWorkspace(kind, opts) {
|
|
|
189
190
|
}
|
|
190
191
|
return { cwd, cleanup };
|
|
191
192
|
}
|
|
193
|
+
/** Never the model's work, and never worth hashing. */
|
|
194
|
+
const SNAPSHOT_SKIP = new Set([".git", "node_modules", "coverage", ".vitest"]);
|
|
195
|
+
/** Snapshot a workspace, or null when there is no workspace to look at. */
|
|
196
|
+
export function snapshotPaths(cwd, kind) {
|
|
197
|
+
if (kind === "none" || !cwd || !existsSync(cwd))
|
|
198
|
+
return null;
|
|
199
|
+
const out = new Map();
|
|
200
|
+
const walk = (dir, prefix) => {
|
|
201
|
+
let entries;
|
|
202
|
+
try {
|
|
203
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return; // an unreadable subtree is not evidence about the model
|
|
207
|
+
}
|
|
208
|
+
for (const e of entries) {
|
|
209
|
+
if (SNAPSHOT_SKIP.has(e.name))
|
|
210
|
+
continue;
|
|
211
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
212
|
+
const abs = join(dir, e.name);
|
|
213
|
+
if (e.isDirectory()) {
|
|
214
|
+
walk(abs, rel);
|
|
215
|
+
}
|
|
216
|
+
else if (e.isFile()) {
|
|
217
|
+
try {
|
|
218
|
+
out.set(rel, createHash("sha256").update(readFileSync(abs)).digest("hex"));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
out.set(rel, "<unreadable>");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
walk(cwd, "");
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Paths whose content changed between two snapshots — added, removed, modified.
|
|
231
|
+
*
|
|
232
|
+
* The ONLY evidence `assert.trace.unchanged_paths` can honestly rest on. A tool
|
|
233
|
+
* trace proves which tool was called with which arguments; it cannot prove what
|
|
234
|
+
* that tool then did to the filesystem, so a path policy has to be checked
|
|
235
|
+
* against the filesystem.
|
|
236
|
+
*
|
|
237
|
+
* Returns null when either snapshot is missing — the caller must treat that as
|
|
238
|
+
* MISSING EVIDENCE, never as "nothing changed". An empty array means
|
|
239
|
+
* observed-and-nothing-changed; null means we could not look.
|
|
240
|
+
*/
|
|
241
|
+
export function diffSnapshots(before, after) {
|
|
242
|
+
if (!before || !after)
|
|
243
|
+
return null;
|
|
244
|
+
const changed = new Set();
|
|
245
|
+
for (const [path, hash] of after)
|
|
246
|
+
if (before.get(path) !== hash)
|
|
247
|
+
changed.add(path);
|
|
248
|
+
for (const path of before.keys())
|
|
249
|
+
if (!after.has(path))
|
|
250
|
+
changed.add(path);
|
|
251
|
+
return [...changed].sort();
|
|
252
|
+
}
|
|
192
253
|
//# sourceMappingURL=workspace.js.map
|