efaimo 0.1.0 → 0.1.1

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 (41) hide show
  1. package/CHANGELOG.md +97 -2
  2. package/README.md +88 -30
  3. package/SKILL.md +13 -1
  4. package/dist/check/check.js +6 -2
  5. package/dist/cli.js +78 -4
  6. package/dist/clients/rawprobe.d.ts +21 -5
  7. package/dist/clients/rawprobe.js +101 -43
  8. package/dist/clients/stateless.js +17 -14
  9. package/dist/core/engine.js +20 -2
  10. package/dist/core/grade.js +7 -1
  11. package/dist/core/types.d.ts +22 -3
  12. package/dist/reporters/markdown.js +16 -4
  13. package/dist/reporters/pretty.js +36 -25
  14. package/dist/rules/injection.js +7 -0
  15. package/dist/rules/mcp/index.d.ts +14 -0
  16. package/dist/rules/mcp/index.js +68 -24
  17. package/dist/skills/parse.js +39 -3
  18. package/dist/targets/clientConfigs.d.ts +7 -0
  19. package/dist/targets/clientConfigs.js +4 -1
  20. package/dist/testing/anthropicRunner.js +2 -0
  21. package/dist/testing/harness.d.ts +21 -0
  22. package/dist/testing/harness.js +62 -11
  23. package/dist/testing/openaiRunner.js +8 -1
  24. package/dist/testing/stats.d.ts +32 -0
  25. package/dist/testing/stats.js +91 -0
  26. package/dist/util/dotenv.d.ts +9 -1
  27. package/dist/util/dotenv.js +11 -1
  28. package/dist/util/safeText.d.ts +8 -0
  29. package/dist/util/safeText.js +48 -0
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/dist/weigh/weigh.js +3 -1
  33. package/dist/weigh/window.d.ts +26 -0
  34. package/dist/weigh/window.js +44 -0
  35. package/docs/METHODOLOGY.md +70 -0
  36. package/docs/RULES.md +29 -23
  37. package/examples/contoso-crm-import/SKILL.md +21 -0
  38. package/examples/csv-cleanup/SKILL.md +17 -0
  39. package/examples/scenario.crm.yaml +22 -0
  40. package/examples/scenario.example.yaml +17 -0
  41. package/package.json +3 -1
