@skill-harness/core 0.5.0 → 0.7.0
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/adapters/types.d.ts +37 -0
- package/dist/adjudication.d.ts +210 -0
- package/dist/adjudication.js +392 -0
- package/dist/affected.d.ts +88 -0
- package/dist/affected.js +222 -0
- package/dist/capture-trace-types.d.ts +228 -0
- package/dist/capture-trace-types.js +23 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +344 -0
- package/dist/execution-trace.d.ts +61 -0
- package/dist/execution-trace.js +299 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/instruction-coverage.d.ts +106 -0
- package/dist/instruction-coverage.js +253 -0
- package/dist/journal.d.ts +17 -0
- package/dist/lint.d.ts +16 -1
- package/dist/lint.js +52 -0
- package/dist/regate.js +80 -17
- package/dist/regrade.js +17 -3
- package/dist/report.d.ts +48 -0
- package/dist/report.js +39 -1
- package/dist/reps.d.ts +14 -1
- package/dist/reps.js +28 -2
- package/dist/rescore.js +11 -2
- package/dist/results.d.ts +128 -6
- package/dist/results.js +155 -6
- package/dist/run.d.ts +9 -1
- package/dist/run.js +129 -9
- package/dist/seeded.d.ts +11 -0
- package/dist/seeded.js +31 -7
- package/dist/sources.d.ts +26 -0
- package/dist/sources.js +82 -3
- package/dist/spec-write.d.ts +62 -0
- package/dist/spec-write.js +106 -0
- package/dist/spec.d.ts +29 -0
- package/dist/spec.js +55 -0
- package/dist/stability.d.ts +144 -0
- package/dist/stability.js +232 -0
- package/dist/trace-gates.d.ts +133 -0
- package/dist/trace-gates.js +519 -0
- package/dist/trends.d.ts +28 -0
- package/dist/trends.js +76 -61
- package/dist/workspace.d.ts +36 -0
- package/dist/workspace.js +61 -0
- package/package.json +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { type Spec } from "./spec.js";
|
|
2
|
+
import { type ScoredRunGroup } from "./trends.js";
|
|
3
|
+
import type { Verdict } from "./score.js";
|
|
4
|
+
/**
|
|
5
|
+
* Run-over-run verdict stability, per scenario, derived on read from committed
|
|
6
|
+
* history. No new measurement, nothing persisted.
|
|
7
|
+
*
|
|
8
|
+
* ## Why this exists
|
|
9
|
+
*
|
|
10
|
+
* Measured in the reference corpus (`plan`, deepseek, two consecutive full force runs,
|
|
11
|
+
* 2026-08-05 → 2026-08-06): **A5 went 3/3 PASS to 0/3 FAIL and D1 went 1/3 to 3/3, each
|
|
12
|
+
* run internally `flakiness 0.00`.** Two unanimous runs, opposite verdicts.
|
|
13
|
+
*
|
|
14
|
+
* `flakiness` is a within-run number: it measures how much the reps of ONE run
|
|
15
|
+
* disagreed. A scenario sitting on a behavioural boundary can be unanimous inside every
|
|
16
|
+
* run and still land on a different side each time — and then `flakiness 0.00` reads as
|
|
17
|
+
* confidence when it is the opposite. Nothing in a single `results.yaml` can see this,
|
|
18
|
+
* because the evidence is spread across files.
|
|
19
|
+
*
|
|
20
|
+
* ## What makes a flip a *stability* signal
|
|
21
|
+
*
|
|
22
|
+
* A verdict that changes because the scenario changed is not instability, it is an
|
|
23
|
+
* edit — and reporting edits as instability would make this feature noise. So a pair of
|
|
24
|
+
* adjacent runs is only compared when all of the following hold, and each rejection is
|
|
25
|
+
* reported with its reason rather than silently dropped:
|
|
26
|
+
*
|
|
27
|
+
* | gate | rejected as | why |
|
|
28
|
+
* |---|---|---|
|
|
29
|
+
* | both verdicts conclusive | `inconclusive` | an ERROR or unresolved misfire says nothing about behaviour |
|
|
30
|
+
* | same reps + pass threshold | `aggregation` | 1 draw vs a majority of 3 is not the same measurement (as in lift.ts) |
|
|
31
|
+
* | the scenario's own recorded sources identical | `sources` | different question, or different rubric |
|
|
32
|
+
* | those sources comparable at all | `unverified` | one run predates `source_hashes`, or the two use different key generations |
|
|
33
|
+
*
|
|
34
|
+
* **`SKILL.md` is deliberately NOT one of those gates.** In the measured case the skill
|
|
35
|
+
* text HAD changed — an edit aimed at a different scenario — while A5's own stimulus and
|
|
36
|
+
* rubric were byte-identical. Excluding that pair would have hidden the exact finding
|
|
37
|
+
* this feature was asked to surface. Such a flip is reported with `skillChanged`, and
|
|
38
|
+
* the note says what it means: either a side effect of that edit or a boundary cell.
|
|
39
|
+
* Which of the two it is cannot be told from the record, and pretending otherwise would
|
|
40
|
+
* be a guess dressed as a fact.
|
|
41
|
+
*
|
|
42
|
+
* Modes are never mixed (`collectScoredRuns` groups per mode), because placement moves
|
|
43
|
+
* verdicts on identical text — a green run and a force run are two deployments.
|
|
44
|
+
*/
|
|
45
|
+
/** One run's contribution to a scenario's history. */
|
|
46
|
+
export interface StabilityPoint {
|
|
47
|
+
timestamp: string;
|
|
48
|
+
label: string | null;
|
|
49
|
+
/** Override-aware, matching what the scorecard claims (an override IS the author's verdict). */
|
|
50
|
+
verdict: Verdict;
|
|
51
|
+
overridden: boolean;
|
|
52
|
+
/** True for an `--only` run: real evidence about this scenario, but not a full run. */
|
|
53
|
+
partial: boolean;
|
|
54
|
+
reps: number;
|
|
55
|
+
/**
|
|
56
|
+
* Every rep in this run agreed AND there was more than one rep. A single rep is not
|
|
57
|
+
* unanimous, it is one draw — the distinction the headline finding turns on.
|
|
58
|
+
*/
|
|
59
|
+
unanimous: boolean;
|
|
60
|
+
}
|
|
61
|
+
/** Why a pair of adjacent runs could, or could not, be compared. */
|
|
62
|
+
export type PairStatus = "compared" | "inconclusive" | "aggregation" | "sources" | "unverified";
|
|
63
|
+
export interface StabilityPair {
|
|
64
|
+
from: StabilityPoint;
|
|
65
|
+
to: StabilityPoint;
|
|
66
|
+
status: PairStatus;
|
|
67
|
+
/** The verdict changed. Only meaningful when `status === "compared"`. */
|
|
68
|
+
flipped: boolean;
|
|
69
|
+
/** The skill text differed between these two runs (not a rejection — see the module doc). */
|
|
70
|
+
skillChanged: boolean;
|
|
71
|
+
/** Human labels of the scenario's own sources that differed (`status === "sources"`). */
|
|
72
|
+
changedSources: string[];
|
|
73
|
+
/** A flip where BOTH runs were internally unanimous — invisible to within-run flakiness. */
|
|
74
|
+
unanimousFlip: boolean;
|
|
75
|
+
}
|
|
76
|
+
export type StabilityState = "stable" | "boundary" | "unmeasured";
|
|
77
|
+
export interface ScenarioStability {
|
|
78
|
+
id: string;
|
|
79
|
+
title: string;
|
|
80
|
+
critical: boolean;
|
|
81
|
+
tag: string;
|
|
82
|
+
mode: string;
|
|
83
|
+
model: string;
|
|
84
|
+
/** The window, chronologically ascending (oldest first). */
|
|
85
|
+
points: StabilityPoint[];
|
|
86
|
+
/** Adjacent pairs within the window, oldest first. */
|
|
87
|
+
pairs: StabilityPair[];
|
|
88
|
+
compared: number;
|
|
89
|
+
flips: number;
|
|
90
|
+
/** Of `flips`, how many happened across a SKILL.md edit. */
|
|
91
|
+
flipsAcrossSkillEdit: number;
|
|
92
|
+
/** Of `flips`, how many were between two internally-unanimous runs. */
|
|
93
|
+
unanimousFlips: number;
|
|
94
|
+
/**
|
|
95
|
+
* `flips / compared`, or null when nothing was comparable. 0 = never flipped in the
|
|
96
|
+
* window; 1 = flipped at every opportunity.
|
|
97
|
+
*
|
|
98
|
+
* Same polarity as `flakiness` (0 is the quiet end) on purpose, and named for the
|
|
99
|
+
* thing being counted rather than for its absence: `stability: 0.0` would have to
|
|
100
|
+
* mean "perfectly stable", which reads exactly backwards next to `flaky 0.00`.
|
|
101
|
+
*/
|
|
102
|
+
volatility: number | null;
|
|
103
|
+
state: StabilityState;
|
|
104
|
+
}
|
|
105
|
+
export interface StabilityOptions {
|
|
106
|
+
/** How many of the most recent scored runs to look at. Default 5. */
|
|
107
|
+
window?: number;
|
|
108
|
+
}
|
|
109
|
+
/** Derive stability for every scenario × tag × mode from an already-read history. */
|
|
110
|
+
export declare function stabilityFrom(groups: ScoredRunGroup[], spec: Spec, opts?: StabilityOptions): ScenarioStability[];
|
|
111
|
+
/**
|
|
112
|
+
* Read `<skillDir>/tests/results/` and derive run-over-run stability per scenario ×
|
|
113
|
+
* model tag × delivery mode. Free and offline: it reads committed results.yaml files
|
|
114
|
+
* and computes; it never runs a model, a judge, or a harness.
|
|
115
|
+
*
|
|
116
|
+
* Deliberately derived on read rather than stored in results.yaml, for the reason lift
|
|
117
|
+
* is: stability is a fact about a SET of runs, so a copy inside one run's file would be
|
|
118
|
+
* wrong the moment the next run lands — the stale-scorecard failure `source_hashes`
|
|
119
|
+
* exists to prevent. It also means this works retroactively on history recorded by
|
|
120
|
+
* every earlier version.
|
|
121
|
+
*/
|
|
122
|
+
export declare function collectStability(skillDir: string, opts?: StabilityOptions): ScenarioStability[];
|
|
123
|
+
/** Scenarios that flipped at least once — the cells a single run reads as too certain. */
|
|
124
|
+
export declare function boundaryCells(all: ScenarioStability[]): ScenarioStability[];
|
|
125
|
+
/**
|
|
126
|
+
* The window as one readable string: `PASS!→FAIL!` or `FAIL⋯PASS→PASS!`.
|
|
127
|
+
*
|
|
128
|
+
* `→` is a step this comparison counted; `⋯` is a step it rejected (an edit, a
|
|
129
|
+
* different aggregation, unverifiable hashes); `!` marks a run whose reps were
|
|
130
|
+
* internally unanimous. The two arrows have to differ, or a path reading `FAIL→PASS`
|
|
131
|
+
* would sit next to "held its verdict" and look like a contradiction — the window shows
|
|
132
|
+
* every run, but only some of the steps between them are evidence.
|
|
133
|
+
*/
|
|
134
|
+
export declare const PATH_LEGEND = "\u2192 comparable step \u00B7 \u22EF step not comparable \u00B7 ! that run's reps were unanimous";
|
|
135
|
+
export declare function verdictPath(s: ScenarioStability): string;
|
|
136
|
+
/**
|
|
137
|
+
* The one-line human statement. This string is the feature: a number nobody can read
|
|
138
|
+
* ("volatility 1.00") would leave the reader exactly where a single run left them.
|
|
139
|
+
*
|
|
140
|
+
* Every branch says what the record supports and no more — which of "the edit did it"
|
|
141
|
+
* and "the cell is bimodal" is true cannot be told from committed results, so the
|
|
142
|
+
* across-an-edit wording names both.
|
|
143
|
+
*/
|
|
144
|
+
export declare function stabilityNote(s: ScenarioStability): string;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadSpec } from "./spec.js";
|
|
3
|
+
import { effectiveVerdicts } from "./results.js";
|
|
4
|
+
import { collectScoredRuns } from "./trends.js";
|
|
5
|
+
import { describeSourceKey, scenarioSourceKeys, PERSONA_KEY, SKILL_KEY } from "./sources.js";
|
|
6
|
+
const DEFAULT_WINDOW = 5;
|
|
7
|
+
/** A verdict that carries evidence about the task rather than about the harness or judge. */
|
|
8
|
+
function conclusive(v) {
|
|
9
|
+
// Same rule lift.ts applies, for the same reason: ERROR is a harness failure and an
|
|
10
|
+
// unresolved misfire is a judge failure. Counting either as a side of a flip would
|
|
11
|
+
// report infrastructure noise as behavioural instability.
|
|
12
|
+
return !v.suspect && v.verdict !== "ERROR" && v.verdict !== "JUDGE-AMBIGUOUS";
|
|
13
|
+
}
|
|
14
|
+
function pointFor(r, s, verdict) {
|
|
15
|
+
const reps = s.reps ?? 1;
|
|
16
|
+
return {
|
|
17
|
+
timestamp: r.timestamp,
|
|
18
|
+
label: r.label,
|
|
19
|
+
verdict,
|
|
20
|
+
overridden: s.override != null,
|
|
21
|
+
partial: Boolean(r.partial),
|
|
22
|
+
reps,
|
|
23
|
+
unanimous: reps > 1 && s.flakiness === 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** reps + threshold, normalised the way lift.ts normalises it (a lone rep has no threshold). */
|
|
27
|
+
function shapeOf(s) {
|
|
28
|
+
const reps = s.reps ?? 1;
|
|
29
|
+
return JSON.stringify([reps, reps > 1 ? s.pass_threshold ?? null : null]);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Compare the recorded hashes of one scenario's own sources across two runs.
|
|
33
|
+
*
|
|
34
|
+
* Only keys BOTH runs recorded are compared: a key one side never hashed cannot be
|
|
35
|
+
* shown to be unchanged. `shared === 0` means unverifiable (a pre-`source_hashes` run,
|
|
36
|
+
* or a split-key run against a legacy combined-key one), which is reported as
|
|
37
|
+
* `unverified` rather than assumed identical — the whole value of this feature is that
|
|
38
|
+
* it does not claim more than the record supports.
|
|
39
|
+
*/
|
|
40
|
+
function compareSources(a, b, keys) {
|
|
41
|
+
if (!a || !b)
|
|
42
|
+
return { shared: 0, changed: [] };
|
|
43
|
+
let shared = 0;
|
|
44
|
+
const changed = [];
|
|
45
|
+
for (const key of keys) {
|
|
46
|
+
const va = a[key];
|
|
47
|
+
const vb = b[key];
|
|
48
|
+
if (va === undefined || vb === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
shared++;
|
|
51
|
+
if (va !== vb)
|
|
52
|
+
changed.push(describeSourceKey(key));
|
|
53
|
+
}
|
|
54
|
+
return { shared, changed };
|
|
55
|
+
}
|
|
56
|
+
/** Derive one scenario's stability within one tag × mode group. */
|
|
57
|
+
function stabilityForScenario(group, scenario, window) {
|
|
58
|
+
// The window is over runs that HOLD this scenario: an `--only` run elsewhere in the
|
|
59
|
+
// history must not consume a slot and shrink the comparison to nothing.
|
|
60
|
+
const relevant = group.runs.filter((r) => r.scenarios.some((s) => s.id === scenario.id));
|
|
61
|
+
const kept = relevant.slice(-window);
|
|
62
|
+
// Skill-wide rubric: the persona moves every verdict in the skill, so it belongs with
|
|
63
|
+
// the scenario's own sources rather than with the SKILL.md caveat.
|
|
64
|
+
const keys = [...scenarioSourceKeys(scenario), PERSONA_KEY];
|
|
65
|
+
const points = [];
|
|
66
|
+
const raw = [];
|
|
67
|
+
for (const r of kept) {
|
|
68
|
+
const i = r.scenarios.findIndex((s) => s.id === scenario.id);
|
|
69
|
+
const s = r.scenarios[i];
|
|
70
|
+
const eff = effectiveVerdicts(r.scenarios)[i];
|
|
71
|
+
points.push(pointFor(r, s, eff.verdict));
|
|
72
|
+
raw.push({ r, s, ok: conclusive(eff) });
|
|
73
|
+
}
|
|
74
|
+
const pairs = [];
|
|
75
|
+
for (let i = 1; i < raw.length; i++) {
|
|
76
|
+
const prev = raw[i - 1];
|
|
77
|
+
const cur = raw[i];
|
|
78
|
+
const from = points[i - 1];
|
|
79
|
+
const to = points[i];
|
|
80
|
+
const skillChanged = prev.r.source_hashes?.[SKILL_KEY] !== undefined &&
|
|
81
|
+
cur.r.source_hashes?.[SKILL_KEY] !== undefined &&
|
|
82
|
+
prev.r.source_hashes[SKILL_KEY] !== cur.r.source_hashes[SKILL_KEY];
|
|
83
|
+
const base = { from, to, flipped: false, skillChanged, changedSources: [], unanimousFlip: false };
|
|
84
|
+
if (!prev.ok || !cur.ok) {
|
|
85
|
+
pairs.push({ ...base, status: "inconclusive" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (shapeOf(prev.s) !== shapeOf(cur.s)) {
|
|
89
|
+
pairs.push({ ...base, status: "aggregation" });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const src = compareSources(prev.r.source_hashes, cur.r.source_hashes, keys);
|
|
93
|
+
if (src.shared === 0) {
|
|
94
|
+
pairs.push({ ...base, status: "unverified" });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (src.changed.length > 0) {
|
|
98
|
+
pairs.push({ ...base, status: "sources", changedSources: src.changed });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const flipped = from.verdict !== to.verdict;
|
|
102
|
+
pairs.push({
|
|
103
|
+
...base,
|
|
104
|
+
status: "compared",
|
|
105
|
+
flipped,
|
|
106
|
+
unanimousFlip: flipped && from.unanimous && to.unanimous,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const compared = pairs.filter((p) => p.status === "compared").length;
|
|
110
|
+
const flipped = pairs.filter((p) => p.status === "compared" && p.flipped);
|
|
111
|
+
return {
|
|
112
|
+
id: scenario.id,
|
|
113
|
+
title: scenario.title,
|
|
114
|
+
critical: scenario.critical,
|
|
115
|
+
tag: group.tag,
|
|
116
|
+
mode: group.mode,
|
|
117
|
+
model: group.model,
|
|
118
|
+
points,
|
|
119
|
+
pairs,
|
|
120
|
+
compared,
|
|
121
|
+
flips: flipped.length,
|
|
122
|
+
flipsAcrossSkillEdit: flipped.filter((p) => p.skillChanged).length,
|
|
123
|
+
unanimousFlips: flipped.filter((p) => p.unanimousFlip).length,
|
|
124
|
+
volatility: compared === 0 ? null : flipped.length / compared,
|
|
125
|
+
// "unmeasured" is a third state on purpose: a scenario with one run, or with no
|
|
126
|
+
// comparable pair, has NOT been shown to be stable. Collapsing it into "stable"
|
|
127
|
+
// would turn absence of evidence into evidence — the same conflation lift.ts
|
|
128
|
+
// refuses when it reports "no red baseline" instead of a zero.
|
|
129
|
+
state: compared === 0 ? "unmeasured" : flipped.length > 0 ? "boundary" : "stable",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Derive stability for every scenario × tag × mode from an already-read history. */
|
|
133
|
+
export function stabilityFrom(groups, spec, opts = {}) {
|
|
134
|
+
const window = Math.max(2, opts.window ?? DEFAULT_WINDOW); // a window of 1 has no pair to compare
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const group of groups) {
|
|
137
|
+
for (const scenario of spec.scenarios) {
|
|
138
|
+
out.push(stabilityForScenario(group, scenario, window));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Read `<skillDir>/tests/results/` and derive run-over-run stability per scenario ×
|
|
145
|
+
* model tag × delivery mode. Free and offline: it reads committed results.yaml files
|
|
146
|
+
* and computes; it never runs a model, a judge, or a harness.
|
|
147
|
+
*
|
|
148
|
+
* Deliberately derived on read rather than stored in results.yaml, for the reason lift
|
|
149
|
+
* is: stability is a fact about a SET of runs, so a copy inside one run's file would be
|
|
150
|
+
* wrong the moment the next run lands — the stale-scorecard failure `source_hashes`
|
|
151
|
+
* exists to prevent. It also means this works retroactively on history recorded by
|
|
152
|
+
* every earlier version.
|
|
153
|
+
*/
|
|
154
|
+
export function collectStability(skillDir, opts = {}) {
|
|
155
|
+
const spec = loadSpec(join(skillDir, "tests", "specification.yaml"));
|
|
156
|
+
return stabilityFrom(collectScoredRuns(skillDir), spec, opts);
|
|
157
|
+
}
|
|
158
|
+
/** Scenarios that flipped at least once — the cells a single run reads as too certain. */
|
|
159
|
+
export function boundaryCells(all) {
|
|
160
|
+
return all.filter((s) => s.state === "boundary");
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The window as one readable string: `PASS!→FAIL!` or `FAIL⋯PASS→PASS!`.
|
|
164
|
+
*
|
|
165
|
+
* `→` is a step this comparison counted; `⋯` is a step it rejected (an edit, a
|
|
166
|
+
* different aggregation, unverifiable hashes); `!` marks a run whose reps were
|
|
167
|
+
* internally unanimous. The two arrows have to differ, or a path reading `FAIL→PASS`
|
|
168
|
+
* would sit next to "held its verdict" and look like a contradiction — the window shows
|
|
169
|
+
* every run, but only some of the steps between them are evidence.
|
|
170
|
+
*/
|
|
171
|
+
export const PATH_LEGEND = "→ comparable step · ⋯ step not comparable · ! that run's reps were unanimous";
|
|
172
|
+
export function verdictPath(s) {
|
|
173
|
+
const label = (p) => `${p.verdict}${p.unanimous ? "!" : ""}${p.overridden ? "(override)" : ""}`;
|
|
174
|
+
let out = s.points.length > 0 ? label(s.points[0]) : "";
|
|
175
|
+
s.pairs.forEach((pair, i) => {
|
|
176
|
+
out += `${pair.status === "compared" ? "→" : "⋯"}${label(s.points[i + 1])}`;
|
|
177
|
+
});
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The one-line human statement. This string is the feature: a number nobody can read
|
|
182
|
+
* ("volatility 1.00") would leave the reader exactly where a single run left them.
|
|
183
|
+
*
|
|
184
|
+
* Every branch says what the record supports and no more — which of "the edit did it"
|
|
185
|
+
* and "the cell is bimodal" is true cannot be told from committed results, so the
|
|
186
|
+
* across-an-edit wording names both.
|
|
187
|
+
*/
|
|
188
|
+
export function stabilityNote(s) {
|
|
189
|
+
if (s.state === "boundary") {
|
|
190
|
+
const parts = [
|
|
191
|
+
`${s.id} flipped its verdict in ${s.flips} of ${s.compared} comparable run-to-run step(s) (${verdictPath(s)})`,
|
|
192
|
+
];
|
|
193
|
+
if (s.unanimousFlips > 0) {
|
|
194
|
+
parts.push(`${s.unanimousFlips === s.flips ? "each flip was" : `${s.unanimousFlips} flip(s) were`} between runs that were` +
|
|
195
|
+
` INTERNALLY UNANIMOUS (flakiness 0.00) — within-run reps cannot see this`);
|
|
196
|
+
}
|
|
197
|
+
if (s.flipsAcrossSkillEdit === s.flips && s.flips > 0) {
|
|
198
|
+
parts.push(`SKILL.md changed across ${s.flips === 1 ? "that step" : "those steps"}, while this scenario's own stimulus` +
|
|
199
|
+
` and rubric did not — so it is either a side effect of that edit or a boundary cell, and the record cannot say which`);
|
|
200
|
+
}
|
|
201
|
+
else if (s.flipsAcrossSkillEdit > 0) {
|
|
202
|
+
parts.push(`${s.flipsAcrossSkillEdit} of them across a SKILL.md edit`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
parts.push(`on unchanged skill text — treat a single run of this cell as one draw, not a measurement`);
|
|
206
|
+
}
|
|
207
|
+
return parts.join("; ");
|
|
208
|
+
}
|
|
209
|
+
if (s.state === "stable") {
|
|
210
|
+
// "across N comparable step(s)", not "across N runs": the window can hold runs whose
|
|
211
|
+
// steps were rejected, and claiming those as agreement would overstate the evidence.
|
|
212
|
+
return `${s.id} held its verdict across ${s.compared} comparable run-to-run step(s) (${verdictPath(s)})`;
|
|
213
|
+
}
|
|
214
|
+
const why = new Map();
|
|
215
|
+
for (const p of s.pairs)
|
|
216
|
+
if (p.status !== "compared")
|
|
217
|
+
why.set(p.status, (why.get(p.status) ?? 0) + 1);
|
|
218
|
+
const reasons = [...why.entries()].map(([status, n]) => `${n} ${REJECTION[status]}`);
|
|
219
|
+
const changed = [...new Set(s.pairs.flatMap((p) => p.changedSources))];
|
|
220
|
+
const detail = changed.length > 0 ? ` (${changed.join(", ")} changed — an edit, not a flip)` : "";
|
|
221
|
+
return s.points.length < 2
|
|
222
|
+
? `${s.id} has ${s.points.length} run in this mode — no run-over-run comparison exists yet`
|
|
223
|
+
: `${s.id} has no comparable run-to-run step: ${reasons.join(", ")}${detail}`;
|
|
224
|
+
}
|
|
225
|
+
const REJECTION = {
|
|
226
|
+
compared: "compared",
|
|
227
|
+
inconclusive: "step(s) with an ERROR or unresolved misfire",
|
|
228
|
+
aggregation: "step(s) aggregated differently (reps or pass threshold)",
|
|
229
|
+
sources: "step(s) where the scenario's own sources changed",
|
|
230
|
+
unverified: "step(s) whose recorded hashes cannot be compared",
|
|
231
|
+
};
|
|
232
|
+
//# sourceMappingURL=stability.js.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ExecutionTraceV1 } from "./capture-trace-types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The objective gate layer: assertions evaluated against a saved execution
|
|
4
|
+
* trace, before any judge is asked anything.
|
|
5
|
+
*
|
|
6
|
+
* The DSL is deliberately tiny and entirely declarative — no expressions, no
|
|
7
|
+
* callbacks, no executable predicates. A spec is data that arrives from a
|
|
8
|
+
* repository; giving it a code path would make "add a test" and "run arbitrary
|
|
9
|
+
* code in CI" the same act. Everything here is a comparison between a value the
|
|
10
|
+
* trace recorded and a literal the spec wrote down.
|
|
11
|
+
*
|
|
12
|
+
* What these assertions can and cannot prove is a hard boundary, restated here
|
|
13
|
+
* because it is easy to over-claim: a trace proves **a registered tool was
|
|
14
|
+
* called with given arguments**. It proves nothing about what that tool then did
|
|
15
|
+
* to the machine. A `bash` command string is not a filesystem audit.
|
|
16
|
+
*/
|
|
17
|
+
export interface ArgPredicate {
|
|
18
|
+
equals?: unknown;
|
|
19
|
+
contains?: string;
|
|
20
|
+
starts_with?: string;
|
|
21
|
+
ends_with?: string;
|
|
22
|
+
matches?: string;
|
|
23
|
+
exists?: boolean;
|
|
24
|
+
/** For array-valued arguments: at least one element satisfies the inner predicate. */
|
|
25
|
+
any?: ArgPredicate;
|
|
26
|
+
}
|
|
27
|
+
export declare const PREDICATE_KEYS: readonly ["equals", "contains", "starts_with", "ends_with", "matches", "exists", "any"];
|
|
28
|
+
export interface CountConstraint {
|
|
29
|
+
min?: number;
|
|
30
|
+
max?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface RequireCall {
|
|
33
|
+
tool: string;
|
|
34
|
+
count?: CountConstraint;
|
|
35
|
+
args?: Record<string, ArgPredicate>;
|
|
36
|
+
}
|
|
37
|
+
export interface ForbidCall {
|
|
38
|
+
tool: string;
|
|
39
|
+
/** When present, only calls whose arguments match are forbidden. */
|
|
40
|
+
args?: Record<string, ArgPredicate>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Convenience syntax for the orchestration case: "the parent delegated to
|
|
44
|
+
* `plan`, and the handoff carried X but not Y".
|
|
45
|
+
*
|
|
46
|
+
* Sugar over `require_calls`, not a second mechanism — it normalizes the known
|
|
47
|
+
* subagent argument shapes and then evaluates through the same path. There is
|
|
48
|
+
* deliberately no universal subagent extension assumed: an unknown extension can
|
|
49
|
+
* still be asserted on with plain `require_calls`, which is why this stays
|
|
50
|
+
* optional sugar rather than the only way in.
|
|
51
|
+
*/
|
|
52
|
+
export interface RequireSubagent {
|
|
53
|
+
/** The registered tool name — declared by the spec, since pi has no standard one. */
|
|
54
|
+
tool: string;
|
|
55
|
+
/** Which subagent the parent should have selected. */
|
|
56
|
+
agent: string;
|
|
57
|
+
count?: CountConstraint;
|
|
58
|
+
/** Substrings the handoff MUST carry. */
|
|
59
|
+
task_contains?: string[];
|
|
60
|
+
/** Substrings the handoff must NOT carry — the leak check. */
|
|
61
|
+
task_excludes?: string[];
|
|
62
|
+
}
|
|
63
|
+
export interface TraceAssert {
|
|
64
|
+
require_calls?: RequireCall[];
|
|
65
|
+
require_subagents?: RequireSubagent[];
|
|
66
|
+
forbid_calls?: ForbidCall[];
|
|
67
|
+
unchanged_paths?: string[];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Subagent invocations extracted from one tool call.
|
|
71
|
+
*
|
|
72
|
+
* A single call can carry several: `{tasks: [...]}` fans out and `{chain: [...]}`
|
|
73
|
+
* sequences. Normalizing to a flat list means a `count` constraint means the same
|
|
74
|
+
* thing — how many subagent invocations happened — whichever shape the extension
|
|
75
|
+
* uses to express them.
|
|
76
|
+
*/
|
|
77
|
+
export interface SubagentInvocation {
|
|
78
|
+
agent: string;
|
|
79
|
+
task: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Recognize the known subagent argument shapes.
|
|
83
|
+
*
|
|
84
|
+
* Three are supported because three exist in the wild; anything else yields an
|
|
85
|
+
* empty list, and the scenario should use plain `require_calls` instead. It
|
|
86
|
+
* deliberately does NOT guess: inventing an `agent` from an unrecognized shape
|
|
87
|
+
* would produce a confident assertion about a field nobody wrote.
|
|
88
|
+
*/
|
|
89
|
+
export declare function normalizeSubagentCall(args: Record<string, unknown>): SubagentInvocation[];
|
|
90
|
+
/**
|
|
91
|
+
* ERROR is a third outcome, not a shade of FAIL: it means the assertion could
|
|
92
|
+
* not be evaluated because the evidence is absent. The two call for different
|
|
93
|
+
* fixes — a FAIL means change the skill, an ERROR means the harness could not
|
|
94
|
+
* look — and only one of them is a finding about the model.
|
|
95
|
+
*/
|
|
96
|
+
export type AssertionStatus = "PASS" | "FAIL" | "ERROR";
|
|
97
|
+
export interface AssertionResult {
|
|
98
|
+
kind: "require_call" | "require_subagent" | "forbid_call" | "unchanged_path";
|
|
99
|
+
status: AssertionStatus;
|
|
100
|
+
detail: string;
|
|
101
|
+
}
|
|
102
|
+
export interface TraceGateResult {
|
|
103
|
+
status: AssertionStatus;
|
|
104
|
+
assertions: AssertionResult[];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Evaluate every assertion. All of them run even after the first failure — a
|
|
108
|
+
* scorecard that reports one problem per run makes the author re-run to find the
|
|
109
|
+
* second, and re-running is the expensive thing this whole layer exists to avoid.
|
|
110
|
+
*
|
|
111
|
+
* (One deliberate exception, marked inline in the `require_subagents` loop: the
|
|
112
|
+
* three sub-questions there are reported separately, and a later one is skipped
|
|
113
|
+
* when an earlier one already established there is nothing to ask it about.)
|
|
114
|
+
*/
|
|
115
|
+
export declare function evaluateTraceGates(assert: TraceAssert, trace: ExecutionTraceV1): TraceGateResult;
|
|
116
|
+
export declare function testPredicate(value: unknown, p: ArgPredicate): boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Minimal glob over workspace-relative paths: `**` any depth, `*` one segment.
|
|
119
|
+
*
|
|
120
|
+
* Paths are normalized to forward slashes and stripped of a leading `./` first,
|
|
121
|
+
* so `./src/a.ts` and `src/a.ts` are the same path — otherwise an assertion
|
|
122
|
+
* would pass or fail on how the runner happened to spell it.
|
|
123
|
+
*/
|
|
124
|
+
export declare function matchesGlob(pattern: string, path: string): boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Validate an `assert.trace` block from a spec.
|
|
127
|
+
*
|
|
128
|
+
* Strict on purpose: an unknown key is an error, not something ignored. A
|
|
129
|
+
* silently-ignored `forbid_call` (singular, say) would read in review as a gate
|
|
130
|
+
* that is protecting something while asserting nothing at all — the worst
|
|
131
|
+
* possible failure for a safety check.
|
|
132
|
+
*/
|
|
133
|
+
export declare function parseTraceAssert(raw: unknown, ctx: string): TraceAssert;
|