@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 +19 -0
- package/dist/lift.js +64 -2
- package/package.json +1 -1
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
|
-
|
|
93
|
-
|
|
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(" · ");
|