@skill-harness/core 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lift.d.ts +42 -15
- package/dist/lift.js +105 -4
- package/package.json +34 -8
package/dist/lift.d.ts
CHANGED
|
@@ -37,10 +37,49 @@ export interface Lift {
|
|
|
37
37
|
/** Ids the green run covered that the red baseline did not (and vice versa). */
|
|
38
38
|
greenOnly: string[];
|
|
39
39
|
redOnly: string[];
|
|
40
|
+
/**
|
|
41
|
+
* Ids both runs covered that a lift cannot speak to, because the harness runs
|
|
42
|
+
* them identically in red and green. Reported rather than compared: folding them
|
|
43
|
+
* in would credit the red side with passes the skill itself produced. See
|
|
44
|
+
* `LiftOptions.modeInsensitive`.
|
|
45
|
+
*/
|
|
46
|
+
modeInsensitive: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Ids both runs covered whose two verdicts were produced by different
|
|
49
|
+
* aggregations, so the comparison is not like-for-like. Excluded rather than
|
|
50
|
+
* compared: see `comparableAggregation`.
|
|
51
|
+
*/
|
|
52
|
+
aggregationMismatch: LiftAggregationMismatch[];
|
|
40
53
|
/** True when either side was an `--only` run, so coverage is a subset by construction. */
|
|
41
54
|
partial: boolean;
|
|
42
55
|
cells: Record<string, LiftCell>;
|
|
43
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* How one side produced a scenario's verdict: over how many reps, and under which
|
|
59
|
+
* majority threshold (null when no aggregation happened).
|
|
60
|
+
*/
|
|
61
|
+
export interface AggregationShape {
|
|
62
|
+
reps: number;
|
|
63
|
+
threshold: number | null;
|
|
64
|
+
}
|
|
65
|
+
export interface LiftAggregationMismatch {
|
|
66
|
+
id: string;
|
|
67
|
+
red: AggregationShape;
|
|
68
|
+
green: AggregationShape;
|
|
69
|
+
}
|
|
70
|
+
export interface LiftOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Scenario ids whose red and green runs are the same run by construction, so
|
|
73
|
+
* comparing them measures nothing.
|
|
74
|
+
*
|
|
75
|
+
* The case that exists today is `system_prompt_file`: the pi adapter treats an
|
|
76
|
+
* agent-file scenario's file AS the system prompt and passes `--no-skills`
|
|
77
|
+
* *whatever the mode*, so the skill is loaded on both sides. Left in, such a
|
|
78
|
+
* cell lands in `kept` (or `both-fail`) and drags the denominator down —
|
|
79
|
+
* understating lift with evidence that the skill worked.
|
|
80
|
+
*/
|
|
81
|
+
modeInsensitive?: Iterable<string>;
|
|
82
|
+
}
|
|
44
83
|
/**
|
|
45
84
|
* Compare a red (baseline, skill off) run against a green (skill active) run of
|
|
46
85
|
* the same model: the "does this skill actually do anything?" measurement.
|
|
@@ -48,22 +87,10 @@ export interface Lift {
|
|
|
48
87
|
* Both sides go through `effectiveVerdicts`, so an author override is what
|
|
49
88
|
* counts and an override resolves a misfire — the same rule scoring uses. Only
|
|
50
89
|
* the intersection of scenario ids is compared; a lift cannot speak to a
|
|
51
|
-
* scenario one side never ran
|
|
90
|
+
* scenario one side never ran, nor to one the harness ran identically in both
|
|
91
|
+
* modes (`opts.modeInsensitive`).
|
|
52
92
|
*/
|
|
53
|
-
export declare function computeLift(red: ResultsFile, green: ResultsFile): Lift;
|
|
93
|
+
export declare function computeLift(red: ResultsFile, green: ResultsFile, opts?: LiftOptions): Lift;
|
|
54
94
|
/** One line for a human: what the skill did, and what it cost. */
|
|
55
95
|
export declare function liftHeadline(lift: Lift): string;
|
|
56
|
-
/**
|
|
57
|
-
* Per model-tag under <skillDir>/tests/results/, pair the most recent red run
|
|
58
|
-
* with the most recent green run and compute the lift.
|
|
59
|
-
*
|
|
60
|
-
* Deliberately derived on read rather than persisted into results.yaml: a lift
|
|
61
|
-
* is a fact about a *pair* of runs, so caching it inside one run's file would go
|
|
62
|
-
* stale the moment a new baseline lands — the stale-scorecard failure mode
|
|
63
|
-
* `source_hashes` exists to prevent. Deriving also means lift works
|
|
64
|
-
* retroactively on results already committed by 0.1.x/0.2.0 users.
|
|
65
|
-
*
|
|
66
|
-
* A tag with no red baseline is omitted entirely rather than reported as a zero
|
|
67
|
-
* lift: "not measured" and "measured no effect" are different claims.
|
|
68
|
-
*/
|
|
69
96
|
export declare function collectLift(skillDir: string): Lift[];
|
package/dist/lift.js
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readResults, effectiveVerdicts } from "./results.js";
|
|
4
|
+
import { loadSpec } from "./spec.js";
|
|
5
|
+
function aggregationShape(s) {
|
|
6
|
+
const reps = s.reps ?? 1;
|
|
7
|
+
// At one rep `outcomesToResult` keeps the single judge verdict and never calls
|
|
8
|
+
// `aggregateReps`, so a `pass_threshold` sitting beside it was applied to
|
|
9
|
+
// nothing. Normalizing it away keeps a stray field from faking a mismatch.
|
|
10
|
+
return { reps, threshold: reps > 1 ? s.pass_threshold ?? null : null };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Whether two verdicts were produced the same way, and so mean the same thing.
|
|
14
|
+
*
|
|
15
|
+
* A one-rep verdict is a single draw; a three-rep verdict is a majority over
|
|
16
|
+
* three. Across that gap `red FAIL -> green PASS` can be sampling alone, and
|
|
17
|
+
* `gained` would be reporting the harness's own asymmetry as skill value — the
|
|
18
|
+
* inverse of the `modeInsensitive` error, and pointing the number the *other*
|
|
19
|
+
* way. The threshold counts too: 1-of-3 versus 3-of-3 is a different majority
|
|
20
|
+
* policy at the same N, so the aggregate is not the same measurement.
|
|
21
|
+
*/
|
|
22
|
+
function comparableAggregation(red, green) {
|
|
23
|
+
return red.reps === green.reps && red.threshold === green.threshold;
|
|
24
|
+
}
|
|
4
25
|
/** A verdict that carries real evidence about the task, rather than about the harness or the judge. */
|
|
5
26
|
function conclusive(verdict, suspect) {
|
|
6
27
|
// ERROR is a harness failure (timeout, empty reply) — it says nothing about
|
|
@@ -30,21 +51,40 @@ function classify(red, green) {
|
|
|
30
51
|
* Both sides go through `effectiveVerdicts`, so an author override is what
|
|
31
52
|
* counts and an override resolves a misfire — the same rule scoring uses. Only
|
|
32
53
|
* the intersection of scenario ids is compared; a lift cannot speak to a
|
|
33
|
-
* scenario one side never ran
|
|
54
|
+
* scenario one side never ran, nor to one the harness ran identically in both
|
|
55
|
+
* modes (`opts.modeInsensitive`).
|
|
34
56
|
*/
|
|
35
|
-
export function computeLift(red, green) {
|
|
57
|
+
export function computeLift(red, green, opts = {}) {
|
|
58
|
+
const insensitive = new Set(opts.modeInsensitive ?? []);
|
|
36
59
|
const redV = new Map(effectiveVerdicts(red.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
|
|
37
60
|
const greenV = new Map(effectiveVerdicts(green.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
|
|
61
|
+
const redShape = new Map(red.scenarios.map((s) => [s.id, aggregationShape(s)]));
|
|
62
|
+
const greenShape = new Map(green.scenarios.map((s) => [s.id, aggregationShape(s)]));
|
|
38
63
|
const cells = {};
|
|
39
64
|
const counts = { gained: 0, regressed: 0, kept: 0, "both-fail": 0, inconclusive: 0 };
|
|
40
65
|
let redPassed = 0;
|
|
41
66
|
let greenPassed = 0;
|
|
42
67
|
// Green order drives display order (it is the run the author is looking at),
|
|
43
68
|
// restricted to ids the red baseline also covered.
|
|
69
|
+
const modeInsensitive = [];
|
|
70
|
+
const aggregationMismatch = [];
|
|
44
71
|
for (const [id, g] of greenV) {
|
|
45
72
|
const r = redV.get(id);
|
|
46
73
|
if (!r)
|
|
47
74
|
continue;
|
|
75
|
+
if (insensitive.has(id)) {
|
|
76
|
+
modeInsensitive.push(id);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// Checked before classification, and reported separately, for the reason
|
|
80
|
+
// modeInsensitive is: there is no honest bucket for two verdicts that were
|
|
81
|
+
// not measured the same way.
|
|
82
|
+
const rShape = redShape.get(id) ?? { reps: 1, threshold: null };
|
|
83
|
+
const gShape = greenShape.get(id) ?? { reps: 1, threshold: null };
|
|
84
|
+
if (!comparableAggregation(rShape, gShape)) {
|
|
85
|
+
aggregationMismatch.push({ id, red: rShape, green: gShape });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
48
88
|
const cls = classify(r, g);
|
|
49
89
|
cells[id] = { red: r.verdict, redSuspect: r.suspect, green: g.verdict, class: cls };
|
|
50
90
|
counts[cls]++;
|
|
@@ -71,14 +111,48 @@ export function computeLift(red, green) {
|
|
|
71
111
|
delta: greenPassed - redPassed,
|
|
72
112
|
greenOnly: [...greenV.keys()].filter((id) => !redV.has(id)),
|
|
73
113
|
redOnly: [...redV.keys()].filter((id) => !greenV.has(id)),
|
|
114
|
+
modeInsensitive,
|
|
115
|
+
aggregationMismatch,
|
|
74
116
|
partial: Boolean(red.partial || green.partial),
|
|
75
117
|
cells,
|
|
76
118
|
};
|
|
77
119
|
}
|
|
120
|
+
function reps(n) {
|
|
121
|
+
return n === 1 ? "1 rep" : `${n} reps`;
|
|
122
|
+
}
|
|
123
|
+
/** What differs between the two sides, in the words of the flag that caused it. */
|
|
124
|
+
function describeMismatch(ms) {
|
|
125
|
+
const distinct = new Set(ms.map((m) => m.red.reps !== m.green.reps
|
|
126
|
+
? `red ${reps(m.red.reps)} vs ${reps(m.green.reps)}`
|
|
127
|
+
: `red pass threshold ${m.red.threshold} vs ${m.green.threshold}`));
|
|
128
|
+
return distinct.size === 1 ? [...distinct][0] : "red and green aggregated differently";
|
|
129
|
+
}
|
|
130
|
+
/** The one command that would make the comparison measurable. */
|
|
131
|
+
function mismatchRemedy(ms) {
|
|
132
|
+
const greenReps = new Set(ms.map((m) => m.green.reps));
|
|
133
|
+
if (greenReps.size === 1 && ms.every((m) => m.red.reps !== m.green.reps)) {
|
|
134
|
+
return `re-run the baseline with --reps ${[...greenReps][0]}`;
|
|
135
|
+
}
|
|
136
|
+
return "re-measure both sides the same way";
|
|
137
|
+
}
|
|
78
138
|
/** One line for a human: what the skill did, and what it cost. */
|
|
79
139
|
export function liftHeadline(lift) {
|
|
80
|
-
if (lift.compared === 0)
|
|
140
|
+
if (lift.compared === 0) {
|
|
141
|
+
// Excluded-but-shared is not the same as never-shared. Claiming the runs had
|
|
142
|
+
// no scenario in common would hide the reason the lift is empty.
|
|
143
|
+
const mismatched = lift.aggregationMismatch.length;
|
|
144
|
+
const insensitive = lift.modeInsensitive.length;
|
|
145
|
+
if (mismatched > 0 && insensitive > 0) {
|
|
146
|
+
return `nothing comparable (${insensitive} run identically in both modes, ${mismatched} ${describeMismatch(lift.aggregationMismatch)})`;
|
|
147
|
+
}
|
|
148
|
+
if (mismatched > 0) {
|
|
149
|
+
return `nothing comparable (${mismatched} shared, ${describeMismatch(lift.aggregationMismatch)} — ${mismatchRemedy(lift.aggregationMismatch)})`;
|
|
150
|
+
}
|
|
151
|
+
if (insensitive > 0) {
|
|
152
|
+
return `nothing comparable (${insensitive} shared, all run identically in both modes)`;
|
|
153
|
+
}
|
|
81
154
|
return "no shared scenarios to compare";
|
|
155
|
+
}
|
|
82
156
|
// Everything inconclusive is NOT "no effect" — it is no measurement. Saying
|
|
83
157
|
// "no measured effect" here would be the same not-measured/measured-no-effect
|
|
84
158
|
// conflation this module refuses to make when a red baseline is missing.
|
|
@@ -98,6 +172,12 @@ export function liftHeadline(lift) {
|
|
|
98
172
|
}
|
|
99
173
|
if (lift.inconclusive > 0)
|
|
100
174
|
segments.push(`${lift.inconclusive} inconclusive`);
|
|
175
|
+
if (lift.modeInsensitive.length > 0) {
|
|
176
|
+
segments.push(`${lift.modeInsensitive.length} not comparable (same run in both modes)`);
|
|
177
|
+
}
|
|
178
|
+
if (lift.aggregationMismatch.length > 0) {
|
|
179
|
+
segments.push(`${lift.aggregationMismatch.length} not comparable (${describeMismatch(lift.aggregationMismatch)})`);
|
|
180
|
+
}
|
|
101
181
|
if (lift.partial)
|
|
102
182
|
segments.push("partial run");
|
|
103
183
|
return segments.join(" · ");
|
|
@@ -124,10 +204,31 @@ function isDir(p) {
|
|
|
124
204
|
* A tag with no red baseline is omitted entirely rather than reported as a zero
|
|
125
205
|
* lift: "not measured" and "measured no effect" are different claims.
|
|
126
206
|
*/
|
|
207
|
+
/**
|
|
208
|
+
* Scenario ids the harness runs identically in red and green, read from the spec
|
|
209
|
+
* rather than from results.yaml: a lift is derived on read, so this has to work
|
|
210
|
+
* on runs recorded before the field existed — and `scenario:<id>` source hashes
|
|
211
|
+
* fold the value in without preserving it.
|
|
212
|
+
*
|
|
213
|
+
* Never throws: an unparseable spec must degrade to "nothing excluded" rather
|
|
214
|
+
* than take down a view that is otherwise readable from the results alone.
|
|
215
|
+
*/
|
|
216
|
+
function modeInsensitiveIds(skillDir) {
|
|
217
|
+
const specPath = join(skillDir, "tests", "specification.yaml");
|
|
218
|
+
if (!existsSync(specPath))
|
|
219
|
+
return [];
|
|
220
|
+
try {
|
|
221
|
+
return loadSpec(specPath).scenarios.filter((s) => s.systemPromptFile).map((s) => s.id);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
127
227
|
export function collectLift(skillDir) {
|
|
128
228
|
const resultsRoot = join(skillDir, "tests", "results");
|
|
129
229
|
if (!existsSync(resultsRoot))
|
|
130
230
|
return [];
|
|
231
|
+
const modeInsensitive = modeInsensitiveIds(skillDir);
|
|
131
232
|
const lifts = [];
|
|
132
233
|
for (const tag of readdirSync(resultsRoot).filter((n) => isDir(join(resultsRoot, n))).sort()) {
|
|
133
234
|
const tagDir = join(resultsRoot, tag);
|
|
@@ -156,7 +257,7 @@ export function collectLift(skillDir) {
|
|
|
156
257
|
}
|
|
157
258
|
if (!red || !green)
|
|
158
259
|
continue;
|
|
159
|
-
lifts.push({ ...computeLift(red, green), tag });
|
|
260
|
+
lifts.push({ ...computeLift(red, green, { modeInsensitive }), tag });
|
|
160
261
|
}
|
|
161
262
|
return lifts;
|
|
162
263
|
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skill-harness/core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
|
-
"exports": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"agent-skills",
|
|
14
|
+
"skill",
|
|
15
|
+
"llm",
|
|
16
|
+
"testing",
|
|
17
|
+
"eval",
|
|
18
|
+
"grading",
|
|
19
|
+
"pi",
|
|
20
|
+
"ci",
|
|
21
|
+
"harness"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"dist/**/*.js",
|
|
25
|
+
"dist/**/*.d.ts",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/mojomanyana/skill-harness.git"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
15
39
|
"scripts": {
|
|
16
40
|
"prepack": "cp ../../LICENSE ./LICENSE"
|
|
17
41
|
},
|
|
18
|
-
"dependencies": {
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"js-yaml": "^4.1.0"
|
|
44
|
+
}
|
|
19
45
|
}
|