@@ -93,7 +93,14 @@ export function scanTextForInjection(text, opts) {
93
93
  // Deliberately info: these are shallow heuristics an attacker evades
94
94
  // trivially. Surfacing them must never move a grade or fail CI, or a
95
95
  // clean report would read as a security pass (it is not one).
96
+ //
97
+ // `graded: false` is what makes the first half of that true. It used
98
+ // to be a comment only, while gradeFindings charged every info finding
99
+ // a point, so ten heuristic hits quietly cost a whole letter -- the
100
+ // exact "clean report reads as a security verdict" failure this
101
+ // comment was written to prevent, running in reverse.
96
102
  severity: "info",
103
+ graded: false,
97
104
  title: "possible injection pattern (heuristic)",
98
105
  message: `${opts.where}: ${p.label}`,
99
106
  target: opts.where,
@@ -1,4 +1,18 @@
1
1
  import type { McpRule } from "../../core/types.js";
2
+ /**
3
+ * Does this tool name read as destructive?
4
+ *
5
+ * This was a single `\b(delete|remove|...)\b` regex, and `_` is a word
6
+ * character, so `\bdelete\b` has no boundary inside `delete_file` - nor inside
7
+ * `deleteFile`. Every conventional MCP tool name escaped it: delete_file,
8
+ * deleteFile, drop_table, removeUser, kill_process, wipe_db all returned
9
+ * false, while only a bare `delete` or `delete-file` matched. E123 is
10
+ * documented in docs/RULES.md as catching "a destructive-looking tool lacking
11
+ * destructiveHint" and it could not fire on essentially any real server.
12
+ *
13
+ * Split on separators AND on camelCase boundaries, then test each segment.
14
+ */
15
+ export declare function readsDestructive(name: string): boolean;
2
16
  export declare const MCP_RULES: McpRule[];
3
17
  /** E101-E118 are 2026-07-28 readiness rules: reported as a migration diff, not graded. */
4
18
  export declare function isReadinessRuleId(id: string): boolean;
@@ -1,7 +1,8 @@
1
1
  import { scanTextForInjection } from "../injection.js";
2
2
  import { looksLikeInitGate } from "../../clients/rawprobe.js";
3
3
  import { formatTokens, truncate } from "../../util/misc.js";
4
- const RC = "2026-07-28";
4
+ import { formatWindowShare } from "../../weigh/window.js";
5
+ const SPEC = "2026-07-28";
5
6
  function isOutcome(x) {
6
7
  return !!x && typeof x === "object" && "ok" in x;
7
8
  }
@@ -25,7 +26,7 @@ const REPO_MATCH_RULES = [
25
26
  category: "sampling",
26
27
  severity: "warn",
27
28
  title: "uses deprecated Sampling",
28
- message: (c) => `source references sampling/createMessage (${matchStr(c)}); Sampling is deprecated in ${RC} (SEP-2577)`,
29
+ message: (c) => `source references sampling/createMessage (${matchStr(c)}); Sampling is deprecated in ${SPEC} (SEP-2577)`,
29
30
  fixHint: "integrate directly with an LLM provider API instead of MCP Sampling",
30
31
  },
31
32
  {
@@ -33,7 +34,7 @@ const REPO_MATCH_RULES = [
33
34
  category: "roots",
34
35
  severity: "warn",
35
36
  title: "uses deprecated Roots",
36
- message: (c) => `source references roots/list (${matchStr(c)}); Roots is deprecated in ${RC} (SEP-2577)`,
37
+ message: (c) => `source references roots/list (${matchStr(c)}); Roots is deprecated in ${SPEC} (SEP-2577)`,
37
38
  fixHint: "pass directories or files via tool parameters, resource URIs, or server configuration",
38
39
  },
39
40
  {
@@ -41,7 +42,7 @@ const REPO_MATCH_RULES = [
41
42
  category: "sse-resume",
42
43
  severity: "info",
43
44
  title: "relies on removed SSE resumability",
44
- message: (c) => `source references Last-Event-ID/resumability (${matchStr(c)}); stream resumability is removed in ${RC}`,
45
+ message: (c) => `source references Last-Event-ID/resumability (${matchStr(c)}); stream resumability is removed in ${SPEC}`,
45
46
  fixHint: "persist cross-call state behind server-minted handles passed as tool arguments",
46
47
  },
47
48
  {
@@ -49,7 +50,7 @@ const REPO_MATCH_RULES = [
49
50
  category: "elicitation",
50
51
  severity: "warn",
51
52
  title: "uses legacy elicitation",
52
- message: (c) => `source references elicitation/create (${matchStr(c)}); replaced in ${RC} by MRTR results (resultType "input_required" with inputRequests)`,
53
+ message: (c) => `source references elicitation/create (${matchStr(c)}); replaced in ${SPEC} by MRTR results (resultType "input_required" with inputRequests)`,
53
54
  fixHint: "return input_required results and correlate retries via requestState (SEP-2322)",
54
55
  },
55
56
  {
@@ -57,21 +58,21 @@ const REPO_MATCH_RULES = [
57
58
  category: "session-state",
58
59
  severity: "info",
59
60
  title: "possible in-process session state",
60
- message: (c) => `source shows session-state patterns (${matchStr(c)}); ${RC} statelessness expects server-minted handles passed via tool arguments`,
61
+ message: (c) => `source shows session-state patterns (${matchStr(c)}); ${SPEC} statelessness expects server-minted handles passed via tool arguments`,
61
62
  },
62
63
  {
63
64
  id: "E114",
64
65
  category: "ping",
65
66
  severity: "info",
66
67
  title: "uses removed ping",
67
- message: (c) => `source references the ping utility (${matchStr(c)}); ping is removed in ${RC}`,
68
+ message: (c) => `source references the ping utility (${matchStr(c)}); ping is removed in ${SPEC}`,
68
69
  },
69
70
  {
70
71
  id: "E115",
71
72
  category: "subscribe",
72
73
  severity: "info",
73
74
  title: "uses replaced resource subscriptions",
74
- message: (c) => `source references resources/subscribe (${matchStr(c)}); replaced by subscriptions/listen in ${RC}`,
75
+ message: (c) => `source references resources/subscribe (${matchStr(c)}); replaced by subscriptions/listen in ${SPEC}`,
75
76
  },
76
77
  ];
77
78
  const repoMatchRules = REPO_MATCH_RULES.map((spec) => ({
@@ -105,10 +106,14 @@ const e101 = {
105
106
  ruleId: "E101",
106
107
  severity: "warn",
107
108
  title: "legacy SDK generation",
108
- message: `depends on ${s.package}${s.range ? `@${s.range}` : ""} (pre-${RC} line)`,
109
+ message: `depends on ${s.package}${s.range ? `@${s.range}` : ""} (pre-${SPEC} line)`,
109
110
  detail: s.language === "ts"
110
- ? `the ${RC} revision ships as the new @modelcontextprotocol/server package (2.x beta since 2026-07)`
111
- : `the ${RC} revision ships as mcp 2.x (pre-releases on PyPI since 2026-06)`,
111
+ ? // No maturity label here on purpose. This said "2.x beta since
112
+ // 2026-07" and stayed saying it after both packages cut 2.0.0 on
113
+ // 2026-07-27, which told people to migrate onto a beta that was
114
+ // not one. The package name and major line are the durable facts.
115
+ `the ${SPEC} revision ships as the new @modelcontextprotocol/server package (2.x)`
116
+ : `the ${SPEC} revision ships as mcp 2.x on PyPI`,
112
117
  fixHint: "upgrade to the 2.x SDK line to get stateless transport and MRTR support",
113
118
  });
114
119
  }
@@ -126,7 +131,7 @@ const e104 = {
126
131
  ruleId: "E104",
127
132
  severity: "warn",
128
133
  title: "uses deprecated MCP Logging",
129
- message: `server declares the logging capability; MCP Logging is deprecated in ${RC} (SEP-2577) and logging/setLevel is removed`,
134
+ message: `server declares the logging capability; MCP Logging is deprecated in ${SPEC} (SEP-2577) and logging/setLevel is removed`,
130
135
  fixHint: "log to stderr (stdio) or use OpenTelemetry; per-request level arrives via _meta io.modelcontextprotocol/logLevel",
131
136
  },
132
137
  ];
@@ -135,7 +140,7 @@ const e104 = {
135
140
  ruleId: "E104",
136
141
  severity: "warn",
137
142
  title: "uses deprecated MCP Logging",
138
- message: `source references MCP logging APIs (${matchStr(count)}); deprecated in ${RC} (SEP-2577)`,
143
+ message: `source references MCP logging APIs (${matchStr(count)}); deprecated in ${SPEC} (SEP-2577)`,
139
144
  detail,
140
145
  fixHint: "log to stderr (stdio) or use OpenTelemetry",
141
146
  }));
@@ -156,7 +161,7 @@ const e105 = {
156
161
  severity: "warn",
157
162
  title: "requires the removed initialize handshake",
158
163
  message: `the server rejected a bare stateless tools/list as not initialized (${bare.errorMessage ?? `code ${bare.errorCode}`})`,
159
- detail: `${RC} removes initialize and sessions; RC servers answer bare requests carrying version info in _meta. Whether an answering server is RC-conformant is judged separately (E107 resultType, E118 cache fields, E106 server/discover).`,
164
+ detail: `${SPEC} removes initialize and sessions; stateless servers answer bare requests carrying version info in _meta. Whether an answering server is fully ${SPEC}-conformant is judged separately (E107 resultType, E118 cache fields, E106 server/discover).`,
160
165
  fixHint: "upgrade to a 2.x SDK, or accept requests without a prior initialize",
161
166
  },
162
167
  ];
@@ -189,7 +194,7 @@ const e106 = {
189
194
  severity: "warn",
190
195
  title: "server/discover not implemented",
191
196
  message: `server/discover is not implemented (${d.errorMessage ?? "method not found"})`,
192
- detail: `${RC} servers MUST implement server/discover (SEP-2575) to advertise versions, capabilities, and identity; clients also use it as the back-compat probe.`,
197
+ detail: `${SPEC} servers MUST implement server/discover (SEP-2575) to advertise versions, capabilities, and identity; clients also use it as the back-compat probe.`,
193
198
  fixHint: "the 2.x SDKs implement server/discover for you",
194
199
  },
195
200
  ];
