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.
Files changed (42) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +68 -6
  3. package/SKILL.md +14 -7
  4. package/dist/cli.js +162 -22
  5. package/dist/core/engine.d.ts +2 -1
  6. package/dist/core/engine.js +4 -0
  7. package/dist/core/types.d.ts +121 -0
  8. package/dist/find/bm25.d.ts +77 -0
  9. package/dist/find/bm25.js +107 -0
  10. package/dist/find/find.d.ts +120 -0
  11. package/dist/find/find.js +403 -0
  12. package/dist/find/tokenize.d.ts +64 -0
  13. package/dist/find/tokenize.js +116 -0
  14. package/dist/index.d.ts +6 -3
  15. package/dist/index.js +13 -3
  16. package/dist/reporters/json.d.ts +19 -1
  17. package/dist/reporters/json.js +13 -2
  18. package/dist/reporters/markdown.d.ts +2 -1
  19. package/dist/reporters/markdown.js +109 -11
  20. package/dist/reporters/pretty.d.ts +12 -1
  21. package/dist/reporters/pretty.js +174 -9
  22. package/dist/rules/find/index.d.ts +4 -0
  23. package/dist/rules/find/index.js +262 -0
  24. package/dist/rules/version.d.ts +26 -0
  25. package/dist/rules/version.js +26 -0
  26. package/dist/testing/anthropicRunner.d.ts +30 -0
  27. package/dist/testing/anthropicRunner.js +43 -8
  28. package/dist/testing/harness.d.ts +32 -1
  29. package/dist/testing/harness.js +28 -3
  30. package/dist/testing/stats.d.ts +16 -0
  31. package/dist/testing/stats.js +26 -0
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/dist/weigh/anthropicExact.d.ts +12 -1
  35. package/dist/weigh/anthropicExact.js +9 -1
  36. package/dist/weigh/weigh.js +10 -3
  37. package/docs/INTEGRATIONS.md +15 -1
  38. package/docs/METHODOLOGY.md +110 -6
  39. package/docs/RULES.md +40 -3
  40. package/examples/scenario.crm.yaml +8 -0
  41. package/examples/scenario.example.yaml +8 -0
  42. package/package.json +1 -1
@@ -155,6 +155,13 @@ export interface ServerWeighResult {
155
155
  instructionsTokens: number;
156
156
  /** Exact count via Anthropic count_tokens API, when a key was provided. */
157
157
  anthropicExactTotal?: number;
158
+ /**
159
+ * The model `anthropicExactTotal` was measured against. Always set when the
160
+ * total is: Claude model lines do not share a tokenizer, so the figure is
161
+ * exact for this model and approximate for any other, and a count printed
162
+ * without its model cannot be reproduced.
163
+ */
164
+ anthropicExactModel?: string;
158
165
  notes: string[];
159
166
  }
