efaimo 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +160 -1
- package/README.md +75 -6
- package/SKILL.md +14 -7
- package/dist/check/diff.d.ts +124 -0
- package/dist/check/diff.js +165 -0
- package/dist/cli.js +237 -22
- package/dist/core/engine.d.ts +2 -1
- package/dist/core/engine.js +4 -0
- package/dist/core/types.d.ts +144 -0
- package/dist/find/bm25.d.ts +77 -0
- package/dist/find/bm25.js +107 -0
- package/dist/find/find.d.ts +122 -0
- package/dist/find/find.js +405 -0
- package/dist/find/tokenize.d.ts +64 -0
- package/dist/find/tokenize.js +116 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +14 -3
- package/dist/reporters/json.d.ts +19 -1
- package/dist/reporters/json.js +13 -2
- package/dist/reporters/markdown.d.ts +2 -1
- package/dist/reporters/markdown.js +109 -11
- package/dist/reporters/pretty.d.ts +25 -1
- package/dist/reporters/pretty.js +228 -9
- package/dist/rules/find/index.d.ts +4 -0
- package/dist/rules/find/index.js +262 -0
- package/dist/rules/skill/index.js +32 -1
- package/dist/rules/version.d.ts +26 -0
- package/dist/rules/version.js +26 -0
- package/dist/testing/anthropicRunner.d.ts +30 -0
- package/dist/testing/anthropicRunner.js +43 -8
- package/dist/testing/harness.d.ts +32 -1
- package/dist/testing/harness.js +28 -3
- package/dist/testing/stats.d.ts +16 -0
- package/dist/testing/stats.js +26 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/weigh/anthropicExact.d.ts +12 -1
- package/dist/weigh/anthropicExact.js +9 -1
- package/dist/weigh/weigh.js +10 -3
- package/docs/INTEGRATIONS.md +15 -1
- package/docs/METHODOLOGY.md +110 -6
- package/docs/RULES.md +40 -3
- package/examples/scenario.crm.yaml +8 -0
- package/examples/scenario.example.yaml +8 -0
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -11,14 +11,17 @@ import { SPEC_VERSION } from "./clients/rawprobe.js";
|
|
|
11
11
|
import { weighServer, weighSkills } from "./weigh/weigh.js";
|
|
12
12
|
import { findSkills } from "./skills/parse.js";
|
|
13
13
|
import { diffServerWeigh } from "./weigh/diff.js";
|
|
14
|
+
import { diffCheck, CheckDiffRefused } from "./check/diff.js";
|
|
14
15
|
import { DEFAULT_CONTEXT_WINDOW, formatWindowShare, setContextWindow } from "./weigh/window.js";
|
|
15
16
|
import { checkMcpRepoOnly, checkMcpTarget, checkSkillSet } from "./check/check.js";
|
|
16
|
-
import {
|
|
17
|
+
import { analyzeFind, DEFAULT_TOP_K } from "./find/find.js";
|
|
18
|
+
import { runFindRules } from "./core/engine.js";
|
|
19
|
+
import { renderCheckPretty, renderDiffPretty, renderFindFindingsPretty, renderFindPretty, renderScenarioPlan, renderServerWeighPretty, renderSkillSetPretty, renderSkillWeighPretty, renderTestReportPretty, setColor, renderCheckDiffPretty, } from "./reporters/pretty.js";
|
|
17
20
|
import { parseScenario, runScenario } from "./testing/harness.js";
|
|
18
|
-
import { anthropicRunner } from "./testing/anthropicRunner.js";
|
|
21
|
+
import { acceptsSamplingParams, anthropicRunner } from "./testing/anthropicRunner.js";
|
|
19
22
|
import { openaiRunner, providerForModel } from "./testing/openaiRunner.js";
|
|
20
23
|
import { toJsonEnvelope } from "./reporters/json.js";
|
|
21
|
-
import { renderCheckMarkdown, renderDiffMarkdown, renderSkillSetMarkdown, renderWeighMarkdown } from "./reporters/markdown.js";
|
|
24
|
+
import { renderCheckMarkdown, renderDiffMarkdown, renderFindMarkdown, renderSkillSetMarkdown, renderWeighMarkdown } from "./reporters/markdown.js";
|
|
22
25
|
import { gradeBadgeSpec, makeBadgeSvg, toShieldsEndpoint, weighBadgeSpec } from "./reporters/badge.js";
|
|
23
26
|
import { loadDotEnv } from "./util/dotenv.js";
|
|
24
27
|
// Load a local .env before any command reads a key. Shell env always wins.
|
|
@@ -36,6 +39,7 @@ examples:
|
|
|
36
39
|
$ npx efaimo weigh --client claude-code
|
|
37
40
|
$ npx efaimo check --mcp "npx -y my-mcp-server" # incl. 2026-07-28 readiness
|
|
38
41
|
$ npx efaimo check --skill ./skills/
|
|
42
|
+
$ npx efaimo find "npx -y my-mcp-server" # would a search surface these tools?
|
|
39
43
|
$ npx efaimo weigh "npx -y my-server" --out base.json && npx efaimo weigh "npx -y my-server" --diff base.json
|
|
40
44
|
`);
|
|
41
45
|
function collectPairs(sep) {
|
|
@@ -104,6 +108,13 @@ function anthropicKeyFor(opts) {
|
|
|
104
108
|
fail("--anthropic needs ANTHROPIC_API_KEY in the environment or a .env file");
|
|
105
109
|
return { apiKey, model: typeof opts.anthropic === "string" ? opts.anthropic : undefined };
|
|
106
110
|
}
|
|
111
|
+
// `--no-timestamp` is commander's negated form, so `opts.timestamp` is true
|
|
112
|
+
// unless the flag is passed. Kept in one helper because three commands take it
|
|
113
|
+
// and the polarity is easy to invert by accident.
|
|
114
|
+
function envelopeOpts(opts) {
|
|
115
|
+
return { timestamp: opts.timestamp !== false };
|
|
116
|
+
}
|
|
117
|
+
const NO_TIMESTAMP_HELP = "omit generatedAt from JSON output (for committed or diffed artifacts)";
|
|
107
118
|
program
|
|
108
119
|
.command("weigh")
|
|
109
120
|
.description("measure the context cost of MCP tool definitions or Agent Skills")
|
|
@@ -116,6 +127,7 @@ program
|
|
|
116
127
|
.option("--timeout <seconds>", "connect timeout in seconds", "45")
|
|
117
128
|
.option("--json", "print JSON")
|
|
118
129
|
.option("--md", "print Markdown")
|
|
130
|
+
.option("--no-timestamp", NO_TIMESTAMP_HELP)
|
|
119
131
|
.option("--out <file>", "write the JSON result to a file (usable as a --diff baseline)")
|
|
120
132
|
.option("--diff <baseline>", "compare against a baseline JSON from --out")
|
|
121
133
|
.option("--max-tokens <n>", "exit 1 if the primary total exceeds n tokens")
|
|
@@ -212,14 +224,18 @@ program
|
|
|
212
224
|
const single = targets.length === 1 && results.length === 1 ? results[0] : undefined;
|
|
213
225
|
const budgetTotal = results.reduce((s, r) => s + (r.kind === "mcp" ? r.totals.claudeStyle : r.totals.metadata + r.totals.body), 0);
|
|
214
226
|
if (opts.out && single) {
|
|
215
|
-
|
|
227
|
+
// Never stamped. A baseline exists to be compared against, so a field
|
|
228
|
+
// that differs on every write is the one field a comparison must ignore,
|
|
229
|
+
// and a committed baseline that diffs on nothing but the clock trains
|
|
230
|
+
// reviewers to skim exactly the file they should read.
|
|
231
|
+
fs.writeFileSync(opts.out, toJsonEnvelope("weigh", single, { timestamp: false }));
|
|
216
232
|
console.error(pc.dim(`baseline written: ${opts.out}`));
|
|
217
233
|
}
|
|
218
234
|
else if (opts.out) {
|
|
219
235
|
console.error(pc.yellow("--out skipped: it writes a single baseline, but multiple targets were requested"));
|
|
220
236
|
}
|
|
221
237
|
if (opts.json) {
|
|
222
|
-
console.log(toJsonEnvelope("weigh", single ?? results));
|
|
238
|
+
console.log(toJsonEnvelope("weigh", single ?? results, envelopeOpts(opts)));
|
|
223
239
|
}
|
|
224
240
|
else if (opts.md) {
|
|
225
241
|
console.log(results.map(renderWeighMarkdown).join("\n\n---\n\n"));
|
|
@@ -286,7 +302,12 @@ program
|
|
|
286
302
|
.option("--timeout <seconds>", "connect timeout in seconds", "45")
|
|
287
303
|
.option("--json", "print JSON")
|
|
288
304
|
.option("--md", "print Markdown")
|
|
305
|
+
.option("--no-timestamp", NO_TIMESTAMP_HELP)
|
|
289
306
|
.option("--badge [file]", "write a grade badge SVG + shields endpoint JSON")
|
|
307
|
+
.option("--out <file>", "write the JSON result to a file (usable as a --diff baseline)")
|
|
308
|
+
.option("--diff <baseline>", "compare against a baseline JSON from --out")
|
|
309
|
+
.option("--allow-rules-drift", "let --diff proceed when the two runs used different rulesets (grade movement is then unattributable)")
|
|
310
|
+
.option("--fail-on-regression", "with --diff, exit 1 if any subject present in both runs scored lower")
|
|
290
311
|
.option("--anthropic [model]", "use exact Claude token counts where relevant")
|
|
291
312
|
.option("--window <tokens>", "context window the share is reported against", String(DEFAULT_CONTEXT_WINDOW))
|
|
292
313
|
.option("--no-color", "disable colors")
|
|
@@ -332,9 +353,48 @@ program
|
|
|
332
353
|
});
|
|
333
354
|
report = res.report;
|
|
334
355
|
}
|
|
356
|
+
// A baseline is written unstamped for the same reason weigh's is: a file
|
|
357
|
+
// that differs on every write is the one field a comparison must ignore,
|
|
358
|
+
// and a committed baseline that diffs on nothing but the clock trains
|
|
359
|
+
// reviewers to skim the file they should be reading. The whole envelope is
|
|
360
|
+
// written rather than just its data, because the version and rulesVersion
|
|
361
|
+
// in it are what make the later comparison trustworthy.
|
|
362
|
+
const baselineAndDiff = (payload) => {
|
|
363
|
+
if (opts.out) {
|
|
364
|
+
fs.writeFileSync(opts.out, toJsonEnvelope("check", payload, { timestamp: false }));
|
|
365
|
+
console.error(pc.dim(`baseline written: ${opts.out}`));
|
|
366
|
+
}
|
|
367
|
+
if (!opts.diff)
|
|
368
|
+
return;
|
|
369
|
+
let baseline;
|
|
370
|
+
try {
|
|
371
|
+
baseline = JSON.parse(fs.readFileSync(opts.diff, "utf8"));
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
fail(`could not read the baseline "${opts.diff}": ${e.message}`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const current = JSON.parse(toJsonEnvelope("check", payload, { timestamp: false }));
|
|
378
|
+
try {
|
|
379
|
+
const d = diffCheck(baseline, current, { allowRulesDrift: opts.allowRulesDrift });
|
|
380
|
+
console.log("");
|
|
381
|
+
console.log(renderCheckDiffPretty(d));
|
|
382
|
+
if (opts.failOnRegression && d.worsened.length)
|
|
383
|
+
process.exitCode = 1;
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
// Every refusal here is a comparison that would have produced a
|
|
387
|
+
// plausible number from incomparable inputs, so it stops rather than
|
|
388
|
+
// printing one.
|
|
389
|
+
if (e instanceof CheckDiffRefused)
|
|
390
|
+
fail(e.message);
|
|
391
|
+
throw e;
|
|
392
|
+
}
|
|
393
|
+
};
|
|
335
394
|
if (skillSet) {
|
|
395
|
+
baselineAndDiff(skillSet);
|
|
336
396
|
if (opts.json)
|
|
337
|
-
console.log(toJsonEnvelope("check", skillSet));
|
|
397
|
+
console.log(toJsonEnvelope("check", skillSet, envelopeOpts(opts)));
|
|
338
398
|
else if (opts.md)
|
|
339
399
|
console.log(renderSkillSetMarkdown(skillSet));
|
|
340
400
|
else
|
|
@@ -349,8 +409,9 @@ program
|
|
|
349
409
|
}
|
|
350
410
|
if (!report)
|
|
351
411
|
return;
|
|
412
|
+
baselineAndDiff(report);
|
|
352
413
|
if (opts.json)
|
|
353
|
-
console.log(toJsonEnvelope("check", report));
|
|
414
|
+
console.log(toJsonEnvelope("check", report, envelopeOpts(opts)));
|
|
354
415
|
else if (opts.md)
|
|
355
416
|
console.log(renderCheckMarkdown(report));
|
|
356
417
|
else
|
|
@@ -405,41 +466,195 @@ program
|
|
|
405
466
|
process.exitCode = 1;
|
|
406
467
|
}
|
|
407
468
|
});
|
|
469
|
+
program
|
|
470
|
+
.command("find")
|
|
471
|
+
.description("would a search surface these tools? (findability under deferred tool loading)")
|
|
472
|
+
.argument("[targets...]", "one or more MCP servers: stdio command | http(s) URL. Two or more are merged into one catalog and measured together")
|
|
473
|
+
.option("--stdio", "treat target as a stdio command string")
|
|
474
|
+
.option("--header <header>", 'HTTP header "Key: Value" (repeatable)', collectPairs(":"))
|
|
475
|
+
.option("--env <pair>", "KEY=VALUE for stdio servers (repeatable)", collectPairs("="))
|
|
476
|
+
.option("--timeout <seconds>", "connect timeout in seconds", "45")
|
|
477
|
+
.option("--top <n>", `result window to simulate (Anthropic's documented default is ${DEFAULT_TOP_K}; the probe is vacuous when tools <= top, so gate on --min-distinct rather than on this)`, String(DEFAULT_TOP_K))
|
|
478
|
+
.option("--min-distinct <pct>", "exit 1 if fewer than pct percent of tools own a word no other tool has")
|
|
479
|
+
.option("--json", "print JSON")
|
|
480
|
+
.option("--md", "print Markdown")
|
|
481
|
+
.option("--no-timestamp", NO_TIMESTAMP_HELP)
|
|
482
|
+
.option("--no-color", "disable colors")
|
|
483
|
+
.addHelpText("after", "\nA tool marked defer_loading is kept out of the context window until a search finds it, so a tool\n" +
|
|
484
|
+
"nothing surfaces costs nothing and does nothing. Two numbers come back. `distinct` is a property of\n" +
|
|
485
|
+
"the catalog: a tool that owns no word the others lack cannot be matched by any query that does not\n" +
|
|
486
|
+
"also match a competitor. `probe` is a simulated BM25 search, offline and deterministic, and it\n" +
|
|
487
|
+
"saturates on well-formed catalogs, so it is a floor test rather than a ranking.\n" +
|
|
488
|
+
"Pass more than one server and their catalogs are MERGED and measured together, with each tool\n" +
|
|
489
|
+
"labelled by where it came from. That is the case worth checking: a server author keeps their own\n" +
|
|
490
|
+
"names apart, nobody coordinates across the several servers a person installs, and the model sees\n" +
|
|
491
|
+
"one flat list. A single-server run is structurally unable to see a collision between two of them.\n" +
|
|
492
|
+
"No API key, no network beyond connecting to the server. docs/METHODOLOGY.md has the method.")
|
|
493
|
+
.action(async (targetArgs, opts) => {
|
|
494
|
+
colorSetup(opts);
|
|
495
|
+
const args = targetArgs ?? [];
|
|
496
|
+
if (!args.length)
|
|
497
|
+
fail("nothing to search: pass an MCP server (stdio command or URL)");
|
|
498
|
+
const timeoutMs = seconds(opts.timeout) * 1000;
|
|
499
|
+
const topK = parseNumberOpt(opts.top, "--top", { min: 1 }) ?? DEFAULT_TOP_K;
|
|
500
|
+
// A window is a count of results, so a fraction of one is not a stricter
|
|
501
|
+
// setting, it is a nonsensical one: `--top 2.5` printed "result window 2.5"
|
|
502
|
+
// while `rank <= 2.5` behaved as 2, so the number in the report and the
|
|
503
|
+
// number in the comparison disagreed.
|
|
504
|
+
if (!Number.isInteger(topK))
|
|
505
|
+
fail(`--top must be a whole number of results, got "${opts.top}"`);
|
|
506
|
+
const minDistinct = parseNumberOpt(opts.minDistinct, "--min-distinct", { min: 0 });
|
|
507
|
+
if (minDistinct !== undefined && minDistinct > 100)
|
|
508
|
+
fail(`--min-distinct is a percentage, got "${opts.minDistinct}"`);
|
|
509
|
+
const targets = args
|
|
510
|
+
.map((a) => resolveTarget(a, { forceStdio: opts.stdio, env: opts.env, headers: opts.header }))
|
|
511
|
+
.map((target) => {
|
|
512
|
+
if (target.kind === "skillset" || target.kind === "repo") {
|
|
513
|
+
fail(`"${target.label}" is a ${target.kind === "skillset" ? "skill path" : "source directory"}; find needs a live MCP server (stdio command or URL). ` +
|
|
514
|
+
`Skills have no searchable tool catalog; their trigger-overlap check is S103 in \`efaimo check --skill\`.`);
|
|
515
|
+
throw new Error("unreachable");
|
|
516
|
+
}
|
|
517
|
+
return target;
|
|
518
|
+
});
|
|
519
|
+
// Several servers are merged into ONE catalog rather than measured one at
|
|
520
|
+
// a time, because the failure worth finding only exists between them. A
|
|
521
|
+
// server's author keeps their own tool names apart as a matter of course;
|
|
522
|
+
// nobody coordinates across the five or ten servers a person actually
|
|
523
|
+
// installs, and the model sees all of them as one flat list with no
|
|
524
|
+
// indication of which came from where. Measuring them separately would
|
|
525
|
+
// report every catalog as tidy and miss exactly the collision that makes a
|
|
526
|
+
// model pick the wrong tool.
|
|
527
|
+
//
|
|
528
|
+
// The origin is attached for the report and NOT for the index. Prefixing
|
|
529
|
+
// names with their server would hand every tool a term no other tool has,
|
|
530
|
+
// and a catalog of indistinguishable tools would score a perfect 100.
|
|
531
|
+
let tools = [];
|
|
532
|
+
let definitionTokens = 0;
|
|
533
|
+
for (const target of targets) {
|
|
534
|
+
console.error(pc.dim(`connecting to ${target.label} ...`));
|
|
535
|
+
const intro = await introspectServer(target, { timeoutMs });
|
|
536
|
+
// Weighed only to answer one question: is this catalog big enough that a
|
|
537
|
+
// host would defer it. No API key is involved; the count is the local
|
|
538
|
+
// o200k estimate the rest of the tool uses.
|
|
539
|
+
const w = await weighServer(intro);
|
|
540
|
+
definitionTokens += w.totals.claudeStyle;
|
|
541
|
+
tools = tools.concat(targets.length > 1 ? intro.tools.map((tool) => ({ ...tool, origin: target.label })) : intro.tools);
|
|
542
|
+
}
|
|
543
|
+
const label = targets.length === 1 ? targets[0].label : `${targets.length} servers`;
|
|
544
|
+
const result = analyzeFind(label, tools, {
|
|
545
|
+
topK,
|
|
546
|
+
definitionTokens,
|
|
547
|
+
sources: targets.map((x) => x.label),
|
|
548
|
+
});
|
|
549
|
+
const findings = runFindRules({ find: result });
|
|
550
|
+
if (opts.json) {
|
|
551
|
+
console.log(toJsonEnvelope("find", { ...result, findings }, envelopeOpts(opts)));
|
|
552
|
+
}
|
|
553
|
+
else if (opts.md) {
|
|
554
|
+
console.log(renderFindMarkdown(result, findings));
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
console.log(renderFindPretty(result));
|
|
558
|
+
console.log(renderFindFindingsPretty(result, findings));
|
|
559
|
+
}
|
|
560
|
+
// Gated on `distinct`, not on the probe. The probe is 100% by construction
|
|
561
|
+
// whenever the catalog fits inside the result window, so a gate on it
|
|
562
|
+
// would pass without examining anything for every small server. Exclusive
|
|
563
|
+
// vocabulary can fail at two tools and up.
|
|
564
|
+
//
|
|
565
|
+
// At ONE tool it cannot: every term is trivially exclusive and the figure
|
|
566
|
+
// is 100% for any tool whatsoever. Exit 2 rather than 0, because "this
|
|
567
|
+
// gate cannot be evaluated here" is a different fact from "this gate
|
|
568
|
+
// passed", and a CI run that silently passes on a catalog nothing was
|
|
569
|
+
// measured against is the failure this whole command was built around.
|
|
570
|
+
if (minDistinct !== undefined && result.distinctVacuous) {
|
|
571
|
+
fail(`--min-distinct cannot be evaluated on a ${result.toolCount}-tool catalog: with nothing to be distinct from, ` +
|
|
572
|
+
`every term is trivially exclusive and the figure is 100% for any tool. Drop the gate for this server.`);
|
|
573
|
+
}
|
|
574
|
+
if (minDistinct !== undefined && result.distinct.pct < minDistinct) {
|
|
575
|
+
console.error(pc.red(`distinct ${result.distinct.pct}% is below --min-distinct ${minDistinct}% ` +
|
|
576
|
+
`(${result.distinct.total - result.distinct.count} of ${result.distinct.total} tools own no word the others lack)`));
|
|
577
|
+
process.exitCode = 1;
|
|
578
|
+
}
|
|
579
|
+
});
|
|
408
580
|
program
|
|
409
581
|
.command("test")
|
|
410
582
|
.description("does a skill actually improve task completion? (experimental A/B outcome harness)")
|
|
411
583
|
.argument("<scenario>", "a scenario YAML file (see examples/scenario.example.yaml)")
|
|
412
584
|
.option("--live", "run for real (spends tokens; Claude models need ANTHROPIC_API_KEY, GPT models need OPENAI_API_KEY)")
|
|
413
585
|
.option("--model <model>", "override the scenario's model (e.g. gpt-4o-mini, claude-sonnet-5)")
|
|
586
|
+
.option("--judge-model <model>", "grade with a different model than the one under test (removes self-preference)")
|
|
414
587
|
.option("--json", "print JSON")
|
|
415
588
|
.option("--no-color", "disable colors")
|
|
416
|
-
.addHelpText("after", "\nWithout --live this validates the scenario and prints the plan, making no API calls
|
|
589
|
+
.addHelpText("after", "\nWithout --live this validates the scenario and prints the plan, making no API calls.\n" +
|
|
590
|
+
"The judge defaults to the model under test, which means a model grades its own answers; set\n" +
|
|
591
|
+
"judge_model in the scenario or --judge-model here to separate them. Subject and judge may be\n" +
|
|
592
|
+
"different providers, in which case both keys are required.")
|
|
417
593
|
.action(async (file, opts) => {
|
|
418
594
|
colorSetup(opts);
|
|
419
595
|
const parsed = parseScenario(file);
|
|
420
|
-
|
|
596
|
+
// --model moves the judge too WHEN the judge was never pinned separately.
|
|
597
|
+
// Without this, overriding the subject on a scenario that has no
|
|
598
|
+
// `judge_model` would silently leave the judge on the scenario's original
|
|
599
|
+
// model: a cross-model judge nobody asked for, in a run whose whole point
|
|
600
|
+
// is that the comparison is controlled.
|
|
601
|
+
const judgeFollowsSubject = !parsed.judgeModelExplicit;
|
|
602
|
+
const model = opts.model ?? parsed.model;
|
|
603
|
+
const judgeModel = opts.judgeModel ?? (judgeFollowsSubject ? model : parsed.judgeModel);
|
|
604
|
+
const scenario = { ...parsed, model, judgeModel };
|
|
421
605
|
if (!opts.live) {
|
|
422
606
|
console.log(renderScenarioPlan(scenario));
|
|
423
607
|
return;
|
|
424
608
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
609
|
+
// Subject and judge are routed independently, so a Claude subject can be
|
|
610
|
+
// graded by a GPT judge (or the reverse). Every distinct provider in play
|
|
611
|
+
// needs its own key, and the error says which model asked for it.
|
|
612
|
+
const runners = new Map();
|
|
613
|
+
for (const [role, m] of [["model", scenario.model], ["judge model", scenario.judgeModel]]) {
|
|
614
|
+
const provider = providerForModel(m);
|
|
615
|
+
if (provider === "unknown") {
|
|
616
|
+
fail(`${role} "${m}" is not supported: efaimo test runs Claude (claude-*) and OpenAI (gpt-*, o*) models`);
|
|
617
|
+
}
|
|
618
|
+
if (runners.has(provider))
|
|
619
|
+
continue;
|
|
620
|
+
const envVar = provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
|
|
621
|
+
const apiKey = process.env[envVar];
|
|
622
|
+
if (!apiKey)
|
|
623
|
+
fail(`${role} "${m}" needs ${envVar} in the environment or a .env file`);
|
|
624
|
+
if (dotEnvKeys.has(envVar))
|
|
625
|
+
console.error(pc.dim(`using ${envVar} from .env`));
|
|
626
|
+
runners.set(provider, provider === "openai" ? openaiRunner(apiKey) : anthropicRunner(apiKey));
|
|
428
627
|
}
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
628
|
+
const runner = (req) => {
|
|
629
|
+
const r = runners.get(providerForModel(req.model));
|
|
630
|
+
// Only the two validated models can reach here today. A future arm that
|
|
631
|
+
// introduces a third would otherwise die as "undefined is not a
|
|
632
|
+
// function" with no idea which model it was.
|
|
633
|
+
if (!r)
|
|
634
|
+
throw new Error(`no runner for model "${req.model}"; efaimo test runs Claude and OpenAI models`);
|
|
635
|
+
return r(req);
|
|
636
|
+
};
|
|
637
|
+
// Say what the measurement cannot pin down, in the report, every time.
|
|
638
|
+
const extraNotes = [];
|
|
639
|
+
if (scenario.judgeModel === scenario.model) {
|
|
640
|
+
extraNotes.push(`the judge is the same model as the subject, so part of any measured effect is a model preferring its own output. Set judge_model (or --judge-model) to a different model to remove that.`);
|
|
641
|
+
}
|
|
642
|
+
if (providerForModel(scenario.judgeModel) === "anthropic" && !acceptsSamplingParams(scenario.judgeModel)) {
|
|
643
|
+
extraNotes.push(`judge sampling is not pinned: ${scenario.judgeModel} does not accept temperature, so the judge is sampled at its default and its own variance is part of this measurement.`);
|
|
644
|
+
}
|
|
645
|
+
const models = scenario.judgeModel === scenario.model ? scenario.model : `${scenario.model} judged by ${scenario.judgeModel}`;
|
|
646
|
+
console.error(pc.dim(`running ~${scenario.trials * 4} API calls against ${models} ...`));
|
|
647
|
+
const report = await runScenario(scenario, runner, { extraNotes });
|
|
438
648
|
if (opts.json)
|
|
439
649
|
console.log(toJsonEnvelope("test", report));
|
|
440
650
|
else
|
|
441
651
|
console.log(renderTestReportPretty(report));
|
|
442
|
-
|
|
652
|
+
// `inconclusive` fails too. It means an arm produced no scoreable trial, or
|
|
653
|
+
// the judge refused on one arm far more than the other, and in both cases
|
|
654
|
+
// the measurement did not happen. Exiting 0 there would report a green for
|
|
655
|
+
// precisely the runs where the instrument broke down, which is this
|
|
656
|
+
// project's documented dominant failure mode wearing a p-value.
|
|
657
|
+
if (report.verdict === "hurts" || report.verdict === "inconclusive")
|
|
443
658
|
process.exitCode = 1;
|
|
444
659
|
});
|
|
445
660
|
program
|
package/dist/core/engine.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { Finding, McpRuleContext } from "./types.js";
|
|
1
|
+
import type { Finding, FindRuleContext, McpRuleContext } from "./types.js";
|
|
2
2
|
/** Run a list of rules, isolating each: a broken rule must never break the audit. */
|
|
3
3
|
export declare function runRules<Ctx>(rules: readonly {
|
|
4
4
|
check(ctx: Ctx): Finding[];
|
|
5
5
|
}[], ctx: Ctx): Finding[];
|
|
6
6
|
export declare function runMcpRules(ctx: McpRuleContext): Finding[];
|
|
7
|
+
export declare function runFindRules(ctx: FindRuleContext): Finding[];
|
package/dist/core/engine.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MCP_RULES } from "../rules/mcp/index.js";
|
|
2
|
+
import { FIND_RULES } from "../rules/find/index.js";
|
|
2
3
|
/** Run a list of rules, isolating each: a broken rule must never break the audit. */
|
|
3
4
|
export function runRules(rules, ctx) {
|
|
4
5
|
const findings = [];
|
|
@@ -33,3 +34,6 @@ export function runRules(rules, ctx) {
|
|
|
33
34
|
export function runMcpRules(ctx) {
|
|
34
35
|
return runRules(MCP_RULES, ctx);
|
|
35
36
|
}
|
|
37
|
+
export function runFindRules(ctx) {
|
|
38
|
+
return runRules(FIND_RULES, ctx);
|
|
39
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -26,6 +26,17 @@ export interface ToolDef {
|
|
|
26
26
|
inputSchema?: unknown;
|
|
27
27
|
outputSchema?: unknown;
|
|
28
28
|
annotations?: Record<string, unknown>;
|
|
29
|
+
/**
|
|
30
|
+
* Which server this tool came from, when several are analysed together.
|
|
31
|
+
*
|
|
32
|
+
* DISPLAY ONLY. It is deliberately absent from `indexedTerms`, and putting
|
|
33
|
+
* it there would be the same bug the query builder already documents: a
|
|
34
|
+
* per-server token has maximal idf, so prefixing names with their origin
|
|
35
|
+
* would hand every tool a word no other tool has and report a catalog of
|
|
36
|
+
* indistinguishable tools as perfectly distinct. The measurement has to see
|
|
37
|
+
* exactly what a model sees, which is a flat list with no origin attached.
|
|
38
|
+
*/
|
|
39
|
+
origin?: string;
|
|
29
40
|
}
|
|
30
41
|
export interface ResourceDef {
|
|
31
42
|
uri: string;
|
|
@@ -155,6 +166,13 @@ export interface ServerWeighResult {
|
|
|
155
166
|
instructionsTokens: number;
|
|
156
167
|
/** Exact count via Anthropic count_tokens API, when a key was provided. */
|
|
157
168
|
anthropicExactTotal?: number;
|
|
169
|
+
/**
|
|
170
|
+
* The model `anthropicExactTotal` was measured against. Always set when the
|
|
171
|
+
* total is: Claude model lines do not share a tokenizer, so the figure is
|
|
172
|
+
* exact for this model and approximate for any other, and a count printed
|
|
173
|
+
* without its model cannot be reproduced.
|
|
174
|
+
*/
|
|
175
|
+
anthropicExactModel?: string;
|
|
158
176
|
notes: string[];
|
|
159
177
|
}
|
|
160
178
|
export interface SkillWeighEntry {
|
|
@@ -178,6 +196,123 @@ export interface SkillSetWeighResult {
|
|
|
178
196
|
notes: string[];
|
|
179
197
|
}
|
|
180
198
|
export type WeighResult = ServerWeighResult | SkillSetWeighResult;
|
|
199
|
+
/** One tool's result in the findability report (`efaimo find`). */
|
|
200
|
+
export interface ToolFindEntry {
|
|
201
|
+
name: string;
|
|
202
|
+
/** The server this tool came from, when more than one was analysed. */
|
|
203
|
+
origin?: string;
|
|
204
|
+
/**
|
|
205
|
+
* Terms this tool has that no other tool in the catalog has.
|
|
206
|
+
*
|
|
207
|
+
* The primary signal, and the only one here that is not a model of anything:
|
|
208
|
+
* if this is empty, every word the tool contains also appears on some other
|
|
209
|
+
* tool, so no query can match it without also matching a competitor. That is
|
|
210
|
+
* a property of the catalog, provable by inspection, with no assumption
|
|
211
|
+
* about how anyone searches. Capped for display.
|
|
212
|
+
*/
|
|
213
|
+
ownTerms: string[];
|
|
214
|
+
/** How many exclusive terms there are in total, before the display cap. */
|
|
215
|
+
ownTermCount: number;
|
|
216
|
+
/**
|
|
217
|
+
* How many of them survive if the searcher does not know the tool's name.
|
|
218
|
+
*
|
|
219
|
+
* Names are searchable, so a tool whose only exclusive word is its own name
|
|
220
|
+
* is findable in principle. It is not findable by anyone describing a task,
|
|
221
|
+
* which is the situation deferred loading creates. E146 is this number
|
|
222
|
+
* reaching zero while `ownTermCount` does not.
|
|
223
|
+
*/
|
|
224
|
+
ownOutsideNameCount: number;
|
|
225
|
+
/** When ownTermCount is 0: tools whose vocabulary covers this one's (capped). */
|
|
226
|
+
sharedWith: string[];
|
|
227
|
+
/**
|
|
228
|
+
* The terms this tool's own description offers a searcher, most distinctive
|
|
229
|
+
* first. Never includes the tool's name: the point of the probe is that the
|
|
230
|
+
* person searching does not know it.
|
|
231
|
+
*/
|
|
232
|
+
query: string[];
|
|
233
|
+
/** 1-based rank for that query. Undefined when the description offers no searchable word. */
|
|
234
|
+
rank?: number;
|
|
235
|
+
/** BM25 score for its own query, kept so a reader can check the arithmetic. */
|
|
236
|
+
score: number;
|
|
237
|
+
/** Tools that scored higher for this tool's own query (capped at 3). */
|
|
238
|
+
outrankedBy: string[];
|
|
239
|
+
/** Tools whose score is indistinguishable from this one's, so only the tie-break separates them. Capped for display. */
|
|
240
|
+
tiedWith: string[];
|
|
241
|
+
/** How many there are in total, before the display cap. */
|
|
242
|
+
tiedWithCount: number;
|
|
243
|
+
/** Rank falls inside the simulated result window. The per-tool form of `probe`. */
|
|
244
|
+
reachable: boolean;
|
|
245
|
+
/** Every token of the name is a generic word (E144). */
|
|
246
|
+
genericName: boolean;
|
|
247
|
+
}
|
|
248
|
+
export interface FindResult {
|
|
249
|
+
kind: "find";
|
|
250
|
+
label: string;
|
|
251
|
+
toolCount: number;
|
|
252
|
+
/** The whole method, carried with the numbers so a report is self-describing. */
|
|
253
|
+
method: {
|
|
254
|
+
tokenizer: string;
|
|
255
|
+
bm25: {
|
|
256
|
+
k1: number;
|
|
257
|
+
b: number;
|
|
258
|
+
};
|
|
259
|
+
queryTerms: number;
|
|
260
|
+
topK: number;
|
|
261
|
+
};
|
|
262
|
+
perTool: ToolFindEntry[];
|
|
263
|
+
/**
|
|
264
|
+
* The servers whose catalogs were merged, when more than one.
|
|
265
|
+
*
|
|
266
|
+
* Present only for a stack. A single server's author avoids collisions
|
|
267
|
+
* inside their own catalog as a matter of course, so a one-server run is
|
|
268
|
+
* structurally unable to see the failure that actually bites: two servers
|
|
269
|
+
* installed side by side, each internally tidy, exposing tools a model
|
|
270
|
+
* cannot tell apart.
|
|
271
|
+
*/
|
|
272
|
+
sources?: string[];
|
|
273
|
+
/**
|
|
274
|
+
* The headline: how many tools own at least one word no other tool has.
|
|
275
|
+
* A measured proportion, deliberately not a letter grade (ADR-030).
|
|
276
|
+
*/
|
|
277
|
+
distinct: {
|
|
278
|
+
count: number;
|
|
279
|
+
total: number;
|
|
280
|
+
pct: number;
|
|
281
|
+
};
|
|
282
|
+
/**
|
|
283
|
+
* Secondary: how many tools a simulated BM25 search for their own
|
|
284
|
+
* description returns inside the result window.
|
|
285
|
+
*
|
|
286
|
+
* Named `probe` in the JSON, in the output and in the docs, all three. It
|
|
287
|
+
* was `reach` in one of them and `probe` in the others for a day, which is
|
|
288
|
+
* how someone scripting a gate on the number they saw printed ends up
|
|
289
|
+
* looking for a field that does not exist.
|
|
290
|
+
*
|
|
291
|
+
* Kept, and kept second, because it is the only number here that models the
|
|
292
|
+
* actual mechanism, and because it saturates: 100% on every real server
|
|
293
|
+
* measured. Treat it as a floor test that catches catastrophe, not as a
|
|
294
|
+
* ranking. See docs/METHODOLOGY.md.
|
|
295
|
+
*/
|
|
296
|
+
probe: {
|
|
297
|
+
returned: number;
|
|
298
|
+
total: number;
|
|
299
|
+
pct: number;
|
|
300
|
+
};
|
|
301
|
+
/** toolCount <= topK, which makes the probe figure 100% by construction. */
|
|
302
|
+
windowCoversCatalog: boolean;
|
|
303
|
+
/**
|
|
304
|
+
* toolCount < 2, which makes the DISTINCT figure 100% by construction: with
|
|
305
|
+
* one tool every term it has is trivially exclusive. `--min-distinct` cannot
|
|
306
|
+
* be evaluated in that state and refuses rather than passing.
|
|
307
|
+
*/
|
|
308
|
+
distinctVacuous: boolean;
|
|
309
|
+
definitionTokens?: number;
|
|
310
|
+
/** Anthropic's guidance recommends tool search for a catalog this shape. */
|
|
311
|
+
deferRecommended: boolean;
|
|
312
|
+
/** Which of the checkable conditions fired, in words. Empty when none did. */
|
|
313
|
+
deferBecause: string[];
|
|
314
|
+
notes: string[];
|
|
315
|
+
}
|
|
181
316
|
export interface SkillInfo {
|
|
182
317
|
dir: string;
|
|
183
318
|
file: string;
|
|
@@ -248,6 +383,9 @@ export interface SkillRuleContext {
|
|
|
248
383
|
set: SkillSet;
|
|
249
384
|
weigh?: SkillSetWeighResult;
|
|
250
385
|
}
|
|
386
|
+
export interface FindRuleContext {
|
|
387
|
+
find: FindResult;
|
|
388
|
+
}
|
|
251
389
|
export interface McpRule {
|
|
252
390
|
id: string;
|
|
253
391
|
title: string;
|
|
@@ -260,3 +398,9 @@ export interface SkillRule {
|
|
|
260
398
|
surface: "skill";
|
|
261
399
|
check(ctx: SkillRuleContext): Finding[];
|
|
262
400
|
}
|
|
401
|
+
export interface FindRule {
|
|
402
|
+
id: string;
|
|
403
|
+
title: string;
|
|
404
|
+
surface: "find";
|
|
405
|
+
check(ctx: FindRuleContext): Finding[];
|
|
406
|
+
}
|
|
@@ -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;
|