efaimo 0.1.1 → 0.2.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +128 -5
  2. package/README.md +69 -7
  3. package/SKILL.md +16 -9
  4. package/dist/check/check.js +8 -0
  5. package/dist/cli.js +179 -27
  6. package/dist/clients/introspect.js +10 -2
  7. package/dist/core/engine.d.ts +2 -1
  8. package/dist/core/engine.js +4 -0
  9. package/dist/core/types.d.ts +121 -0
  10. package/dist/find/bm25.d.ts +77 -0
  11. package/dist/find/bm25.js +107 -0
  12. package/dist/find/find.d.ts +120 -0
  13. package/dist/find/find.js +403 -0
  14. package/dist/find/tokenize.d.ts +64 -0
  15. package/dist/find/tokenize.js +116 -0
  16. package/dist/index.d.ts +6 -3
  17. package/dist/index.js +13 -3
  18. package/dist/reporters/json.d.ts +19 -1
  19. package/dist/reporters/json.js +13 -2
  20. package/dist/reporters/markdown.d.ts +2 -1
  21. package/dist/reporters/markdown.js +109 -11
  22. package/dist/reporters/pretty.d.ts +12 -1
  23. package/dist/reporters/pretty.js +177 -10
  24. package/dist/rules/find/index.d.ts +4 -0
  25. package/dist/rules/find/index.js +262 -0
  26. package/dist/rules/injection.d.ts +1 -2
  27. package/dist/rules/injection.js +0 -11
  28. package/dist/rules/mcp/index.js +21 -1
  29. package/dist/rules/skill/index.js +5 -3
  30. package/dist/rules/version.d.ts +26 -0
  31. package/dist/rules/version.js +26 -0
  32. package/dist/targets/repoScan.js +0 -0
  33. package/dist/testing/anthropicRunner.d.ts +30 -0
  34. package/dist/testing/anthropicRunner.js +43 -8
  35. package/dist/testing/harness.d.ts +33 -1
  36. package/dist/testing/harness.js +66 -7
  37. package/dist/testing/openaiRunner.js +15 -8
  38. package/dist/testing/stats.d.ts +16 -2
  39. package/dist/testing/stats.js +26 -2
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/dist/weigh/anthropicExact.d.ts +12 -1
  43. package/dist/weigh/anthropicExact.js +9 -1
  44. package/dist/weigh/weigh.js +10 -3
  45. package/docs/INTEGRATIONS.md +15 -1
  46. package/docs/METHODOLOGY.md +111 -7
  47. package/docs/RULES.md +42 -5
  48. package/examples/scenario.crm.yaml +8 -0
  49. package/examples/scenario.example.yaml +8 -0
  50. package/package.json +2 -2
@@ -16,8 +16,10 @@ const s101 = {
16
16
  const push = (severity, message, fixHint) => findings.push({ ruleId: "S101", severity, title: "frontmatter does not match the spec", message, target: label, fixHint });
17
17
  if (skill.parseError) {
18
18
  push("error", `${skill.file}: ${skill.parseError}`, "SKILL.md must start with a --- YAML frontmatter block");
19
- if (!skill.frontmatterRaw)
20
- return findings;
19
+ // No early return on empty frontmatter. Returning here after one error let
20
+ // a skill with NO frontmatter grade higher than one with BROKEN frontmatter
21
+ // (the required-field checks below never ran), so total absence outscored a
22
+ // typo by two errors. Absent fields ARE missing fields; report them too.
21
23
  }
22
24
  const name = skill.frontmatter.name;
23
25
  if (typeof name !== "string" || !name.trim()) {
@@ -254,7 +256,7 @@ const s106 = {
254
256
  fixHint: "agents will fail mid-task when they try to open it",
255
257
  });
256
258
  }