160
167
  export interface SkillWeighEntry {
@@ -178,6 +185,111 @@ export interface SkillSetWeighResult {
178
185
  notes: string[];
179
186
  }
180
187
  export type WeighResult = ServerWeighResult | SkillSetWeighResult;
188
+ /** One tool's result in the findability report (`efaimo find`). */
189
+ export interface ToolFindEntry {
190
+ name: string;
191
+ /**
192
+ * Terms this tool has that no other tool in the catalog has.
193
+ *
194
+ * The primary signal, and the only one here that is not a model of anything:
195
+ * if this is empty, every word the tool contains also appears on some other
196
+ * tool, so no query can match it without also matching a competitor. That is
197
+ * a property of the catalog, provable by inspection, with no assumption
198
+ * about how anyone searches. Capped for display.
199
+ */
200
+ ownTerms: string[];
201
+ /** How many exclusive terms there are in total, before the display cap. */
202
+ ownTermCount: number;
203
+ /**
204
+ * How many of them survive if the searcher does not know the tool's name.
205
+ *
206
+ * Names are searchable, so a tool whose only exclusive word is its own name
207
+ * is findable in principle. It is not findable by anyone describing a task,
208
+ * which is the situation deferred loading creates. E146 is this number
209
+ * reaching zero while `ownTermCount` does not.
210
+ */
211
+ ownOutsideNameCount: number;
212
+ /** When ownTermCount is 0: tools whose vocabulary covers this one's (capped). */
213
+ sharedWith: string[];
214
+ /**
215
+ * The terms this tool's own description offers a searcher, most distinctive
216
+ * first. Never includes the tool's name: the point of the probe is that the
217
+ * person searching does not know it.
218
+ */
219
+ query: string[];
220
+ /** 1-based rank for that query. Undefined when the description offers no searchable word. */
221
+ rank?: number;
222
+ /** BM25 score for its own query, kept so a reader can check the arithmetic. */
223
+ score: number;
224
+ /** Tools that scored higher for this tool's own query (capped at 3). */
225
+ outrankedBy: string[];
226
+ /** Tools whose score is indistinguishable from this one's, so only the tie-break separates them. Capped for display. */
227
+ tiedWith: string[];
228
+ /** How many there are in total, before the display cap. */
229
+ tiedWithCount: number;
230
+ /** Rank falls inside the simulated result window. The per-tool form of `probe`. */
231
+ reachable: boolean;
232
+ /** Every token of the name is a generic word (E144). */
233
+ genericName: boolean;
234
+ }
235
+ export interface FindResult {
236
+ kind: "find";
237
+ label: string;
238
+ toolCount: number;
239
+ /** The whole method, carried with the numbers so a report is self-describing. */
240
+ method: {
241
+ tokenizer: string;
242
+ bm25: {
243
+ k1: number;
244
+ b: number;
245
+ };
246
+ queryTerms: number;
247
+ topK: number;
248
+ };
249
+ perTool: ToolFindEntry[];
250
+ /**
251
+ * The headline: how many tools own at least one word no other tool has.
252
+ * A measured proportion, deliberately not a letter grade (ADR-030).
253
+ */
254
+ distinct: {
255
+ count: number;
256
+ total: number;
257
+ pct: number;
258
+ };
259
+ /**
260
+ * Secondary: how many tools a simulated BM25 search for their own
261
+ * description returns inside the result window.
262
+ *
263
+ * Named `probe` in the JSON, in the output and in the docs, all three. It
264
+ * was `reach` in one of them and `probe` in the others for a day, which is
265
+ * how someone scripting a gate on the number they saw printed ends up
266
+ * looking for a field that does not exist.
267
+ *
268
+ * Kept, and kept second, because it is the only number here that models the
269
+ * actual mechanism, and because it saturates: 100% on every real server
270
+ * measured. Treat it as a floor test that catches catastrophe, not as a
271
+ * ranking. See docs/METHODOLOGY.md.
272
+ */
273
+ probe: {
274
+ returned: number;
275
+ total: number;
276
+ pct: number;
277
+ };
278
+ /** toolCount <= topK, which makes the probe figure 100% by construction. */
279
+ windowCoversCatalog: boolean;
280
+ /**
281
+ * toolCount < 2, which makes the DISTINCT figure 100% by construction: with
282
+ * one tool every term it has is trivially exclusive. `--min-distinct` cannot
283
+ * be evaluated in that state and refuses rather than passing.
284
+ */
285
+ distinctVacuous: boolean;
286
+ definitionTokens?: number;
287
+ /** Anthropic's guidance recommends tool search for a catalog this shape. */
288
+ deferRecommended: boolean;
289
+ /** Which of the checkable conditions fired, in words. Empty when none did. */
290
+ deferBecause: string[];
291
+ notes: string[];
292
+ }
181
293
  export interface SkillInfo {
182
294
  dir: string;
183
295
  file: string;
@@ -248,6 +360,9 @@ export interface SkillRuleContext {
248
360
  set: SkillSet;
249
361
  weigh?: SkillSetWeighResult;
250
362
  }
363
+ export interface FindRuleContext {
364
+ find: FindResult;
365
+ }
251
366
  export interface McpRule {
252
367
  id: string;
253
368
  title: string;
@@ -260,3 +375,9 @@ export interface SkillRule {
260
375
  surface: "skill";
261
376
  check(ctx: SkillRuleContext): Finding[];
262
377
  }
378
+ export interface FindRule {
379
+ id: string;
380
+ title: string;
381
+ surface: "find";
382
+ check(ctx: FindRuleContext): Finding[];
383
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A small, exact BM25 index.
3
+ *
4
+ * `efaimo find` needs to answer "would a search over this catalog surface this
5
+ * tool", and the built-in tool search is server-side: its parameters are not
6
+ * published, so nothing here can be a reproduction. It is the textbook
7
+ * Robertson/Sparck-Jones BM25 with its conventional parameters, run offline on
8
+ * the four fields Anthropic documents as searchable, and both the formula and
9
+ * the parameters are printed in the output. A reader can disagree with the
10
+ * model and still check the arithmetic.
11
+ *
12
+ * Two properties this file exists to guarantee:
13
+ *
14
+ * - **Determinism.** Ties break on the document id by code point, never by
15
+ * `localeCompare`, whose collation depends on the host's ICU data. A tool
16
+ * that reports other people's nondeterminism (E112) must not have any of its
17
+ * own, and "it ranked differently on CI" is exactly the bug that would be
18
+ * impossible to reproduce.
19
+ * - **No silent empties.** `score()` on an empty query returns an empty array
20
+ * rather than the whole catalog at score 0. Callers treat that as a finding,
21
+ * not as a pass.
22
+ */
23
+ export interface Bm25Doc {
24
+ id: string;
25
+ terms: string[];
26
+ }
27
+ export interface Bm25Hit {
28
+ id: string;
29
+ score: number;
30
+ /**
31
+ * The document's position in the corpus.
32
+ *
33
+ * Present because `id` is not a key. MCP does not forbid two tools sharing a
34
+ * name, and a caller that located its own row with `findIndex(h => h.id ===
35
+ * name)` got the better-scoring twin's rank, score and tie set: a tool that
36
+ * really sat eighth reported rank 1, and the headline moved in the flattering
37
+ * direction. Match on this instead.
38
+ */
39
+ index: number;
40
+ }
41
+ export interface Bm25Params {
42
+ k1: number;
43
+ b: number;
44
+ }
45
+ /** The conventional defaults. Printed in every report so the method travels with the number. */
46
+ export declare const DEFAULT_BM25: Bm25Params;
47
+ export declare class Bm25Index {
48
+ readonly params: Bm25Params;
49
+ readonly size: number;
50
+ private readonly ids;
51
+ private readonly tf;
52
+ private readonly len;
53
+ private readonly df;
54
+ private readonly avgdl;
55
+ constructor(docs: readonly Bm25Doc[], params?: Bm25Params);
56
+ /** BM25 IDF. Always positive, so a term present in every document still contributes. */
57
+ idf(term: string): number;
58
+ /** Document frequency: how many tools contain this term at all. */
59
+ documentFrequency(term: string): number;
60
+ termFrequency(index: number, term: string): number;
61
+ /**
62
+ * Score every document against a query, highest first.
63
+ *
64
+ * Duplicate query terms are collapsed: a query is a set of keywords here,
65
+ * not a passage, and counting a repeated term twice would silently weight it.
66
+ */
67
+ score(query: readonly string[]): Bm25Hit[];
68
+ }
69
+ /**
70
+ * Are two scores the same as far as a ranker is concerned?
71
+ *
72
+ * Relative, because BM25 scores are unbounded: an absolute epsilon that is
73
+ * right at score 2 is meaningless at score 40. Two tools this close are
74
+ * separated only by the tie-break, which means the search picks between them
75
+ * on the alphabet rather than on relevance.
76
+ */
77
+ export declare function scoresTie(a: number, bScore: number, rel?: number): boolean;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * A small, exact BM25 index.
3
+ *
4
+ * `efaimo find` needs to answer "would a search over this catalog surface this
5
+ * tool", and the built-in tool search is server-side: its parameters are not
6
+ * published, so nothing here can be a reproduction. It is the textbook
7
+ * Robertson/Sparck-Jones BM25 with its conventional parameters, run offline on
8
+ * the four fields Anthropic documents as searchable, and both the formula and
9
+ * the parameters are printed in the output. A reader can disagree with the
10
+ * model and still check the arithmetic.
11
+ *
12
+ * Two properties this file exists to guarantee:
13
+ *
14
+ * - **Determinism.** Ties break on the document id by code point, never by
15
+ * `localeCompare`, whose collation depends on the host's ICU data. A tool
16
+ * that reports other people's nondeterminism (E112) must not have any of its
17
+ * own, and "it ranked differently on CI" is exactly the bug that would be
18
+ * impossible to reproduce.
19
+ * - **No silent empties.** `score()` on an empty query returns an empty array
20
+ * rather than the whole catalog at score 0. Callers treat that as a finding,
21
+ * not as a pass.
22
+ */
23
+ /** The conventional defaults. Printed in every report so the method travels with the number. */
24
+ export const DEFAULT_BM25 = { k1: 1.2, b: 0.75 };
25
+ export class Bm25Index {
26
+ params;
27
+ size;
28
+ ids;
29
+ tf;
30
+ len;
31
+ df = new Map();
32
+ avgdl;
33
+ constructor(docs, params = DEFAULT_BM25) {
34
+ this.params = params;
35
+ this.size = docs.length;
36
+ this.ids = docs.map((d) => d.id);
37
+ this.tf = docs.map((d) => {
38
+ const m = new Map();
39
+ for (const t of d.terms)
40
+ m.set(t, (m.get(t) ?? 0) + 1);
41
+ return m;
42
+ });
43
+ this.len = docs.map((d) => d.terms.length);
44
+ for (const m of this.tf) {
45
+ for (const t of m.keys())
46
+ this.df.set(t, (this.df.get(t) ?? 0) + 1);
47
+ }
48
+ const total = this.len.reduce((a, b) => a + b, 0);
49
+ // A catalog of zero-length documents would divide by zero in the length
50
+ // normalization. It cannot happen with real tools (a name always tokenizes
51
+ // to something) but the guard costs nothing and a NaN score would rank
52
+ // unpredictably rather than fail.
53
+ this.avgdl = docs.length ? Math.max(1, total / docs.length) : 1;
54
+ }
55
+ /** BM25 IDF. Always positive, so a term present in every document still contributes. */
56
+ idf(term) {
57
+ const n = this.df.get(term) ?? 0;
58
+ return Math.log(1 + (this.size - n + 0.5) / (n + 0.5));
59
+ }
60
+ /** Document frequency: how many tools contain this term at all. */
61
+ documentFrequency(term) {
62
+ return this.df.get(term) ?? 0;
63
+ }
64
+ termFrequency(index, term) {
65
+ return this.tf[index]?.get(term) ?? 0;
66
+ }
67
+ /**
68
+ * Score every document against a query, highest first.
69
+ *
70
+ * Duplicate query terms are collapsed: a query is a set of keywords here,
71
+ * not a passage, and counting a repeated term twice would silently weight it.
72
+ */
73
+ score(query) {
74
+ const terms = [...new Set(query)];
75
+ if (!terms.length)
76
+ return [];
77
+ const { k1, b } = this.params;
78
+ const hits = [];
79
+ for (let i = 0; i < this.size; i++) {
80
+ let s = 0;
81
+ const norm = k1 * (1 - b + (b * this.len[i]) / this.avgdl);
82
+ for (const t of terms) {
83
+ const f = this.tf[i].get(t);
84
+ if (!f)
85
+ continue;
86
+ s += this.idf(t) * ((f * (k1 + 1)) / (f + norm));
87
+ }
88
+ hits.push({ id: this.ids[i], score: s, index: i });
89
+ }
90
+ // Ties break on the id by code point, then on position, so two documents
91
+ // with the same name and the same score still have a fixed order.
92
+ hits.sort((x, y) => (y.score - x.score) || (x.id < y.id ? -1 : x.id > y.id ? 1 : 0) || (x.index - y.index));
93
+ return hits;
94
+ }
95
+ }
96
+ /**
97
+ * Are two scores the same as far as a ranker is concerned?
98
+ *
99
+ * Relative, because BM25 scores are unbounded: an absolute epsilon that is
100
+ * right at score 2 is meaningless at score 40. Two tools this close are
101
+ * separated only by the tie-break, which means the search picks between them
102
+ * on the alphabet rather than on relevance.
103
+ */
104
+ export function scoresTie(a, bScore, rel = 1e-9) {
105
+ const scale = Math.max(Math.abs(a), Math.abs(bScore), 1);
106
+ return Math.abs(a - bScore) <= rel * scale;
107
+ }
@@ -0,0 +1,120 @@
1
+ import type { FindResult, ToolDef } from "../core/types.js";
2
+ import { Bm25Index } from "./bm25.js";
3
+ /**
4
+ * Findability: can a search over this catalog pick this tool out at all?
5
+ *
6
+ * Why this exists. Since 2026-01 a host can mark tools `defer_loading: true`,
7
+ * which keeps them out of the system-prompt prefix entirely; the model then
8
+ * finds them by searching the catalog, and Anthropic recommends turning that
9
+ * on once tool definitions pass ~10k tokens. Under deferral a tool no search
10
+ * surfaces costs zero tokens and provides zero capability, and no existing
11
+ * check looks at that: `weigh` measures what a tool costs, `check` measures
12
+ * what a model experiences once the tool is already in front of it.
13
+ *
14
+ * Two measurements, and they are not the same kind of thing.
15
+ *
16
+ * 1. EXCLUSIVE VOCABULARY, the headline. Which terms does a tool have that no
17
+ * other tool in the catalog has? A tool with none cannot be matched by any
18
+ * query that does not also match a competitor, because every word it
19
+ * contains is somewhere else too. That is a fact about the catalog, checked
20
+ * by inspection, with no model of how anyone searches, no parameters, and
21
+ * nothing to disagree with except the tokenizer.
22
+ *
23
+ * It also discriminates, which the alternative did not. Measured on
24
+ * `@playwright/mcp` 0.0.78: 22 of 24 tools own a word, and `browser_close`
25
+ * and `browser_navigate` own none.
26
+ *
27
+ * 2. THE SEARCH PROBE, secondary. Simulated BM25 over the four fields tool
28
+ * search documents as searchable, each tool queried with the top terms of
29
+ * its own description. This is a model, and a generous one: the query comes
30
+ * from the tool's own words. It reads 100% on both the official reference
31
+ * server and playwright, so it is a floor test that catches catastrophe -
32
+ * an empty description, a literal duplicate - and not a ranking. The report
33
+ * says so on every run rather than letting a saturated number look like a
34
+ * good score.
35
+ *
36
+ * Neither is a grade. See ADR-030 for why this ships as proportions.
37
+ */
38
+ /** How many of a tool's own terms make up its self-query. */
39
+ export declare const DEFAULT_QUERY_TERMS = 4;
40
+ /**
41
+ * How many results a search returns. Anthropic documents a default of 5 (the
42
+ * model may raise it), so 5 is what a tool has to land inside to be seen
43
+ * without the model deciding to look harder.
44
+ */
45
+ export declare const DEFAULT_TOP_K = 5;
46
+ /**
47
+ * When Anthropic's own guidance says to turn tool search on.
48
+ *
49
+ * The doc lists five conditions joined by "any of the following apply", and
50
+ * two of them can be checked from a tool list: "You have 10 or more tools
51
+ * available" and "Your tool definitions consume more than 10k tokens". The
52
+ * other three are about the operator's situation, not the catalog.
53
+ *
54
+ * Both are checked. An earlier version implemented only the token clause,
55
+ * which told a reader that a 24-tool, 3.5k-token server was "probably loaded
56
+ * up front today" when Anthropic's first condition already applied to it, and
57
+ * left E145 structurally unable to fire on the most common shape there is: a
58
+ * catalog with plenty of tools and modest descriptions.
59
+ */
60
+ export declare const DEFER_RECOMMENDED_TOKENS = 10000;
61
+ export declare const DEFER_RECOMMENDED_TOOLS = 10;
62
+ /**
63
+ * The four fields tool search matches on: name, description, argument names,
64
+ * argument descriptions. Kept in one function so the index and the docs can
65
+ * never disagree about what is searched.
66
+ *
67
+ * `title` is deliberately NOT among them, for two reasons that point the same
68
+ * way. Anthropic's list of searchable fields is these four and does not
69
+ * include it. And MCP `title` is a human-readable restatement of the name:
70
+ * every one of the reference server's thirteen tools sets one, and every one
71
+ * is the name title-cased ("Echo Tool", "Get Annotated Message Tool"). An
72
+ * earlier version indexed and PROBED with it, which quietly put the tool's own
73
+ * name back into its own query through the back door and undid the whole
74
+ * reason the name is excluded. Two tools with identical descriptions and
75
+ * name-derived titles scored a clean rank 1 each instead of tying.
76
+ *
77
+ * Indexing a field the real search does not read would also make `distinct`
78
+ * optimistic: a tool could "own" a word that exists only in its title, where
79
+ * no query can reach it.
80
+ */
81
+ export declare function indexedTerms(tool: ToolDef): string[];
82
+ /**
83
+ * The terms a person would plausibly type to reach this tool.
84
+ *
85
+ * Drawn from the description, and deliberately NOT from the name, the title,
86
+ * or the argument names.
87
+ *
88
+ * Not the name, because the person searching does not know it. That is the
89
+ * whole situation being modelled: the tool is deferred, the user describes a
90
+ * task, and the model turns that description into a query. An earlier version
91
+ * of this drew query terms from the name too, and the result was a metric that
92
+ * could barely fail - a unique name token has maximal idf, so it entered every
93
+ * query and pulled its own tool to the top. Two tools with the same
94
+ * description and different names both scored a clean rank 1, which is exactly
95
+ * backwards: identical descriptions are the case where a searcher cannot tell
96
+ * them apart. The name stays in the INDEX, so a name whose words echo the
97
+ * description still helps the tool rank.
98
+ *
99
+ * Not the title either, and that one is the same bug wearing a different hat:
100
+ * MCP `title` is usually the name title-cased, so reading it here put the name
101
+ * back into its own query after all the trouble taken to keep it out. See
102
+ * `indexedTerms`, which drops it from the index for the same reason.
103
+ *
104
+ * Not argument names, because a user describes a task ("post a message to
105
+ * slack"), not a parameter list. They are indexed, so a search can still reach
106
+ * them; they are just never what the tool is probed with.
107
+ *
108
+ * Ranked by tf*idf inside this catalog, so the terms chosen are the ones that
109
+ * separate this tool from its siblings, with stopwords excluded because in a
110
+ * small catalog a function word repeated in one description can outrank the
111
+ * noun that identifies the tool.
112
+ */
113
+ export declare function taskQuery(tool: ToolDef, index: Bm25Index, limit?: number): string[];
114
+ export interface FindOptions {
115
+ topK?: number;
116
+ queryTerms?: number;
117
+ /** Claude-style tool-definition tokens, used only to say whether deferral is likely. */
118
+ definitionTokens?: number;
119
+ }
120
+ export declare function analyzeFind(label: string, tools: readonly ToolDef[], opts?: FindOptions): FindResult;