@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.
Files changed (3) hide show
  1. package/dist/lift.d.ts +42 -15
  2. package/dist/lift.js +105 -4
  3. 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.0",
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": { ".": "./dist/index.js" },
10
- "keywords": ["agent-skills", "skill", "llm", "testing", "eval", "grading", "pi", "ci", "harness"],
11
- "files": ["dist/**/*.js", "dist/**/*.d.ts", "LICENSE", "README.md"],
12
- "repository": { "type": "git", "url": "git+https://github.com/mojomanyana/skill-harness.git" },
13
- "publishConfig": { "access": "public" },
14
- "engines": { "node": ">=20" },
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": { "js-yaml": "^4.1.0" }
42
+ "dependencies": {
43
+ "js-yaml": "^4.1.0"
44
+ }
19
45
  }