@skill-harness/core 0.3.1 → 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 CHANGED
@@ -44,10 +44,29 @@ export interface Lift {
44
44
  * `LiftOptions.modeInsensitive`.
45
45
  */
46
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[];
47
53
  /** True when either side was an `--only` run, so coverage is a subset by construction. */
48
54
  partial: boolean;
49
55
  cells: Record<string, LiftCell>;
50
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
+ }
51
70
  export interface LiftOptions {
52
71
  /**
53
72
  * Scenario ids whose red and green runs are the same run by construction, so
package/dist/lift.js CHANGED
@@ -2,6 +2,26 @@ import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { readResults, effectiveVerdicts } from "./results.js";
4
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
+ }
5
25
  /** A verdict that carries real evidence about the task, rather than about the harness or the judge. */
6
26
  function conclusive(verdict, suspect) {
7
27
  // ERROR is a harness failure (timeout, empty reply) — it says nothing about
@@ -38,6 +58,8 @@ export function computeLift(red, green, opts = {}) {
38
58
  const insensitive = new Set(opts.modeInsensitive ?? []);
39
59
  const redV = new Map(effectiveVerdicts(red.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
40
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)]));
41
63
  const cells = {};
42
64
  const counts = { gained: 0, regressed: 0, kept: 0, "both-fail": 0, inconclusive: 0 };
43
65
  let redPassed = 0;
@@ -45,6 +67,7 @@ export function computeLift(red, green, opts = {}) {
45
67
  // Green order drives display order (it is the run the author is looking at),
46
68
  // restricted to ids the red baseline also covered.
47
69
  const modeInsensitive = [];
70
+ const aggregationMismatch = [];
48
71
  for (const [id, g] of greenV) {
49
72
  const r = redV.get(id);
50
73
  if (!r)
@@ -53,6 +76,15 @@ export function computeLift(red, green, opts = {}) {
53
76
  modeInsensitive.push(id);
54
77
  continue;
55
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
+ }
56
88
  const cls = classify(r, g);
57
89
  cells[id] = { red: r.verdict, redSuspect: r.suspect, green: g.verdict, class: cls };
58
90
  counts[cls]++;
@@ -80,17 +112,44 @@ export function computeLift(red, green, opts = {}) {
80
112
  greenOnly: [...greenV.keys()].filter((id) => !redV.has(id)),
81
113
  redOnly: [...redV.keys()].filter((id) => !greenV.has(id)),
82
114
  modeInsensitive,
115
+ aggregationMismatch,
83
116
  partial: Boolean(red.partial || green.partial),
84
117
  cells,
85
118
  };
86
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
+ }
87
138
  /** One line for a human: what the skill did, and what it cost. */
88
139
  export function liftHeadline(lift) {
89
140
  if (lift.compared === 0) {
90
141
  // Excluded-but-shared is not the same as never-shared. Claiming the runs had
91
142
  // 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)`;
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)`;
94
153
  }
95
154
  return "no shared scenarios to compare";
96
155
  }
@@ -116,6 +175,9 @@ export function liftHeadline(lift) {
116
175
  if (lift.modeInsensitive.length > 0) {
117
176
  segments.push(`${lift.modeInsensitive.length} not comparable (same run in both modes)`);
118
177
  }
178
+ if (lift.aggregationMismatch.length > 0) {
179
+ segments.push(`${lift.aggregationMismatch.length} not comparable (${describeMismatch(lift.aggregationMismatch)})`);
180
+ }
119
181
  if (lift.partial)
120
182
  segments.push("partial run");
121
183
  return segments.join(" · ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.3.1",
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",