@skill-harness/core 0.3.0 → 0.3.1

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 CHANGED
@@ -37,10 +37,30 @@ 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[];
40
47
  /** True when either side was an `--only` run, so coverage is a subset by construction. */
41
48
  partial: boolean;
42
49
  cells: Record<string, LiftCell>;
43
50
  }
51
+ export interface LiftOptions {
52
+ /**
53
+ * Scenario ids whose red and green runs are the same run by construction, so
54
+ * comparing them measures nothing.
55
+ *
56
+ * The case that exists today is `system_prompt_file`: the pi adapter treats an
57
+ * agent-file scenario's file AS the system prompt and passes `--no-skills`
58
+ * *whatever the mode*, so the skill is loaded on both sides. Left in, such a
59
+ * cell lands in `kept` (or `both-fail`) and drags the denominator down —
60
+ * understating lift with evidence that the skill worked.
61
+ */
62
+ modeInsensitive?: Iterable<string>;
63
+ }
44
64
  /**
45
65
  * Compare a red (baseline, skill off) run against a green (skill active) run of
46
66
  * the same model: the "does this skill actually do anything?" measurement.
@@ -48,22 +68,10 @@ export interface Lift {
48
68
  * Both sides go through `effectiveVerdicts`, so an author override is what
49
69
  * counts and an override resolves a misfire — the same rule scoring uses. Only
50
70
  * the intersection of scenario ids is compared; a lift cannot speak to a
51
- * scenario one side never ran.
71
+ * scenario one side never ran, nor to one the harness ran identically in both
72
+ * modes (`opts.modeInsensitive`).
52
73
  */
53
- export declare function computeLift(red: ResultsFile, green: ResultsFile): Lift;
74
+ export declare function computeLift(red: ResultsFile, green: ResultsFile, opts?: LiftOptions): Lift;
54
75
  /** One line for a human: what the skill did, and what it cost. */
55
76
  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
77
  export declare function collectLift(skillDir: string): Lift[];
package/dist/lift.js CHANGED
@@ -1,6 +1,7 @@
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";
4
5
  /** A verdict that carries real evidence about the task, rather than about the harness or the judge. */
5
6
  function conclusive(verdict, suspect) {
6
7
  // ERROR is a harness failure (timeout, empty reply) — it says nothing about
@@ -30,9 +31,11 @@ function classify(red, green) {
30
31
  * Both sides go through `effectiveVerdicts`, so an author override is what
31
32
  * counts and an override resolves a misfire — the same rule scoring uses. Only
32
33
  * the intersection of scenario ids is compared; a lift cannot speak to a
33
- * scenario one side never ran.
34
+ * scenario one side never ran, nor to one the harness ran identically in both
35
+ * modes (`opts.modeInsensitive`).
34
36
  */
35
- export function computeLift(red, green) {
37
+ export function computeLift(red, green, opts = {}) {
38
+ const insensitive = new Set(opts.modeInsensitive ?? []);
36
39
  const redV = new Map(effectiveVerdicts(red.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
37
40
  const greenV = new Map(effectiveVerdicts(green.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
38
41
  const cells = {};
@@ -41,10 +44,15 @@ export function computeLift(red, green) {
41
44
  let greenPassed = 0;
42
45
  // Green order drives display order (it is the run the author is looking at),
43
46
  // restricted to ids the red baseline also covered.
47
+ const modeInsensitive = [];
44
48
  for (const [id, g] of greenV) {
45
49
  const r = redV.get(id);
46
50
  if (!r)
47
51
  continue;
52
+ if (insensitive.has(id)) {
53
+ modeInsensitive.push(id);
54
+ continue;
55
+ }
48
56
  const cls = classify(r, g);
49
57
  cells[id] = { red: r.verdict, redSuspect: r.suspect, green: g.verdict, class: cls };
50
58
  counts[cls]++;
@@ -71,14 +79,21 @@ export function computeLift(red, green) {
71
79
  delta: greenPassed - redPassed,
72
80
  greenOnly: [...greenV.keys()].filter((id) => !redV.has(id)),
73
81
  redOnly: [...redV.keys()].filter((id) => !greenV.has(id)),
82
+ modeInsensitive,
74
83
  partial: Boolean(red.partial || green.partial),
75
84
  cells,
76
85
  };
77
86
  }
78
87
  /** One line for a human: what the skill did, and what it cost. */
79
88
  export function liftHeadline(lift) {
80
- if (lift.compared === 0)
89
+ if (lift.compared === 0) {
90
+ // Excluded-but-shared is not the same as never-shared. Claiming the runs had
91
+ // no scenario in common would hide the reason the lift is empty.
92
+ if (lift.modeInsensitive.length > 0) {
93
+ return `nothing comparable (${lift.modeInsensitive.length} shared, all run identically in both modes)`;
94
+ }
81
95
  return "no shared scenarios to compare";
96
+ }
82
97
  // Everything inconclusive is NOT "no effect" — it is no measurement. Saying
83
98
  // "no measured effect" here would be the same not-measured/measured-no-effect
84
99
  // conflation this module refuses to make when a red baseline is missing.
@@ -98,6 +113,9 @@ export function liftHeadline(lift) {
98
113
  }
99
114
  if (lift.inconclusive > 0)
100
115
  segments.push(`${lift.inconclusive} inconclusive`);
116
+ if (lift.modeInsensitive.length > 0) {
117
+ segments.push(`${lift.modeInsensitive.length} not comparable (same run in both modes)`);
118
+ }
101
119
  if (lift.partial)
102
120
  segments.push("partial run");
103
121
  return segments.join(" · ");
@@ -124,10 +142,31 @@ function isDir(p) {
124
142
  * A tag with no red baseline is omitted entirely rather than reported as a zero
125
143
  * lift: "not measured" and "measured no effect" are different claims.
126
144
  */
145
+ /**
146
+ * Scenario ids the harness runs identically in red and green, read from the spec
147
+ * rather than from results.yaml: a lift is derived on read, so this has to work
148
+ * on runs recorded before the field existed — and `scenario:<id>` source hashes
149
+ * fold the value in without preserving it.
150
+ *
151
+ * Never throws: an unparseable spec must degrade to "nothing excluded" rather
152
+ * than take down a view that is otherwise readable from the results alone.
153
+ */
154
+ function modeInsensitiveIds(skillDir) {
155
+ const specPath = join(skillDir, "tests", "specification.yaml");
156
+ if (!existsSync(specPath))
157
+ return [];
158
+ try {
159
+ return loadSpec(specPath).scenarios.filter((s) => s.systemPromptFile).map((s) => s.id);
160
+ }
161
+ catch {
162
+ return [];
163
+ }
164
+ }
127
165
  export function collectLift(skillDir) {
128
166
  const resultsRoot = join(skillDir, "tests", "results");
129
167
  if (!existsSync(resultsRoot))
130
168
  return [];
169
+ const modeInsensitive = modeInsensitiveIds(skillDir);
131
170
  const lifts = [];
132
171
  for (const tag of readdirSync(resultsRoot).filter((n) => isDir(join(resultsRoot, n))).sort()) {
133
172
  const tagDir = join(resultsRoot, tag);
@@ -156,7 +195,7 @@ export function collectLift(skillDir) {
156
195
  }
157
196
  if (!red || !green)
158
197
  continue;
159
- lifts.push({ ...computeLift(red, green), tag });
198
+ lifts.push({ ...computeLift(red, green, { modeInsensitive }), tag });
160
199
  }
161
200
  return lifts;
162
201
  }
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.1",
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
  }