skill-probe 0.7.1 → 0.9.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/README.md +108 -16
- package/dist/adapters/claudeCode.d.ts +8 -1
- package/dist/adapters/claudeCode.js +22 -7
- package/dist/baseline.d.ts +56 -0
- package/dist/baseline.js +0 -0
- package/dist/cli.js +39 -7
- package/dist/context.d.ts +37 -3
- package/dist/context.js +74 -7
- package/dist/diagnose.d.ts +3 -0
- package/dist/diagnose.js +2 -0
- package/dist/eval.d.ts +3 -0
- package/dist/eval.js +5 -3
- package/dist/manifest.d.ts +21 -0
- package/dist/manifest.js +48 -0
- package/dist/report.d.ts +3 -0
- package/dist/report.js +68 -2
- package/dist/stats.d.ts +4 -0
- package/dist/stats.js +17 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,8 +27,10 @@ doctor → gen → audit → context → diagnose → fix
|
|
|
27
27
|
|
|
28
28
|
- `doctor`: verify setup, auth, runtime, skills, and config before spending probes.
|
|
29
29
|
- `gen`: draft a probe config from your existing skills.
|
|
30
|
-
- `audit`: measure which skill actually fires when the whole library is co-loaded
|
|
31
|
-
|
|
30
|
+
- `audit`: measure which skill actually fires when the whole library is co-loaded — with
|
|
31
|
+
`--baseline`, gate CI on statistically significant regressions only.
|
|
32
|
+
- `context`: compare isolation vs co-loaded activation to catch library interference —
|
|
33
|
+
add `--ablate` to name **which** sibling is stealing the trigger.
|
|
32
34
|
- `diagnose`: explain whether a failure is a routing miss or a description problem.
|
|
33
35
|
- `fix`: rewrite a skill description and keep it only if the measured reliability improves.
|
|
34
36
|
|
|
@@ -156,16 +158,64 @@ Claude Code vs OpenCode comparison is fair — otherwise you're comparing two co
|
|
|
156
158
|
|
|
157
159
|
## CI usage
|
|
158
160
|
|
|
159
|
-
skill-probe exits non-zero on a real problem, so it drops straight into a pipeline:
|
|
161
|
+
skill-probe exits non-zero on a real problem, so it drops straight into a pipeline. Exit codes:
|
|
162
|
+
**`0`** all pass · **`1`** a behavioral fail / trigger-theft / interference / regression · **`2`**
|
|
163
|
+
inconclusive or an infrastructure error — so a runtime outage *fails* the build instead of silently
|
|
164
|
+
passing. Add `--json` to archive the full result (it includes a **run manifest**: tool version,
|
|
165
|
+
model, date, config hash — so two runs are comparable and a report is citable).
|
|
166
|
+
|
|
167
|
+
### Regression gating (`--baseline`)
|
|
168
|
+
|
|
169
|
+
The CI question isn't "are my skills perfect?" — it's **"did this PR make any skill worse?"**
|
|
170
|
+
Activation is stochastic, so comparing raw rates makes CI flaky. The baseline gate compares each
|
|
171
|
+
case against a saved baseline with **Fisher's exact test, Benjamini-Hochberg corrected** — noise
|
|
172
|
+
passes, real drops fail:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
# once, on a good main build (commit the file):
|
|
176
|
+
skill-probe --config probe.config.json --save-baseline baselines/main.json
|
|
177
|
+
|
|
178
|
+
# on every PR:
|
|
179
|
+
skill-probe --config probe.config.json --baseline baselines/main.json
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
Baseline gate — vs baseline saved 2026-07-02T09:12:03Z (skill-probe 0.9.0)
|
|
184
|
+
[▼ REGRESSED] greeter | write a birthday greeting
|
|
185
|
+
90% (9/10) → 20% (2/10) Δ-70% p=0.005 · adj 0.005
|
|
186
|
+
Gate: ▼ FAIL — 1 significant regression(s)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The gate warns (but still runs, matching cases by prompt) if the config, runtime, or model changed
|
|
190
|
+
since the baseline — apples-to-oranges comparisons are flagged, never silent. Significant
|
|
191
|
+
*improvements* are reported too, as a nudge to re-save the baseline.
|
|
192
|
+
|
|
193
|
+
### GitHub Action
|
|
160
194
|
|
|
161
195
|
```yaml
|
|
162
|
-
# .github/workflows/skills.yml
|
|
163
|
-
|
|
196
|
+
# .github/workflows/skills.yml
|
|
197
|
+
name: skills
|
|
198
|
+
on: pull_request
|
|
199
|
+
jobs:
|
|
200
|
+
probe:
|
|
201
|
+
runs-on: ubuntu-latest
|
|
202
|
+
steps:
|
|
203
|
+
- uses: actions/checkout@v4
|
|
204
|
+
- uses: actions/setup-node@v4
|
|
205
|
+
with: { node-version: "22" }
|
|
206
|
+
- uses: HystonKayange/skill-probe@main
|
|
207
|
+
with:
|
|
208
|
+
config: probe.config.json
|
|
209
|
+
args: --baseline baselines/main.json
|
|
210
|
+
env:
|
|
211
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
164
212
|
```
|
|
165
213
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
214
|
+
The action installs skill-probe plus the runtime CLI (Claude Code by default; set
|
|
215
|
+
`runtime-package: ""` if your runner already has one) and runs the command. `command:` selects
|
|
216
|
+
`audit` (default), `context`, `diagnose`, or `doctor`; `args:` passes extra flags like `--ablate`.
|
|
217
|
+
The runtime authenticates via `ANTHROPIC_API_KEY` — headless probes each cost real tokens, so
|
|
218
|
+
smoke settings (`k: 10`, few cases) are the sweet spot for per-PR gating.
|
|
169
219
|
|
|
170
220
|
## Activation rate by context (`skill-probe context`)
|
|
171
221
|
|
|
@@ -179,16 +229,20 @@ skill-probe context --config probe.config.json
|
|
|
179
229
|
|
|
180
230
|
For each case it measures the expected skill **in isolation** (a throwaway project with only that
|
|
181
231
|
one skill) **and co-loaded** (your full library), then tests the drop with **Fisher's exact test**
|
|
182
|
-
— so interference is reported as a real effect with a p-value, not eyeballed
|
|
232
|
+
— so interference is reported as a real effect with a p-value, not eyeballed. Across multiple
|
|
233
|
+
cases the p-values are **Benjamini-Hochberg corrected** (one Fisher test per case is a *family*:
|
|
234
|
+
raw `p<0.05` over a 40-case library expects ~2 false flags by chance alone — interference is
|
|
235
|
+
decided on the adjusted p, and the report shows both):
|
|
183
236
|
|
|
184
237
|
```
|
|
185
238
|
skill-probe context — runtime: claude-code model: (runtime default) threshold: 70% library: 4 skills
|
|
186
239
|
isolation = only that skill loaded · co-loaded = the full library (4) loaded
|
|
240
|
+
3 comparisons — interference decided on Benjamini-Hochberg adjusted p<0.05 (raw p across a family overcounts)
|
|
187
241
|
|
|
188
242
|
[⚠ INTERFERENCE] greeter | write a birthday greeting
|
|
189
243
|
isolation 100% [72%, 100%] k=10
|
|
190
244
|
co-loaded 30% [11%, 60%] k=10
|
|
191
|
-
Δ -70% under load Fisher p=0.003
|
|
245
|
+
Δ -70% under load Fisher p=0.003 · BH-adj p=0.009
|
|
192
246
|
↳ fires reliably alone but is suppressed when the library is co-loaded (stolen by: welcomer)
|
|
193
247
|
|
|
194
248
|
Result: 1 interference / 3 measured | exit 1
|
|
@@ -200,6 +254,38 @@ in the library is a config error (typo), surfaced before any probes run. Exit `1
|
|
|
200
254
|
shows interference, `2` if a case is untrustworthy (infra errors dominated — never a silent pass),
|
|
201
255
|
else `0`.
|
|
202
256
|
|
|
257
|
+
### Name the thief (`--ablate`)
|
|
258
|
+
|
|
259
|
+
Knowing *that* a skill is suppressed is half the diagnosis — the other half is **which sibling is
|
|
260
|
+
stealing it**. With 3 skills you can guess; with 40 you can't. `--ablate` answers it with evidence:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
skill-probe context --config probe.config.json --ablate
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
For each interference case it takes the suspects (ranked by how many probes they actually stole in
|
|
267
|
+
the co-loaded run, capped by `--suspects`, default 3), removes them from the library **one at a
|
|
268
|
+
time**, and re-measures. If activation recovers significantly (Fisher vs the co-loaded run,
|
|
269
|
+
BH-corrected across all ablation runs), that sibling is the thief — named, with a p-value:
|
|
270
|
+
|
|
271
|
+
```
|
|
272
|
+
[⚠ INTERFERENCE] greeter | write a greeting for my new teammate
|
|
273
|
+
isolation 100% [61%, 100%] k=6
|
|
274
|
+
co-loaded 0% [0%, 56%] k=3
|
|
275
|
+
Δ -100% under load Fisher p=0.012
|
|
276
|
+
↳ fires reliably alone but is suppressed when the library is co-loaded (stolen by: welcomer)
|
|
277
|
+
leave-one-out (each suspect removed, recovery vs co-loaded):
|
|
278
|
+
− welcomer removed → 100% [61%, 100%] k=6 Δ+100% p=0.012 · adj 0.012 ← THIEF (full recovery)
|
|
279
|
+
|
|
280
|
+
Result: 1 interference / 1 measured | thieves named: welcomer | exit 1
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
(That's a real run.) Honest edge cases: if the co-loaded probes were suppressed to **None** —
|
|
284
|
+
nothing stole them, the skill just stops firing under load — there's no suspect to remove, and the
|
|
285
|
+
report says *"dilution, not theft; nothing to ablate"* instead of guessing. If no single removal
|
|
286
|
+
recovers activation, it says so ("interference may be combinatorial") rather than naming an
|
|
287
|
+
innocent sibling.
|
|
288
|
+
|
|
203
289
|
## Why a skill fails (`skill-probe diagnose`)
|
|
204
290
|
|
|
205
291
|
When a skill doesn't fire, there are two very different root causes — and the activation rate alone
|
|
@@ -281,11 +367,18 @@ should-fire prompts, near-misses, and decoys.
|
|
|
281
367
|
|
|
282
368
|
**Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping + four-state verdict
|
|
283
369
|
(pass / fail / inconclusive / **error**), across two runtimes (Claude Code, OpenCode).
|
|
284
|
-
Infrastructure failures (timeout / auth / crash / empty output
|
|
285
|
-
|
|
370
|
+
Infrastructure failures (timeout / auth / crash / empty output / a zero-cost response where the
|
|
371
|
+
model never actually ran, e.g. a usage-limit window) are reported as `error`, never as a
|
|
372
|
+
behavioral pass/fail — a decoy can't falsely pass because the runtime was down. Every run carries
|
|
373
|
+
a **manifest** (version, model, date, config hash); `--save-baseline` / `--baseline` turn audits
|
|
374
|
+
into a CI regression gate (Fisher + BH — noise passes, real drops fail), and a **GitHub Action**
|
|
375
|
+
(`HystonKayange/skill-probe@main`) wraps the whole thing for per-PR gating.
|
|
286
376
|
|
|
287
377
|
**Context (`skill-probe context`):** isolation-vs-co-loaded activation rates, with **Fisher's exact
|
|
288
|
-
test** on the drop — catches skills that fire
|
|
378
|
+
test** on the drop and **Benjamini-Hochberg correction** across cases — catches skills that fire
|
|
379
|
+
alone but are suppressed under the full library's load, without buying false flags on big libraries.
|
|
380
|
+
With `--ablate`, leave-one-out re-measurement **names the thieving sibling** when removing it
|
|
381
|
+
significantly restores activation.
|
|
289
382
|
|
|
290
383
|
**Diagnose (`skill-probe diagnose`):** compares actual runtime activation against intended
|
|
291
384
|
forced-choice routing to classify failures as routing-miss vs description-problem. Intended routing
|
|
@@ -294,9 +387,8 @@ is a heuristic, not ground truth, and the README calls that out explicitly.
|
|
|
294
387
|
**Fix (`skill-probe fix`):** uses the **Bayesian Beta-Binomial** to gate description rewrites on a
|
|
295
388
|
*proven* lift (interleaved before/after, applied only if P(improvement) clears the bar).
|
|
296
389
|
|
|
297
|
-
|
|
298
|
-
(
|
|
299
|
-
its presence as the audit using it. (Fisher's exact test is now wired into `context`.)
|
|
390
|
+
Every statistical function in `src/stats.ts` is now wired into a command: Wilson + sequential
|
|
391
|
+
stopping (audit), Fisher's exact + Benjamini-Hochberg (context/ablate), Bayesian Beta-Binomial (fix).
|
|
300
392
|
|
|
301
393
|
## Dev
|
|
302
394
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RuntimeAdapter } from "../types.ts";
|
|
1
|
+
import type { ProbeResult, RuntimeAdapter } from "../types.ts";
|
|
2
2
|
export interface ParseInfo {
|
|
3
3
|
skillFired: string | null;
|
|
4
4
|
trajectory: string[];
|
|
@@ -7,4 +7,11 @@ export interface ParseInfo {
|
|
|
7
7
|
sawAssistant: boolean;
|
|
8
8
|
}
|
|
9
9
|
export declare function parseStream(stdout: string): ParseInfo;
|
|
10
|
+
/** Decide what a finished (non-timeout) probe means. A probe is a VALID behavioral outcome only
|
|
11
|
+
* if there is evidence the model actually ran: assistant output AND a nonzero billed cost. A
|
|
12
|
+
* zero-cost "response" (observed live 2026-07-02: a usage-limit window returned assistant text
|
|
13
|
+
* with total_cost_usd=0 and no tool_use, for several minutes) means the model never really ran —
|
|
14
|
+
* counting it as "no skill fired" silently poisons every verdict. It must surface as an infra
|
|
15
|
+
* error → untrustworthy, never as a behavioral result. */
|
|
16
|
+
export declare function interpretProbe(info: ParseInfo, stderrText: string): ProbeResult;
|
|
10
17
|
export declare const claudeCodeAdapter: RuntimeAdapter;
|
|
@@ -39,6 +39,27 @@ export function parseStream(stdout) {
|
|
|
39
39
|
}
|
|
40
40
|
return { skillFired, trajectory, cost, sawInit, sawAssistant };
|
|
41
41
|
}
|
|
42
|
+
/** Decide what a finished (non-timeout) probe means. A probe is a VALID behavioral outcome only
|
|
43
|
+
* if there is evidence the model actually ran: assistant output AND a nonzero billed cost. A
|
|
44
|
+
* zero-cost "response" (observed live 2026-07-02: a usage-limit window returned assistant text
|
|
45
|
+
* with total_cost_usd=0 and no tool_use, for several minutes) means the model never really ran —
|
|
46
|
+
* counting it as "no skill fired" silently poisons every verdict. It must surface as an infra
|
|
47
|
+
* error → untrustworthy, never as a behavioral result. */
|
|
48
|
+
export function interpretProbe(info, stderrText) {
|
|
49
|
+
if (!info.sawAssistant) {
|
|
50
|
+
return {
|
|
51
|
+
status: "error", skillFired: null, trajectory: info.trajectory, cost: info.cost,
|
|
52
|
+
error: stderrText.trim() ? stderrText.trim().slice(-200) : "no assistant output (auth/config/empty response?)",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (info.cost === 0) {
|
|
56
|
+
return {
|
|
57
|
+
status: "error", skillFired: null, trajectory: info.trajectory, cost: 0,
|
|
58
|
+
error: "zero-cost response — the model did not actually run (usage limit / degraded CLI state?)",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { status: "ok", skillFired: info.skillFired, trajectory: info.trajectory, cost: info.cost };
|
|
62
|
+
}
|
|
42
63
|
export const claudeCodeAdapter = {
|
|
43
64
|
name: "claude-code",
|
|
44
65
|
probe(prompt, { cwd, timeoutMs = 120_000, model }) {
|
|
@@ -71,13 +92,7 @@ export const claudeCodeAdapter = {
|
|
|
71
92
|
error: `timed out after ${timeoutMs}ms`,
|
|
72
93
|
});
|
|
73
94
|
}
|
|
74
|
-
|
|
75
|
-
return finish({
|
|
76
|
-
status: "error", skillFired: null, trajectory: info.trajectory, cost: info.cost,
|
|
77
|
-
error: err.trim() ? err.trim().slice(-200) : "no assistant output (auth/config/empty response?)",
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
finish({ status: "ok", skillFired: info.skillFired, trajectory: info.trajectory, cost: info.cost });
|
|
95
|
+
finish(interpretProbe(info, err));
|
|
81
96
|
});
|
|
82
97
|
});
|
|
83
98
|
},
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AuditResult } from "./eval.ts";
|
|
2
|
+
import type { RunManifest } from "./manifest.ts";
|
|
3
|
+
export interface BaselineCase {
|
|
4
|
+
prompt: string;
|
|
5
|
+
expected: string | null;
|
|
6
|
+
hits: number;
|
|
7
|
+
n: number;
|
|
8
|
+
}
|
|
9
|
+
export interface BaselineFile {
|
|
10
|
+
manifest: RunManifest;
|
|
11
|
+
cases: BaselineCase[];
|
|
12
|
+
}
|
|
13
|
+
export declare function toBaseline(result: AuditResult): BaselineFile;
|
|
14
|
+
export declare function saveBaseline(result: AuditResult, path: string): void;
|
|
15
|
+
export declare function loadBaseline(path: string): BaselineFile;
|
|
16
|
+
export interface RegressionCase {
|
|
17
|
+
prompt: string;
|
|
18
|
+
expected: string | null;
|
|
19
|
+
before: {
|
|
20
|
+
hits: number;
|
|
21
|
+
n: number;
|
|
22
|
+
pHat: number;
|
|
23
|
+
};
|
|
24
|
+
after: {
|
|
25
|
+
hits: number;
|
|
26
|
+
n: number;
|
|
27
|
+
pHat: number;
|
|
28
|
+
};
|
|
29
|
+
/** after.pHat - before.pHat (negative = dropped since baseline) */
|
|
30
|
+
delta: number;
|
|
31
|
+
fisherP: number;
|
|
32
|
+
/** BH-adjusted across all compared cases — regression is decided on THIS */
|
|
33
|
+
fisherPAdj: number;
|
|
34
|
+
/** one side has no valid probes — not comparable (never reads as "no regression") */
|
|
35
|
+
untrustworthy: boolean;
|
|
36
|
+
/** significant drop since baseline (BH-corrected) */
|
|
37
|
+
regressed: boolean;
|
|
38
|
+
/** significant rise since baseline (informational — consider re-saving the baseline) */
|
|
39
|
+
improved: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface BaselineComparison {
|
|
42
|
+
baselineDate: string;
|
|
43
|
+
baselineVersion: string;
|
|
44
|
+
/** apples-to-oranges warnings: config hash / runtime / model drift between runs */
|
|
45
|
+
mismatches: string[];
|
|
46
|
+
cases: RegressionCase[];
|
|
47
|
+
/** prompts in the current run with no baseline entry (measured, but not gated) */
|
|
48
|
+
newCases: string[];
|
|
49
|
+
/** prompts in the baseline that the current run no longer has */
|
|
50
|
+
missingCases: string[];
|
|
51
|
+
regressions: number;
|
|
52
|
+
improvements: number;
|
|
53
|
+
/** 1 = at least one significant regression (CI should fail) */
|
|
54
|
+
exitCode: 0 | 1;
|
|
55
|
+
}
|
|
56
|
+
export declare function compareToBaseline(result: AuditResult, base: BaselineFile): BaselineComparison;
|
package/dist/baseline.js
ADDED
|
Binary file
|
package/dist/cli.js
CHANGED
|
@@ -12,12 +12,13 @@ import { runDiagnose } from "./diagnose.js";
|
|
|
12
12
|
import { runDoctor } from "./doctor.js";
|
|
13
13
|
import { listSkills, generateCases, genConfig } from "./gen.js";
|
|
14
14
|
import { loadConfig, parseProbability, parseIntFlag, ConfigError } from "./config.js";
|
|
15
|
-
import {
|
|
15
|
+
import { loadBaseline, saveBaseline, compareToBaseline } from "./baseline.js";
|
|
16
|
+
import { renderTable, renderMarkdown, renderJson, renderFix, renderContext, renderDoctor, renderDiagnose, renderBaseline } from "./report.js";
|
|
16
17
|
const HELP = `skill-probe — audit a co-loaded agent skill library by real activation behavior.
|
|
17
18
|
|
|
18
19
|
USAGE
|
|
19
20
|
skill-probe --config <file.json> [options] # audit
|
|
20
|
-
skill-probe context --config <file.json> [
|
|
21
|
+
skill-probe context --config <file.json> [--ablate] # isolation vs co-loaded activation rates
|
|
21
22
|
skill-probe diagnose --config <file.json> [options] # why a skill fails: routing-miss vs description
|
|
22
23
|
skill-probe doctor [--cwd <dir>] [--config <file.json>] # preflight: setup/auth/config sanity
|
|
23
24
|
skill-probe gen [--cwd <dir>] > probe.config.json # draft a test config from your skills
|
|
@@ -36,6 +37,16 @@ OPTIONS
|
|
|
36
37
|
--markdown emit a Markdown table (paste into a PR/README)
|
|
37
38
|
--no-cost hide the cost line (e.g. when on a Claude subscription)
|
|
38
39
|
--quiet suppress the live progress line on stderr
|
|
40
|
+
audit only (CI gating):
|
|
41
|
+
--save-baseline <f> after the run, save per-case counts + a run manifest to <f>
|
|
42
|
+
--baseline <f> compare this run against a saved baseline: per-case Fisher's exact,
|
|
43
|
+
Benjamini-Hochberg corrected — exit 1 ONLY on a significant regression
|
|
44
|
+
(noise passes; real drops fail)
|
|
45
|
+
context only:
|
|
46
|
+
--ablate leave-one-out: for each interference case, re-run with each suspect
|
|
47
|
+
sibling removed and NAME the thief when activation recovers (Fisher +
|
|
48
|
+
Benjamini-Hochberg corrected)
|
|
49
|
+
--suspects <n> max siblings to ablate per case, ranked by probes stolen (default 3)
|
|
39
50
|
doctor only:
|
|
40
51
|
--cwd <dir> project dir containing .claude/skills/ (default: .)
|
|
41
52
|
--config <file> also sanity-check a config (expected skills exist, k/threshold feasible)
|
|
@@ -74,7 +85,8 @@ function makeContextProgress(values) {
|
|
|
74
85
|
return undefined;
|
|
75
86
|
return (e) => {
|
|
76
87
|
const p = e.prompt.length > 28 ? e.prompt.slice(0, 25) + "…" : e.prompt;
|
|
77
|
-
const
|
|
88
|
+
const cond = e.condition === "ablation" ? `−${e.removed}` : e.condition;
|
|
89
|
+
const line = ` [${e.caseIndex}/${e.caseTotal}] ${cond.padEnd(9)} "${p}" — run ${e.validN}/${e.maxK}`;
|
|
78
90
|
const pad = Math.max(0, progressLen - line.length);
|
|
79
91
|
process.stderr.write("\r" + line + " ".repeat(pad));
|
|
80
92
|
progressLen = line.length;
|
|
@@ -100,10 +112,21 @@ async function audit(values) {
|
|
|
100
112
|
const result = await runAudit(cfg, makeProgress(values));
|
|
101
113
|
clearProgress();
|
|
102
114
|
const showCost = !values["no-cost"];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
let cmp;
|
|
116
|
+
if (values["baseline"])
|
|
117
|
+
cmp = compareToBaseline(result, loadBaseline(values["baseline"]));
|
|
118
|
+
if (values["save-baseline"]) {
|
|
119
|
+
saveBaseline(result, values["save-baseline"]);
|
|
120
|
+
console.error(`baseline saved to ${values["save-baseline"]}`);
|
|
121
|
+
}
|
|
122
|
+
const text = values["json"] ? renderJson(cmp ? { ...result, baseline: cmp } : result)
|
|
123
|
+
: values["markdown"] ? renderMarkdown(result, { showCost }) + (cmp ? "\n\n" + renderBaseline(cmp) : "")
|
|
124
|
+
: renderTable(result, { showCost }) + (cmp ? "\n\n" + renderBaseline(cmp) : "");
|
|
106
125
|
console.log(text);
|
|
126
|
+
// precedence mirrors the rest of the tool: a behavioral signal (fail/regression, 1) outranks
|
|
127
|
+
// inconclusive/infra (2), which outranks clean (0).
|
|
128
|
+
if (result.exitCode === 1 || cmp?.exitCode === 1)
|
|
129
|
+
return 1;
|
|
107
130
|
return result.exitCode;
|
|
108
131
|
}
|
|
109
132
|
async function diagnose(values) {
|
|
@@ -137,7 +160,12 @@ async function context(values) {
|
|
|
137
160
|
k: values["k"], threshold: values["threshold"],
|
|
138
161
|
conf: values["conf"],
|
|
139
162
|
});
|
|
140
|
-
const
|
|
163
|
+
const suspects = parseIntFlag(values["suspects"], "--suspects", 3, 1);
|
|
164
|
+
const onProgress = makeContextProgress(values);
|
|
165
|
+
const result = await runContextAudit(cfg, {
|
|
166
|
+
ablate: Boolean(values["ablate"]), suspects,
|
|
167
|
+
...(onProgress ? { onProgress } : {}),
|
|
168
|
+
});
|
|
141
169
|
clearProgress();
|
|
142
170
|
const showCost = !values["no-cost"];
|
|
143
171
|
console.log(values["json"] ? renderJson(result) : renderContext(result, { showCost }));
|
|
@@ -191,6 +219,10 @@ async function main() {
|
|
|
191
219
|
cwd: { type: "string" },
|
|
192
220
|
"per-skill": { type: "string" },
|
|
193
221
|
decoys: { type: "string" },
|
|
222
|
+
ablate: { type: "boolean", default: false },
|
|
223
|
+
suspects: { type: "string" },
|
|
224
|
+
baseline: { type: "string" },
|
|
225
|
+
"save-baseline": { type: "string" },
|
|
194
226
|
"skip-probe": { type: "boolean", default: false },
|
|
195
227
|
json: { type: "boolean", default: false },
|
|
196
228
|
markdown: { type: "boolean", default: false },
|
package/dist/context.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Config } from "./types.ts";
|
|
2
2
|
import { reliability } from "./stats.ts";
|
|
3
3
|
import { type RunStats } from "./orchestrator.ts";
|
|
4
|
+
import { type RunManifest } from "./manifest.ts";
|
|
4
5
|
/** Build a throwaway project dir whose .claude/skills/ holds ONLY the named skills (copied from
|
|
5
6
|
* srcCwd). Returns its path plus a cleanup(). The probe runs against this dir to load a subset. */
|
|
6
7
|
export declare function buildContextDir(srcCwd: string, skillNames: string[]): {
|
|
@@ -12,6 +13,20 @@ interface Condition {
|
|
|
12
13
|
stats: RunStats;
|
|
13
14
|
rel: Rel;
|
|
14
15
|
}
|
|
16
|
+
/** One leave-one-out run: the flagged case re-measured with ONE suspect sibling removed from the
|
|
17
|
+
* library. A significant recovery (BH-corrected Fisher vs the co-loaded run) names that sibling
|
|
18
|
+
* as the thief — evidence, not the trigger-theft heuristic. */
|
|
19
|
+
export interface AblationResult {
|
|
20
|
+
removed: string;
|
|
21
|
+
stolen: number;
|
|
22
|
+
stats: RunStats;
|
|
23
|
+
rel: Rel;
|
|
24
|
+
deltaVsCoLoaded: number;
|
|
25
|
+
fisherP: number;
|
|
26
|
+
fisherPAdj: number;
|
|
27
|
+
untrustworthy: boolean;
|
|
28
|
+
culprit: boolean;
|
|
29
|
+
}
|
|
15
30
|
export interface ContextCaseResult {
|
|
16
31
|
prompt: string;
|
|
17
32
|
expected: string;
|
|
@@ -19,10 +34,17 @@ export interface ContextCaseResult {
|
|
|
19
34
|
coLoaded: Condition;
|
|
20
35
|
deltaP: number;
|
|
21
36
|
fisherP: number;
|
|
37
|
+
/** Benjamini-Hochberg adjusted p across all trustworthy comparisons in this run — one Fisher
|
|
38
|
+
* test per case is a FAMILY, and raw p<0.05 over a 40-case library expects ~2 false flags by
|
|
39
|
+
* chance alone. Interference is decided on THIS value. (1 for untrustworthy cases.) */
|
|
40
|
+
fisherPAdj: number;
|
|
22
41
|
/** infra failures dominated one side — the comparison is not trustworthy (NOT a behavioral result) */
|
|
23
42
|
untrustworthy: boolean;
|
|
24
|
-
/** fires reliably alone but drops significantly when the
|
|
43
|
+
/** fires reliably alone but drops significantly (BH-corrected) when the library is co-loaded */
|
|
25
44
|
interference: boolean;
|
|
45
|
+
/** leave-one-out runs (only with { ablate: true }, only for interference cases). Empty array =
|
|
46
|
+
* ablation requested but no named suspect stole probes (suppressed to None — dilution, not theft). */
|
|
47
|
+
ablation?: AblationResult[];
|
|
26
48
|
}
|
|
27
49
|
export interface ContextAuditResult {
|
|
28
50
|
runtime: string;
|
|
@@ -35,14 +57,26 @@ export interface ContextAuditResult {
|
|
|
35
57
|
totalCost: number;
|
|
36
58
|
/** 0 = no interference; 1 = a skill is suppressed under load; 2 = an untrustworthy (infra) case */
|
|
37
59
|
exitCode: 0 | 1 | 2;
|
|
60
|
+
/** who/what/when of this run — makes reports citable and comparable */
|
|
61
|
+
manifest: RunManifest;
|
|
38
62
|
}
|
|
39
63
|
export interface ContextProgress {
|
|
40
64
|
caseIndex: number;
|
|
41
65
|
caseTotal: number;
|
|
42
66
|
prompt: string;
|
|
43
|
-
condition: "isolation" | "co-loaded";
|
|
67
|
+
condition: "isolation" | "co-loaded" | "ablation";
|
|
44
68
|
validN: number;
|
|
45
69
|
maxK: number;
|
|
70
|
+
/** the sibling removed for this run (condition === "ablation" only) */
|
|
71
|
+
removed?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface ContextOpts {
|
|
74
|
+
/** leave-one-out: for each interference case, re-measure with each suspect sibling removed and
|
|
75
|
+
* name the thief when activation recovers significantly. */
|
|
76
|
+
ablate?: boolean;
|
|
77
|
+
/** max suspect siblings to ablate per flagged case, ranked by probes stolen (default 3) */
|
|
78
|
+
suspects?: number;
|
|
79
|
+
onProgress?: (e: ContextProgress) => void;
|
|
46
80
|
}
|
|
47
|
-
export declare function runContextAudit(cfg: Config,
|
|
81
|
+
export declare function runContextAudit(cfg: Config, optsOrProgress?: ContextOpts | ((e: ContextProgress) => void)): Promise<ContextAuditResult>;
|
|
48
82
|
export {};
|
package/dist/context.js
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync } from "node:fs";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { reliability, fisherExact2x2 } from "./stats.js";
|
|
9
|
+
import { reliability, fisherExact2x2, bhAdjust } from "./stats.js";
|
|
10
10
|
import { measure, infraUntrustworthy } from "./orchestrator.js";
|
|
11
11
|
import { getAdapter } from "./adapters/index.js";
|
|
12
12
|
import { listSkills } from "./gen.js";
|
|
13
13
|
import { ConfigError } from "./config.js";
|
|
14
|
+
import { buildManifest } from "./manifest.js";
|
|
14
15
|
/** Build a throwaway project dir whose .claude/skills/ holds ONLY the named skills (copied from
|
|
15
16
|
* srcCwd). Returns its path plus a cleanup(). The probe runs against this dir to load a subset. */
|
|
16
17
|
export function buildContextDir(srcCwd, skillNames) {
|
|
@@ -25,7 +26,9 @@ export function buildContextDir(srcCwd, skillNames) {
|
|
|
25
26
|
}
|
|
26
27
|
return { cwd: root, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
27
28
|
}
|
|
28
|
-
export async function runContextAudit(cfg,
|
|
29
|
+
export async function runContextAudit(cfg, optsOrProgress = {}) {
|
|
30
|
+
const opts = typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress;
|
|
31
|
+
const onProgress = opts.onProgress;
|
|
29
32
|
const adapter = getAdapter(cfg.runtime);
|
|
30
33
|
const library = listSkills(cfg.cwd);
|
|
31
34
|
const present = new Set(library.map((s) => s.name));
|
|
@@ -82,17 +85,80 @@ export async function runContextAudit(cfg, onProgress) {
|
|
|
82
85
|
// if infra failures dominated either side, the comparison isn't a behavioral result at all —
|
|
83
86
|
// it must never read as "no interference / pass" just because fisherP defaulted to 1.
|
|
84
87
|
const untrustworthy = infraUntrustworthy(iso) || infraUntrustworthy(co);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
deltaP < 0 && // and drops when co-loaded
|
|
88
|
-
fisherP < 0.05; // and the drop is statistically real
|
|
88
|
+
// interference is decided AFTER the loop, once the whole family of comparisons is known
|
|
89
|
+
// (Benjamini-Hochberg) — placeholders here.
|
|
89
90
|
measured.push({
|
|
90
91
|
prompt: c.prompt, expected,
|
|
91
92
|
isolation: { stats: iso, rel: isoRel },
|
|
92
93
|
coLoaded: { stats: co, rel: coRel },
|
|
93
|
-
deltaP, fisherP, untrustworthy, interference,
|
|
94
|
+
deltaP, fisherP, fisherPAdj: 1, untrustworthy, interference: false,
|
|
94
95
|
});
|
|
95
96
|
}
|
|
97
|
+
// One Fisher test per case = a family of comparisons: correct across the trustworthy ones so a
|
|
98
|
+
// big library doesn't buy false interference flags by chance (raw p<0.05 × 40 cases ≈ 2 flukes).
|
|
99
|
+
const trustworthy = measured.filter((m) => !m.untrustworthy);
|
|
100
|
+
const adj = bhAdjust(trustworthy.map((m) => m.fisherP));
|
|
101
|
+
trustworthy.forEach((m, j) => { m.fisherPAdj = adj[j]; });
|
|
102
|
+
for (const m of measured) {
|
|
103
|
+
m.interference =
|
|
104
|
+
!m.untrustworthy &&
|
|
105
|
+
m.isolation.rel.pHat >= cfg.threshold && // fires reliably alone
|
|
106
|
+
m.deltaP < 0 && // and drops when co-loaded
|
|
107
|
+
m.fisherPAdj < 0.05; // and the drop survives BH correction
|
|
108
|
+
}
|
|
109
|
+
// leave-one-out ablation: for each flagged case, re-measure with each suspect sibling removed.
|
|
110
|
+
// Suspects are ranked by the probes they actually stole in the co-loaded run — evidence-guided,
|
|
111
|
+
// not a sweep of the whole library. Recovery is Fisher-tested against the co-loaded run and
|
|
112
|
+
// BH-corrected across all ablation runs (their own family). A significant recovery NAMES the
|
|
113
|
+
// thief; no suspects in the outcome distribution means the drop was dilution, not theft.
|
|
114
|
+
if (opts.ablate) {
|
|
115
|
+
const libraryNames = library.map((s) => s.name);
|
|
116
|
+
const flagged = measured.filter((m) => m.interference);
|
|
117
|
+
const cap = opts.suspects ?? 3;
|
|
118
|
+
const allRuns = [];
|
|
119
|
+
for (let fi = 0; fi < flagged.length; fi++) {
|
|
120
|
+
const m = flagged[fi];
|
|
121
|
+
const suspects = Object.entries(m.coLoaded.stats.dist)
|
|
122
|
+
.filter(([name]) => name !== m.expected && name !== "None")
|
|
123
|
+
.sort((x, y) => y[1] - x[1])
|
|
124
|
+
.slice(0, cap);
|
|
125
|
+
m.ablation = [];
|
|
126
|
+
for (const [suspect, stolen] of suspects) {
|
|
127
|
+
const ctx = buildContextDir(cfg.cwd, libraryNames.filter((n) => n !== suspect));
|
|
128
|
+
let st;
|
|
129
|
+
try {
|
|
130
|
+
st = await measure(adapter, m.prompt, m.expected, {
|
|
131
|
+
cwd: ctx.cwd, ...common,
|
|
132
|
+
...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: fi + 1, caseTotal: flagged.length, prompt: m.prompt, condition: "ablation", removed: suspect, validN, maxK }) } : {}),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
ctx.cleanup();
|
|
137
|
+
}
|
|
138
|
+
totalCost += st.totalCost;
|
|
139
|
+
const rel = reliability(st.hits, st.n, cfg.conf);
|
|
140
|
+
const co = m.coLoaded.stats;
|
|
141
|
+
const fisherP = (st.n > 0 && co.n > 0)
|
|
142
|
+
? fisherExact2x2(st.hits, st.n - st.hits, co.hits, co.n - co.hits)
|
|
143
|
+
: 1;
|
|
144
|
+
const abl = {
|
|
145
|
+
removed: suspect, stolen, stats: st, rel,
|
|
146
|
+
deltaVsCoLoaded: rel.pHat - m.coLoaded.rel.pHat,
|
|
147
|
+
fisherP, fisherPAdj: 1,
|
|
148
|
+
untrustworthy: infraUntrustworthy(st),
|
|
149
|
+
culprit: false,
|
|
150
|
+
};
|
|
151
|
+
m.ablation.push(abl);
|
|
152
|
+
allRuns.push(abl);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const ablFamily = allRuns.filter((r) => !r.untrustworthy);
|
|
156
|
+
const ablAdj = bhAdjust(ablFamily.map((r) => r.fisherP));
|
|
157
|
+
ablFamily.forEach((r, j) => { r.fisherPAdj = ablAdj[j]; });
|
|
158
|
+
for (const r of allRuns) {
|
|
159
|
+
r.culprit = !r.untrustworthy && r.deltaVsCoLoaded > 0 && r.fisherPAdj < 0.05;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
96
162
|
// precedence mirrors the audit: a real behavioral signal (interference, exit 1) outranks an
|
|
97
163
|
// untrustworthy/infra case (exit 2), which outranks a clean run (exit 0).
|
|
98
164
|
const exitCode = measured.some((m) => m.interference) ? 1
|
|
@@ -102,5 +168,6 @@ export async function runContextAudit(cfg, onProgress) {
|
|
|
102
168
|
runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
|
|
103
169
|
threshold: cfg.threshold, conf: cfg.conf, librarySize: library.length,
|
|
104
170
|
cases: measured, skipped, totalCost, exitCode,
|
|
171
|
+
manifest: buildManifest("context", cfg),
|
|
105
172
|
};
|
|
106
173
|
}
|
package/dist/diagnose.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { Config } from "./types.ts";
|
|
|
15
15
|
import { reliability } from "./stats.ts";
|
|
16
16
|
import { type RunStats } from "./orchestrator.ts";
|
|
17
17
|
import { type SkillInfo } from "./gen.ts";
|
|
18
|
+
import { type RunManifest } from "./manifest.ts";
|
|
18
19
|
export type DiagVerdict = "routes-ok" | "routing-miss" | "description-problem" | "inconclusive" | "error";
|
|
19
20
|
/** Returns the chosen skill name, "none", or null when the answer is unusable (LLM error/garbage). */
|
|
20
21
|
export type ChoiceFn = (skills: SkillInfo[], prompt: string, model?: string) => Promise<string | null>;
|
|
@@ -50,6 +51,8 @@ export interface DiagnoseResult {
|
|
|
50
51
|
skipped: string[];
|
|
51
52
|
totalCost: number;
|
|
52
53
|
exitCode: 0 | 1 | 2;
|
|
54
|
+
/** who/what/when of this run — makes reports citable and comparable */
|
|
55
|
+
manifest: RunManifest;
|
|
53
56
|
}
|
|
54
57
|
export interface DiagProgress {
|
|
55
58
|
caseIndex: number;
|
package/dist/diagnose.js
CHANGED
|
@@ -4,6 +4,7 @@ import { getAdapter } from "./adapters/index.js";
|
|
|
4
4
|
import { listSkills } from "./gen.js";
|
|
5
5
|
import { complete } from "./llm.js";
|
|
6
6
|
import { ConfigError } from "./config.js";
|
|
7
|
+
import { buildManifest } from "./manifest.js";
|
|
7
8
|
const ROUTER_SYSTEM = "You are a skill router. Given a user request and a list of available skills (name + " +
|
|
8
9
|
"description), decide which single skill should handle the request. Reply with EXACTLY one skill " +
|
|
9
10
|
"name from the list, or the word none if no skill fits. Output only that one word — no punctuation, " +
|
|
@@ -150,5 +151,6 @@ export async function runDiagnose(cfg, deps = {}) {
|
|
|
150
151
|
return {
|
|
151
152
|
runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
|
|
152
153
|
threshold: cfg.threshold, conf: cfg.conf, cases, skipped, totalCost, exitCode,
|
|
154
|
+
manifest: buildManifest("diagnose", cfg),
|
|
153
155
|
};
|
|
154
156
|
}
|
package/dist/eval.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { Config } from "./types.ts";
|
|
4
4
|
import { reliability } from "./stats.ts";
|
|
5
5
|
import { type RunStats } from "./orchestrator.ts";
|
|
6
|
+
import { type RunManifest } from "./manifest.ts";
|
|
6
7
|
/** Four honest states. `error` = the runtime failed enough that the measurement is untrustworthy. */
|
|
7
8
|
export type Verdict = "pass" | "fail" | "inconclusive" | "error";
|
|
8
9
|
export interface CaseResult {
|
|
@@ -29,6 +30,8 @@ export interface AuditResult {
|
|
|
29
30
|
};
|
|
30
31
|
/** 0 = all pass; 1 = a behavioral failure/theft; 2 = inconclusive or infrastructure error */
|
|
31
32
|
exitCode: 0 | 1 | 2;
|
|
33
|
+
/** who/what/when of this run — makes reports citable and baselines comparable */
|
|
34
|
+
manifest: RunManifest;
|
|
32
35
|
}
|
|
33
36
|
export interface ProgressEvent {
|
|
34
37
|
caseIndex: number;
|
package/dist/eval.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { reliability, sequentialDecision } from "./stats.js";
|
|
2
2
|
import { measure } from "./orchestrator.js";
|
|
3
3
|
import { getAdapter } from "./adapters/index.js";
|
|
4
|
+
import { buildManifest } from "./manifest.js";
|
|
4
5
|
function verdictFor(stats, decision, theft) {
|
|
5
6
|
// `error` means INFRASTRUCTURE failures dominate — untrustworthy. It is NOT about small k:
|
|
6
7
|
// a small-but-all-valid run is `inconclusive` (wide CI), which the report explains.
|
|
@@ -39,9 +40,9 @@ export async function runAudit(cfg, onProgress) {
|
|
|
39
40
|
theft, decision, verdict: verdictFor(stats, decision, theft),
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
|
-
// NOTE:
|
|
43
|
-
//
|
|
44
|
-
//
|
|
43
|
+
// NOTE: per-case verdicts here are CI decisions vs the threshold, not p-values — so there is no
|
|
44
|
+
// family to BH-correct in this path. Where families of Fisher tests exist (context, ablation,
|
|
45
|
+
// baseline comparison) the correction is applied.
|
|
45
46
|
const counts = {
|
|
46
47
|
pass: cases.filter((c) => c.verdict === "pass").length,
|
|
47
48
|
fail: cases.filter((c) => c.verdict === "fail").length,
|
|
@@ -52,5 +53,6 @@ export async function runAudit(cfg, onProgress) {
|
|
|
52
53
|
return {
|
|
53
54
|
runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
|
|
54
55
|
threshold: cfg.threshold, cases, totalCost, counts, exitCode,
|
|
56
|
+
manifest: buildManifest("audit", cfg),
|
|
55
57
|
};
|
|
56
58
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Config } from "./types.ts";
|
|
2
|
+
export interface RunManifest {
|
|
3
|
+
tool: "skill-probe";
|
|
4
|
+
version: string;
|
|
5
|
+
command: "audit" | "context" | "diagnose";
|
|
6
|
+
/** ISO 8601 UTC */
|
|
7
|
+
date: string;
|
|
8
|
+
runtime: string;
|
|
9
|
+
model: string;
|
|
10
|
+
k: number;
|
|
11
|
+
threshold: number;
|
|
12
|
+
conf: number;
|
|
13
|
+
/** sha256 (first 16 hex chars) of the resolved config — cases + settings, NOT cwd */
|
|
14
|
+
configHash: string;
|
|
15
|
+
node: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function toolVersion(): string;
|
|
18
|
+
/** Hash the parts of the config that change what was measured. `cwd` is deliberately EXCLUDED:
|
|
19
|
+
* the same library checked out at a different path (CI runner vs laptop) is the same experiment. */
|
|
20
|
+
export declare function configHash(cfg: Config): string;
|
|
21
|
+
export declare function buildManifest(command: RunManifest["command"], cfg: Config): RunManifest;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Run manifest — stamps every probe-running result so two runs are comparable and a report is
|
|
2
|
+
* citable: tool version, command, when it ran, runtime/model, the statistical settings, and a
|
|
3
|
+
* hash of the resolved config. Same configHash = same cases + settings = apples to apples; the
|
|
4
|
+
* baseline comparison uses it to warn when a gate compares different experiments. */
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
let cachedVersion;
|
|
9
|
+
export function toolVersion() {
|
|
10
|
+
if (cachedVersion === undefined) {
|
|
11
|
+
try {
|
|
12
|
+
const pkg = JSON.parse(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8"));
|
|
13
|
+
cachedVersion = pkg.version ?? "unknown";
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
cachedVersion = "unknown";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return cachedVersion;
|
|
20
|
+
}
|
|
21
|
+
/** Hash the parts of the config that change what was measured. `cwd` is deliberately EXCLUDED:
|
|
22
|
+
* the same library checked out at a different path (CI runner vs laptop) is the same experiment. */
|
|
23
|
+
export function configHash(cfg) {
|
|
24
|
+
const canonical = JSON.stringify({
|
|
25
|
+
runtime: cfg.runtime,
|
|
26
|
+
model: cfg.model ?? null,
|
|
27
|
+
k: cfg.k,
|
|
28
|
+
threshold: cfg.threshold,
|
|
29
|
+
conf: cfg.conf,
|
|
30
|
+
cases: cfg.cases.map((c) => ({ prompt: c.prompt, expected: c.expected })),
|
|
31
|
+
});
|
|
32
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
export function buildManifest(command, cfg) {
|
|
35
|
+
return {
|
|
36
|
+
tool: "skill-probe",
|
|
37
|
+
version: toolVersion(),
|
|
38
|
+
command,
|
|
39
|
+
date: new Date().toISOString(),
|
|
40
|
+
runtime: cfg.runtime,
|
|
41
|
+
model: cfg.model ?? "(runtime default)",
|
|
42
|
+
k: cfg.k,
|
|
43
|
+
threshold: cfg.threshold,
|
|
44
|
+
conf: cfg.conf,
|
|
45
|
+
configHash: configHash(cfg),
|
|
46
|
+
node: process.version,
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/report.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { FixResult } from "./fix.ts";
|
|
|
3
3
|
import type { ContextAuditResult } from "./context.ts";
|
|
4
4
|
import type { DoctorResult } from "./doctor.ts";
|
|
5
5
|
import type { DiagnoseResult } from "./diagnose.ts";
|
|
6
|
+
import type { BaselineComparison } from "./baseline.ts";
|
|
6
7
|
/** Smallest k at which an all-success run could certify reliability >= threshold. */
|
|
7
8
|
export declare function kToCertify(threshold: number, conf: number): number;
|
|
8
9
|
export interface RenderOpts {
|
|
@@ -17,4 +18,6 @@ export declare function renderDiagnose(d: DiagnoseResult, opts?: RenderOpts): st
|
|
|
17
18
|
export declare function renderDoctor(d: DoctorResult): string;
|
|
18
19
|
/** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
|
|
19
20
|
export declare function renderContext(a: ContextAuditResult, opts?: RenderOpts): string;
|
|
21
|
+
/** regression gate vs a saved baseline: only significant (BH-corrected) drops fail the build. */
|
|
22
|
+
export declare function renderBaseline(cmp: BaselineComparison): string;
|
|
20
23
|
export declare function renderFix(f: FixResult): string;
|
package/dist/report.js
CHANGED
|
@@ -131,6 +131,11 @@ export function renderContext(a, opts = {}) {
|
|
|
131
131
|
out.push(`skill-probe context — runtime: ${a.runtime} model: ${a.model} ` +
|
|
132
132
|
`threshold: ${pct(a.threshold)} library: ${a.librarySize} skills`);
|
|
133
133
|
out.push(` isolation = only that skill loaded · co-loaded = the full library (${a.librarySize}) loaded`);
|
|
134
|
+
const familyN = a.cases.filter((c) => !c.untrustworthy).length;
|
|
135
|
+
if (familyN > 1) {
|
|
136
|
+
out.push(` ${familyN} comparisons — interference decided on Benjamini-Hochberg adjusted p<0.05` +
|
|
137
|
+
` (raw p across a family overcounts)`);
|
|
138
|
+
}
|
|
134
139
|
out.push("");
|
|
135
140
|
const ci = (r) => `${pct(r.pHat)} [${pct(r.ciLow)}, ${pct(r.ciHigh)}] k=${r.k}`;
|
|
136
141
|
for (const c of a.cases) {
|
|
@@ -148,12 +153,37 @@ export function renderContext(a, opts = {}) {
|
|
|
148
153
|
out.push(` [${flag}] ${c.expected} | ${c.prompt}`);
|
|
149
154
|
out.push(` isolation ${ci(c.isolation.rel)}`);
|
|
150
155
|
out.push(` co-loaded ${ci(c.coLoaded.rel)}`);
|
|
151
|
-
out.push(` Δ ${c.deltaP >= 0 ? "+" : ""}${pct(c.deltaP)} under load Fisher p=${pval(c.fisherP)}`
|
|
156
|
+
out.push(` Δ ${c.deltaP >= 0 ? "+" : ""}${pct(c.deltaP)} under load Fisher p=${pval(c.fisherP)}` +
|
|
157
|
+
(familyN > 1 ? ` · BH-adj p=${pval(c.fisherPAdj)}` : ""));
|
|
152
158
|
if (c.interference) {
|
|
153
159
|
const thieves = Object.keys(c.coLoaded.stats.dist)
|
|
154
160
|
.filter((s) => s !== c.expected && s !== "None");
|
|
155
161
|
out.push(` ↳ fires reliably alone but is suppressed when the library is co-loaded` +
|
|
156
162
|
(thieves.length ? ` (stolen by: ${thieves.join(", ")})` : ""));
|
|
163
|
+
if (c.ablation) {
|
|
164
|
+
if (c.ablation.length === 0) {
|
|
165
|
+
out.push(` leave-one-out: no sibling stole these probes (suppressed to None) — ` +
|
|
166
|
+
`dilution, not theft; nothing to ablate`);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
out.push(` leave-one-out (each suspect removed, recovery vs co-loaded):`);
|
|
170
|
+
for (const abl of c.ablation) {
|
|
171
|
+
if (abl.untrustworthy) {
|
|
172
|
+
out.push(` − ${abl.removed}: infra errors dominated — run not trustworthy`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const tag = abl.culprit
|
|
176
|
+
? (abl.rel.pHat >= a.threshold ? " ← THIEF (full recovery)" : " ← THIEF (partial recovery)")
|
|
177
|
+
: "";
|
|
178
|
+
out.push(` − ${abl.removed} removed → ${ci(abl.rel)} ` +
|
|
179
|
+
`Δ${abl.deltaVsCoLoaded >= 0 ? "+" : ""}${pct(abl.deltaVsCoLoaded)} ` +
|
|
180
|
+
`p=${pval(abl.fisherP)} · adj ${pval(abl.fisherPAdj)}${tag}`);
|
|
181
|
+
}
|
|
182
|
+
if (!c.ablation.some((abl) => abl.culprit)) {
|
|
183
|
+
out.push(` no single culprit confirmed — interference may be combinatorial`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
157
187
|
}
|
|
158
188
|
}
|
|
159
189
|
if (a.skipped.length) {
|
|
@@ -163,11 +193,47 @@ export function renderContext(a, opts = {}) {
|
|
|
163
193
|
out.push("");
|
|
164
194
|
const interfN = a.cases.filter((c) => c.interference).length;
|
|
165
195
|
const errN = a.cases.filter((c) => c.untrustworthy).length;
|
|
196
|
+
const culprits = [...new Set(a.cases.flatMap((c) => (c.ablation ?? []).filter((abl) => abl.culprit).map((abl) => abl.removed)))];
|
|
166
197
|
out.push(`Result: ${interfN} interference / ${a.cases.length} measured` +
|
|
167
|
-
(errN ? ` / ${errN} error` : "") +
|
|
198
|
+
(errN ? ` / ${errN} error` : "") +
|
|
199
|
+
(culprits.length ? ` | thieves named: ${culprits.join(", ")}` : "") +
|
|
200
|
+
` | exit ${a.exitCode}` +
|
|
168
201
|
(showCost ? ` | cost $${a.totalCost.toFixed(4)}` : ""));
|
|
169
202
|
return out.join("\n");
|
|
170
203
|
}
|
|
204
|
+
/** regression gate vs a saved baseline: only significant (BH-corrected) drops fail the build. */
|
|
205
|
+
export function renderBaseline(cmp) {
|
|
206
|
+
const out = [];
|
|
207
|
+
out.push(`Baseline gate — vs baseline saved ${cmp.baselineDate} (skill-probe ${cmp.baselineVersion})`);
|
|
208
|
+
for (const m of cmp.mismatches)
|
|
209
|
+
out.push(` ⚠ ${m}`);
|
|
210
|
+
for (const r of cmp.cases) {
|
|
211
|
+
if (r.untrustworthy) {
|
|
212
|
+
out.push(` [⛔ NOT COMPARABLE] ${r.expected ?? "(decoy)"} | ${r.prompt} — a side has no valid probes`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!r.regressed && !r.improved)
|
|
216
|
+
continue; // stable cases are summarized, not listed
|
|
217
|
+
const flag = r.regressed ? "▼ REGRESSED" : "▲ improved";
|
|
218
|
+
out.push(` [${flag}] ${r.expected ?? "(decoy)"} | ${r.prompt}`);
|
|
219
|
+
out.push(` ${pct(r.before.pHat)} (${r.before.hits}/${r.before.n}) → ${pct(r.after.pHat)} ` +
|
|
220
|
+
`(${r.after.hits}/${r.after.n}) Δ${r.delta >= 0 ? "+" : ""}${pct(r.delta)} ` +
|
|
221
|
+
`p=${pval(r.fisherP)} · adj ${pval(r.fisherPAdj)}`);
|
|
222
|
+
}
|
|
223
|
+
const extras = [];
|
|
224
|
+
if (cmp.newCases.length)
|
|
225
|
+
extras.push(`${cmp.newCases.length} new case(s) not in baseline (measured, not gated)`);
|
|
226
|
+
if (cmp.missingCases.length)
|
|
227
|
+
extras.push(`${cmp.missingCases.length} baseline case(s) missing from this run`);
|
|
228
|
+
if (extras.length)
|
|
229
|
+
out.push(` ${extras.join(" · ")}`);
|
|
230
|
+
out.push(cmp.regressions
|
|
231
|
+
? `Gate: ▼ FAIL — ${cmp.regressions} significant regression(s)` +
|
|
232
|
+
(cmp.improvements ? `, ${cmp.improvements} improvement(s)` : "")
|
|
233
|
+
: `Gate: ✅ no significant regressions (${cmp.cases.length} gated` +
|
|
234
|
+
(cmp.improvements ? `, ${cmp.improvements} improved — consider re-saving the baseline` : "") + `)`);
|
|
235
|
+
return out.join("\n");
|
|
236
|
+
}
|
|
171
237
|
export function renderFix(f) {
|
|
172
238
|
const out = [];
|
|
173
239
|
out.push(`skill-probe fix — skill: ${f.skill}`);
|
package/dist/stats.d.ts
CHANGED
|
@@ -30,5 +30,9 @@ export interface ImprovementResult {
|
|
|
30
30
|
}
|
|
31
31
|
/** Bayesian Beta-Binomial: P(p_after > p_before) + delta credible interval. */
|
|
32
32
|
export declare function probImprovement(xBefore: number, nBefore: number, xAfter: number, nAfter: number, prior?: number, samples?: number, seed?: number): ImprovementResult;
|
|
33
|
+
/** Benjamini-Hochberg step-up ADJUSTED p-values: adj[i] = min over ranks j>=rank(i) of m*p_(j)/j.
|
|
34
|
+
* adj[i] <= q ⟺ benjaminiHochberg(pvals, q) rejects i, but the adjusted value is reportable
|
|
35
|
+
* next to the raw p so the multiple-comparisons correction is visible, not silent. */
|
|
36
|
+
export declare function bhAdjust(pvals: number[]): number[];
|
|
33
37
|
/** Benjamini-Hochberg FDR. Returns booleans (true = flag as real). */
|
|
34
38
|
export declare function benjaminiHochberg(pvals: number[], q?: number): boolean[];
|
package/dist/stats.js
CHANGED
|
@@ -135,6 +135,23 @@ export function probImprovement(xBefore, nBefore, xAfter, nAfter, prior = 0.5, s
|
|
|
135
135
|
deltaCi: [deltas[Math.floor(0.025 * samples)], deltas[Math.floor(0.975 * samples)]],
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
|
+
/** Benjamini-Hochberg step-up ADJUSTED p-values: adj[i] = min over ranks j>=rank(i) of m*p_(j)/j.
|
|
139
|
+
* adj[i] <= q ⟺ benjaminiHochberg(pvals, q) rejects i, but the adjusted value is reportable
|
|
140
|
+
* next to the raw p so the multiple-comparisons correction is visible, not silent. */
|
|
141
|
+
export function bhAdjust(pvals) {
|
|
142
|
+
const m = pvals.length;
|
|
143
|
+
if (m === 0)
|
|
144
|
+
return [];
|
|
145
|
+
const order = [...pvals.keys()].sort((i, j) => pvals[i] - pvals[j]);
|
|
146
|
+
const adj = new Array(m).fill(1);
|
|
147
|
+
let running = 1;
|
|
148
|
+
for (let idx = m - 1; idx >= 0; idx--) {
|
|
149
|
+
const i = order[idx];
|
|
150
|
+
running = Math.min(running, (pvals[i] * m) / (idx + 1));
|
|
151
|
+
adj[i] = Math.min(1, running);
|
|
152
|
+
}
|
|
153
|
+
return adj;
|
|
154
|
+
}
|
|
138
155
|
/** Benjamini-Hochberg FDR. Returns booleans (true = flag as real). */
|
|
139
156
|
export function benjaminiHochberg(pvals, q = 0.05) {
|
|
140
157
|
const m = pvals.length;
|
package/package.json
CHANGED