skill-probe 0.7.0 → 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 +53 -9
- package/assets/skill-probe-context-demo.gif +0 -0
- package/assets/skill-probe-context-demo.png +0 -0
- package/dist/adapters/claudeCode.d.ts +8 -1
- package/dist/adapters/claudeCode.js +22 -7
- package/dist/cli.js +16 -3
- package/dist/context.d.ts +34 -3
- package/dist/context.js +72 -7
- package/dist/report.js +35 -2
- package/dist/stats.d.ts +4 -0
- package/dist/stats.js +17 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -4,6 +4,10 @@ Runtime-agnostic, **co-loaded-aware** auditor for an AI agent's skill library. I
|
|
|
4
4
|
which skill *actually* fires (by real activation, not keyword matching) when your whole
|
|
5
5
|
`SKILL.md` library is loaded together — with **statistical confidence**, not single-shot guesses.
|
|
6
6
|
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
> **Your skill works alone. Does it still work when the whole library is loaded?**
|
|
10
|
+
|
|
7
11
|
> Most skill tooling tests one skill, in isolation, once. Skills only conflict when loaded
|
|
8
12
|
> *together*, and activation is *stochastic*. skill-probe is the tool that tests the real thing.
|
|
9
13
|
|
|
@@ -24,7 +28,8 @@ doctor → gen → audit → context → diagnose → fix
|
|
|
24
28
|
- `doctor`: verify setup, auth, runtime, skills, and config before spending probes.
|
|
25
29
|
- `gen`: draft a probe config from your existing skills.
|
|
26
30
|
- `audit`: measure which skill actually fires when the whole library is co-loaded.
|
|
27
|
-
- `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.
|
|
28
33
|
- `diagnose`: explain whether a failure is a routing miss or a description problem.
|
|
29
34
|
- `fix`: rewrite a skill description and keep it only if the measured reliability improves.
|
|
30
35
|
|
|
@@ -175,16 +180,20 @@ skill-probe context --config probe.config.json
|
|
|
175
180
|
|
|
176
181
|
For each case it measures the expected skill **in isolation** (a throwaway project with only that
|
|
177
182
|
one skill) **and co-loaded** (your full library), then tests the drop with **Fisher's exact test**
|
|
178
|
-
— 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):
|
|
179
187
|
|
|
180
188
|
```
|
|
181
189
|
skill-probe context — runtime: claude-code model: (runtime default) threshold: 70% library: 4 skills
|
|
182
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)
|
|
183
192
|
|
|
184
193
|
[⚠ INTERFERENCE] greeter | write a birthday greeting
|
|
185
194
|
isolation 100% [72%, 100%] k=10
|
|
186
195
|
co-loaded 30% [11%, 60%] k=10
|
|
187
|
-
Δ -70% under load Fisher p=0.003
|
|
196
|
+
Δ -70% under load Fisher p=0.003 · BH-adj p=0.009
|
|
188
197
|
↳ fires reliably alone but is suppressed when the library is co-loaded (stolen by: welcomer)
|
|
189
198
|
|
|
190
199
|
Result: 1 interference / 3 measured | exit 1
|
|
@@ -196,6 +205,38 @@ in the library is a config error (typo), surfaced before any probes run. Exit `1
|
|
|
196
205
|
shows interference, `2` if a case is untrustworthy (infra errors dominated — never a silent pass),
|
|
197
206
|
else `0`.
|
|
198
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
|
+
|
|
199
240
|
## Why a skill fails (`skill-probe diagnose`)
|
|
200
241
|
|
|
201
242
|
When a skill doesn't fire, there are two very different root causes — and the activation rate alone
|
|
@@ -277,11 +318,15 @@ should-fire prompts, near-misses, and decoys.
|
|
|
277
318
|
|
|
278
319
|
**Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping + four-state verdict
|
|
279
320
|
(pass / fail / inconclusive / **error**), across two runtimes (Claude Code, OpenCode).
|
|
280
|
-
Infrastructure failures (timeout / auth / crash / empty output
|
|
281
|
-
|
|
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.
|
|
282
324
|
|
|
283
325
|
**Context (`skill-probe context`):** isolation-vs-co-loaded activation rates, with **Fisher's exact
|
|
284
|
-
test** on the drop — catches skills that fire
|
|
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.
|
|
285
330
|
|
|
286
331
|
**Diagnose (`skill-probe diagnose`):** compares actual runtime activation against intended
|
|
287
332
|
forced-choice routing to classify failures as routing-miss vs description-problem. Intended routing
|
|
@@ -290,9 +335,8 @@ is a heuristic, not ground truth, and the README calls that out explicitly.
|
|
|
290
335
|
**Fix (`skill-probe fix`):** uses the **Bayesian Beta-Binomial** to gate description rewrites on a
|
|
291
336
|
*proven* lift (interleaved before/after, applied only if P(improvement) clears the bar).
|
|
292
337
|
|
|
293
|
-
|
|
294
|
-
(
|
|
295
|
-
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).
|
|
296
340
|
|
|
297
341
|
## Dev
|
|
298
342
|
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
},
|
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> [
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
86
|
-
|
|
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` : "") +
|
|
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.
|
|
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": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"files": [
|
|
43
43
|
"dist",
|
|
44
44
|
"examples",
|
|
45
|
+
"assets",
|
|
45
46
|
"README.md",
|
|
46
47
|
"LICENSE"
|
|
47
48
|
]
|