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.
- package/CHANGELOG.md +128 -5
- package/README.md +69 -7
- package/SKILL.md +16 -9
- package/dist/check/check.js +8 -0
- package/dist/cli.js +179 -27
- package/dist/clients/introspect.js +10 -2
- package/dist/core/engine.d.ts +2 -1
- package/dist/core/engine.js +4 -0
- package/dist/core/types.d.ts +121 -0
- package/dist/find/bm25.d.ts +77 -0
- package/dist/find/bm25.js +107 -0
- package/dist/find/find.d.ts +120 -0
- package/dist/find/find.js +403 -0
- package/dist/find/tokenize.d.ts +64 -0
- package/dist/find/tokenize.js +116 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +13 -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 +12 -1
- package/dist/reporters/pretty.js +177 -10
- package/dist/rules/find/index.d.ts +4 -0
- package/dist/rules/find/index.js +262 -0
- package/dist/rules/injection.d.ts +1 -2
- package/dist/rules/injection.js +0 -11
- package/dist/rules/mcp/index.js +21 -1
- package/dist/rules/skill/index.js +5 -3
- package/dist/rules/version.d.ts +26 -0
- package/dist/rules/version.js +26 -0
- package/dist/targets/repoScan.js +0 -0
- package/dist/testing/anthropicRunner.d.ts +30 -0
- package/dist/testing/anthropicRunner.js +43 -8
- package/dist/testing/harness.d.ts +33 -1
- package/dist/testing/harness.js +66 -7
- package/dist/testing/openaiRunner.js +15 -8
- package/dist/testing/stats.d.ts +16 -2
- package/dist/testing/stats.js +26 -2
- 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 +111 -7
- package/docs/RULES.md +42 -5
- package/examples/scenario.crm.yaml +8 -0
- package/examples/scenario.example.yaml +8 -0
- 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
|
-
|
|
20
|
-
|
|
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.
|
|
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";
|
package/dist/targets/repoScan.js
CHANGED
|
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
|
|
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>;
|
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);
|
|
@@ -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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/testing/stats.d.ts
CHANGED
|
@@ -28,5 +28,19 @@ export declare function deltaInterval(withPass: number, withTrials: number, with
|
|
|
28
28
|
lo: number;
|
|
29
29
|
hi: number;
|
|
30
30
|
};
|
|
31
|
-
/**
|
|
32
|
-
|
|
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,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
|
-
/**
|
|
91
|
-
|
|
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.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.
|
|
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<
|
|
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";
|