@@ -207,8 +212,8 @@ const e107 = {
207
212
  ruleId: "E107",
208
213
  severity: "info",
209
214
  title: "results missing resultType",
210
- message: `results do not carry the resultType field required in ${RC} ("complete" | "input_required")`,
211
- detail: "the RC requires resultType on every result (SEP-2322); on list results the value must be \"complete\". RC clients treat missing resultType from earlier-protocol servers as \"complete\", so this is informational until you upgrade.",
215
+ message: `results do not carry the resultType field required in ${SPEC} ("complete" | "input_required")`,
216
+ detail: `${SPEC} requires resultType on every result (SEP-2322); on list results the value must be "complete". ${SPEC} clients treat missing resultType from earlier-protocol servers as "complete", so this is informational until you upgrade.`,
212
217
  },
213
218
  ];
214
219
  },
@@ -256,7 +261,7 @@ const e112 = {
256
261
  ruleId: "E112",
257
262
  severity: "warn",
258
263
  title: "nondeterministic tools/list order",
259
- message: "tool order differed across two fresh connections; deterministic ordering enables prompt-cache hits in RC hosts",
264
+ message: "tool order differed across two fresh connections; deterministic ordering enables prompt-cache hits in stateless hosts",
260
265
  fixHint: "sort tools stably before returning them",
261
266
  },
