efaimo 0.1.0 → 0.1.2
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 -4
- package/README.md +89 -31
- package/SKILL.md +13 -1
- package/dist/check/check.js +14 -2
- package/dist/cli.js +95 -9
- package/dist/clients/introspect.js +10 -2
- package/dist/clients/rawprobe.d.ts +21 -5
- package/dist/clients/rawprobe.js +101 -43
- package/dist/clients/stateless.js +17 -14
- package/dist/core/engine.js +20 -2
- package/dist/core/grade.js +7 -1
- package/dist/core/types.d.ts +22 -3
- package/dist/reporters/markdown.js +16 -4
- package/dist/reporters/pretty.js +38 -25
- package/dist/rules/injection.d.ts +1 -2
- package/dist/rules/injection.js +7 -11
- package/dist/rules/mcp/index.d.ts +14 -0
- package/dist/rules/mcp/index.js +89 -25
- package/dist/rules/skill/index.js +5 -3
- package/dist/skills/parse.js +39 -3
- package/dist/targets/clientConfigs.d.ts +7 -0
- package/dist/targets/clientConfigs.js +4 -1
- package/dist/targets/repoScan.js +0 -0
- package/dist/testing/anthropicRunner.js +2 -0
- package/dist/testing/harness.d.ts +22 -0
- package/dist/testing/harness.js +98 -13
- package/dist/testing/openaiRunner.js +15 -1
- package/dist/testing/stats.d.ts +30 -0
- package/dist/testing/stats.js +89 -0
- package/dist/util/dotenv.d.ts +9 -1
- package/dist/util/dotenv.js +11 -1
- package/dist/util/safeText.d.ts +8 -0
- package/dist/util/safeText.js +48 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/weigh/weigh.js +3 -1
- package/dist/weigh/window.d.ts +26 -0
- package/dist/weigh/window.js +44 -0
- package/docs/METHODOLOGY.md +70 -0
- package/docs/RULES.md +31 -25
- package/examples/contoso-crm-import/SKILL.md +21 -0
- package/examples/csv-cleanup/SKILL.md +17 -0
- package/examples/scenario.crm.yaml +22 -0
- package/examples/scenario.example.yaml +17 -0
- package/package.json +4 -2
|
@@ -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;
|
package/dist/targets/repoScan.js
CHANGED
|
Binary file
|
|
@@ -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
|
}
|
|
@@ -42,4 +63,5 @@ export declare function armSystems(scenario: Scenario): {
|
|
|
42
63
|
withoutSkill: string | undefined;
|
|
43
64
|
withSkill: string;
|
|
44
65
|
};
|
|
66
|
+
export declare function withRetry(runner: Runner, tries?: number, baseDelayMs?: number): Runner;
|
|
45
67
|
export declare function runScenario(scenario: Scenario, runner: Runner): Promise<TestReport>;
|
package/dist/testing/harness.js
CHANGED
|
@@ -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
|
-
|
|
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,121 @@ 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
|
-
|
|
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";
|
|
73
|
+
}
|
|
74
|
+
// A live run makes trials * 2 arms * 2 (answer + judge) API calls. One
|
|
75
|
+
// transient 429 or 5xx among them used to throw and discard every trial already
|
|
76
|
+
// paid for. Retry the transient failures with backoff; a non-429 4xx (bad key,
|
|
77
|
+
// unknown model) is a real error and is not retried.
|
|
78
|
+
function isTransient(err) {
|
|
79
|
+
const m = String(err?.message ?? err);
|
|
80
|
+
return (/\b(429|500|502|503|504)\b/.test(m) ||
|
|
81
|
+
/timeout|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch failed|network/i.test(m));
|
|
82
|
+
}
|
|
83
|
+
export function withRetry(runner, tries = 4, baseDelayMs = 500) {
|
|
84
|
+
return async (req) => {
|
|
85
|
+
let lastErr;
|
|
86
|
+
for (let attempt = 0; attempt < tries; attempt++) {
|
|
87
|
+
try {
|
|
88
|
+
return await runner(req);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
lastErr = e;
|
|
92
|
+
if (attempt === tries - 1 || !isTransient(e))
|
|
93
|
+
throw e;
|
|
94
|
+
await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw lastErr;
|
|
98
|
+
};
|
|
57
99
|
}
|
|
58
100
|
async function runArm(runner, scenario, system) {
|
|
59
101
|
let passes = 0;
|
|
102
|
+
let unparseable = 0;
|
|
60
103
|
for (let i = 0; i < scenario.trials; i++) {
|
|
61
104
|
const answer = await runner({ system, user: scenario.task, model: scenario.model });
|
|
62
|
-
|
|
105
|
+
const v = await judgeOne(runner, scenario, answer);
|
|
106
|
+
if (v === "pass")
|
|
63
107
|
passes++;
|
|
108
|
+
else if (v === "unparseable")
|
|
109
|
+
unparseable++;
|
|
64
110
|
}
|
|
65
|
-
|
|
111
|
+
// Scored trials exclude the ones the judge did not answer, so the rate is a
|
|
112
|
+
// rate over evidence rather than over attempts.
|
|
113
|
+
const scored = scenario.trials - unparseable;
|
|
114
|
+
return {
|
|
115
|
+
trials: scenario.trials,
|
|
116
|
+
scored,
|
|
117
|
+
unparseable,
|
|
118
|
+
passes,
|
|
119
|
+
passRate: scored > 0 ? (passes / scored) * 100 : 0,
|
|
120
|
+
};
|
|
66
121
|
}
|
|
67
122
|
export async function runScenario(scenario, runner) {
|
|
68
123
|
const systems = armSystems(scenario);
|
|
69
|
-
|
|
70
|
-
|
|
124
|
+
// Wrap once so both the subject answers and the judge calls survive a
|
|
125
|
+
// transient blip instead of throwing away the whole (paid-for) scenario.
|
|
126
|
+
const r = withRetry(runner);
|
|
127
|
+
const withoutSkill = await runArm(r, scenario, systems.withoutSkill);
|
|
128
|
+
const withSkill = await runArm(r, scenario, systems.withSkill);
|
|
71
129
|
const deltaPoints = Math.round((withSkill.passRate - withoutSkill.passRate) * 10) / 10;
|
|
130
|
+
// The verdict is a significance test, not a threshold on the gap.
|
|
131
|
+
//
|
|
132
|
+
// It used to be `>= +15 points helps, <= -15 hurts`, with 5 trials per arm
|
|
133
|
+
// as the default. At that size 5/5 against 4/5 is +20 points and a two-sided
|
|
134
|
+
// Fisher p of 1.0000: the least significant result obtainable, reported as a
|
|
135
|
+
// finding, and `hurts` set exit code 1. The +-15 band sits inside the noise
|
|
136
|
+
// floor at every trial count below roughly 25 per arm, so the threshold was
|
|
137
|
+
// measuring sample size more than it was measuring the skill.
|
|
138
|
+
const p = fisherExactTwoSided(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
|
|
139
|
+
const ci = deltaInterval(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
|
|
140
|
+
// With no scoreable trial in an arm the pass rate and its interval are
|
|
141
|
+
// 0/0 = NaN; say so plainly instead of printing "NaN to NaN points".
|
|
142
|
+
const bothScored = withSkill.scored > 0 && withoutSkill.scored > 0;
|
|
72
143
|
const notes = [
|
|
73
|
-
|
|
144
|
+
bothScored
|
|
145
|
+
? `${scenario.trials} trials per arm. Two-sided Fisher exact p = ${p < 0.0001 ? "<0.0001" : p.toFixed(4)}; ` +
|
|
146
|
+
`95% interval on the delta ${ci.lo >= 0 ? "+" : ""}${ci.lo} to ${ci.hi >= 0 ? "+" : ""}${ci.hi} points.`
|
|
147
|
+
: `${scenario.trials} trials per arm. An arm produced no scoreable trial, so no p-value or delta interval can be computed.`,
|
|
74
148
|
];
|
|
149
|
+
const unscored = withSkill.unparseable + withoutSkill.unparseable;
|
|
150
|
+
if (unscored) {
|
|
151
|
+
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.`);
|
|
152
|
+
}
|
|
75
153
|
let verdict;
|
|
76
|
-
if (
|
|
154
|
+
if (withSkill.scored === 0 || withoutSkill.scored === 0) {
|
|
77
155
|
verdict = "inconclusive";
|
|
78
|
-
notes.push("
|
|
156
|
+
notes.push("an arm produced no scoreable trial; there is nothing to compare.");
|
|
79
157
|
}
|
|
80
|
-
else if (
|
|
158
|
+
else if (p >= 0.05) {
|
|
159
|
+
verdict = "no measurable effect";
|
|
160
|
+
if (Math.abs(deltaPoints) >= 15) {
|
|
161
|
+
notes.push(`the ${deltaPoints >= 0 ? "+" : ""}${deltaPoints} point gap is not significant at this sample size (p = ${p.toFixed(4)}). ` +
|
|
162
|
+
`Raise trials rather than reading the gap.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else if (deltaPoints > 0)
|
|
81
166
|
verdict = "helps";
|
|
82
|
-
else if (deltaPoints <= -15)
|
|
83
|
-
verdict = "hurts";
|
|
84
167
|
else
|
|
85
|
-
verdict = "
|
|
168
|
+
verdict = "hurts";
|
|
86
169
|
return {
|
|
87
170
|
scenario: scenario.name,
|
|
88
171
|
skill: scenario.skillName,
|
|
@@ -90,6 +173,8 @@ export async function runScenario(scenario, runner) {
|
|
|
90
173
|
withSkill,
|
|
91
174
|
withoutSkill,
|
|
92
175
|
deltaPoints,
|
|
176
|
+
p,
|
|
177
|
+
ci,
|
|
93
178
|
verdict,
|
|
94
179
|
notes,
|
|
95
180
|
};
|
|
@@ -8,13 +8,27 @@ export function openaiRunner(apiKey) {
|
|
|
8
8
|
if (req.system)
|
|
9
9
|
messages.push({ role: "system", content: req.system });
|
|
10
10
|
messages.push({ role: "user", content: req.user });
|
|
11
|
+
// o-series reasoning models (o1, o3, o4, ...) reject any non-default
|
|
12
|
+
// temperature with a 400, and a chat-sized 1024 cap starves them: their
|
|
13
|
+
// hidden reasoning tokens count against max_completion_tokens, so a small
|
|
14
|
+
// cap can return empty content. So omit temperature and give them headroom.
|
|
15
|
+
const isReasoning = /^o[0-9]/i.test(req.model);
|
|
16
|
+
const body = {
|
|
17
|
+
model: req.model,
|
|
18
|
+
messages,
|
|
19
|
+
max_completion_tokens: isReasoning ? 8192 : 1024,
|
|
20
|
+
};
|
|
21
|
+
// temperature: 0 for the judge, 1 for the subject (see
|
|
22
|
+
// RunnerRequest.deterministic), but only where the model accepts it.
|
|
23
|
+
if (!isReasoning)
|
|
24
|
+
body.temperature = req.deterministic ? 0 : 1;
|
|
11
25
|
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
12
26
|
method: "POST",
|
|
13
27
|
headers: {
|
|
14
28
|
"content-type": "application/json",
|
|
15
29
|
authorization: `Bearer ${apiKey}`,
|
|
16
30
|
},
|
|
17
|
-
body: JSON.stringify(
|
|
31
|
+
body: JSON.stringify(body),
|
|
18
32
|
redirect: "error",
|
|
19
33
|
signal: AbortSignal.timeout(60000),
|
|
20
34
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The statistics behind an `efaimo test` verdict.
|
|
2
|
+
//
|
|
3
|
+
// The verdict used to be a bare threshold on the difference in pass rates:
|
|
4
|
+
// >= +15 points "helps", <= -15 "hurts", exit code 1 on "hurts". With the
|
|
5
|
+
// default 5 trials per arm, 5/5 against 4/5 is +20 points and would have been
|
|
6
|
+
// reported as "helps" with a two-sided Fisher p of 1.0000 - the least
|
|
7
|
+
// significant result the test can produce, labelled as a finding. At 8 trials
|
|
8
|
+
// per arm the 95% interval on a difference of proportions is roughly +-49
|
|
9
|
+
// points wide, so the whole +-15 band sits inside the noise for every trial
|
|
10
|
+
// count the tool supports below about 25 per arm.
|
|
11
|
+
//
|
|
12
|
+
// A tool whose argument is that people publish numbers they cannot support
|
|
13
|
+
// should not do that itself. So the verdict is now gated on a p-value, the
|
|
14
|
+
// interval is reported, and both are printed rather than hidden.
|
|
15
|
+
//
|
|
16
|
+
// Fisher's exact test, not chi-squared: the counts here are single digits, and
|
|
17
|
+
// chi-squared's approximation is not trustworthy at that size.
|
|
18
|
+
/** ln(n!) via lgamma, so the 2x2 table math stays exact enough at small n. */
|
|
19
|
+
function lnFactorial(n) {
|
|
20
|
+
let s = 0;
|
|
21
|
+
for (let i = 2; i <= n; i++)
|
|
22
|
+
s += Math.log(i);
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
/** Probability of exactly this 2x2 table under the hypergeometric null. */
|
|
26
|
+
function lnHypergeom(a, b, c, d) {
|
|
27
|
+
const n = a + b + c + d;
|
|
28
|
+
return (lnFactorial(a + b) + lnFactorial(c + d) + lnFactorial(a + c) + lnFactorial(b + d) -
|
|
29
|
+
lnFactorial(n) - lnFactorial(a) - lnFactorial(b) - lnFactorial(c) - lnFactorial(d));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Two-sided Fisher exact test on
|
|
33
|
+
* [withPass, withFail]
|
|
34
|
+
* [withoutPass, withoutFail]
|
|
35
|
+
*
|
|
36
|
+
* Sums the probability of every table at least as extreme as the observed one,
|
|
37
|
+
* which is the standard two-sided construction and needs no normal
|
|
38
|
+
* approximation.
|
|
39
|
+
*/
|
|
40
|
+
export function fisherExactTwoSided(withPass, withTrials, withoutPass, withoutTrials) {
|
|
41
|
+
const a = withPass, b = withTrials - withPass;
|
|
42
|
+
const c = withoutPass, d = withoutTrials - withoutPass;
|
|
43
|
+
if (withTrials <= 0 || withoutTrials <= 0)
|
|
44
|
+
return 1;
|
|
45
|
+
const obs = lnHypergeom(a, b, c, d);
|
|
46
|
+
const rowTop = a + b, rowBot = c + d, colPass = a + c;
|
|
47
|
+
const lo = Math.max(0, colPass - rowBot);
|
|
48
|
+
const hi = Math.min(rowTop, colPass);
|
|
49
|
+
// A tiny epsilon so floating point does not drop the observed table itself.
|
|
50
|
+
const EPS = 1e-9;
|
|
51
|
+
let p = 0;
|
|
52
|
+
for (let x = lo; x <= hi; x++) {
|
|
53
|
+
const l = lnHypergeom(x, rowTop - x, colPass - x, rowBot - (colPass - x));
|
|
54
|
+
if (l <= obs + EPS)
|
|
55
|
+
p += Math.exp(l);
|
|
56
|
+
}
|
|
57
|
+
return Math.min(1, p);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Wilson score interval for one proportion. Chosen over the normal-approximation
|
|
61
|
+
* interval because that one produces a zero-width interval at 0/n and n/n,
|
|
62
|
+
* which is exactly where these runs land (8/8 and 0/8 both occur in the two
|
|
63
|
+
* committed example runs).
|
|
64
|
+
*/
|
|
65
|
+
export function wilson(passes, trials, z = 1.96) {
|
|
66
|
+
if (trials <= 0)
|
|
67
|
+
return { lo: 0, hi: 1 };
|
|
68
|
+
const p = passes / trials;
|
|
69
|
+
const z2 = z * z;
|
|
70
|
+
const denom = 1 + z2 / trials;
|
|
71
|
+
const centre = p + z2 / (2 * trials);
|
|
72
|
+
const half = z * Math.sqrt((p * (1 - p)) / trials + z2 / (4 * trials * trials));
|
|
73
|
+
return { lo: Math.max(0, (centre - half) / denom), hi: Math.min(1, (centre + half) / denom) };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Interval on the DIFFERENCE of two proportions, in percentage points, by the
|
|
77
|
+
* Newcombe method: combine each arm's Wilson interval. Reported so a reader can
|
|
78
|
+
* see how wide the uncertainty actually is instead of inferring it from a
|
|
79
|
+
* point estimate.
|
|
80
|
+
*/
|
|
81
|
+
export function deltaInterval(withPass, withTrials, withoutPass, withoutTrials) {
|
|
82
|
+
const w = wilson(withPass, withTrials);
|
|
83
|
+
const o = wilson(withoutPass, withoutTrials);
|
|
84
|
+
const p1 = withPass / withTrials, p2 = withoutPass / withoutTrials;
|
|
85
|
+
const d = p1 - p2;
|
|
86
|
+
const lo = d - Math.sqrt((p1 - w.lo) ** 2 + (o.hi - p2) ** 2);
|
|
87
|
+
const hi = d + Math.sqrt((w.hi - p1) ** 2 + (p2 - o.lo) ** 2);
|
|
88
|
+
return { lo: Math.round(Math.max(-1, lo) * 1000) / 10, hi: Math.round(Math.min(1, hi) * 1000) / 10 };
|
|
89
|
+
}
|
package/dist/util/dotenv.d.ts
CHANGED
|
@@ -9,4 +9,12 @@
|
|
|
9
9
|
* secrets are not forwarded to spawned MCP servers: minimalChildEnv() passes an
|
|
10
10
|
* allowlist that excludes API keys.
|
|
11
11
|
*/
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The only keys this loader may set. It used to set ANY key found in a .env in
|
|
14
|
+
* the CURRENT DIRECTORY, which means a repository you cloned could inject
|
|
15
|
+
* NODE_TLS_REJECT_UNAUTHORIZED=0 and silently disable certificate verification
|
|
16
|
+
* for every later HTTPS call, including the ones carrying your API key. The
|
|
17
|
+
* file header already said only two keys were wanted; this enforces it.
|
|
18
|
+
*/
|
|
19
|
+
export declare const ALLOWED: Set<string>;
|
|
20
|
+
export declare function loadDotEnv(dir?: string, allowed?: ReadonlySet<string>): string[];
|
package/dist/util/dotenv.js
CHANGED
|
@@ -11,7 +11,15 @@ import { resolve } from "node:path";
|
|
|
11
11
|
* secrets are not forwarded to spawned MCP servers: minimalChildEnv() passes an
|
|
12
12
|
* allowlist that excludes API keys.
|
|
13
13
|
*/
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The only keys this loader may set. It used to set ANY key found in a .env in
|
|
16
|
+
* the CURRENT DIRECTORY, which means a repository you cloned could inject
|
|
17
|
+
* NODE_TLS_REJECT_UNAUTHORIZED=0 and silently disable certificate verification
|
|
18
|
+
* for every later HTTPS call, including the ones carrying your API key. The
|
|
19
|
+
* file header already said only two keys were wanted; this enforces it.
|
|
20
|
+
*/
|
|
21
|
+
export const ALLOWED = new Set(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
|
|
22
|
+
export function loadDotEnv(dir = process.cwd(), allowed = ALLOWED) {
|
|
15
23
|
let text;
|
|
16
24
|
try {
|
|
17
25
|
text = readFileSync(resolve(dir, ".env"), "utf8");
|
|
@@ -32,6 +40,8 @@ export function loadDotEnv(dir = process.cwd()) {
|
|
|
32
40
|
const key = line.slice(0, eq).trim();
|
|
33
41
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
34
42
|
continue;
|
|
43
|
+
if (!allowed.has(key))
|
|
44
|
+
continue;
|
|
35
45
|
if (process.env[key] !== undefined)
|
|
36
46
|
continue;
|
|
37
47
|
let val = line.slice(eq + 1).trim();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip terminal control characters from text that originated outside efaimo.
|
|
3
|
+
* Tab and newline survive because layout depends on them; everything else that
|
|
4
|
+
* can move a cursor, clear a line, or address the terminal is removed.
|
|
5
|
+
*/
|
|
6
|
+
export declare function safeText(s: string): string;
|
|
7
|
+
/** Same, for a value that may be undefined. */
|
|
8
|
+
export declare function safeTextOpt(s: string | undefined): string | undefined;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Text that came from the thing being audited must not be able to drive the
|
|
2
|
+
// terminal it is reported in.
|
|
3
|
+
//
|
|
4
|
+
// Tool names, titles, descriptions, server instructions, stderr tails and
|
|
5
|
+
// repo-scan excerpts all originate with the target. They were interpolated
|
|
6
|
+
// into stdout raw, so a hostile server could emit ESC[1A ESC[2K inside a tool
|
|
7
|
+
// name and rewrite the grade line printed above its own finding: the auditor
|
|
8
|
+
// reads "grade A (100), 0 errors" while the finding that contradicts it is
|
|
9
|
+
// erased. OSC 8 forges a hyperlink; OSC 52 writes the reader's clipboard.
|
|
10
|
+
// `--no-color` did not help, because that only suppresses the colours efaimo
|
|
11
|
+
// itself adds.
|
|
12
|
+
//
|
|
13
|
+
// This matters out of proportion to its severity. An auditing tool whose
|
|
14
|
+
// verdict can be rewritten by the thing it audits has a credibility problem
|
|
15
|
+
// before it has a security one, and the HTTP transport is exactly where a user
|
|
16
|
+
// expects no code execution and therefore no consequences.
|
|
17
|
+
//
|
|
18
|
+
// Applied at the RENDER boundary, never on ingestion: escape bytes in a
|
|
19
|
+
// description are real bytes that a model would receive and really do cost
|
|
20
|
+
// tokens, so `weigh` has to keep counting them. What must not happen is that
|
|
21
|
+
// they reach a terminal as control codes.
|
|
22
|
+
//
|
|
23
|
+
// JSON output needs no pass here: JSON.stringify already encodes control
|
|
24
|
+
// characters as escape sequences, which are inert text.
|
|
25
|
+
// C0 except tab and newline, DEL, and the C1 block. C1 is included because a
|
|
26
|
+
// lone 0x9B is CSI in a terminal that decodes single-byte C1, which is the
|
|
27
|
+
// same attack without an ESC byte to grep for.
|
|
28
|
+
// Built from code points rather than written as literals so this file does not
|
|
29
|
+
// itself contain the bytes it exists to remove; the same reason gates.mjs next
|
|
30
|
+
// door builds its dash class with String.fromCharCode.
|
|
31
|
+
const CONTROLS = new RegExp("[" +
|
|
32
|
+
"\\u0000-\\u0008" + // C0 up to backspace (tab 0x09 and newline 0x0A kept)
|
|
33
|
+
"\\u000B-\\u001F" + // vertical tab through unit separator, ESC included
|
|
34
|
+
"\\u007F" + // DEL
|
|
35
|
+
"\\u0080-\\u009F" + // C1, single-byte CSI/OSC among them
|
|
36
|
+
"]", "g");
|
|
37
|
+
/**
|
|
38
|
+
* Strip terminal control characters from text that originated outside efaimo.
|
|
39
|
+
* Tab and newline survive because layout depends on them; everything else that
|
|
40
|
+
* can move a cursor, clear a line, or address the terminal is removed.
|
|
41
|
+
*/
|
|
42
|
+
export function safeText(s) {
|
|
43
|
+
return s.replace(CONTROLS, "");
|
|
44
|
+
}
|
|
45
|
+
/** Same, for a value that may be undefined. */
|
|
46
|
+
export function safeTextOpt(s) {
|
|
47
|
+
return s === undefined ? undefined : safeText(s);
|
|
48
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in sync with package.json by test/meta.test.ts. */
|
|
2
|
-
export declare const VERSION = "0.1.
|
|
2
|
+
export declare const VERSION = "0.1.2";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in sync with package.json by test/meta.test.ts. */
|
|
2
|
-
export const VERSION = "0.1.
|
|
2
|
+
export const VERSION = "0.1.2";
|
package/dist/weigh/weigh.js
CHANGED
|
@@ -69,7 +69,9 @@ export async function weighSkills(set) {
|
|
|
69
69
|
let refFileTokens = 0;
|
|
70
70
|
let refFileCount = 0;
|
|
71
71
|
for (const ref of skill.referencedPaths) {
|
|
72
|
-
|
|
72
|
+
// Never read outside the skill root. S106 warns about these, but only
|
|
73
|
+
// after weigh had already opened the file and counted its tokens.
|
|
74
|
+
if (ref.escapes || !ref.exists || isBinaryPath(ref.resolved))
|
|
73
75
|
continue;
|
|
74
76
|
const text = readTextSafe(ref.resolved);
|
|
75
77
|
if (text === undefined)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context window a token count is reported as a share of.
|
|
3
|
+
*
|
|
4
|
+
* There is no single correct denominator: it is a property of the model the
|
|
5
|
+
* host happens to be running, not of the server being measured. Every current
|
|
6
|
+
* frontier Claude model (Fable 5, Opus 5, Opus 4.8/4.7/4.6, Sonnet 5, Sonnet
|
|
7
|
+
* 4.6) is 1M; Haiku 4.5 is 200K, as are many non-Claude and local models, where the
|
|
8
|
+
* same tool definitions cost 5x the share shown here.
|
|
9
|
+
*
|
|
10
|
+
* So the share is always printed with the window it was computed against, and
|
|
11
|
+
* `--window` lets the caller name their own. The absolute token count is the
|
|
12
|
+
* number efaimo actually stands behind; this is a readability aid on top of it.
|
|
13
|
+
*
|
|
14
|
+
* Note that no rule grades on this share -- E128's thresholds are absolute
|
|
15
|
+
* token counts, so changing the window never moves a grade.
|
|
16
|
+
*/
|
|
17
|
+
export declare const DEFAULT_CONTEXT_WINDOW = 1000000;
|
|
18
|
+
export declare function setContextWindow(tokens: number): void;
|
|
19
|
+
export declare function getContextWindow(): number;
|
|
20
|
+
/** "1M", "200k", "128k", "4,096" -- how the window is named in output. */
|
|
21
|
+
export declare function formatWindow(tokens?: number): string;
|
|
22
|
+
/**
|
|
23
|
+
* "~0.1% of a 1M window" -- always names the denominator, so the reader is
|
|
24
|
+
* never left guessing which model it assumed.
|
|
25
|
+
*/
|
|
26
|
+
export declare function formatWindowShare(tokens: number, window?: number): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context window a token count is reported as a share of.
|
|
3
|
+
*
|
|
4
|
+
* There is no single correct denominator: it is a property of the model the
|
|
5
|
+
* host happens to be running, not of the server being measured. Every current
|
|
6
|
+
* frontier Claude model (Fable 5, Opus 5, Opus 4.8/4.7/4.6, Sonnet 5, Sonnet
|
|
7
|
+
* 4.6) is 1M; Haiku 4.5 is 200K, as are many non-Claude and local models, where the
|
|
8
|
+
* same tool definitions cost 5x the share shown here.
|
|
9
|
+
*
|
|
10
|
+
* So the share is always printed with the window it was computed against, and
|
|
11
|
+
* `--window` lets the caller name their own. The absolute token count is the
|
|
12
|
+
* number efaimo actually stands behind; this is a readability aid on top of it.
|
|
13
|
+
*
|
|
14
|
+
* Note that no rule grades on this share -- E128's thresholds are absolute
|
|
15
|
+
* token counts, so changing the window never moves a grade.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_CONTEXT_WINDOW = 1_000_000;
|
|
18
|
+
let contextWindow = DEFAULT_CONTEXT_WINDOW;
|
|
19
|
+
export function setContextWindow(tokens) {
|
|
20
|
+
if (!Number.isFinite(tokens) || tokens <= 0) {
|
|
21
|
+
throw new Error(`--window must be a positive number of tokens, got ${tokens}`);
|
|
22
|
+
}
|
|
23
|
+
contextWindow = tokens;
|
|
24
|
+
}
|
|
25
|
+
export function getContextWindow() {
|
|
26
|
+
return contextWindow;
|
|
27
|
+
}
|
|
28
|
+
/** "1M", "200k", "128k", "4,096" -- how the window is named in output. */
|
|
29
|
+
export function formatWindow(tokens = contextWindow) {
|
|
30
|
+
if (tokens >= 1_000_000 && tokens % 1_000_000 === 0)
|
|
31
|
+
return `${tokens / 1_000_000}M`;
|
|
32
|
+
if (tokens >= 1_000 && tokens % 1_000 === 0)
|
|
33
|
+
return `${tokens / 1_000}k`;
|
|
34
|
+
return tokens.toLocaleString("en-US");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* "~0.1% of a 1M window" -- always names the denominator, so the reader is
|
|
38
|
+
* never left guessing which model it assumed.
|
|
39
|
+
*/
|
|
40
|
+
export function formatWindowShare(tokens, window = contextWindow) {
|
|
41
|
+
const pct = (tokens / window) * 100;
|
|
42
|
+
const shown = pct < 0.1 && pct > 0 ? "<0.1" : pct.toFixed(1);
|
|
43
|
+
return `~${shown}% of a ${formatWindow(window)} window`;
|
|
44
|
+
}
|