efaimo 0.1.2 → 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 +63 -1
- package/README.md +68 -6
- package/SKILL.md +14 -7
- package/dist/cli.js +162 -22
- 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 +174 -9
- package/dist/rules/find/index.d.ts +4 -0
- package/dist/rules/find/index.js +262 -0
- 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 +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Findability rules, E141-E146.
|
|
3
|
+
*
|
|
4
|
+
* A separate family with its own command on purpose. They are not readiness
|
|
5
|
+
* (which asks whether a 2026-07-28 client can talk to this server) and not
|
|
6
|
+
* quality (which asks what a model experiences once a tool is in front of it).
|
|
7
|
+
* They ask a third thing: under deferred loading, does anything ever put this
|
|
8
|
+
* tool in front of the model at all.
|
|
9
|
+
*
|
|
10
|
+
* E141 is the one that is not a heuristic. The others read a simulation and
|
|
11
|
+
* are worth less; they are kept because the simulation is the only thing here
|
|
12
|
+
* that models the real mechanism, and because the cases they do catch (a tool
|
|
13
|
+
* with no description, two tools with the same one) are worth catching.
|
|
14
|
+
*
|
|
15
|
+
* None of them feeds `check`'s grade, and `find` produces no grade of its own.
|
|
16
|
+
* ADR-030 has the reasoning.
|
|
17
|
+
*/
|
|
18
|
+
function cap(items, n) {
|
|
19
|
+
return { shown: items.slice(0, n), more: Math.max(0, items.length - n) };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A target-supplied value going into a rule message or detail.
|
|
23
|
+
*
|
|
24
|
+
* The reporters strip control characters but keep newlines, because a detail
|
|
25
|
+
* block is legitimately multi-line. That makes a newline inside a tool name a
|
|
26
|
+
* way for the audited server to add its own line to the auditor's output. The
|
|
27
|
+
* values these rules interpolate - names, terms - are all single-line by
|
|
28
|
+
* nature, so they are flattened here, at the point where a multi-line region
|
|
29
|
+
* is built out of them.
|
|
30
|
+
*/
|
|
31
|
+
function one(s) {
|
|
32
|
+
return s.replace(/[\r\n]+/g, " ");
|
|
33
|
+
}
|
|
34
|
+
const list = (xs) => xs.map(one).join(", ");
|
|
35
|
+
const e141 = {
|
|
36
|
+
id: "E141",
|
|
37
|
+
title: "tool owns no exclusive vocabulary",
|
|
38
|
+
surface: "find",
|
|
39
|
+
check({ find }) {
|
|
40
|
+
const shared = find.perTool.filter((t) => t.ownTermCount === 0);
|
|
41
|
+
const { shown, more } = cap(shared, 5);
|
|
42
|
+
const findings = shown.map((t) => ({
|
|
43
|
+
ruleId: "E141",
|
|
44
|
+
severity: "warn",
|
|
45
|
+
title: "tool owns no exclusive vocabulary",
|
|
46
|
+
// Stated as what it is: an existence claim about queries, not a
|
|
47
|
+
// prediction about one search. Every term this tool has appears on some
|
|
48
|
+
// other tool, so there is no query that matches it alone. Whether it
|
|
49
|
+
// still wins a shared query depends on term frequencies, which is a much
|
|
50
|
+
// weaker thing to rely on than owning a word.
|
|
51
|
+
message: `tool "${one(t.name)}" has no term that no other tool has, so no search can return it without also returning a competitor`,
|
|
52
|
+
target: t.name,
|
|
53
|
+
// "also appears on", not "covered by": no single tool on this list
|
|
54
|
+
// necessarily carries the whole vocabulary. They are the tools with the
|
|
55
|
+
// most overlap, which is where to look first when writing the fix.
|
|
56
|
+
detail: t.sharedWith.length ? `every word it has also appears on: ${list(t.sharedWith)} (most overlap first)` : undefined,
|
|
57
|
+
fixHint: "put something in the description that only this tool does: the object it acts on, the format it returns, the case it is for",
|
|
58
|
+
}));
|
|
59
|
+
if (more) {
|
|
60
|
+
findings.push({
|
|
61
|
+
ruleId: "E141",
|
|
62
|
+
severity: "warn",
|
|
63
|
+
title: "tool owns no exclusive vocabulary",
|
|
64
|
+
message: `...and ${more} more tools that share every one of their terms with another tool`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return findings;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const e142 = {
|
|
71
|
+
id: "E142",
|
|
72
|
+
title: "a search for the tool's own description does not return it",
|
|
73
|
+
surface: "find",
|
|
74
|
+
check({ find }) {
|
|
75
|
+
const findings = [];
|
|
76
|
+
const unsearchable = find.perTool.filter((t) => t.rank === undefined);
|
|
77
|
+
for (const t of cap(unsearchable, 5).shown) {
|
|
78
|
+
findings.push({
|
|
79
|
+
ruleId: "E142",
|
|
80
|
+
severity: "warn",
|
|
81
|
+
title: "a search for the tool's own description does not return it",
|
|
82
|
+
message: `tool "${one(t.name)}" offers no words to search for: its description is empty or made only of function words, so nothing but the exact name reaches it`,
|
|
83
|
+
target: t.name,
|
|
84
|
+
fixHint: "write a description containing the words someone would use to describe the task this tool does",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const missed = find.perTool.filter((t) => t.rank !== undefined && !t.reachable);
|
|
88
|
+
for (const t of cap(missed, 5).shown) {
|
|
89
|
+
findings.push({
|
|
90
|
+
ruleId: "E142",
|
|
91
|
+
severity: "warn",
|
|
92
|
+
title: "a search for the tool's own description does not return it",
|
|
93
|
+
message: `tool "${one(t.name)}" ranks ${t.rank} of ${find.toolCount} for a search built from its own description (${list(t.query)}), outside a result window of ${find.method.topK}`,
|
|
94
|
+
target: t.name,
|
|
95
|
+
detail: t.outrankedBy.length ? `outranked by: ${list(t.outrankedBy)}` : undefined,
|
|
96
|
+
fixHint: "make the description carry a term the other tools do not share",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const more = cap(unsearchable, 5).more + cap(missed, 5).more;
|
|
100
|
+
if (more) {
|
|
101
|
+
findings.push({
|
|
102
|
+
ruleId: "E142",
|
|
103
|
+
severity: "warn",
|
|
104
|
+
title: "a search for the tool's own description does not return it",
|
|
105
|
+
message: `...and ${more} more tools a search for their own description does not return`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return findings;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const e143 = {
|
|
112
|
+
id: "E143",
|
|
113
|
+
title: "tools are indistinguishable to a search",
|
|
114
|
+
surface: "find",
|
|
115
|
+
check({ find }) {
|
|
116
|
+
// Report each pair once. A ties with B is the same fact as B ties with A,
|
|
117
|
+
// and printing both halves doubles the finding count, which would double
|
|
118
|
+
// the apparent size of the problem.
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
const findings = [];
|
|
121
|
+
for (const t of find.perTool) {
|
|
122
|
+
for (const other of t.tiedWith) {
|
|
123
|
+
const key = t.name < other ? `${one(t.name)} ${one(other)}` : `${one(other)} ${one(t.name)}`;
|
|
124
|
+
if (seen.has(key))
|
|
125
|
+
continue;
|
|
126
|
+
seen.add(key);
|
|
127
|
+
if (findings.length >= 5)
|
|
128
|
+
continue;
|
|
129
|
+
findings.push({
|
|
130
|
+
ruleId: "E143",
|
|
131
|
+
severity: "warn",
|
|
132
|
+
title: "tools are indistinguishable to a search",
|
|
133
|
+
message: `tools "${one(t.name)}" and "${one(other)}" score identically for a search built from "${one(t.name)}"'s description (${list(t.query)}); only the alphabetical tie-break separates them`,
|
|
134
|
+
target: t.name,
|
|
135
|
+
fixHint: "give each description a term the other does not have, or merge the tools",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// The true pair count comes from tiedWithCount, not from the names above.
|
|
140
|
+
// `tiedWith` is capped at three per tool for display, so counting the
|
|
141
|
+
// pairs it yields reported 30 for a catalog whose real answer was 66: a
|
|
142
|
+
// count of what survived a display cap, printed as a count of the problem.
|
|
143
|
+
// Each pair is counted from both ends, hence the halving.
|
|
144
|
+
const totalPairs = Math.round(find.perTool.reduce((n, t) => n + t.tiedWithCount, 0) / 2);
|
|
145
|
+
if (totalPairs > findings.length) {
|
|
146
|
+
findings.push({
|
|
147
|
+
ruleId: "E143",
|
|
148
|
+
severity: "warn",
|
|
149
|
+
title: "tools are indistinguishable to a search",
|
|
150
|
+
message: `...and ${totalPairs - findings.length} more indistinguishable pairs (${totalPairs} in total)`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return findings;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
const e146 = {
|
|
157
|
+
id: "E146",
|
|
158
|
+
title: "the only word a tool owns is its own name",
|
|
159
|
+
surface: "find",
|
|
160
|
+
check({ find }) {
|
|
161
|
+
// Names ARE searchable, so this tool is findable in principle and E141
|
|
162
|
+
// correctly does not fire. It is not findable by anyone describing a task,
|
|
163
|
+
// which is the situation deferred loading creates, so the two cases are
|
|
164
|
+
// reported separately rather than merged into one number.
|
|
165
|
+
const nameOnly = find.perTool.filter((t) => t.ownTermCount > 0 && t.ownOutsideNameCount === 0);
|
|
166
|
+
const { shown, more } = cap(nameOnly, 5);
|
|
167
|
+
const findings = shown.map((t) => ({
|
|
168
|
+
ruleId: "E146",
|
|
169
|
+
severity: "info",
|
|
170
|
+
title: "the only word a tool owns is its own name",
|
|
171
|
+
message: t.ownTermCount === 1
|
|
172
|
+
? `tool "${one(t.name)}" owns one term and it comes from its own name; nothing in its description is unique, so only someone who already knows the name can single it out`
|
|
173
|
+
: `tool "${one(t.name)}" owns ${t.ownTermCount} terms and every one of them comes from its own name; nothing in its description is unique, so only someone who already knows the name can single it out`,
|
|
174
|
+
target: t.name,
|
|
175
|
+
fixHint: "put a word in the description that no other tool's description uses",
|
|
176
|
+
}));
|
|
177
|
+
if (more) {
|
|
178
|
+
findings.push({
|
|
179
|
+
ruleId: "E146",
|
|
180
|
+
severity: "info",
|
|
181
|
+
title: "the only word a tool owns is its own name",
|
|
182
|
+
message: `...and ${more} more tools whose only exclusive words are their own name`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return findings;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
const e144 = {
|
|
189
|
+
id: "E144",
|
|
190
|
+
title: "tool name carries no domain term",
|
|
191
|
+
surface: "find",
|
|
192
|
+
check({ find }) {
|
|
193
|
+
// All-generic AND no name token of its own.
|
|
194
|
+
//
|
|
195
|
+
// "Generic" alone was too weak: the fixture server's `add` is a perfectly
|
|
196
|
+
// descriptive name for an arithmetic tool, and the rule told its author to
|
|
197
|
+
// "put the subject in the name" with no usable suggestion. What separates
|
|
198
|
+
// `add` from `run` is not the word list, it is whether the word belongs to
|
|
199
|
+
// this tool alone in this catalog. `ownTermCount > ownOutsideNameCount`
|
|
200
|
+
// means at least one exclusive term came from the name, which is exactly
|
|
201
|
+
// the case where a broad pattern search does reach it.
|
|
202
|
+
const generic = find.perTool.filter((t) => t.genericName && t.ownTermCount === t.ownOutsideNameCount);
|
|
203
|
+
const { shown, more } = cap(generic, 5);
|
|
204
|
+
const findings = shown.map((t) => ({
|
|
205
|
+
ruleId: "E144",
|
|
206
|
+
severity: "info",
|
|
207
|
+
title: "tool name carries no domain term",
|
|
208
|
+
message: `tool "${one(t.name)}" is named only with generic words, so a broad pattern search (Claude writes things like ".*weather.*") can reach it only through its description`,
|
|
209
|
+
target: t.name,
|
|
210
|
+
fixHint: "put the subject in the name: what does it act on",
|
|
211
|
+
}));
|
|
212
|
+
if (more) {
|
|
213
|
+
findings.push({
|
|
214
|
+
ruleId: "E144",
|
|
215
|
+
severity: "info",
|
|
216
|
+
title: "tool name carries no domain term",
|
|
217
|
+
message: `...and ${more} more tools named only with generic words`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return findings;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
const e145 = {
|
|
224
|
+
id: "E145",
|
|
225
|
+
title: "deferred catalog with tools a search cannot single out",
|
|
226
|
+
surface: "find",
|
|
227
|
+
check({ find }) {
|
|
228
|
+
if (!find.deferRecommended)
|
|
229
|
+
return [];
|
|
230
|
+
// The union, not the sum. These two sets overlap heavily - a tool with no
|
|
231
|
+
// exclusive vocabulary is often also one the probe misses - and adding
|
|
232
|
+
// them read as twice as many problems as there are. On a 15-tool server
|
|
233
|
+
// this line once claimed 24.
|
|
234
|
+
const affected = new Set();
|
|
235
|
+
for (const t of find.perTool) {
|
|
236
|
+
if (t.ownTermCount === 0 || !t.reachable)
|
|
237
|
+
affected.add(t.name);
|
|
238
|
+
}
|
|
239
|
+
if (!affected.size)
|
|
240
|
+
return [];
|
|
241
|
+
return [
|
|
242
|
+
{
|
|
243
|
+
ruleId: "E145",
|
|
244
|
+
severity: "warn",
|
|
245
|
+
title: "deferred catalog with tools a search cannot single out",
|
|
246
|
+
// The reason comes from the analysis, not from a repeat of one
|
|
247
|
+
// threshold. Anthropic lists several conditions; hardcoding the token
|
|
248
|
+
// one here printed "~3500 tokens, past the ~10.0k" on a catalog that
|
|
249
|
+
// qualified on tool count instead.
|
|
250
|
+
message: `tool search is recommended for this catalog (${find.deferBecause.map(one).join("; ")}), ` +
|
|
251
|
+
`and ${affected.size} of ${find.toolCount} tool${affected.size === 1 ? " cannot" : "s cannot"} be singled out by a search. ` +
|
|
252
|
+
`Once this catalog is deferred, a tool nothing surfaces costs nothing and does nothing.`,
|
|
253
|
+
fixHint: "fix the descriptions above, or split the server so fewer tools compete in one catalog",
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
export const FIND_RULES = [e141, e142, e143, e144, e145, e146];
|
|
259
|
+
/** E141-E146 are findability rules: reported by `efaimo find`, never part of a `check` grade. */
|
|
260
|
+
export function isFindabilityRuleId(id) {
|
|
261
|
+
return /^E14\d$/.test(id);
|
|
262
|
+
}
|
|
@@ -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.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;
|