skill-probe 0.7.1 → 0.8.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
@@ -28,7 +28,8 @@ doctor → gen → audit → context → diagnose → fix
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
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.
31
+ - `context`: compare isolation vs co-loaded activation to catch library interference —
32
+ add `--ablate` to name **which** sibling is stealing the trigger.
32
33
  - `diagnose`: explain whether a failure is a routing miss or a description problem.
33
34
  - `fix`: rewrite a skill description and keep it only if the measured reliability improves.
34
35
 
@@ -179,16 +180,20 @@ skill-probe context --config probe.config.json
179
180
 
180
181
  For each case it measures the expected skill **in isolation** (a throwaway project with only that
181
182
  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:
183
+ — so interference is reported as a real effect with a p-value, not eyeballed. Across multiple
184
+ cases the p-values are **Benjamini-Hochberg corrected** (one Fisher test per case is a *family*:
185
+ raw `p<0.05` over a 40-case library expects ~2 false flags by chance alone — interference is
186
+ decided on the adjusted p, and the report shows both):
183
187
 
184
188
  ```
185
189
  skill-probe context — runtime: claude-code model: (runtime default) threshold: 70% library: 4 skills
186
190
  isolation = only that skill loaded · co-loaded = the full library (4) loaded
191
+ 3 comparisons — interference decided on Benjamini-Hochberg adjusted p<0.05 (raw p across a family overcounts)
187
192
 
188
193
  [⚠ INTERFERENCE] greeter | write a birthday greeting
189
194
  isolation 100% [72%, 100%] k=10
190
195
  co-loaded 30% [11%, 60%] k=10
191
- Δ -70% under load Fisher p=0.003
196
+ Δ -70% under load Fisher p=0.003 · BH-adj p=0.009
192
197
  ↳ fires reliably alone but is suppressed when the library is co-loaded (stolen by: welcomer)
193
198
 
194
199
  Result: 1 interference / 3 measured | exit 1
@@ -200,6 +205,38 @@ in the library is a config error (typo), surfaced before any probes run. Exit `1
200
205
  shows interference, `2` if a case is untrustworthy (infra errors dominated — never a silent pass),
201
206
  else `0`.
202
207
 
208
+ ### Name the thief (`--ablate`)
209
+
210
+ Knowing *that* a skill is suppressed is half the diagnosis — the other half is **which sibling is
211
+ stealing it**. With 3 skills you can guess; with 40 you can't. `--ablate` answers it with evidence:
212
+
213
+ ```bash
214
+ skill-probe context --config probe.config.json --ablate
215
+ ```
216
+
217
+ For each interference case it takes the suspects (ranked by how many probes they actually stole in
218
+ the co-loaded run, capped by `--suspects`, default 3), removes them from the library **one at a
219
+ time**, and re-measures. If activation recovers significantly (Fisher vs the co-loaded run,
220
+ BH-corrected across all ablation runs), that sibling is the thief — named, with a p-value:
221
+
222
+ ```
223
+ [⚠ INTERFERENCE] greeter | write a greeting for my new teammate
224
+ isolation 100% [61%, 100%] k=6
225
+ co-loaded 0% [0%, 56%] k=3
226
+ Δ -100% under load Fisher p=0.012
227
+ ↳ fires reliably alone but is suppressed when the library is co-loaded (stolen by: welcomer)
228
+ leave-one-out (each suspect removed, recovery vs co-loaded):
229
+ − welcomer removed → 100% [61%, 100%] k=6 Δ+100% p=0.012 · adj 0.012 ← THIEF (full recovery)
230
+
231
+ Result: 1 interference / 1 measured | thieves named: welcomer | exit 1
232
+ ```
233
+
234
+ (That's a real run.) Honest edge cases: if the co-loaded probes were suppressed to **None** —
235
+ nothing stole them, the skill just stops firing under load — there's no suspect to remove, and the
236
+ report says *"dilution, not theft; nothing to ablate"* instead of guessing. If no single removal
237
+ recovers activation, it says so ("interference may be combinatorial") rather than naming an
238
+ innocent sibling.
239
+
203
240
  ## Why a skill fails (`skill-probe diagnose`)
204
241
 
205
242
  When a skill doesn't fire, there are two very different root causes — and the activation rate alone
@@ -281,11 +318,15 @@ should-fire prompts, near-misses, and decoys.
281
318
 
282
319
  **Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping + four-state verdict
283
320
  (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.
321
+ Infrastructure failures (timeout / auth / crash / empty output / a zero-cost response where the
322
+ model never actually ran, e.g. a usage-limit window) are reported as `error`, never as a
323
+ behavioral pass/fail — a decoy can't falsely pass because the runtime was down.
286
324
 
287
325
  **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.
326
+ test** on the drop and **Benjamini-Hochberg correction** across cases — catches skills that fire
327
+ alone but are suppressed under the full library's load, without buying false flags on big libraries.
328
+ With `--ablate`, leave-one-out re-measurement **names the thieving sibling** when removing it
329
+ significantly restores activation.
289
330
 
290
331
  **Diagnose (`skill-probe diagnose`):** compares actual runtime activation against intended
291
332
  forced-choice routing to classify failures as routing-miss vs description-problem. Intended routing
@@ -294,9 +335,8 @@ is a heuristic, not ground truth, and the README calls that out explicitly.
294
335
  **Fix (`skill-probe fix`):** uses the **Bayesian Beta-Binomial** to gate description rewrites on a
295
336
  *proven* lift (interleaved before/after, applied only if P(improvement) clears the bar).
296
337
 
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`.)
338
+ Every statistical function in `src/stats.ts` is now wired into a command: Wilson + sequential
339
+ stopping (audit), Fisher's exact + Benjamini-Hochberg (context/ablate), Bayesian Beta-Binomial (fix).
300
340
 
301
341
  ## Dev
302
342
 
@@ -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
  },
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ const HELP = `skill-probe — audit a co-loaded agent skill library by real acti
17
17
 
18
18
  USAGE
19
19
  skill-probe --config <file.json> [options] # audit
20
- skill-probe context --config <file.json> [options] # isolation vs co-loaded activation rates
20
+ skill-probe context --config <file.json> [--ablate] # isolation vs co-loaded activation rates
21
21
  skill-probe diagnose --config <file.json> [options] # why a skill fails: routing-miss vs description
22
22
  skill-probe doctor [--cwd <dir>] [--config <file.json>] # preflight: setup/auth/config sanity
23
23
  skill-probe gen [--cwd <dir>] > probe.config.json # draft a test config from your skills
@@ -36,6 +36,11 @@ OPTIONS
36
36
  --markdown emit a Markdown table (paste into a PR/README)
37
37
  --no-cost hide the cost line (e.g. when on a Claude subscription)
38
38
  --quiet suppress the live progress line on stderr
39
+ context only:
40
+ --ablate leave-one-out: for each interference case, re-run with each suspect
41
+ sibling removed and NAME the thief when activation recovers (Fisher +
42
+ Benjamini-Hochberg corrected)
43
+ --suspects <n> max siblings to ablate per case, ranked by probes stolen (default 3)
39
44
  doctor only:
40
45
  --cwd <dir> project dir containing .claude/skills/ (default: .)
41
46
  --config <file> also sanity-check a config (expected skills exist, k/threshold feasible)
@@ -74,7 +79,8 @@ function makeContextProgress(values) {
74
79
  return undefined;
75
80
  return (e) => {
76
81
  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}`;
82
+ const cond = e.condition === "ablation" ? `−${e.removed}` : e.condition;
83
+ const line = ` [${e.caseIndex}/${e.caseTotal}] ${cond.padEnd(9)} "${p}" — run ${e.validN}/${e.maxK}`;
78
84
  const pad = Math.max(0, progressLen - line.length);
79
85
  process.stderr.write("\r" + line + " ".repeat(pad));
80
86
  progressLen = line.length;
@@ -137,7 +143,12 @@ async function context(values) {
137
143
  k: values["k"], threshold: values["threshold"],
138
144
  conf: values["conf"],
139
145
  });
140
- const result = await runContextAudit(cfg, makeContextProgress(values));
146
+ const suspects = parseIntFlag(values["suspects"], "--suspects", 3, 1);
147
+ const onProgress = makeContextProgress(values);
148
+ const result = await runContextAudit(cfg, {
149
+ ablate: Boolean(values["ablate"]), suspects,
150
+ ...(onProgress ? { onProgress } : {}),
151
+ });
141
152
  clearProgress();
142
153
  const showCost = !values["no-cost"];
143
154
  console.log(values["json"] ? renderJson(result) : renderContext(result, { showCost }));
@@ -191,6 +202,8 @@ async function main() {
191
202
  cwd: { type: "string" },
192
203
  "per-skill": { type: "string" },
193
204
  decoys: { type: "string" },
205
+ ablate: { type: "boolean", default: false },
206
+ suspects: { type: "string" },
194
207
  "skip-probe": { type: "boolean", default: false },
195
208
  json: { type: "boolean", default: false },
196
209
  markdown: { type: "boolean", default: false },
package/dist/context.d.ts CHANGED
@@ -12,6 +12,20 @@ interface Condition {
12
12
  stats: RunStats;
13
13
  rel: Rel;
14
14
  }
15
+ /** One leave-one-out run: the flagged case re-measured with ONE suspect sibling removed from the
16
+ * library. A significant recovery (BH-corrected Fisher vs the co-loaded run) names that sibling
17
+ * as the thief — evidence, not the trigger-theft heuristic. */
18
+ export interface AblationResult {
19
+ removed: string;
20
+ stolen: number;
21
+ stats: RunStats;
22
+ rel: Rel;
23
+ deltaVsCoLoaded: number;
24
+ fisherP: number;
25
+ fisherPAdj: number;
26
+ untrustworthy: boolean;
27
+ culprit: boolean;
28
+ }
15
29
  export interface ContextCaseResult {
16
30
  prompt: string;
17
31
  expected: string;
@@ -19,10 +33,17 @@ export interface ContextCaseResult {
19
33
  coLoaded: Condition;
20
34
  deltaP: number;
21
35
  fisherP: number;
36
+ /** Benjamini-Hochberg adjusted p across all trustworthy comparisons in this run — one Fisher
37
+ * test per case is a FAMILY, and raw p<0.05 over a 40-case library expects ~2 false flags by
38
+ * chance alone. Interference is decided on THIS value. (1 for untrustworthy cases.) */
39
+ fisherPAdj: number;
22
40
  /** infra failures dominated one side — the comparison is not trustworthy (NOT a behavioral result) */
23
41
  untrustworthy: boolean;
24
- /** fires reliably alone but drops significantly when the full library is co-loaded */
42
+ /** fires reliably alone but drops significantly (BH-corrected) when the library is co-loaded */
25
43
  interference: boolean;
44
+ /** leave-one-out runs (only with { ablate: true }, only for interference cases). Empty array =
45
+ * ablation requested but no named suspect stole probes (suppressed to None — dilution, not theft). */
46
+ ablation?: AblationResult[];
26
47
  }
27
48
  export interface ContextAuditResult {
28
49
  runtime: string;
@@ -40,9 +61,19 @@ export interface ContextProgress {
40
61
  caseIndex: number;
41
62
  caseTotal: number;
42
63
  prompt: string;
43
- condition: "isolation" | "co-loaded";
64
+ condition: "isolation" | "co-loaded" | "ablation";
44
65
  validN: number;
45
66
  maxK: number;
67
+ /** the sibling removed for this run (condition === "ablation" only) */
68
+ removed?: string;
69
+ }
70
+ export interface ContextOpts {
71
+ /** leave-one-out: for each interference case, re-measure with each suspect sibling removed and
72
+ * name the thief when activation recovers significantly. */
73
+ ablate?: boolean;
74
+ /** max suspect siblings to ablate per flagged case, ranked by probes stolen (default 3) */
75
+ suspects?: number;
76
+ onProgress?: (e: ContextProgress) => void;
46
77
  }
47
- export declare function runContextAudit(cfg: Config, onProgress?: (e: ContextProgress) => void): Promise<ContextAuditResult>;
78
+ export declare function runContextAudit(cfg: Config, optsOrProgress?: ContextOpts | ((e: ContextProgress) => void)): Promise<ContextAuditResult>;
48
79
  export {};
package/dist/context.js CHANGED
@@ -6,7 +6,7 @@
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";
@@ -25,7 +25,9 @@ export function buildContextDir(srcCwd, skillNames) {
25
25
  }
26
26
  return { cwd: root, cleanup: () => rmSync(root, { recursive: true, force: true }) };
27
27
  }
28
- export async function runContextAudit(cfg, onProgress) {
28
+ export async function runContextAudit(cfg, optsOrProgress = {}) {
29
+ const opts = typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress;
30
+ const onProgress = opts.onProgress;
29
31
  const adapter = getAdapter(cfg.runtime);
30
32
  const library = listSkills(cfg.cwd);
31
33
  const present = new Set(library.map((s) => s.name));
@@ -82,17 +84,80 @@ export async function runContextAudit(cfg, onProgress) {
82
84
  // if infra failures dominated either side, the comparison isn't a behavioral result at all —
83
85
  // it must never read as "no interference / pass" just because fisherP defaulted to 1.
84
86
  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
87
+ // interference is decided AFTER the loop, once the whole family of comparisons is known
88
+ // (Benjamini-Hochberg) — placeholders here.
89
89
  measured.push({
90
90
  prompt: c.prompt, expected,
91
91
  isolation: { stats: iso, rel: isoRel },
92
92
  coLoaded: { stats: co, rel: coRel },
93
- deltaP, fisherP, untrustworthy, interference,
93
+ deltaP, fisherP, fisherPAdj: 1, untrustworthy, interference: false,
94
94
  });
95
95
  }
96
+ // One Fisher test per case = a family of comparisons: correct across the trustworthy ones so a
97
+ // big library doesn't buy false interference flags by chance (raw p<0.05 × 40 cases ≈ 2 flukes).
98
+ const trustworthy = measured.filter((m) => !m.untrustworthy);
99
+ const adj = bhAdjust(trustworthy.map((m) => m.fisherP));
100
+ trustworthy.forEach((m, j) => { m.fisherPAdj = adj[j]; });
101
+ for (const m of measured) {
102
+ m.interference =
103
+ !m.untrustworthy &&
104
+ m.isolation.rel.pHat >= cfg.threshold && // fires reliably alone
105
+ m.deltaP < 0 && // and drops when co-loaded
106
+ m.fisherPAdj < 0.05; // and the drop survives BH correction
107
+ }
108
+ // leave-one-out ablation: for each flagged case, re-measure with each suspect sibling removed.
109
+ // Suspects are ranked by the probes they actually stole in the co-loaded run — evidence-guided,
110
+ // not a sweep of the whole library. Recovery is Fisher-tested against the co-loaded run and
111
+ // BH-corrected across all ablation runs (their own family). A significant recovery NAMES the
112
+ // thief; no suspects in the outcome distribution means the drop was dilution, not theft.
113
+ if (opts.ablate) {
114
+ const libraryNames = library.map((s) => s.name);
115
+ const flagged = measured.filter((m) => m.interference);
116
+ const cap = opts.suspects ?? 3;
117
+ const allRuns = [];
118
+ for (let fi = 0; fi < flagged.length; fi++) {
119
+ const m = flagged[fi];
120
+ const suspects = Object.entries(m.coLoaded.stats.dist)
121
+ .filter(([name]) => name !== m.expected && name !== "None")
122
+ .sort((x, y) => y[1] - x[1])
123
+ .slice(0, cap);
124
+ m.ablation = [];
125
+ for (const [suspect, stolen] of suspects) {
126
+ const ctx = buildContextDir(cfg.cwd, libraryNames.filter((n) => n !== suspect));
127
+ let st;
128
+ try {
129
+ st = await measure(adapter, m.prompt, m.expected, {
130
+ cwd: ctx.cwd, ...common,
131
+ ...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: fi + 1, caseTotal: flagged.length, prompt: m.prompt, condition: "ablation", removed: suspect, validN, maxK }) } : {}),
132
+ });
133
+ }
134
+ finally {
135
+ ctx.cleanup();
136
+ }
137
+ totalCost += st.totalCost;
138
+ const rel = reliability(st.hits, st.n, cfg.conf);
139
+ const co = m.coLoaded.stats;
140
+ const fisherP = (st.n > 0 && co.n > 0)
141
+ ? fisherExact2x2(st.hits, st.n - st.hits, co.hits, co.n - co.hits)
142
+ : 1;
143
+ const abl = {
144
+ removed: suspect, stolen, stats: st, rel,
145
+ deltaVsCoLoaded: rel.pHat - m.coLoaded.rel.pHat,
146
+ fisherP, fisherPAdj: 1,
147
+ untrustworthy: infraUntrustworthy(st),
148
+ culprit: false,
149
+ };
150
+ m.ablation.push(abl);
151
+ allRuns.push(abl);
152
+ }
153
+ }
154
+ const ablFamily = allRuns.filter((r) => !r.untrustworthy);
155
+ const ablAdj = bhAdjust(ablFamily.map((r) => r.fisherP));
156
+ ablFamily.forEach((r, j) => { r.fisherPAdj = ablAdj[j]; });
157
+ for (const r of allRuns) {
158
+ r.culprit = !r.untrustworthy && r.deltaVsCoLoaded > 0 && r.fisherPAdj < 0.05;
159
+ }
160
+ }
96
161
  // precedence mirrors the audit: a real behavioral signal (interference, exit 1) outranks an
97
162
  // untrustworthy/infra case (exit 2), which outranks a clean run (exit 0).
98
163
  const exitCode = measured.some((m) => m.interference) ? 1
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,8 +193,11 @@ 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
  }
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.8.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": {