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.
Files changed (50) hide show
  1. package/CHANGELOG.md +128 -5
  2. package/README.md +69 -7
  3. package/SKILL.md +16 -9
  4. package/dist/check/check.js +8 -0
  5. package/dist/cli.js +179 -27
  6. package/dist/clients/introspect.js +10 -2
  7. package/dist/core/engine.d.ts +2 -1
  8. package/dist/core/engine.js +4 -0
  9. package/dist/core/types.d.ts +121 -0
  10. package/dist/find/bm25.d.ts +77 -0
  11. package/dist/find/bm25.js +107 -0
  12. package/dist/find/find.d.ts +120 -0
  13. package/dist/find/find.js +403 -0
  14. package/dist/find/tokenize.d.ts +64 -0
  15. package/dist/find/tokenize.js +116 -0
  16. package/dist/index.d.ts +6 -3
  17. package/dist/index.js +13 -3
  18. package/dist/reporters/json.d.ts +19 -1
  19. package/dist/reporters/json.js +13 -2
  20. package/dist/reporters/markdown.d.ts +2 -1
  21. package/dist/reporters/markdown.js +109 -11
  22. package/dist/reporters/pretty.d.ts +12 -1
  23. package/dist/reporters/pretty.js +177 -10
  24. package/dist/rules/find/index.d.ts +4 -0
  25. package/dist/rules/find/index.js +262 -0
  26. package/dist/rules/injection.d.ts +1 -2
  27. package/dist/rules/injection.js +0 -11
  28. package/dist/rules/mcp/index.js +21 -1
  29. package/dist/rules/skill/index.js +5 -3
  30. package/dist/rules/version.d.ts +26 -0
  31. package/dist/rules/version.js +26 -0
  32. package/dist/targets/repoScan.js +0 -0
  33. package/dist/testing/anthropicRunner.d.ts +30 -0
  34. package/dist/testing/anthropicRunner.js +43 -8
  35. package/dist/testing/harness.d.ts +33 -1
  36. package/dist/testing/harness.js +66 -7
  37. package/dist/testing/openaiRunner.js +15 -8
  38. package/dist/testing/stats.d.ts +16 -2
  39. package/dist/testing/stats.js +26 -2
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/dist/weigh/anthropicExact.d.ts +12 -1
  43. package/dist/weigh/anthropicExact.js +9 -1
  44. package/dist/weigh/weigh.js +10 -3
  45. package/docs/INTEGRATIONS.md +15 -1
  46. package/docs/METHODOLOGY.md +111 -7
  47. package/docs/RULES.md +42 -5
  48. package/examples/scenario.crm.yaml +8 -0
  49. package/examples/scenario.example.yaml +8 -0
  50. package/package.json +2 -2
