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.
Files changed (45) hide show
  1. package/CHANGELOG.md +160 -1
  2. package/README.md +75 -6
  3. package/SKILL.md +14 -7
  4. package/dist/check/diff.d.ts +124 -0
  5. package/dist/check/diff.js +165 -0
  6. package/dist/cli.js +237 -22
  7. package/dist/core/engine.d.ts +2 -1
  8. package/dist/core/engine.js +4 -0
  9. package/dist/core/types.d.ts +144 -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 +122 -0
  13. package/dist/find/find.js +405 -0
  14. package/dist/find/tokenize.d.ts +64 -0
  15. package/dist/find/tokenize.js +116 -0
  16. package/dist/index.d.ts +7 -3
  17. package/dist/index.js +14 -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 +25 -1
  23. package/dist/reporters/pretty.js +228 -9
  24. package/dist/rules/find/index.d.ts +4 -0
  25. package/dist/rules/find/index.js +262 -0
  26. package/dist/rules/skill/index.js +32 -1
  27. package/dist/rules/version.d.ts +26 -0
  28. package/dist/rules/version.js +26 -0
  29. package/dist/testing/anthropicRunner.d.ts +30 -0
  30. package/dist/testing/anthropicRunner.js +43 -8
  31. package/dist/testing/harness.d.ts +32 -1
  32. package/dist/testing/harness.js +28 -3
  33. package/dist/testing/stats.d.ts +16 -0
  34. package/dist/testing/stats.js +26 -0
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/dist/weigh/anthropicExact.d.ts +12 -1
  38. package/dist/weigh/anthropicExact.js +9 -1
  39. package/dist/weigh/weigh.js +10 -3
  40. package/docs/INTEGRATIONS.md +15 -1
  41. package/docs/METHODOLOGY.md +110 -6
  42. package/docs/RULES.md +40 -3
  43. package/examples/scenario.crm.yaml +8 -0
  44. package/examples/scenario.example.yaml +8 -0
  45. 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 { renderCheckPretty, renderDiffPretty, renderScenarioPlan, renderServerWeighPretty, renderSkillSetPretty, renderSkillWeighPretty, renderTestReportPretty, setColor, } from "./reporters/pretty.js";
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
- fs.writeFileSync(opts.out, toJsonEnvelope("weigh", single));
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
- const scenario = opts.model ? { ...parsed, model: opts.model } : parsed;
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
- const provider = providerForModel(scenario.model);
426
- if (provider === "unknown") {
427
- fail(`model "${scenario.model}" is not supported: efaimo test runs Claude (claude-*) and OpenAI (gpt-*, o*) models`);
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 envVar = provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
430
- const apiKey = process.env[envVar];
431
- if (!apiKey)
432
- fail(`model "${scenario.model}" needs ${envVar} in the environment or a .env file`);
433
- if (dotEnvKeys.has(envVar))
434
- console.error(pc.dim(`using ${envVar} from .env`));
435
- const runner = provider === "openai" ? openaiRunner(apiKey) : anthropicRunner(apiKey);
436
- console.error(pc.dim(`running ~${scenario.trials * 4} ${provider} API calls against ${scenario.model} ...`));
437
- const report = await runScenario(scenario, runner);
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
- if (report.verdict === "hurts")
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
@@ -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[];
@@ -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
+ }
@@ -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;