efaimo 0.1.2 → 0.3.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 (45) hide show
  1. package/CHANGELOG.md +160 -1
  2. package/README.md +75 -6
  3. package/SKILL.md +14 -7
  4. package/dist/check/diff.d.ts +124 -0
  5. package/dist/check/diff.js +165 -0
  6. package/dist/cli.js +237 -22
  7. package/dist/core/engine.d.ts +2 -1
  8. package/dist/core/engine.js +4 -0
  9. package/dist/core/types.d.ts +144 -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 +122 -0
  13. package/dist/find/find.js +405 -0
  14. package/dist/find/tokenize.d.ts +64 -0
  15. package/dist/find/tokenize.js +116 -0
  16. package/dist/index.d.ts +7 -3
  17. package/dist/index.js +14 -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 +25 -1
  23. package/dist/reporters/pretty.js +228 -9
  24. package/dist/rules/find/index.d.ts +4 -0
  25. package/dist/rules/find/index.js +262 -0
  26. package/dist/rules/skill/index.js +32 -1
  27. package/dist/rules/version.d.ts +26 -0
  28. package/dist/rules/version.js +26 -0
  29. package/dist/testing/anthropicRunner.d.ts +30 -0
  30. package/dist/testing/anthropicRunner.js +43 -8
  31. package/dist/testing/harness.d.ts +32 -1
  32. package/dist/testing/harness.js +28 -3
  33. package/dist/testing/stats.d.ts +16 -0
  34. package/dist/testing/stats.js +26 -0
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/dist/weigh/anthropicExact.d.ts +12 -1
  38. package/dist/weigh/anthropicExact.js +9 -1
  39. package/dist/weigh/weigh.js +10 -3
  40. package/docs/INTEGRATIONS.md +15 -1
  41. package/docs/METHODOLOGY.md +110 -6
  42. package/docs/RULES.md +40 -3
  43. package/examples/scenario.crm.yaml +8 -0
  44. package/examples/scenario.example.yaml +8 -0
  45. package/package.json +3 -1
@@ -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";
@@ -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;
@@ -64,4 +93,6 @@ export declare function armSystems(scenario: Scenario): {
64
93
  withSkill: string;
65
94
  };
66
95
  export declare function withRetry(runner: Runner, tries?: number, baseDelayMs?: number): Runner;
67
- export declare function runScenario(scenario: Scenario, runner: Runner): Promise<TestReport>;
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);
@@ -119,7 +123,7 @@ async function runArm(runner, scenario, system) {
119
123
  passRate: scored > 0 ? (passes / scored) * 100 : 0,
120
124
  };
121
125
  }
