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 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
- - `context`: compare isolation vs co-loaded activation to catch library interference.
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 (the claude/opencode CLI must be installed + authed on the runner)
163
- - run: npx skill-probe --config probe.config.json --quiet --no-cost
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
- Exit codes: **`0`** all pass · **`1`** a behavioral fail / trigger-theft / interference · **`2`**
167
- inconclusive or an infrastructure error — so a runtime outage *fails* the build instead of silently
168
- passing. Add `--json` to archive the full result as a build artifact.
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) are reported as `error`, never as
285
- a behavioral pass/fail — a decoy can't falsely pass because the runtime was down.
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 alone but are suppressed under the full library's load.
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
- **Implemented + unit-tested but NOT yet used by any command:** Benjamini-Hochberg FDR
298
- (`src/stats.ts`) — reserved for cross-case multiplicity control (a planned enhancement). Don't read
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
- if (!info.sawAssistant) {
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;
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 { renderTable, renderMarkdown, renderJson, renderFix, renderContext, renderDoctor, renderDiagnose } from "./report.js";
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> [options] # isolation vs co-loaded activation rates
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 line = ` [${e.caseIndex}/${e.caseTotal}] ${e.condition.padEnd(9)} "${p}" — run ${e.validN}/${e.maxK}`;
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
- const text = values["json"] ? renderJson(result)
104
- : values["markdown"] ? renderMarkdown(result, { showCost })
105
- : renderTable(result, { showCost });
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 result = await runContextAudit(cfg, makeContextProgress(values));
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 full library is co-loaded */
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, onProgress?: (e: ContextProgress) => void): Promise<ContextAuditResult>;
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, onProgress) {
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
- const interference = !untrustworthy &&
86
- isoRel.pHat >= cfg.threshold && // fires reliably alone
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
  }
@@ -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: Benjamini-Hochberg multiplicity control over per-case exact-test p-values lands in M2,
43
- // once each case carries a real p-value rather than a CI decision. The functions are tested
44
- // in stats.ts but are NOT yet wired into this audit path.
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;
@@ -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` : "") + ` | exit ${a.exitCode}` +
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill-probe",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Runtime-agnostic, co-loaded-aware auditor for AI agent skill libraries: measures which skill actually fires, with statistical confidence.",
5
5
  "type": "module",
6
6
  "bin": {