262
267
  ];
@@ -322,14 +327,26 @@ const e118 = {
322
327
  title: "missing cache fields (ttlMs, cacheScope)",
323
328
  surface: "mcp",
324
329
  check(ctx) {
325
- if (ctx.probes?.cacheFieldsPresent !== false)
330
+ // Two measured surfaces, not one. server/discover only became cacheable in
331
+ // the spec that published on 2026-07-28 (DiscoverResult extends
332
+ // CacheableResult); under the locked RC it extended plain Result, so a
333
+ // server that migrated against the RC can pass tools/list and still miss
334
+ // this. Undefined means the surface was never measured, which is not a
335
+ // finding: E106 already owns "server/discover did not answer".
336
+ const surfaces = [];
337
+ if (ctx.probes?.cacheFieldsPresent === false)
338
+ surfaces.push("tools/list");
339
+ if (ctx.probes?.discoverCacheFieldsPresent === false)
340
+ surfaces.push("server/discover");
341
+ if (!surfaces.length)
326
342
  return [];
343
+ const subject = surfaces.length > 1 ? `${surfaces.join(" and ")} results omit` : `${surfaces[0]} result omits`;
327
344
  return [
328
345
  {
329
346
  ruleId: "E118",
330
347
  severity: "warn",
331
348
  title: "missing cache fields (ttlMs, cacheScope)",
332
- message: `tools/list result omits ttlMs and/or cacheScope, which ${RC} requires on list and resource-read results (SEP-2549, CacheableResult)`,
349
+ message: `${subject} ttlMs and/or cacheScope, which ${SPEC} requires on list and resource-read results (SEP-2549, CacheableResult)`,
333
350
  detail: "required on tools/list, prompts/list, resources/list, resources/read, resources/templates/list, and server/discover; cacheScope is \"public\" or \"private\"",
334
351
  fixHint: "return ttlMs and cacheScope on these results so clients can cache and stop polling; the 2.x SDKs add them for you",
335
352
  },
@@ -421,7 +438,34 @@ const e122 = {
421
438
  return capList(findings, 5).shown;
422
439
  },
423
440
  };
424
- const DESTRUCTIVE_NAME_RE = /\b(delete|remove|drop|purge|destroy|overwrite|truncate|erase|wipe|revoke|reset|format|deploy|kill)\b/i;
441
+ // "format" was in this list and is dropped. It only ever meant "format a
442
+ // disk" in the author's head, but `format_date`, `format_output` and
443
+ // `format_json` are among the most common tool names there are, and once the
444
+ // matcher below started working the word turned into pure noise. Nobody could
445
+ // have found that while the rule was firing on nothing.
446
+ const DESTRUCTIVE_WORDS = new Set([
447
+ "delete", "remove", "drop", "purge", "destroy", "overwrite",
448
+ "truncate", "erase", "wipe", "revoke", "reset", "deploy", "kill",
449
+ ]);
450
+ /**
451
+ * Does this tool name read as destructive?
452
+ *
453
+ * This was a single `\b(delete|remove|...)\b` regex, and `_` is a word
454
+ * character, so `\bdelete\b` has no boundary inside `delete_file` - nor inside
455
+ * `deleteFile`. Every conventional MCP tool name escaped it: delete_file,
456
+ * deleteFile, drop_table, removeUser, kill_process, wipe_db all returned
457
+ * false, while only a bare `delete` or `delete-file` matched. E123 is
458
+ * documented in docs/RULES.md as catching "a destructive-looking tool lacking
459
+ * destructiveHint" and it could not fire on essentially any real server.
460
+ *
461
+ * Split on separators AND on camelCase boundaries, then test each segment.
462
+ */
463
+ export function readsDestructive(name) {
464
+ return name
465
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
466
+ .split(/[^A-Za-z0-9]+/)
467
+ .some((seg) => DESTRUCTIVE_WORDS.has(seg.toLowerCase()));
468
+ }
425
469
  const e123 = {
426
470
  id: "E123",
427
471
  title: "missing tool annotations",
@@ -441,7 +485,7 @@ const e123 = {
441
485
  fixHint: "annotations let hosts gate confirmations and parallelize safely",
442
486
  });
443
487
  }
444
- const risky = tools.filter((t) => DESTRUCTIVE_NAME_RE.test(t.name) && !(t.annotations && "destructiveHint" in t.annotations));
488
+ const risky = tools.filter((t) => readsDestructive(t.name) && !(t.annotations && "destructiveHint" in t.annotations));
445
489
  for (const t of capList(risky, 5).shown) {
446
490
  findings.push({
447
491
  ruleId: "E123",
@@ -586,7 +630,7 @@ const e128 = {
586
630
  ruleId: "E128",
587
631
  severity: "warn",
588
632
  title: "total context tax",
589
- message: `tool definitions alone are ~${formatTokens(total)} tokens (~${((total / 200000) * 100).toFixed(1)}% of a 200k window, estimated) before any work happens`,
633
+ message: `tool definitions alone are ~${formatTokens(total)} tokens (${formatWindowShare(total)}, estimated) before any work happens`,
590
634
  fixHint: "trim descriptions/schemas, split the server, or rely on deferred tool loading",
591
635
  },
592
636
  ];
@@ -597,7 +641,7 @@ const e128 = {
597
641
  ruleId: "E128",
598
642
  severity: "warn",
599
643
  title: "total context tax",
600
- message: `tool definitions are ~${formatTokens(total)} tokens (~${((total / 200000) * 100).toFixed(1)}% of a 200k window)`,
644
+ message: `tool definitions are ~${formatTokens(total)} tokens (${formatWindowShare(total)})`,
601
645
  fixHint: "run \`efaimo weigh\` for the per-tool breakdown",
602
646
  },
603
647
  ];
@@ -1,7 +1,17 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
- import { walkFiles } from "../util/misc.js";
4
+ import { walkFiles, readTextSafe } from "../util/misc.js";
5
+ /**
6
+ * Cap on a single SKILL.md. Referenced files were already capped at 512KB by
7
+ * readTextSafe while the body itself went through an uncapped readFileSync:
8
+ * the safer path had the limit and the main one did not. A 35MB SKILL.md
9
+ * tokenized to 7.2 million tokens at 367MB resident, and auditing skills you
10
+ * have NOT installed yet is the stated use case, so this is untrusted input
11
+ * by construction. 2MB is far above any real skill (the largest in the graded
12
+ * corpus is a few tens of KB) and far below a memory problem.
13
+ */
14
+ const MAX_SKILL_BYTES = 2 * 1024 * 1024;
5
15
  const MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
6
16
  // Only backticked strings that look like a real intra-skill path: a directory
7
17
  // separator, no glob star. A bare filename in prose (`package.json`, `*.py`) is
@@ -21,7 +31,15 @@ export function parseSkillFile(file) {
21
31
  };
22
32
  let raw;
23
33
  try {
24
- raw = fs.readFileSync(file, "utf8");
34
+ // Capped, like the referenced files this same module already caps at 512KB
35
+ // via readTextSafe. The body went through plain readFileSync with no limit
36
+ // while the SAFER path had a cap, and the stated use case is auditing
37
+ // skills BEFORE you install them, which is untrusted input by definition.
38
+ // A 35MB SKILL.md tokenized to 7.2M tokens and 367MB resident.
39
+ const capped = readTextSafe(file, MAX_SKILL_BYTES);
40
+ if (capped === undefined)
41
+ throw new Error("unreadable");
42
+ raw = capped;
25
43
  }
26
44
  catch (e) {
27
45
  return { ...base, parseError: `cannot read file: ${e.message}` };
@@ -79,7 +97,25 @@ export function parseSkillFile(file) {
79
97
  continue;
80
98
  seen.add(cleaned);
81
99
  const resolved = path.resolve(dir, cleaned);
82
- base.referencedPaths.push({ raw: cleaned, resolved, exists: fs.existsSync(resolved), source });
100
+ // Does this reference stay inside the skill? Absolute paths, URLs and
101
+ // globs are already rejected above; `../` was not, and weigh.ts then
102
+ // READ every referenced file to count its tokens. A skill fetched from
103
+ // anywhere could point at ../../../.ssh/id_rsa and get back an
104
+ // existence-and-size oracle, published if --md goes to a CI job summary.
105
+ // S106 already knew this was wrong and warned about it - but only after
106
+ // the read had happened, and `weigh` does not run S106 at all.
107
+ //
108
+ // Recorded here rather than filtered, so S106 can still report it and
109
+ // the reader still learns the reference exists. weigh skips the read.
110
+ const rel = path.relative(dir, resolved);
111
+ const escapes = rel.startsWith("..") || path.isAbsolute(rel);
112
+ base.referencedPaths.push({
113
+ raw: cleaned,
114
+ resolved,
115
+ exists: fs.existsSync(resolved),
116
+ escapes,
117
+ source,
118
+ });
83
119
  }
84
120
  }
85
121
  for (const f of walkFiles(dir, { maxDepth: 4 })) {
@@ -7,6 +7,13 @@ export interface ClientServerEntry {
7
7
  }
8
8
  export interface ClientConfigResult {
9
9
  entries: ClientServerEntry[];
10
+ /**
11
+ * Config files that came from the CURRENT DIRECTORY rather than the user
12
+ * config. Every command in these was written by whoever wrote the
13
+ * repository you are standing in, and --client spawns them. The CLI warns
14
+ * about these by name before it executes anything.
15
+ */
16
+ fromCwd: string[];
10
17
  /** Config files that were found and parsed. */
11
18
  sources: string[];
12
19
  /** Paths that were checked but absent. */
@@ -27,7 +27,7 @@ export function loadClientServers(client, cwd = process.cwd()) {
27
27
  default:
28
28
  throw new Error(`unknown client "${client}" (supported: ${SUPPORTED_CLIENTS.join(", ")})`);
29
29
  }
30
- const result = { entries: [], sources: [], missing: [] };
30
+ const result = { entries: [], sources: [], missing: [], fromCwd: [] };
31
31
  const seen = new Set();
32
32
  for (const { file, pick } of candidates) {
33
33
  if (!fs.existsSync(file)) {
@@ -48,6 +48,9 @@ export function loadClientServers(client, cwd = process.cwd()) {
48
48
  continue;
49
49
  }
50
50
  result.sources.push(file);
51
+ // Project-scoped config is the untrusted kind: it ships with a repo.
52
+ if (file.startsWith(cwd))
53
+ result.fromCwd.push(file);
51
54
  for (const [name, valueRaw] of Object.entries(servers)) {
52
55
  if (seen.has(name))
53
56
  continue;
@@ -13,6 +13,8 @@ export function anthropicRunner(apiKey) {
13
13
  },
14
14
  body: JSON.stringify({
15
15
  model: req.model,
16
+ // 0 for the judge, 1 for the subject. See RunnerRequest.deterministic.
17
+ temperature: req.deterministic ? 0 : 1,
16
18
  max_tokens: 1024,
17
19
  ...(req.system ? { system: req.system } : {}),
18
20
  messages: [{ role: "user", content: req.user }],
@@ -18,10 +18,24 @@ export interface RunnerRequest {
18
18
  system?: string;
19
19
  user: string;
20
20
  model: string;
21
+ /**
22
+ * Sample deterministically. Set for the JUDGE, never for the subject.
23
+ * Neither runner set a temperature, so both defaulted to 1.0 and a "strict
24
+ * grader" asked for one word was being sampled: the same answer could be
25
+ * graded PASS on one trial and FAIL on the next, and that variance lands in
26
+ * the measurement as if it came from the skill. The subject arm keeps
27
+ * temperature 1 on purpose, because the thing being measured is how the
28
+ * model behaves normally.
29
+ */
30
+ deterministic?: boolean;
21
31
  }
22
32
  export type Runner = (req: RunnerRequest) => Promise<string>;
23
33
  export interface ArmResult {
24
34
  trials: number;
35
+ /** Trials the judge actually answered PASS or FAIL on. */
36
+ scored: number;
37
+ /** Trials whose judge verdict was neither; excluded from passRate. */
38
+ unparseable: number;
25
39
  passes: number;
26
40
  passRate: number;
27
41
  }
@@ -33,6 +47,13 @@ export interface TestReport {
33
47
  withoutSkill: ArmResult;
34
48
  /** withSkill.passRate - withoutSkill.passRate, in percentage points. */
35
49
  deltaPoints: number;
50
+ /** Two-sided Fisher exact p on the 2x2 of scored trials. */
51
+ p: number;
52
+ /** 95% interval on the delta, in percentage points (Newcombe). */
53
+ ci: {
54
+ lo: number;
55
+ hi: number;
56
+ };
36
57
  verdict: "helps" | "hurts" | "no measurable effect" | "inconclusive";
37
58
  notes: string[];
38
59
  }
@@ -1,3 +1,4 @@
1
+ import { fisherExactTwoSided, deltaInterval } from "./stats.js";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import YAML from "yaml";
@@ -24,7 +25,10 @@ export function parseScenario(file) {
24
25
  const skill = set.skills[0];
25
26
  if (!skill)
26
27
  throw new Error(`${file}: no SKILL.md found at '${skillRel}'`);
27
- const trials = Math.min(50, Math.max(1, Math.round(num(doc.trials) ?? 5)));
28
+ // Default 20, not 5. At 5 per arm a total separation (5/5 vs 0/5) is the ONLY
29
+ // outcome that reaches p < 0.05, so every partial result is uninterpretable
30
+ // and the old default guaranteed a verdict nobody could stand behind.
31
+ const trials = Math.min(50, Math.max(1, Math.round(num(doc.trials) ?? 20)));
28
32
  return {
29
33
  name,
30
34
  skillPath,
@@ -47,42 +51,87 @@ export function armSystems(scenario) {
47
51
  const withSkill = `You have access to the following skill. Use it if relevant.\n\n<skill name="${scenario.skillName}">\n${scenario.skillBody}\n</skill>`;
48
52
  return { withoutSkill: undefined, withSkill };
49
53
  }
54
+ // PASS, FAIL, or neither. "Neither" used to be counted as FAIL: the old test
55
+ // was `/pass/i.test(out) && !/fail/i.test(out)`, so a refusal, an API
56
+ // error string, or a hedge like "passes on X but fails on Y" all became a
57
+ // failed trial. Those are not evidence about the skill, and quietly scoring
58
+ // them against whichever arm produced them biases the result.
50
59
  async function judgeOne(runner, scenario, answer) {
51
60
  const out = await runner({
52
61
  system: JUDGE_SYSTEM,
53
62
  user: `TASK:\n${scenario.task}\n\nRUBRIC:\n${scenario.judge}\n\nASSISTANT ANSWER:\n${answer}\n\nVerdict (PASS or FAIL):`,
54
63
  model: scenario.model,
64
+ deterministic: true,
55
65
  });
56
- return /\bpass\b/i.test(out) && !/\bfail\b/i.test(out);
66
+ const saysPass = /\bpass(ed|es)?\b/i.test(out);
67
+ const saysFail = /\bfail(ed|s|ure)?\b/i.test(out);
68
+ if (saysPass && !saysFail)
69
+ return "pass";
70
+ if (saysFail && !saysPass)
71
+ return "fail";
72
+ return "unparseable";
57
73
  }
58
74
  async function runArm(runner, scenario, system) {
59
75
  let passes = 0;
76
+ let unparseable = 0;
60
77
  for (let i = 0; i < scenario.trials; i++) {
61
78
  const answer = await runner({ system, user: scenario.task, model: scenario.model });
62
- if (await judgeOne(runner, scenario, answer))
79
+ const v = await judgeOne(runner, scenario, answer);
80
+ if (v === "pass")
63
81
  passes++;
82
+ else if (v === "unparseable")
83
+ unparseable++;
64
84
  }
65
- return { trials: scenario.trials, passes, passRate: (passes / scenario.trials) * 100 };
85
+ // Scored trials exclude the ones the judge did not answer, so the rate is a
86
+ // rate over evidence rather than over attempts.
87
+ const scored = scenario.trials - unparseable;
88
+ return {
89
+ trials: scenario.trials,
90
+ scored,
91
+ unparseable,
92
+ passes,
93
+ passRate: scored > 0 ? (passes / scored) * 100 : 0,
94
+ };
66
95
  }
67
96
  export async function runScenario(scenario, runner) {
68
97
  const systems = armSystems(scenario);
69
98
  const withoutSkill = await runArm(runner, scenario, systems.withoutSkill);
70
99
  const withSkill = await runArm(runner, scenario, systems.withSkill);
71
100
  const deltaPoints = Math.round((withSkill.passRate - withoutSkill.passRate) * 10) / 10;
101
+ // The verdict is a significance test, not a threshold on the gap.
102
+ //
103
+ // It used to be `>= +15 points helps, <= -15 hurts`, with 5 trials per arm
104
+ // as the default. At that size 5/5 against 4/5 is +20 points and a two-sided
105
+ // Fisher p of 1.0000: the least significant result obtainable, reported as a
106
+ // finding, and `hurts` set exit code 1. The +-15 band sits inside the noise
107
+ // floor at every trial count below roughly 25 per arm, so the threshold was
108
+ // measuring sample size more than it was measuring the skill.
109
+ const p = fisherExactTwoSided(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
110
+ const ci = deltaInterval(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
72
111
  const notes = [
73
- `${scenario.trials} trials per arm; probabilistic. Treat small deltas as noise and raise trials for confidence.`,
112
+ `${scenario.trials} trials per arm. Two-sided Fisher exact p = ${p < 0.0001 ? "<0.0001" : p.toFixed(4)}; ` +
113
+ `95% interval on the delta ${ci.lo >= 0 ? "+" : ""}${ci.lo} to ${ci.hi >= 0 ? "+" : ""}${ci.hi} points.`,
74
114
  ];
115
+ const unscored = withSkill.unparseable + withoutSkill.unparseable;
116
+ if (unscored) {
117
+ notes.push(`${unscored} trial(s) produced a judge verdict that was neither PASS nor FAIL and are excluded from the rates, not counted as failures.`);
118
+ }
75
119
  let verdict;
76
- if (scenario.trials < 5) {
120
+ if (withSkill.scored === 0 || withoutSkill.scored === 0) {
77
121
  verdict = "inconclusive";
78
- notes.push("fewer than 5 trials: not enough signal to conclude.");
122
+ notes.push("an arm produced no scoreable trial; there is nothing to compare.");
123
+ }
124
+ else if (p >= 0.05) {
125
+ verdict = "no measurable effect";
126
+ if (Math.abs(deltaPoints) >= 15) {
127
+ notes.push(`the ${deltaPoints >= 0 ? "+" : ""}${deltaPoints} point gap is not significant at this sample size (p = ${p.toFixed(4)}). ` +
128
+ `Raise trials rather than reading the gap.`);
129
+ }
79
130
  }
80
- else if (deltaPoints >= 15)
131
+ else if (deltaPoints > 0)
81
132
  verdict = "helps";
82
- else if (deltaPoints <= -15)
83
- verdict = "hurts";
84
133
  else
85
- verdict = "no measurable effect";
134
+ verdict = "hurts";
86
135
  return {
87
136
  scenario: scenario.name,
88
137
  skill: scenario.skillName,
@@ -90,6 +139,8 @@ export async function runScenario(scenario, runner) {
90
139
  withSkill,
91
140
  withoutSkill,
92
141
  deltaPoints,
142
+ p,
143
+ ci,
93
144
  verdict,
94
145
  notes,
95
146
  };
@@ -14,7 +14,14 @@ export function openaiRunner(apiKey) {
14
14
  "content-type": "application/json",
15
15
  authorization: `Bearer ${apiKey}`,
16
16
  },
17
- body: JSON.stringify({ model: req.model, messages, max_completion_tokens: 1024 }),
17
+ // temperature: 0 for the judge, 1 for the subject. See
18
+ // RunnerRequest.deterministic in harness.ts.
19
+ body: JSON.stringify({
20
+ model: req.model,
21
+ temperature: req.deterministic ? 0 : 1,
22
+ messages,
23
+ max_completion_tokens: 1024,
24
+ }),
18
25
  redirect: "error",
19
26
  signal: AbortSignal.timeout(60000),
20
27
  });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Two-sided Fisher exact test on
3
+ * [withPass, withFail]
4
+ * [withoutPass, withoutFail]
5
+ *
6
+ * Sums the probability of every table at least as extreme as the observed one,
7
+ * which is the standard two-sided construction and needs no normal
8
+ * approximation.
9
+ */
10
+ export declare function fisherExactTwoSided(withPass: number, withTrials: number, withoutPass: number, withoutTrials: number): number;
11
+ /**
12
+ * Wilson score interval for one proportion. Chosen over the normal-approximation
13
+ * interval because that one produces a zero-width interval at 0/n and n/n,
14
+ * which is exactly where these runs land (8/8 and 0/8 both occur in the two
15
+ * committed example runs).
16
+ */
17
+ export declare function wilson(passes: number, trials: number, z?: number): {
18
+ lo: number;
19
+ hi: number;
20
+ };
21
+ /**
22
+ * Interval on the DIFFERENCE of two proportions, in percentage points, by the
23
+ * Newcombe method: combine each arm's Wilson interval. Reported so a reader can
24
+ * see how wide the uncertainty actually is instead of inferring it from a
25
+ * point estimate.
26
+ */
27
+ export declare function deltaInterval(withPass: number, withTrials: number, withoutPass: number, withoutTrials: number): {
28
+ lo: number;
29
+ hi: number;
30
+ };
31
+ /** The smallest per-arm trial count at which a total separation (n/n vs 0/n) reaches p < 0.05. */
32
+ export declare const MIN_TRIALS_FOR_SIGNIFICANCE = 4;