122
- export async function runScenario(scenario, runner) {
126
+ export async function runScenario(scenario, runner, opts = {}) {
123
127
  const systems = armSystems(scenario);
124
128
  // Wrap once so both the subject answers and the judge calls survive a
125
129
  // transient blip instead of throwing away the whole (paid-for) scenario.
@@ -150,11 +154,28 @@ export async function runScenario(scenario, runner) {
150
154
  if (unscored) {
151
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.`);
152
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;
153
166
  let verdict;
154
167
  if (withSkill.scored === 0 || withoutSkill.scored === 0) {
155
168
  verdict = "inconclusive";
156
169
  notes.push("an arm produced no scoreable trial; there is nothing to compare.");
157
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
+ }
158
179
  else if (p >= 0.05) {
159
180
  verdict = "no measurable effect";
160
181
  if (Math.abs(deltaPoints) >= 15) {
@@ -166,14 +187,18 @@ export async function runScenario(scenario, runner) {
166
187
  verdict = "helps";
167
188
  else
168
189
  verdict = "hurts";
190
+ if (opts.extraNotes?.length)
191
+ notes.push(...opts.extraNotes);
169
192
  return {
170
193
  scenario: scenario.name,
171
194
  skill: scenario.skillName,
172
195
  model: scenario.model,
196
+ judgeModel: scenario.judgeModel,
173
197
  withSkill,
174
198
  withoutSkill,
175
199
  deltaPoints,
176
200
  p,
201
+ unparseableSkewP,
177
202
  ci,
178
203
  verdict,
179
204
  notes,
@@ -28,3 +28,19 @@ export declare function deltaInterval(withPass: number, withTrials: number, with
28
28
  lo: number;
29
29
  hi: number;
30
30
  };
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,3 +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
+ /**
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.2";
2
+ export declare const VERSION = "0.3.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.2";
2
+ export const VERSION = "0.3.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";
@@ -131,19 +131,28 @@ trials. At that size 5/5 against 4/5 is +20 points and p = 1.0000.
131
131
 
132
132
  Two things the judge does, which matter for reading a result:
133
133
 
134
- - it runs at **temperature 0**, so the same answer grades the same way twice.
135
- The subject arm stays at temperature 1, because normal behaviour is the thing
136
- being measured;
134
+ - it is asked to sample deterministically **where the model still accepts a
135
+ sampling parameter**. Claude removed `temperature` from the 4.7 line onward,
136
+ so on Sonnet 5, Opus 5 and their siblings the judge runs at the model's own
137
+ default and its variance is part of the measurement. The report says so on
138
+ every run that this applies to, rather than claiming a determinism it does
139
+ not have. The subject arm is unaffected: it
140
+ wanted temperature 1, which is the default anyway;
137
141
  - a reply that is neither PASS nor FAIL is **excluded and counted separately**,
138
142
  not scored as a failure. A refusal or an API error is not evidence about the
139
- skill.
143
+ skill. Exclusion is only unbiased while it falls on both arms alike, so the
144
+ report also carries a **two-sided Fisher exact p on the unparseable counts**,
145
+ and a skew significant at the same 0.05 makes the verdict `inconclusive`:
146
+ two pass rates computed over different populations cannot be subtracted.
140
147
 
141
148
  ### What this design still cannot tell you
142
149
 
143
150
  Named here rather than left for a reader to discover:
144
151
 
145
- - **The judge is the same model as the subject.** A separate judge model would
146
- be stronger.
152
+ - **The judge defaults to the same model as the subject**, so part of any
153
+ measured effect is a model preferring its own output. `judge_model:` in the
154
+ scenario, or `--judge-model`, points it at another model, and the report
155
+ prints both. Subject and judge may be different providers.
147
156
  - **The control arm receives no system prompt at all**, so "the skill's
148
157
  content" is confounded with "having any system prompt". A length-matched
149
158
  placebo is the fix and is not implemented.
@@ -151,3 +160,98 @@ Named here rather than left for a reader to discover:
151
160
  show the method works, not that skills in general do or do not help.
152
161
 
153
162
  Treat a single scenario as evidence about that scenario.
163
+
164
+ ## `efaimo find`: findability
165
+
166
+ A host may mark tools `defer_loading: true`, which keeps their definitions out
167
+ of the system-prompt prefix until a search returns them. Anthropic lists five
168
+ conditions for turning that on, joined by "any of the following apply", and two
169
+ of them can be checked from a tool list: **ten or more tools**, or **more than
170
+ 10k tokens of definitions**. `find` checks both and names the one that fired.
171
+ An earlier version implemented only the token clause, which told a reader that
172
+ a 24-tool, 3.5k-token catalog was probably loaded up front when Anthropic's
173
+ first condition already applied to it.
174
+
175
+ Under deferral, a tool nothing surfaces costs no context and provides no
176
+ capability, so "can this be found" becomes a separate question from "what does
177
+ this cost" and "how good is this tool".
178
+
179
+ `find` answers it with two numbers that are **not the same kind of claim**, and
180
+ prints both with their denominators. In JSON they are `data.distinct` and
181
+ `data.probe`; the terminal uses the same two words.
182
+
183
+ ### `distinct`: a property of the catalog
184
+
185
+ For every tool, which of its terms does no other tool in the catalog have?
186
+
187
+ If the answer is none, then every word that tool contains appears somewhere
188
+ else, so **no query exists that matches it without also matching a
189
+ competitor**. That is not a prediction about a particular search. It follows
190
+ from the index, and the only assumption in it is the tokenizer.
191
+
192
+ The precise limit of the claim, stated because it is easy to overstate: a tool
193
+ with no exclusive vocabulary can still be ranked *first* for a shared query,
194
+ because BM25 also weighs term frequency and document length. What it can never
195
+ do is come back alone. Owning a word is a much stronger position than winning a
196
+ tie-break.
197
+
198
+ Terms come from the four fields the tool search documentation names as
199
+ searchable: **tool name, description, argument names, argument descriptions**.
200
+ Tokenization splits camelCase, `snake_case` and `kebab-case` onto the same
201
+ terms, lowercases, drops single characters, and does no stemming, so `page` and
202
+ `pages` are different terms (`src/find/tokenize.ts`, identified in JSON as
203
+ `data.method.tokenizer`, a string that changes when the splitting rules do).
204
+ English function words are excluded from ownership as well as from queries: a
205
+ tool can be the only one in a catalog that says "the", and counting that as
206
+ vocabulary it owns would be true and useless.
207
+
208
+ **`title` is deliberately not indexed**, though MCP tools may carry one.
209
+ Anthropic's list of searchable fields does not include it, and a `title` is
210
+ usually the name restated for humans, so reading it would put the tool's own
211
+ name back into its own probe. Indexing a field the real search cannot read also
212
+ makes the number optimistic: a tool would "own" a word no query can reach.
213
+ Measured cost of getting this wrong: Notion's server scored 17/24 with `title`
214
+ indexed and 16/24 without, and the 16 is the honest one.
215
+
216
+ **At one tool the number cannot be computed.** Every term is trivially
217
+ exclusive, so the figure is 100% for any tool whatsoever. The report says so and
218
+ `--min-distinct` refuses to be evaluated rather than reporting a pass.
219
+
220
+ ### `probe`: a simulation, and a saturated one
221
+
222
+ BM25 (k1=1.2, b=0.75) over those same fields, with each tool queried using the
223
+ highest tf-idf terms **of its own description** and a result window of 5, which
224
+ is the documented default number of results a search returns.
225
+
226
+ Two honest limits:
227
+
228
+ - The real search runs server-side. Its analyzer and parameters are not
229
+ published, so this is a model of a documented mechanism, not a reproduction.
230
+ - **It saturates.** Measured on four public servers: 13/13, 24/24, 26/26 and
231
+ 24/24, and every query variant tried (tf-idf, raw tf, leading terms) gave the
232
+ same 100%. The query comes from the tool's own words, which is friendlier
233
+ than anything a user would type. Treat it as a floor test that catches an
234
+ empty description or a literal duplicate, not as a ranking.
235
+ [The findability report](../research/findability/REPORT.md) has the numbers
236
+ and the commands that reproduce them.
237
+
238
+ The query deliberately excludes the tool's **name** and its **title**. An
239
+ earlier version included both and the metric could barely fail: a unique name
240
+ token has maximal idf, so it entered every query and pulled its own tool to the
241
+ top, and two tools with identical descriptions both scored a clean first place.
242
+ The name stays in the index, so a name whose words echo the description still
243
+ helps it rank.
244
+
245
+ ### Why there is no findability grade
246
+
247
+ Because the two numbers answer different questions and only one of them is a
248
+ measurement rather than a model, and because a letter would have inherited the
249
+ defect the quality grade already has: it cannot see size. See ADR-030. The gate
250
+ is `--min-distinct`, which the operator sets, on the number that can actually
251
+ fail at two tools and up.
252
+
253
+ The colour the terminal puts on `distinct` is not a grade either: red means the
254
+ E141 condition holds (some tool owns nothing), green means it does not. An
255
+ earlier version banded it at 95 and 80 percent, two cutoffs that appeared in no
256
+ rule and no document, inside the one command whose design note says it does not
257
+ grade.