@@ -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;
@@ -0,0 +1,403 @@
1
+ import { Bm25Index, DEFAULT_BM25, scoresTie } from "./bm25.js";
2
+ import { nameIsGeneric, QUERY_STOPWORDS, tokenize } from "./tokenize.js";
3
+ // QUERY_STOPWORDS is used twice here on purpose: a word nobody would type is
4
+ // neither a query term nor vocabulary that makes a tool findable.
5
+ /**
6
+ * Findability: can a search over this catalog pick this tool out at all?
7
+ *
8
+ * Why this exists. Since 2026-01 a host can mark tools `defer_loading: true`,
9
+ * which keeps them out of the system-prompt prefix entirely; the model then
10
+ * finds them by searching the catalog, and Anthropic recommends turning that
11
+ * on once tool definitions pass ~10k tokens. Under deferral a tool no search
12
+ * surfaces costs zero tokens and provides zero capability, and no existing
13
+ * check looks at that: `weigh` measures what a tool costs, `check` measures
14
+ * what a model experiences once the tool is already in front of it.
15
+ *
16
+ * Two measurements, and they are not the same kind of thing.
17
+ *
18
+ * 1. EXCLUSIVE VOCABULARY, the headline. Which terms does a tool have that no
19
+ * other tool in the catalog has? A tool with none cannot be matched by any
20
+ * query that does not also match a competitor, because every word it
21
+ * contains is somewhere else too. That is a fact about the catalog, checked
22
+ * by inspection, with no model of how anyone searches, no parameters, and
23
+ * nothing to disagree with except the tokenizer.
24
+ *
25
+ * It also discriminates, which the alternative did not. Measured on
26
+ * `@playwright/mcp` 0.0.78: 22 of 24 tools own a word, and `browser_close`
27
+ * and `browser_navigate` own none.
28
+ *
29
+ * 2. THE SEARCH PROBE, secondary. Simulated BM25 over the four fields tool
30
+ * search documents as searchable, each tool queried with the top terms of
31
+ * its own description. This is a model, and a generous one: the query comes
32
+ * from the tool's own words. It reads 100% on both the official reference
33
+ * server and playwright, so it is a floor test that catches catastrophe -
34
+ * an empty description, a literal duplicate - and not a ranking. The report
35
+ * says so on every run rather than letting a saturated number look like a
36
+ * good score.
37
+ *
38
+ * Neither is a grade. See ADR-030 for why this ships as proportions.
39
+ */
40
+ /** How many of a tool's own terms make up its self-query. */
41
+ export const DEFAULT_QUERY_TERMS = 4;
42
+ /**
43
+ * How many results a search returns. Anthropic documents a default of 5 (the
44
+ * model may raise it), so 5 is what a tool has to land inside to be seen
45
+ * without the model deciding to look harder.
46
+ */
47
+ export const DEFAULT_TOP_K = 5;
48
+ /**
49
+ * When Anthropic's own guidance says to turn tool search on.
50
+ *
51
+ * The doc lists five conditions joined by "any of the following apply", and
52
+ * two of them can be checked from a tool list: "You have 10 or more tools
53
+ * available" and "Your tool definitions consume more than 10k tokens". The
54
+ * other three are about the operator's situation, not the catalog.
55
+ *
56
+ * Both are checked. An earlier version implemented only the token clause,
57
+ * which told a reader that a 24-tool, 3.5k-token server was "probably loaded
58
+ * up front today" when Anthropic's first condition already applied to it, and
59
+ * left E145 structurally unable to fire on the most common shape there is: a
60
+ * catalog with plenty of tools and modest descriptions.
61
+ */
62
+ export const DEFER_RECOMMENDED_TOKENS = 10_000;
63
+ export const DEFER_RECOMMENDED_TOOLS = 10;
64
+ /**
65
+ * Bounded walk of a JSON Schema, collecting argument names and descriptions.
66
+ *
67
+ * Two things this got wrong and now does not.
68
+ *
69
+ * It used `out.push(...tokenize(text))`, which passes every token as a separate
70
+ * argument. One argument description long enough to produce ~125,000 tokens
71
+ * (about a megabyte, entirely within reach of an untrusted third-party server)
72
+ * blew the call-stack limit and took the whole run down with a `RangeError`.
73
+ * Tokens are appended one at a time now, and there is a token budget as well
74
+ * as a node budget: bounding node COUNT does not bound the work one node can do.
75
+ *
76
+ * And it walked only `properties` and a singular `items`, which misses the two
77
+ * shapes generated schemas actually take: pydantic hoists everything into
78
+ * `$defs` and points at it with `$ref`, and zod emits `anyOf`/`oneOf`. Against
79
+ * those, argument names and descriptions went uncollected, which makes
80
+ * `distinct` PESSIMISTIC: a tool that really does own a word gets reported as
81
+ * owning none and E141 fires on a server that is fine. `$defs` is walked
82
+ * directly rather than resolving `$ref`, because the text is what is wanted,
83
+ * not the structure, and a walk cannot loop that way.
84
+ */
85
+ function schemaTerms(schema, depth = 0, budget = { nodes: 600, tokens: 20_000 }) {
86
+ const out = [];
87
+ if (depth > 4 || budget.nodes <= 0 || budget.tokens <= 0 || !schema || typeof schema !== "object")
88
+ return out;
89
+ budget.nodes--;
90
+ const node = schema;
91
+ const take = (text) => {
92
+ if (typeof text !== "string")
93
+ return;
94
+ for (const t of tokenize(text)) {
95
+ if (budget.tokens-- <= 0)
96
+ return;
97
+ out.push(t);
98
+ }
99
+ };
100
+ const descend = (child) => {
101
+ for (const t of schemaTerms(child, depth + 1, budget)) {
102
+ if (budget.tokens-- <= 0)
103
+ return;
104
+ out.push(t);
105
+ }
106
+ };
107
+ for (const key of ["properties", "$defs", "definitions", "patternProperties"]) {
108
+ const bag = node[key];
109
+ if (!bag || typeof bag !== "object" || Array.isArray(bag))
110
+ continue;
111
+ for (const [name, value] of Object.entries(bag)) {
112
+ if (budget.nodes <= 0 || budget.tokens <= 0)
113
+ break;
114
+ // Only `properties` and `patternProperties` keys are argument names a
115
+ // caller types; a `$defs` key is a type name the model never sees.
116
+ if (key === "properties" || key === "patternProperties")
117
+ take(name);
118
+ if (value && typeof value === "object") {
119
+ // Description only. A property `title` is no more a searchable field
120
+ // than a tool `title` is, and indexing it would be the same mistake
121
+ // one level down.
122
+ take(value.description);
123
+ descend(value);
124
+ }
125
+ }
126
+ }
127
+ for (const key of ["items", "additionalProperties", "not", "contains"]) {
128
+ const child = node[key];
129
+ // A tuple schema writes `items` as an ARRAY; that shape is handled below,
130
+ // and descending into the array itself would spend a node on nothing.
131
+ if (child && typeof child === "object" && !Array.isArray(child))
132
+ descend(child);
133
+ }
134
+ for (const key of ["anyOf", "oneOf", "allOf", "prefixItems"]) {
135
+ const arr = node[key];
136
+ if (!Array.isArray(arr))
137
+ continue;
138
+ for (const child of arr) {
139
+ if (budget.nodes <= 0 || budget.tokens <= 0)
140
+ break;
141
+ descend(child);
142
+ }
143
+ }
144
+ // A tuple schema writes `items: [ {...}, {...} ]`, which the object branch
145
+ // above skips because an array is typeof "object" but has no keys of interest.
146
+ if (Array.isArray(node.items)) {
147
+ for (const child of node.items) {
148
+ if (budget.nodes <= 0 || budget.tokens <= 0)
149
+ break;
150
+ descend(child);
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+ /**
156
+ * The four fields tool search matches on: name, description, argument names,
157
+ * argument descriptions. Kept in one function so the index and the docs can
158
+ * never disagree about what is searched.
159
+ *
160
+ * `title` is deliberately NOT among them, for two reasons that point the same
161
+ * way. Anthropic's list of searchable fields is these four and does not
162
+ * include it. And MCP `title` is a human-readable restatement of the name:
163
+ * every one of the reference server's thirteen tools sets one, and every one
164
+ * is the name title-cased ("Echo Tool", "Get Annotated Message Tool"). An
165
+ * earlier version indexed and PROBED with it, which quietly put the tool's own
166
+ * name back into its own query through the back door and undid the whole
167
+ * reason the name is excluded. Two tools with identical descriptions and
168
+ * name-derived titles scored a clean rank 1 each instead of tying.
169
+ *
170
+ * Indexing a field the real search does not read would also make `distinct`
171
+ * optimistic: a tool could "own" a word that exists only in its title, where
172
+ * no query can reach it.
173
+ */
174
+ export function indexedTerms(tool) {
175
+ return [
176
+ ...tokenize(tool.name),
177
+ ...tokenize(tool.description ?? ""),
178
+ ...schemaTerms(tool.inputSchema),
179
+ ];
180
+ }
181
+ /**
182
+ * The terms a person would plausibly type to reach this tool.
183
+ *
184
+ * Drawn from the description, and deliberately NOT from the name, the title,
185
+ * or the argument names.
186
+ *
187
+ * Not the name, because the person searching does not know it. That is the
188
+ * whole situation being modelled: the tool is deferred, the user describes a
189
+ * task, and the model turns that description into a query. An earlier version
190
+ * of this drew query terms from the name too, and the result was a metric that
191
+ * could barely fail - a unique name token has maximal idf, so it entered every
192
+ * query and pulled its own tool to the top. Two tools with the same
193
+ * description and different names both scored a clean rank 1, which is exactly
194
+ * backwards: identical descriptions are the case where a searcher cannot tell
195
+ * them apart. The name stays in the INDEX, so a name whose words echo the
196
+ * description still helps the tool rank.
197
+ *
198
+ * Not the title either, and that one is the same bug wearing a different hat:
199
+ * MCP `title` is usually the name title-cased, so reading it here put the name
200
+ * back into its own query after all the trouble taken to keep it out. See
201
+ * `indexedTerms`, which drops it from the index for the same reason.
202
+ *
203
+ * Not argument names, because a user describes a task ("post a message to
204
+ * slack"), not a parameter list. They are indexed, so a search can still reach
205
+ * them; they are just never what the tool is probed with.
206
+ *
207
+ * Ranked by tf*idf inside this catalog, so the terms chosen are the ones that
208
+ * separate this tool from its siblings, with stopwords excluded because in a
209
+ * small catalog a function word repeated in one description can outrank the
210
+ * noun that identifies the tool.
211
+ */
212
+ export function taskQuery(tool, index, limit = DEFAULT_QUERY_TERMS) {
213
+ const source = tokenize(tool.description ?? "");
214
+ const tf = new Map();
215
+ for (const t of source) {
216
+ if (QUERY_STOPWORDS.has(t))
217
+ continue;
218
+ tf.set(t, (tf.get(t) ?? 0) + 1);
219
+ }
220
+ return [...tf.entries()]
221
+ .map(([term, count]) => ({ term, weight: count * index.idf(term) }))
222
+ // Code-point tie-break, not localeCompare: the query has to be the same on
223
+ // every machine or the whole report is unreproducible.
224
+ .sort((a, b) => (b.weight - a.weight) || (a.term < b.term ? -1 : a.term > b.term ? 1 : 0))
225
+ .slice(0, limit)
226
+ .map((x) => x.term);
227
+ }
228
+ export function analyzeFind(label, tools, opts = {}) {
229
+ const topK = opts.topK ?? DEFAULT_TOP_K;
230
+ const queryTerms = opts.queryTerms ?? DEFAULT_QUERY_TERMS;
231
+ // An empty catalog is a failure, never a clean report. `find` on a server
232
+ // that exposes no tools would otherwise print a perfect 0/0 = 100% reach,
233
+ // which is the project's documented dominant failure mode: a green that
234
+ // examined nothing. The caller turns this into an exit code.
235
+ if (!tools.length) {
236
+ throw new Error(`"${label}" exposes no tools, so there is nothing to search for. ` +
237
+ `An empty catalog is not a findable one; check that the server started and listed its tools.`);
238
+ }
239
+ const docs = tools.map((t) => ({ id: t.name, terms: indexedTerms(t) }));
240
+ const index = new Bm25Index(docs, DEFAULT_BM25);
241
+ // Exclusive vocabulary. Computed from the document frequencies the index
242
+ // already holds, so the two measurements cannot disagree about what a term
243
+ // is: one tokenizer, one set of documents.
244
+ const termSets = docs.map((d) => new Set(d.terms));
245
+ const nameTermSets = tools.map((t) => new Set(tokenize(t.name)));
246
+ /** How many "who shares my vocabulary" scans to run. See the comment at its use. */
247
+ const SHARED_WITH_BUDGET = 200;
248
+ let sharedComputed = 0;
249
+ const perTool = tools.map((tool, i) => {
250
+ // Exclusive terms, with function words removed.
251
+ //
252
+ // A tool can be the only one in a catalog that happens to say "the", and
253
+ // counting that as vocabulary it owns would be true and useless: nobody
254
+ // searches for it. Function words are already excluded from the queries
255
+ // this file builds, for the same reason, and letting them count here would
256
+ // have let a catalog of near-identical tools score a clean 100%.
257
+ //
258
+ // Most-used first, then alphabetical. The list is capped for display, and
259
+ // an alphabetical cap shows whichever exclusive words happen to start with
260
+ // an early letter rather than the ones the tool is actually about: on
261
+ // `get_weather` that hid `weather` behind `city, conditions, current`.
262
+ const own = [...termSets[i]]
263
+ .filter((t) => index.documentFrequency(t) === 1 && !QUERY_STOPWORDS.has(t))
264
+ .sort((a, b) => index.termFrequency(i, b) - index.termFrequency(i, a) || (a < b ? -1 : a > b ? 1 : 0));
265
+ // How much of that survives if the searcher does not know the name. A tool
266
+ // whose only exclusive word IS its name is reachable in principle (names
267
+ // are searchable) and unreachable in practice by anyone describing a task.
268
+ const ownOutsideName = own.filter((t) => !nameTermSets[i].has(t));
269
+ // Who shares this tool's vocabulary when it owns nothing. Ranked by how
270
+ // much of it they cover, so the first name listed is the tool most worth
271
+ // comparing against.
272
+ // Only for tools that own nothing, and only for the first few of those.
273
+ //
274
+ // This is the one quadratic step here, and it used to re-materialise
275
+ // `[...termSets[i]]` as a fresh array inside the loop, once per sibling:
276
+ // 1,000 tools that all share their vocabulary took about four seconds and
277
+ // 10,000 extrapolated to over half an hour. Iterating the smaller set with
278
+ // no allocation removes most of that, and the cap removes the rest. The
279
+ // cap costs nothing visible: the rules report at most five of these and
280
+ // the table shows fifteen rows.
281
+ const sharedWith = own.length || sharedComputed >= SHARED_WITH_BUDGET ? [] : (() => {
282
+ sharedComputed++;
283
+ const mine = termSets[i];
284
+ const scored = [];
285
+ for (let j = 0; j < tools.length; j++) {
286
+ if (j === i)
287
+ continue;
288
+ const other = termSets[j];
289
+ const [small, big] = mine.size <= other.size ? [mine, other] : [other, mine];
290
+ let overlap = 0;
291
+ for (const t of small)
292
+ if (big.has(t))
293
+ overlap++;
294
+ if (overlap > 0)
295
+ scored.push({ name: tools[j].name, overlap });
296
+ }
297
+ return scored
298
+ .sort((a, b) => b.overlap - a.overlap || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
299
+ .slice(0, 3)
300
+ .map((x) => x.name);
301
+ })();
302
+ const query = taskQuery(tool, index, queryTerms);
303
+ const hits = index.score(query);
304
+ // By POSITION, not by name. Two tools may share a name, and matching on it
305
+ // handed every duplicate the better twin's rank, score and tie set.
306
+ const at = hits.findIndex((h) => h.index === i);
307
+ const rank = at >= 0 ? at + 1 : undefined;
308
+ const score = at >= 0 ? hits[at].score : 0;
309
+ const outrankedBy = at > 0 ? hits.slice(0, at).map((h) => h.id) : [];
310
+ const tied = at >= 0
311
+ ? hits.filter((h, pos) => pos !== at && scoresTie(h.score, score) && h.score > 0).map((h) => h.id)
312
+ : [];
313
+ return {
314
+ name: tool.name,
315
+ ownTerms: own.slice(0, 4),
316
+ ownTermCount: own.length,
317
+ ownOutsideNameCount: ownOutsideName.length,
318
+ sharedWith,
319
+ query,
320
+ rank,
321
+ score,
322
+ outrankedBy: outrankedBy.slice(0, 3),
323
+ tiedWith: tied.slice(0, 3),
324
+ // Kept separately from the display cap. The rule that counts pairs was
325
+ // counting the capped list, so a catalog where every tool ties with
326
+ // every other reported 30 pairs instead of the real 66: a count of what
327
+ // survived a display cap, presented as a count of the problem.
328
+ tiedWithCount: tied.length,
329
+ reachable: rank !== undefined && rank <= topK,
330
+ genericName: nameIsGeneric(tool.name),
331
+ };
332
+ });
333
+ const distinctCount = perTool.filter((t) => t.ownTermCount > 0).length;
334
+ const reachable = perTool.filter((t) => t.reachable).length;
335
+ const windowCoversCatalog = tools.length <= topK;
336
+ const distinctVacuous = tools.length < 2;
337
+ const deferBecause = [];
338
+ if (tools.length >= DEFER_RECOMMENDED_TOOLS) {
339
+ deferBecause.push(`${tools.length} tools (Anthropic recommends tool search at ${DEFER_RECOMMENDED_TOOLS} or more)`);
340
+ }
341
+ if ((opts.definitionTokens ?? 0) > DEFER_RECOMMENDED_TOKENS) {
342
+ deferBecause.push(`~${(opts.definitionTokens ?? 0).toLocaleString("en-US")} tokens of definitions (Anthropic recommends tool search over ${DEFER_RECOMMENDED_TOKENS.toLocaleString("en-US")})`);
343
+ }
344
+ const deferRecommended = deferBecause.length > 0;
345
+ const notes = [
346
+ `method: terms come from the four fields tool search documents as searchable (tool name, description, argument names, argument descriptions). ` +
347
+ `"distinct" is a property of the catalog, not a model. The probe is a simulated BM25 (k1=${DEFAULT_BM25.k1}, b=${DEFAULT_BM25.b}), ` +
348
+ `each tool queried with the ${queryTerms} highest tf-idf terms of its own description (never its name or title, which a searcher does not know), window ${topK}. docs/METHODOLOGY.md.`,
349
+ `the probe reads 100% on every real server measured so far, so treat it as a floor test that catches an empty or duplicated description, not as a ranking.`,
350
+ ];
351
+ // Vacuity is stated, not hidden, and there are TWO kinds of it.
352
+ //
353
+ // The probe goes vacuous whenever the catalog fits inside the result window.
354
+ // `distinct` goes vacuous only at one tool, where every term trivially has
355
+ // document frequency 1 and the figure is 100% for any catalog whatsoever.
356
+ // The first version of this said "exclusive vocabulary can fail at any
357
+ // catalog size, including two tools", which is true at two and false at one,
358
+ // and then reassured the reader that everything except the probe was "still
359
+ // meaningful" while the headline number was the one that had stopped being
360
+ // evidence. One-tool servers are real: sequential-thinking is one.
361
+ if (windowCoversCatalog) {
362
+ notes.push(`VACUOUS PROBE: ${tools.length} tools and a result window of ${topK}, so no tool can fall outside it. ` +
363
+ `The probe figure is 100% by construction. Ranks, exclusive vocabulary and the findings below are still evidence.`);
364
+ }
365
+ if (distinctVacuous) {
366
+ notes.push(`VACUOUS DISTINCT: a one-tool catalog has nothing to be distinct from, so every term it has is trivially exclusive ` +
367
+ `and the figure is 100% for any tool whatsoever. It is not evidence about this server, and --min-distinct cannot be evaluated here.`);
368
+ }
369
+ // Duplicate names are a real defect and nothing else here reports them. The
370
+ // ranking no longer confuses two tools that share a name, but a client that
371
+ // routes by name still will.
372
+ const dupNames = [...new Set(tools.map((t) => t.name).filter((nm, i, all) => all.indexOf(nm) !== i))];
373
+ if (dupNames.length) {
374
+ notes.push(`${dupNames.length} tool name${dupNames.length === 1 ? " is" : "s are"} used by more than one tool (${dupNames.slice(0, 3).join(", ")}). ` +
375
+ `Ranks here are per tool, but a client that addresses tools by name cannot tell them apart at all.`);
376
+ }
377
+ notes.push(deferRecommended
378
+ ? `tool search is recommended for this catalog (${deferBecause.join("; ")}), so assume these tools are deferred and that findability is what decides whether they get used.`
379
+ : `${tools.length} tools and ~${(opts.definitionTokens ?? 0).toLocaleString("en-US")} tokens of definitions, under both thresholds where Anthropic recommends tool search, so these tools are probably loaded up front today and this report is advisory rather than urgent.`);
380
+ return {
381
+ kind: "find",
382
+ label,
383
+ toolCount: tools.length,
384
+ method: { tokenizer: "efaimo-v1", bm25: { ...DEFAULT_BM25 }, queryTerms, topK },
385
+ perTool,
386
+ distinct: {
387
+ count: distinctCount,
388
+ total: tools.length,
389
+ pct: Math.round((distinctCount / tools.length) * 1000) / 10,
390
+ },
391
+ probe: {
392
+ returned: reachable,
393
+ total: tools.length,
394
+ pct: Math.round((reachable / tools.length) * 1000) / 10,
395
+ },
396
+ windowCoversCatalog,
397
+ distinctVacuous,
398
+ definitionTokens: opts.definitionTokens,
399
+ deferRecommended,
400
+ deferBecause,
401
+ notes,
402
+ };
403
+ }