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.
- package/CHANGELOG.md +160 -1
- package/README.md +75 -6
- package/SKILL.md +14 -7
- package/dist/check/diff.d.ts +124 -0
- package/dist/check/diff.js +165 -0
- package/dist/cli.js +237 -22
- package/dist/core/engine.d.ts +2 -1
- package/dist/core/engine.js +4 -0
- package/dist/core/types.d.ts +144 -0
- package/dist/find/bm25.d.ts +77 -0
- package/dist/find/bm25.js +107 -0
- package/dist/find/find.d.ts +122 -0
- package/dist/find/find.js +405 -0
- package/dist/find/tokenize.d.ts +64 -0
- package/dist/find/tokenize.js +116 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +14 -3
- package/dist/reporters/json.d.ts +19 -1
- package/dist/reporters/json.js +13 -2
- package/dist/reporters/markdown.d.ts +2 -1
- package/dist/reporters/markdown.js +109 -11
- package/dist/reporters/pretty.d.ts +25 -1
- package/dist/reporters/pretty.js +228 -9
- package/dist/rules/find/index.d.ts +4 -0
- package/dist/rules/find/index.js +262 -0
- package/dist/rules/skill/index.js +32 -1
- package/dist/rules/version.d.ts +26 -0
- package/dist/rules/version.js +26 -0
- package/dist/testing/anthropicRunner.d.ts +30 -0
- package/dist/testing/anthropicRunner.js +43 -8
- package/dist/testing/harness.d.ts +32 -1
- package/dist/testing/harness.js +28 -3
- package/dist/testing/stats.d.ts +16 -0
- package/dist/testing/stats.js +26 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/weigh/anthropicExact.d.ts +12 -1
- package/dist/weigh/anthropicExact.js +9 -1
- package/dist/weigh/weigh.js +10 -3
- package/docs/INTEGRATIONS.md +15 -1
- package/docs/METHODOLOGY.md +110 -6
- package/docs/RULES.md +40 -3
- package/examples/scenario.crm.yaml +8 -0
- package/examples/scenario.example.yaml +8 -0
- 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
|
|
96
|
+
export declare function runScenario(scenario: Scenario, runner: Runner, opts?: {
|
|
97
|
+
extraNotes?: string[];
|
|
98
|
+
}): Promise<TestReport>;
|
package/dist/testing/harness.js
CHANGED
|
@@ -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
|
|
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.
|
|
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,
|
package/dist/testing/stats.d.ts
CHANGED
|
@@ -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;
|
package/dist/testing/stats.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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<
|
|
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;
|
package/dist/weigh/weigh.js
CHANGED
|
@@ -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
|
-
|
|
38
|
+
const exact = await countClaudeToolTokens(intro.tools, {
|
|
38
39
|
apiKey: opts.anthropicApiKey,
|
|
39
40
|
model: opts.anthropicModel,
|
|
40
41
|
});
|
|
41
|
-
if (
|
|
42
|
-
|
|
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
|
}
|
package/docs/INTEGRATIONS.md
CHANGED
|
@@ -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";
|
package/docs/METHODOLOGY.md
CHANGED
|
@@ -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
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
146
|
-
|
|
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.
|