257
- else if (ref.raw.includes("..")) {
259
+ else if (ref.escapes) {
258
260
  findings.push({
259
261
  ruleId: "S106",
260
262
  severity: "warn",
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Which ruleset produced a report.
3
+ *
4
+ * The tool version does not answer this. A patch release can change what a
5
+ * rule fires on without changing any documented behaviour (0.1.2 did exactly
6
+ * that: E123 started matching `delete_file`, and every grade that rule touched
7
+ * moved), so "efaimo 0.1.2 said A(95)" is only reproducible if the reader can
8
+ * also tell which rules were running. Every JSON envelope now carries this.
9
+ *
10
+ * Values:
11
+ * "1" every ruleset through efaimo 0.1.2. Reports from those versions carry
12
+ * no `rulesVersion` field at all; the absence IS the value.
13
+ * "2" the first ruleset that declares itself. Adds the findability family
14
+ * (E141-E145), which no earlier version had.
15
+ *
16
+ * Bump it when a rule is added, removed, renumbered, or changed in what it
17
+ * fires on. `test/meta.test.ts` pins the rule inventory, so an added or
18
+ * removed rule cannot land without someone looking at this file. It cannot
19
+ * detect a changed threshold inside an existing rule: that one is human
20
+ * discipline, and this comment is where the discipline is written down.
21
+ *
22
+ * Deliberately not derived from package.json. Tying it to the release number
23
+ * would make it move on every release whether or not a rule moved, which is
24
+ * the thing it exists to distinguish.
25
+ */
26
+ export declare const RULES_VERSION = "2";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Which ruleset produced a report.
3
+ *
4
+ * The tool version does not answer this. A patch release can change what a
5
+ * rule fires on without changing any documented behaviour (0.1.2 did exactly
6
+ * that: E123 started matching `delete_file`, and every grade that rule touched
7
+ * moved), so "efaimo 0.1.2 said A(95)" is only reproducible if the reader can
8
+ * also tell which rules were running. Every JSON envelope now carries this.
9
+ *
10
+ * Values:
11
+ * "1" every ruleset through efaimo 0.1.2. Reports from those versions carry
12
+ * no `rulesVersion` field at all; the absence IS the value.
13
+ * "2" the first ruleset that declares itself. Adds the findability family
14
+ * (E141-E145), which no earlier version had.
15
+ *
16
+ * Bump it when a rule is added, removed, renumbered, or changed in what it
17
+ * fires on. `test/meta.test.ts` pins the rule inventory, so an added or
18
+ * removed rule cannot land without someone looking at this file. It cannot
19
+ * detect a changed threshold inside an existing rule: that one is human
20
+ * discipline, and this comment is where the discipline is written down.
21
+ *
22
+ * Deliberately not derived from package.json. Tying it to the release number
23
+ * would make it move on every release whether or not a rule moved, which is
24
+ * the thing it exists to distinguish.
25
+ */
26
+ export const RULES_VERSION = "2";
Binary file
@@ -1,4 +1,34 @@
1
1
  import type { Runner } from "./harness.js";
2
+ /**
3
+ * Which Claude models still accept `temperature` / `top_p` / `top_k`.
4
+ *
5
+ * Sampling parameters were removed from the Claude 4.7 line onward and now
6
+ * return a 400 on Fable 5, Opus 5, Opus 4.8, Opus 4.7 and Sonnet 5. This
7
+ * runner used to send `temperature` on every request, and the scenario default
8
+ * model is `claude-sonnet-5`, so `efaimo test --live` sent a rejected
9
+ * parameter on its first call; `isTransient()` correctly does not retry a 400,
10
+ * so the whole scenario threw before a single trial completed.
11
+ *
12
+ * Nothing in the suite could see it. The runner is injected precisely so the
13
+ * A/B logic can be tested without spending tokens, which means every test in
14
+ * this repo exercises a fake and none of them ever built this request body.
15
+ * The fix is therefore paired with a test that asserts on the body itself.
16
+ *
17
+ * The list is an ALLOWLIST of models that accept the parameter, and it is
18
+ * closed: it names model lines that already shipped, so it cannot go stale in
19
+ * the direction that breaks a run. A model released after this was written is
20
+ * unknown to it and gets no `temperature`, which costs a little determinism on
21
+ * the judge and cannot cost a 400. The opposite shape - a denylist of models
22
+ * that reject it - would fail every future release by default.
23
+ */
24
+ export declare function acceptsSamplingParams(model: string): boolean;
25
+ /** The request body, split out so a test can assert on it without a network call. */
26
+ export declare function anthropicRequestBody(req: {
27
+ model: string;
28
+ system?: string;
29
+ user: string;
30
+ deterministic?: boolean;
31
+ }): Record<string, unknown>;
2
32
  /**
3
33
  * The live runner. Used only by `efaimo test --live`, because every call spends
4
34
  * tokens on the caller's ANTHROPIC_API_KEY.
@@ -1,3 +1,45 @@
1
+ /**
2
+ * Which Claude models still accept `temperature` / `top_p` / `top_k`.
3
+ *
4
+ * Sampling parameters were removed from the Claude 4.7 line onward and now
5
+ * return a 400 on Fable 5, Opus 5, Opus 4.8, Opus 4.7 and Sonnet 5. This
6
+ * runner used to send `temperature` on every request, and the scenario default
7
+ * model is `claude-sonnet-5`, so `efaimo test --live` sent a rejected
8
+ * parameter on its first call; `isTransient()` correctly does not retry a 400,
9
+ * so the whole scenario threw before a single trial completed.
10
+ *
11
+ * Nothing in the suite could see it. The runner is injected precisely so the
12
+ * A/B logic can be tested without spending tokens, which means every test in
13
+ * this repo exercises a fake and none of them ever built this request body.
14
+ * The fix is therefore paired with a test that asserts on the body itself.
15
+ *
16
+ * The list is an ALLOWLIST of models that accept the parameter, and it is
17
+ * closed: it names model lines that already shipped, so it cannot go stale in
18
+ * the direction that breaks a run. A model released after this was written is
19
+ * unknown to it and gets no `temperature`, which costs a little determinism on
20
+ * the judge and cannot cost a 400. The opposite shape - a denylist of models
21
+ * that reject it - would fail every future release by default.
22
+ */
23
+ export function acceptsSamplingParams(model) {
24
+ return /^claude-(3|(sonnet|haiku|opus)-4-5|(opus|sonnet)-4-6)\b/i.test(model);
25
+ }
26
+ /** The request body, split out so a test can assert on it without a network call. */
27
+ export function anthropicRequestBody(req) {
28
+ const body = {
29
+ model: req.model,
30
+ max_tokens: 1024,
31
+ ...(req.system ? { system: req.system } : {}),
32
+ messages: [{ role: "user", content: req.user }],
33
+ };
34
+ // 0 for the judge, 1 for the subject (see RunnerRequest.deterministic), but
35
+ // only where the model still has the parameter. Where it does not, the
36
+ // subject arm is unaffected (1 was the default anyway) and the judge simply
37
+ // is not pinned; `runScenario` is told so it can say it in the report,
38
+ // because an unpinned judge is variance that lands in the measurement.
39
+ if (acceptsSamplingParams(req.model))
40
+ body.temperature = req.deterministic ? 0 : 1;
41
+ return body;
42
+ }
1
43
  /**
2
44
  * The live runner. Used only by `efaimo test --live`, because every call spends
3
45
  * tokens on the caller's ANTHROPIC_API_KEY.
@@ -11,14 +53,7 @@ export function anthropicRunner(apiKey) {
11
53
  "x-api-key": apiKey,
12
54
  "anthropic-version": "2023-06-01",
13
55
  },
14
- body: JSON.stringify({
15
- model: req.model,
16
- // 0 for the judge, 1 for the subject. See RunnerRequest.deterministic.
17
- temperature: req.deterministic ? 0 : 1,
18
- max_tokens: 1024,
19
- ...(req.system ? { system: req.system } : {}),
20
- messages: [{ role: "user", content: req.user }],
21
- }),
56
+ body: JSON.stringify(anthropicRequestBody(req)),
22
57
  redirect: "error",
23
58
  signal: AbortSignal.timeout(60000),
24
59
  });
@@ -10,6 +10,24 @@ export interface Scenario {
10
10
  skillName: string;
11
11
  skillBody: string;
12
12
  model: string;
13
+ /**
14
+ * The model that grades the attempts. Defaults to `model`, which is what it
15
+ * always was, and that default is a known confound: a model grading its own
16
+ * output prefers it, so an effect measured that way is partly a measurement
17
+ * of self-preference. Set it to a different model (`judge_model:` in the
18
+ * scenario, or `--judge-model`) and the report prints both.
19
+ */
20
+ judgeModel: string;
21
+ /**
22
+ * Whether `judge_model:` was written in the scenario.
23
+ *
24
+ * `--model` moves the judge with the subject only when the judge was never
25
+ * pinned. Without this flag the two cases are indistinguishable: a scenario
26
+ * that deliberately pins `judge_model` to the same id as `model` looked
27
+ * identical to one that omitted the key, so `--model gpt-4o-mini` would have
28
+ * moved a deliberately pinned judge.
29
+ */
30
+ judgeModelExplicit: boolean;
13
31
  trials: number;
14
32
  task: string;
15
33
  judge: string;
@@ -43,12 +61,23 @@ export interface TestReport {
43
61
  scenario: string;
44
62
  skill: string;
45
63
  model: string;
64
+ judgeModel: string;
46
65
  withSkill: ArmResult;
47
66
  withoutSkill: ArmResult;
48
67
  /** withSkill.passRate - withoutSkill.passRate, in percentage points. */
49
68
  deltaPoints: number;
50
69
  /** Two-sided Fisher exact p on the 2x2 of scored trials. */
51
70
  p: number;
71
+ /**
72
+ * Two-sided Fisher exact p on the 2x2 of UNPARSEABLE trials.
73
+ *
74
+ * Dropping a trial the judge did not answer is only unbiased if the dropping
75
+ * is unrelated to which arm produced it. If one arm loses materially more
76
+ * trials than the other, the surviving denominators are not comparable and
77
+ * the delta is measuring the judge as much as the skill. Low p here means
78
+ * the exclusions are skewed.
79
+ */
80
+ unparseableSkewP: number;
52
81
  /** 95% interval on the delta, in percentage points (Newcombe). */
53
82
  ci: {
54
83
  lo: number;
@@ -63,4 +92,7 @@ export declare function armSystems(scenario: Scenario): {
63
92
  withoutSkill: string | undefined;
64
93
  withSkill: string;
65
94
  };
66
- export declare function runScenario(scenario: Scenario, runner: Runner): Promise<TestReport>;
95
+ export declare function withRetry(runner: Runner, tries?: number, baseDelayMs?: number): Runner;
96
+ export declare function runScenario(scenario: Scenario, runner: Runner, opts?: {
97
+ extraNotes?: string[];
98
+ }): Promise<TestReport>;
@@ -29,12 +29,16 @@ export function parseScenario(file) {
29
29
  // outcome that reaches p < 0.05, so every partial result is uninterpretable
30
30
  // and the old default guaranteed a verdict nobody could stand behind.
31
31
  const trials = Math.min(50, Math.max(1, Math.round(num(doc.trials) ?? 20)));
32
+ const model = str(doc.model) ?? "claude-sonnet-5";
33
+ const judgeModel = str(doc.judge_model);
32
34
  return {
33
35
  name,
34
36
  skillPath,
35
37
  skillName: skill.name ?? path.basename(skill.dir),
36
38
  skillBody: skill.body.trim(),
37
- model: str(doc.model) ?? "claude-sonnet-5",
39
+ model,
40
+ judgeModel: judgeModel ?? model,
41
+ judgeModelExplicit: judgeModel !== undefined,
38
42
  trials,
39
43
  task,
40
44
  judge,
@@ -60,7 +64,7 @@ async function judgeOne(runner, scenario, answer) {
60
64
  const out = await runner({
61
65
  system: JUDGE_SYSTEM,
62
66
  user: `TASK:\n${scenario.task}\n\nRUBRIC:\n${scenario.judge}\n\nASSISTANT ANSWER:\n${answer}\n\nVerdict (PASS or FAIL):`,
63
- model: scenario.model,
67
+ model: scenario.judgeModel,
64
68
  deterministic: true,
65
69
  });
66
70
  const saysPass = /\bpass(ed|es)?\b/i.test(out);
@@ -71,6 +75,32 @@ async function judgeOne(runner, scenario, answer) {
71
75
  return "fail";
72
76
  return "unparseable";
73
77
  }
78
+ // A live run makes trials * 2 arms * 2 (answer + judge) API calls. One
79
+ // transient 429 or 5xx among them used to throw and discard every trial already
80
+ // paid for. Retry the transient failures with backoff; a non-429 4xx (bad key,
81
+ // unknown model) is a real error and is not retried.
82
+ function isTransient(err) {
83
+ const m = String(err?.message ?? err);
84
+ return (/\b(429|500|502|503|504)\b/.test(m) ||
85
+ /timeout|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch failed|network/i.test(m));
86
+ }
87
+ export function withRetry(runner, tries = 4, baseDelayMs = 500) {
88
+ return async (req) => {
89
+ let lastErr;
90
+ for (let attempt = 0; attempt < tries; attempt++) {
91
+ try {
92
+ return await runner(req);
93
+ }
94
+ catch (e) {
95
+ lastErr = e;
96
+ if (attempt === tries - 1 || !isTransient(e))
97
+ throw e;
98
+ await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt));
99
+ }
100
+ }
101
+ throw lastErr;
102
+ };
103
+ }
74
104
  async function runArm(runner, scenario, system) {
75
105
  let passes = 0;
76
106
  let unparseable = 0;
@@ -93,10 +123,13 @@ async function runArm(runner, scenario, system) {
93
123
  passRate: scored > 0 ? (passes / scored) * 100 : 0,
94
124
  };
95
125
  }
96
- export async function runScenario(scenario, runner) {
126
+ export async function runScenario(scenario, runner, opts = {}) {
97
127
  const systems = armSystems(scenario);
98
- const withoutSkill = await runArm(runner, scenario, systems.withoutSkill);
99
- const withSkill = await runArm(runner, scenario, systems.withSkill);
128
+ // Wrap once so both the subject answers and the judge calls survive a
129
+ // transient blip instead of throwing away the whole (paid-for) scenario.
130
+ const r = withRetry(runner);
131
+ const withoutSkill = await runArm(r, scenario, systems.withoutSkill);
132
+ const withSkill = await runArm(r, scenario, systems.withSkill);
100
133
  const deltaPoints = Math.round((withSkill.passRate - withoutSkill.passRate) * 10) / 10;
101
134
  // The verdict is a significance test, not a threshold on the gap.
102
135
  //
@@ -108,19 +141,41 @@ export async function runScenario(scenario, runner) {
108
141
  // measuring sample size more than it was measuring the skill.
109
142
  const p = fisherExactTwoSided(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
110
143
  const ci = deltaInterval(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
144
+ // With no scoreable trial in an arm the pass rate and its interval are
145
+ // 0/0 = NaN; say so plainly instead of printing "NaN to NaN points".
146
+ const bothScored = withSkill.scored > 0 && withoutSkill.scored > 0;
111
147
  const notes = [
112
- `${scenario.trials} trials per arm. Two-sided Fisher exact p = ${p < 0.0001 ? "<0.0001" : p.toFixed(4)}; ` +
113
- `95% interval on the delta ${ci.lo >= 0 ? "+" : ""}${ci.lo} to ${ci.hi >= 0 ? "+" : ""}${ci.hi} points.`,
148
+ bothScored
149
+ ? `${scenario.trials} trials per arm. Two-sided Fisher exact p = ${p < 0.0001 ? "<0.0001" : p.toFixed(4)}; ` +
150
+ `95% interval on the delta ${ci.lo >= 0 ? "+" : ""}${ci.lo} to ${ci.hi >= 0 ? "+" : ""}${ci.hi} points.`
151
+ : `${scenario.trials} trials per arm. An arm produced no scoreable trial, so no p-value or delta interval can be computed.`,
114
152
  ];
115
153
  const unscored = withSkill.unparseable + withoutSkill.unparseable;
116
154
  if (unscored) {
117
155
  notes.push(`${unscored} trial(s) produced a judge verdict that was neither PASS nor FAIL and are excluded from the rates, not counted as failures.`);
118
156
  }
157
+ // Excluding a trial the judge did not answer is the right call, and it is
158
+ // unbiased only while the exclusions fall on both arms alike. They need not:
159
+ // a skill that makes answers longer, or hedged, or formatted differently can
160
+ // make the judge hedge too, and then one arm quietly loses more trials than
161
+ // the other and the two surviving pass rates are rates over different things.
162
+ // Tested with the same statistic and the same threshold used for the effect
163
+ // itself, so there is one standard of evidence in this report, not two.
164
+ const unparseableSkewP = fisherExactTwoSided(withSkill.unparseable, withSkill.trials, withoutSkill.unparseable, withoutSkill.trials);
165
+ const exclusionsAreSkewed = unscored > 0 && unparseableSkewP < 0.05;
119
166
  let verdict;
120
167
  if (withSkill.scored === 0 || withoutSkill.scored === 0) {
121
168
  verdict = "inconclusive";
122
169
  notes.push("an arm produced no scoreable trial; there is nothing to compare.");
123
170
  }
171
+ else if (exclusionsAreSkewed) {
172
+ verdict = "inconclusive";
173
+ notes.push(`the judge failed to answer on ${withSkill.unparseable} of ${withSkill.trials} with-skill trials and ` +
174
+ `${withoutSkill.unparseable} of ${withoutSkill.trials} without-skill trials, a difference this size at ` +
175
+ `p = ${unparseableSkewP < 0.0001 ? "<0.0001" : unparseableSkewP.toFixed(4)}. The excluded trials are not ` +
176
+ `spread evenly across the arms, so the two pass rates are computed over different populations and the ` +
177
+ `delta cannot be attributed to the skill. Tighten the rubric so the judge always answers, then re-run.`);
178
+ }
124
179
  else if (p >= 0.05) {
125
180
  verdict = "no measurable effect";
126
181
  if (Math.abs(deltaPoints) >= 15) {
@@ -132,14 +187,18 @@ export async function runScenario(scenario, runner) {
132
187
  verdict = "helps";
133
188
  else
134
189
  verdict = "hurts";
190
+ if (opts.extraNotes?.length)
191
+ notes.push(...opts.extraNotes);
135
192
  return {
136
193
  scenario: scenario.name,
137
194
  skill: scenario.skillName,
138
195
  model: scenario.model,
196
+ judgeModel: scenario.judgeModel,
139
197
  withSkill,
140
198
  withoutSkill,
141
199
  deltaPoints,
142
200
  p,
201
+ unparseableSkewP,
143
202
  ci,
144
203
  verdict,
145
204
  notes,
@@ -8,20 +8,27 @@ export function openaiRunner(apiKey) {
8
8
  if (req.system)
9
9
  messages.push({ role: "system", content: req.system });
10
10
  messages.push({ role: "user", content: req.user });
11
+ // o-series reasoning models (o1, o3, o4, ...) reject any non-default
12
+ // temperature with a 400, and a chat-sized 1024 cap starves them: their
13
+ // hidden reasoning tokens count against max_completion_tokens, so a small
14
+ // cap can return empty content. So omit temperature and give them headroom.
15
+ const isReasoning = /^o[0-9]/i.test(req.model);
16
+ const body = {
17
+ model: req.model,
18
+ messages,
19
+ max_completion_tokens: isReasoning ? 8192 : 1024,
20
+ };
21
+ // temperature: 0 for the judge, 1 for the subject (see
22
+ // RunnerRequest.deterministic), but only where the model accepts it.
23
+ if (!isReasoning)
24
+ body.temperature = req.deterministic ? 0 : 1;
11
25
  const res = await fetch("https://api.openai.com/v1/chat/completions", {
12
26
  method: "POST",
13
27
  headers: {
14
28
  "content-type": "application/json",
15
29
  authorization: `Bearer ${apiKey}`,
16
30
  },
17
- // temperature: 0 for the judge, 1 for the subject. See
18
- // RunnerRequest.deterministic in harness.ts.
19
- body: JSON.stringify({
20
- model: req.model,
21
- temperature: req.deterministic ? 0 : 1,
22
- messages,
23
- max_completion_tokens: 1024,
24
- }),
31
+ body: JSON.stringify(body),
25
32
  redirect: "error",
26
33
  signal: AbortSignal.timeout(60000),
27
34
  });
@@ -28,5 +28,19 @@ export declare function deltaInterval(withPass: number, withTrials: number, with
28
28
  lo: number;
29
29
  hi: number;
30
30
  };
31
- /** The smallest per-arm trial count at which a total separation (n/n vs 0/n) reaches p < 0.05. */
32
- export declare const MIN_TRIALS_FOR_SIGNIFICANCE = 4;
31
+ /**
32
+ * The smallest difference in pass rate this many trials per arm could ever
33
+ * call significant, in percentage points.
34
+ *
35
+ * A plan that cannot detect the effect it is looking for produces a green that
36
+ * means nothing, and the only place to notice that is before spending the
37
+ * tokens. So the dry run prints this: at 8 trials per arm nothing under about
38
+ * 50 points is reachable, which is worth knowing before paying for 32 API
39
+ * calls to find out.
40
+ *
41
+ * Computed by exhaustive search over every 2x2 the plan can produce, because
42
+ * the number of tables is small (trials is capped at 50, so at most 2,601
43
+ * Fisher evaluations) and a closed form for the exact test is not worth
44
+ * deriving. Returns Infinity when no outcome at this size reaches alpha.
45
+ */
46
+ export declare function minimumDetectableDelta(trials: number, alpha?: number): number;
@@ -87,5 +87,29 @@ export function deltaInterval(withPass, withTrials, withoutPass, withoutTrials)
87
87
  const hi = d + Math.sqrt((w.hi - p1) ** 2 + (p2 - o.lo) ** 2);
88
88
  return { lo: Math.round(Math.max(-1, lo) * 1000) / 10, hi: Math.round(Math.min(1, hi) * 1000) / 10 };
89
89
  }
90
- /** The smallest per-arm trial count at which a total separation (n/n vs 0/n) reaches p < 0.05. */
91
- export const MIN_TRIALS_FOR_SIGNIFICANCE = 4;
90
+ /**
91
+ * The smallest difference in pass rate this many trials per arm could ever
92
+ * call significant, in percentage points.
93
+ *
94
+ * A plan that cannot detect the effect it is looking for produces a green that
95
+ * means nothing, and the only place to notice that is before spending the
96
+ * tokens. So the dry run prints this: at 8 trials per arm nothing under about
97
+ * 50 points is reachable, which is worth knowing before paying for 32 API
98
+ * calls to find out.
99
+ *
100
+ * Computed by exhaustive search over every 2x2 the plan can produce, because
101
+ * the number of tables is small (trials is capped at 50, so at most 2,601
102
+ * Fisher evaluations) and a closed form for the exact test is not worth
103
+ * deriving. Returns Infinity when no outcome at this size reaches alpha.
104
+ */
105
+ export function minimumDetectableDelta(trials, alpha = 0.05) {
106
+ let best = Infinity;
107
+ for (let a = 0; a <= trials; a++) {
108
+ for (let b = 0; b < a; b++) {
109
+ if (fisherExactTwoSided(a, trials, b, trials) < alpha) {
110
+ best = Math.min(best, ((a - b) / trials) * 100);
111
+ }
112
+ }
113
+ }
114
+ return best;
115
+ }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in sync with package.json by test/meta.test.ts. */
2
- export declare const VERSION = "0.1.1";
2
+ export declare const VERSION = "0.2.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in sync with package.json by test/meta.test.ts. */
2
- export const VERSION = "0.1.1";
2
+ export const VERSION = "0.2.0";
@@ -4,8 +4,19 @@ import type { ToolDef } from "../core/types.js";
4
4
  * /v1/messages/count_tokens with and without the tools array (the endpoint
5
5
  * accepts `tools`; verified against the official API reference 2026-07-16).
6
6
  * Returns undefined on any failure; callers treat this as optional precision.
7
+ *
8
+ * Returns the model alongside the count, and the model is not optional in the
9
+ * result. Claude model lines do not share a tokenizer, so "the exact count" is
10
+ * exact for one model and an approximation for the next, and a number printed
11
+ * without the model it was measured on cannot be reproduced or compared. This
12
+ * is the same rule `src/weigh/window.ts` already applies to the context-window
13
+ * denominator: never print a figure whose basis is an assumption without
14
+ * naming the assumption.
7
15
  */
8
16
  export declare function countClaudeToolTokens(tools: ToolDef[], opts: {
9
17
  apiKey: string;
10
18
  model?: string;
11
- }): Promise<number | undefined>;
19
+ }): Promise<{
20
+ tokens: number;
21
+ model: string;
22
+ } | undefined>;
@@ -5,6 +5,14 @@ const DEFAULT_MODEL = "claude-sonnet-5";
5
5
  * /v1/messages/count_tokens with and without the tools array (the endpoint
6
6
  * accepts `tools`; verified against the official API reference 2026-07-16).
7
7
  * Returns undefined on any failure; callers treat this as optional precision.
8
+ *
9
+ * Returns the model alongside the count, and the model is not optional in the
10
+ * result. Claude model lines do not share a tokenizer, so "the exact count" is
11
+ * exact for one model and an approximation for the next, and a number printed
12
+ * without the model it was measured on cannot be reproduced or compared. This
13
+ * is the same rule `src/weigh/window.ts` already applies to the context-window
14
+ * denominator: never print a figure whose basis is an assumption without
15
+ * naming the assumption.
8
16
  */
9
17
  export async function countClaudeToolTokens(tools, opts) {
10
18
  const model = opts.model ?? DEFAULT_MODEL;
@@ -22,7 +30,7 @@ export async function countClaudeToolTokens(tools, opts) {
22
30
  if (withTools === undefined || withoutTools === undefined)
23
31
  return undefined;
24
32
  const delta = withTools - withoutTools;
25
- return delta >= 0 ? delta : undefined;
33
+ return delta >= 0 ? { tokens: delta, model } : undefined;
26
34
  }
27
35
  catch {
28
36
  return undefined;
@@ -33,13 +33,19 @@ export async function weighServer(intro, opts = {}) {
33
33
  "token counts are estimates using the o200k_base tokenizer; hosts add fixed framing text on top (see docs/METHODOLOGY.md)",
34
34
  ];
35
35
  let anthropicExactTotal;
36
+ let anthropicExactModel;
36
37
  if (opts.anthropicApiKey && intro.tools.length > 0) {
37
- anthropicExactTotal = await countClaudeToolTokens(intro.tools, {
38
+ const exact = await countClaudeToolTokens(intro.tools, {
38
39
  apiKey: opts.anthropicApiKey,
39
40
  model: opts.anthropicModel,
40
41
  });
41
- if (anthropicExactTotal !== undefined) {
42
- notes.push("anthropic-exact measured via /v1/messages/count_tokens (tools delta)");
42
+ if (exact !== undefined) {
43
+ anthropicExactTotal = exact.tokens;
44
+ anthropicExactModel = exact.model;
45
+ // Name the model. Claude model lines do not share a tokenizer, so this
46
+ // figure is exact for `exact.model` and an estimate for anything else,
47
+ // and a reader comparing two runs has to be able to see which is which.
48
+ notes.push(`anthropic-exact measured via /v1/messages/count_tokens (tools delta) against ${exact.model}`);
43
49
  }
44
50
  else {
45
51
  notes.push("anthropic-exact measurement failed; showing o200k estimates only");
@@ -56,6 +62,7 @@ export async function weighServer(intro, opts = {}) {
56
62
  framingTokens,
57
63
  instructionsTokens: count(intro.instructions ?? ""),
58
64
  anthropicExactTotal,
65
+ anthropicExactModel,
59
66
  notes,
60
67
  };
61
68
  }
@@ -36,6 +36,18 @@ Ratchet MCP tool-definition cost with the budget gate (commit `base.json` once w
36
36
  - run: npx efaimo weigh "npx -y your-server" --diff base.json --allow-increase 10
37
37
  ```
38
38
 
39
+ Keep every tool findable once a host defers your catalog. `--min-distinct` fails the run
40
+ when a tool owns no word the other tools lack, which means no search can return
41
+ it without also returning a competitor:
42
+
43
+ ```yaml
44
+ - run: npx efaimo find "npx -y your-server" --min-distinct 100
45
+ ```
46
+
47
+ Start it at the number you have today rather than at 100 and raise it as you
48
+ fix tools; the command prints the line to copy. It refuses to run on a one-tool
49
+ catalog, where the figure is 100% by construction, rather than passing.
50
+
39
51
  Post the grade on the PR with `--md` (pipe into any comment action):
40
52
 
41
53
  ```yaml
@@ -108,7 +120,9 @@ the current SDK.
108
120
 
109
121
  ## Programmatic use
110
122
 
111
- Every capability is also a typed function:
123
+ Every capability is also a typed function. `analyzeFind`, `runFindRules` and the
124
+ two `find` renderers are exported alongside the rest; the rule
125
+ tables themselves stay private, like the other two families.
112
126
 
113
127
  ```ts
114
128
  import { weighServer, introspectServer, checkSkillSet } from "efaimo";