webmcp-gauge 0.1.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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/action.yml +162 -0
  4. package/bin/webmcp-gauge.mjs +544 -0
  5. package/bin/webmcp-gauge.test.mjs +354 -0
  6. package/browser/launch.mjs +188 -0
  7. package/browser/serve.mjs +78 -0
  8. package/browser/session.mjs +210 -0
  9. package/browser/webmcp.mjs +432 -0
  10. package/browser/webmcp.test.mjs +299 -0
  11. package/core/args.mjs +93 -0
  12. package/core/args.test.mjs +85 -0
  13. package/core/capture-seam.test.mjs +86 -0
  14. package/core/cohort.mjs +432 -0
  15. package/core/cohort.test.mjs +370 -0
  16. package/core/gallery.mjs +145 -0
  17. package/core/gallery.test.mjs +128 -0
  18. package/core/gate.mjs +164 -0
  19. package/core/gate.test.mjs +213 -0
  20. package/core/lint.mjs +381 -0
  21. package/core/lint.test.mjs +346 -0
  22. package/core/orchestrate.mjs +128 -0
  23. package/core/orchestrate.test.mjs +191 -0
  24. package/core/stats.mjs +172 -0
  25. package/core/stats.test.mjs +156 -0
  26. package/core/sweep.mjs +274 -0
  27. package/core/sweep.test.mjs +162 -0
  28. package/core/taxonomy.mjs +175 -0
  29. package/core/taxonomy.test.mjs +198 -0
  30. package/core/trial.mjs +248 -0
  31. package/core/visibility.mjs +163 -0
  32. package/core/visibility.test.mjs +164 -0
  33. package/docs/concept.md +468 -0
  34. package/docs/explainer.md +161 -0
  35. package/docs/getting-started.md +331 -0
  36. package/fixtures/README.md +42 -0
  37. package/fixtures/airlock.utterances.json +284 -0
  38. package/fixtures/broken/compose.mjs +52 -0
  39. package/fixtures/broken/compose.test.mjs +270 -0
  40. package/fixtures/broken/sample-expenses.csv +966 -0
  41. package/fixtures/broken/tools.json +1311 -0
  42. package/fixtures/broken/twin.html +482 -0
  43. package/fixtures/broken/widget.html +62 -0
  44. package/fixtures/gallery/gallery.html +56 -0
  45. package/judges/openai-compatible.mjs +145 -0
  46. package/package.json +53 -0
  47. package/report/badge.mjs +110 -0
  48. package/report/badge.test.mjs +97 -0
  49. package/report/emit.mjs +282 -0
  50. package/report/published-runs.test.mjs +77 -0
  51. package/report/scorecard.mjs +157 -0
  52. package/report/scorecard.test.mjs +130 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Judge adapter for any OpenAI-compatible chat-completions endpoint.
3
+ *
4
+ * Invocation rate is a property of (page, client, judge model), so the model id
5
+ * and endpoint are returned with every answer and belong in every report. The
6
+ * adapter deliberately does no retrying and no repair of malformed output: a
7
+ * judge that cannot follow the response contract is a measurement result, not
8
+ * an error to paper over.
9
+ */
10
+
11
+ const DEFAULT_TIMEOUT_MS = 60000;
12
+
13
+ /**
14
+ * Reasoning models spend completion tokens on thinking before they emit anything,
15
+ * and the budget covers both. At 1024, glm-5.3 spent 1021 tokens reasoning about
16
+ * one utterance and returned an empty string with finish_reason "length" - which
17
+ * looked exactly like a judge declining to pick a tool. A truncated judge is a
18
+ * harness misconfiguration, so the ceiling is set where truncation is unlikely and
19
+ * the caller is told when it happens anyway. Unused headroom costs nothing.
20
+ */
21
+ const DEFAULT_MAX_TOKENS = 4096;
22
+
23
+ /**
24
+ * agentrouter.org rejects requests that do not look like a CLI client, with
25
+ * `401 unauthorized client detected`, even when the key is valid.
26
+ */
27
+ const HOST_HEADERS = {
28
+ 'agentrouter.org': { 'User-Agent': 'claude-cli/1.0.80 (external, cli)', 'x-app': 'cli' },
29
+ };
30
+
31
+ const hostHeadersFor = (baseUrl) => {
32
+ const { hostname } = new URL(baseUrl);
33
+ return HOST_HEADERS[hostname] ?? {};
34
+ };
35
+
36
+ /**
37
+ * Key selection has to be deterministic and reportable. Qwen Code exports one
38
+ * `QWEN_CUSTOM_API_KEY_*` variable per configured provider, and picking whichever
39
+ * one enumerates first silently grabbed an Anthropic-scoped key for an OpenAI
40
+ * endpoint on the first run - it happened to work, which is worse than failing.
41
+ * Candidates are therefore ranked: explicit variable, then the project's own
42
+ * variable, then session variables whose name matches this endpoint's host,
43
+ * preferring the OpenAI-scoped one, in sorted order.
44
+ */
45
+ const hostTokens = (baseUrl) => {
46
+ const { hostname } = new URL(baseUrl);
47
+ return hostname.toUpperCase().split('.').filter((part) => part.length > 2 && part !== 'WWW');
48
+ };
49
+
50
+ export const resolveApiKey = (env = process.env, explicitVar, baseUrl) => {
51
+ if (explicitVar) {
52
+ const value = env[explicitVar];
53
+ if (!value) throw new Error(`Judge API key variable ${explicitVar} is set to nothing`);
54
+ return { key: value, source: explicitVar };
55
+ }
56
+
57
+ if (env.WEBMCP_GAUGE_JUDGE_API_KEY) {
58
+ return { key: env.WEBMCP_GAUGE_JUDGE_API_KEY, source: 'WEBMCP_GAUGE_JUDGE_API_KEY' };
59
+ }
60
+
61
+ const sessionVars = Object.keys(env)
62
+ .filter((name) => name.startsWith('QWEN_CUSTOM_API_KEY_') && env[name])
63
+ .sort();
64
+ const tokens = baseUrl ? hostTokens(baseUrl) : [];
65
+ const hostMatches = sessionVars.filter((name) => tokens.every((token) => name.includes(token)));
66
+ const ranked = [
67
+ ...hostMatches.filter((name) => name.includes('OPENAI')),
68
+ ...hostMatches.filter((name) => !name.includes('OPENAI')),
69
+ ];
70
+
71
+ if (ranked.length > 0) return { key: env[ranked[0]], source: ranked[0] };
72
+
73
+ throw new Error(
74
+ `No judge API key found for ${baseUrl ?? 'the configured endpoint'}. Set WEBMCP_GAUGE_JUDGE_API_KEY (see .env.example).` +
75
+ (sessionVars.length > 0 ? ` Session variables present but not host-matched: ${sessionVars.join(', ')}` : '')
76
+ );
77
+ };
78
+
79
+ export const createJudge = ({
80
+ baseUrl,
81
+ model,
82
+ apiKeyVar,
83
+ env = process.env,
84
+ timeoutMs = DEFAULT_TIMEOUT_MS,
85
+ temperature = 0,
86
+ }) => {
87
+ if (!baseUrl || !model) throw new Error('createJudge requires baseUrl and model');
88
+ const { key, source } = resolveApiKey(env, apiKeyVar, baseUrl);
89
+ const endpoint = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
90
+
91
+ return {
92
+ id: model,
93
+ baseUrl,
94
+ keySource: source,
95
+
96
+ async complete({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
97
+ const body = {
98
+ model,
99
+ temperature,
100
+ max_tokens: maxTokens,
101
+ messages: [
102
+ ...(system ? [{ role: 'system', content: system }] : []),
103
+ { role: 'user', content: user },
104
+ ],
105
+ };
106
+
107
+ const startedAt = Date.now();
108
+ const response = await fetch(endpoint, {
109
+ method: 'POST',
110
+ headers: {
111
+ 'Content-Type': 'application/json',
112
+ Authorization: `Bearer ${key}`,
113
+ ...hostHeadersFor(baseUrl),
114
+ },
115
+ body: JSON.stringify(body),
116
+ signal: AbortSignal.timeout(timeoutMs),
117
+ });
118
+
119
+ const text = await response.text();
120
+ const elapsedMs = Date.now() - startedAt;
121
+
122
+ if (!response.ok) {
123
+ // The body carries the useful part - a whitelist rejection reads very
124
+ // differently from an exhausted quota - so it is surfaced, not swallowed.
125
+ throw new Error(`judge HTTP ${response.status}: ${text.slice(0, 500)}`);
126
+ }
127
+
128
+ const payload = JSON.parse(text);
129
+ const message = payload.choices?.[0]?.message ?? {};
130
+ const finishReason = payload.choices?.[0]?.finish_reason ?? null;
131
+
132
+ return {
133
+ content: (message.content ?? '').trim(),
134
+ raw: text,
135
+ model: payload.model ?? model,
136
+ finishReason,
137
+ // Truncation is the caller's problem to classify, not something to hide:
138
+ // an empty answer cut off mid-thought is not a judge declining to answer.
139
+ truncated: finishReason === 'length',
140
+ usage: payload.usage ?? null,
141
+ elapsedMs,
142
+ };
143
+ },
144
+ };
145
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "webmcp-gauge",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Measures whether an AI agent actually calls the tools a page exposes through WebMCP.",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "webmcp-gauge": "bin/webmcp-gauge.mjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=24.0.0"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "core",
16
+ "browser",
17
+ "judges",
18
+ "report",
19
+ "fixtures/airlock.utterances.json",
20
+ "fixtures/gallery",
21
+ "fixtures/broken",
22
+ "fixtures/README.md",
23
+ "docs",
24
+ "README.md",
25
+ "LICENSE",
26
+ "action.yml"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/Svishwa2004/webmcp-gauge.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/Svishwa2004/webmcp-gauge/issues"
34
+ },
35
+ "homepage": "https://github.com/Svishwa2004/webmcp-gauge#readme",
36
+ "keywords": [
37
+ "webmcp",
38
+ "mcp",
39
+ "model-context-protocol",
40
+ "ai-agents",
41
+ "linter",
42
+ "measurement",
43
+ "browser-automation",
44
+ "invocation-rate"
45
+ ],
46
+ "scripts": {
47
+ "gauge": "node bin/webmcp-gauge.mjs",
48
+ "test": "node --test"
49
+ },
50
+ "dependencies": {
51
+ "chrome-remote-interface": "0.33.3"
52
+ }
53
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The badge a CI run leaves behind.
3
+ *
4
+ * A badge is a bare number in a coloured pill, which is exactly the thing this
5
+ * project refuses to publish. That tension is not resolved by making the badge
6
+ * prettier; it is resolved by making the badge unable to overstate:
7
+ *
8
+ * - a run with unmeasured trials shows **incomplete**, never a rate, because a
9
+ * rate over a denominator the run did not choose is the wrong number however
10
+ * it is coloured (this mirrors the exit-code contract, where incomplete
11
+ * outranks a breach);
12
+ * - the message carries `n`, so a 100% from twenty trials cannot be mistaken
13
+ * for a 100% from a thousand;
14
+ * - colour is a threshold, not a grade, and the thresholds are stated here
15
+ * rather than tuned to flatter a particular subject.
16
+ *
17
+ * The output is the Shields "endpoint" schema, which any README can point at
18
+ * without this project hosting anything, plus a self-contained SVG for repos
19
+ * that would rather not call out to a third party at page load.
20
+ */
21
+
22
+ /** Thresholds are deliberately coarse: a badge is a smoke alarm, not a gauge. */
23
+ export const BADGE_THRESHOLDS = [
24
+ { atLeast: 0.95, color: 'brightgreen' },
25
+ { atLeast: 0.85, color: 'green' },
26
+ { atLeast: 0.7, color: 'yellowgreen' },
27
+ { atLeast: 0.5, color: 'yellow' },
28
+ { atLeast: 0.0, color: 'red' },
29
+ ];
30
+
31
+ const colorFor = (rate) => BADGE_THRESHOLDS.find((band) => rate >= band.atLeast)?.color ?? 'lightgrey';
32
+
33
+ /**
34
+ * Reads only what a finished report already carries, so a badge can never
35
+ * disagree with the report beside it.
36
+ */
37
+ export const buildBadge = (report, { label = 'webmcp invocation' } = {}) => {
38
+ const overall = report?.invocation?.overall ?? null;
39
+
40
+ if (!overall || overall.trials === 0) {
41
+ return { schemaVersion: 1, label, message: 'no trials', color: 'lightgrey', isError: true };
42
+ }
43
+
44
+ // A schema-2 report has no `coverage` block, and `report/emit.mjs` is explicit
45
+ // that its absence means **unknown**, not complete — that is why the schema
46
+ // version moved. Reading a missing block as zero missing trials would publish a
47
+ // rate for a run whose completeness nobody established, which is the exact
48
+ // overstatement this file exists to prevent.
49
+ if (!report.coverage) {
50
+ return { schemaVersion: 1, label, message: 'coverage unknown', color: 'lightgrey', isError: true };
51
+ }
52
+
53
+ const missing = report.coverage.missingTrials ?? 0;
54
+ if (missing > 0) {
55
+ const expected = report.coverage.expectedTrials ?? overall.trials + missing;
56
+ return {
57
+ schemaVersion: 1,
58
+ label,
59
+ // Named for what it is. "incomplete" is not a bad score, it is the absence
60
+ // of a score, and a reader must not be able to read it as one.
61
+ message: `incomplete (${expected - missing}/${expected})`,
62
+ color: 'lightgrey',
63
+ isError: true,
64
+ };
65
+ }
66
+
67
+ const rate = overall.ok / overall.trials;
68
+ return {
69
+ schemaVersion: 1,
70
+ label,
71
+ message: `${(rate * 100).toFixed(0)}% (n=${overall.trials})`,
72
+ color: colorFor(rate),
73
+ };
74
+ };
75
+
76
+ const PALETTE = {
77
+ brightgreen: '#4c1',
78
+ green: '#97ca00',
79
+ yellowgreen: '#a4a61d',
80
+ yellow: '#dfb317',
81
+ red: '#e05d44',
82
+ lightgrey: '#9f9f9f',
83
+ };
84
+
85
+ /** Rough advance width for the 11px DejaVu-ish face Shields uses. */
86
+ const textWidth = (text) => Math.round([...text].reduce((sum, ch) => sum + (/[iljI.,:'|]/.test(ch) ? 3 : /[mwMW%]/.test(ch) ? 9 : 6.4), 0)) + 10;
87
+
88
+ /**
89
+ * A self-contained SVG, so a README can commit the badge instead of fetching it.
90
+ * Deliberately plain: no gradients, no external font reference, nothing that
91
+ * needs a network request to render.
92
+ */
93
+ export const renderBadgeSvg = (badge) => {
94
+ const labelWidth = textWidth(badge.label);
95
+ const messageWidth = textWidth(badge.message);
96
+ const total = labelWidth + messageWidth;
97
+ const fill = PALETTE[badge.color] ?? PALETTE.lightgrey;
98
+ const escape = (text) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
99
+
100
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="20" role="img" aria-label="${escape(badge.label)}: ${escape(badge.message)}">
101
+ <title>${escape(badge.label)}: ${escape(badge.message)}</title>
102
+ <rect width="${labelWidth}" height="20" fill="#555"/>
103
+ <rect x="${labelWidth}" width="${messageWidth}" height="20" fill="${fill}"/>
104
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
105
+ <text x="${labelWidth / 2}" y="14">${escape(badge.label)}</text>
106
+ <text x="${labelWidth + messageWidth / 2}" y="14">${escape(badge.message)}</text>
107
+ </g>
108
+ </svg>
109
+ `;
110
+ };
@@ -0,0 +1,97 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { buildBadge, renderBadgeSvg, BADGE_THRESHOLDS } from './badge.mjs';
5
+
6
+ const report = ({ ok, trials, missing = 0, expected = trials }) => ({
7
+ invocation: { overall: { ok, trials } },
8
+ coverage: missing > 0 ? { missingTrials: missing, expectedTrials: expected } : { missingTrials: 0, expectedTrials: expected },
9
+ });
10
+
11
+ test('a complete run shows the rate and the trial count, so 20 cannot pass for 1000', () => {
12
+ const badge = buildBadge(report({ ok: 399, trials: 480 }));
13
+ assert.equal(badge.message, '83% (n=480)');
14
+ assert.equal(badge.schemaVersion, 1);
15
+ assert.ok(!badge.isError);
16
+ });
17
+
18
+ /**
19
+ * The rule this file exists for. A rate over a denominator the run did not choose
20
+ * is the wrong number however it is coloured, and the exit-code contract already
21
+ * says incomplete outranks everything else.
22
+ */
23
+ test('a run with unmeasured trials shows incomplete, never a rate', () => {
24
+ const badge = buildBadge(report({ ok: 100, trials: 100, missing: 60, expected: 160 }));
25
+ assert.equal(badge.message, 'incomplete (100/160)');
26
+ assert.equal(badge.color, 'lightgrey');
27
+ assert.equal(badge.isError, true);
28
+ assert.ok(!/%/.test(badge.message), 'a percentage must not appear on an incomplete run');
29
+ });
30
+
31
+ test('a perfect but incomplete run still says incomplete, not 100%', () => {
32
+ const badge = buildBadge(report({ ok: 20, trials: 20, missing: 140, expected: 160 }));
33
+ assert.match(badge.message, /^incomplete/);
34
+ });
35
+
36
+ test('no trials at all is an error state rather than a zero', () => {
37
+ const badge = buildBadge(report({ ok: 0, trials: 0 }));
38
+ assert.equal(badge.message, 'no trials');
39
+ assert.equal(badge.isError, true);
40
+ const missingReport = buildBadge({});
41
+ assert.equal(missingReport.message, 'no trials');
42
+ });
43
+
44
+ /**
45
+ * Caught by generating a badge from a real published report: the 960-trial
46
+ * reference run predates schema 3 and carries no `coverage` block at all, and
47
+ * `report/emit.mjs` says plainly that its absence means unknown rather than
48
+ * complete. The first version of this file read it as zero missing trials and
49
+ * happily published "99% (n=840)" for a run whose completeness nobody had
50
+ * established.
51
+ */
52
+ test('a schema-2 report with no coverage block says unknown, not a rate', () => {
53
+ const badge = buildBadge({ invocation: { overall: { ok: 831, trials: 840 } } });
54
+ assert.equal(badge.message, 'coverage unknown');
55
+ assert.equal(badge.color, 'lightgrey');
56
+ assert.equal(badge.isError, true);
57
+ assert.ok(!/%/.test(badge.message));
58
+ });
59
+
60
+ test('zero of many is red and says so, which is different from having no trials', () => {
61
+ const badge = buildBadge(report({ ok: 0, trials: 60 }));
62
+ assert.equal(badge.message, '0% (n=60)');
63
+ assert.equal(badge.color, 'red');
64
+ });
65
+
66
+ test('colour bands are ordered and cover the whole range', () => {
67
+ const rates = BADGE_THRESHOLDS.map((band) => band.atLeast);
68
+ assert.deepEqual(rates, [...rates].sort((a, b) => b - a), 'bands must be descending or the first match is wrong');
69
+ assert.equal(rates.at(-1), 0, 'the last band must catch every remaining rate');
70
+ });
71
+
72
+ test('the band boundaries are inclusive, so exactly 95% is the better colour', () => {
73
+ assert.equal(buildBadge(report({ ok: 95, trials: 100 })).color, 'brightgreen');
74
+ assert.equal(buildBadge(report({ ok: 94, trials: 100 })).color, 'green');
75
+ assert.equal(buildBadge(report({ ok: 85, trials: 100 })).color, 'green');
76
+ assert.equal(buildBadge(report({ ok: 60, trials: 100 })).color, 'yellow');
77
+ });
78
+
79
+ test('the label can be overridden, because a repo may measure more than one page', () => {
80
+ const badge = buildBadge(report({ ok: 1, trials: 1 }), { label: 'airlock' });
81
+ assert.equal(badge.label, 'airlock');
82
+ });
83
+
84
+ test('the SVG is self-contained: no external font, no network reference', () => {
85
+ const svg = renderBadgeSvg(buildBadge(report({ ok: 399, trials: 480 })));
86
+ assert.match(svg, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
87
+ assert.ok(!/<image|xlink:href|@import|https?:\/\/(?!www\.w3\.org)/.test(svg), 'nothing may be fetched to render this');
88
+ assert.match(svg, /83% \(n=480\)/);
89
+ assert.match(svg, /role="img"/);
90
+ assert.match(svg, /<title>/, 'a badge without a title is unreadable to a screen reader');
91
+ });
92
+
93
+ test('angle brackets and ampersands in a label cannot break the SVG', () => {
94
+ const svg = renderBadgeSvg(buildBadge(report({ ok: 1, trials: 1 }), { label: 'a & b <c>' }));
95
+ assert.match(svg, /a &amp; b &lt;c&gt;/);
96
+ assert.ok(!/<c>/.test(svg));
97
+ });
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Report emitters.
3
+ *
4
+ * A report that does not name its judge, its browser build and its utterance-set
5
+ * version is not a measurement, because invocation rate is a property of
6
+ * (page, client, judge, utterances) rather than of the page alone. Every stamp
7
+ * therefore travels with the numbers, and the Markdown is generated from the same
8
+ * object as the JSON so the two cannot drift.
9
+ *
10
+ * Two spreads are reported and never merged: between-session sigma, which compares
11
+ * whole sessions that shared nothing but the machine, and within-session sigma,
12
+ * which compares repeats that shared a browser and a warm cache. The first two
13
+ * sweeps of this project reported the second and described it as the first.
14
+ *
15
+ * Schema 3 adds `coverage` and `gate`: whether the run measured what it planned to,
16
+ * and the verdict a CI job exits on. In schema 2 reports the absence of `coverage`
17
+ * means unknown rather than complete, which is why the version moved.
18
+ */
19
+ import { rollUpControls, rollUpTool } from '../core/stats.mjs';
20
+
21
+ const percent = (value) =>
22
+ value === null || value === undefined ? '—' : `${(value * 100).toFixed(1)}%`;
23
+
24
+ const sigma = (value) => (typeof value === 'number' ? value.toFixed(3) : '—');
25
+
26
+ const interval = (wilsonResult) =>
27
+ !wilsonResult || wilsonResult.rate === null
28
+ ? '—'
29
+ : `${percent(wilsonResult.rate)} [${percent(wilsonResult.low)}, ${percent(wilsonResult.high)}]`;
30
+
31
+ export const buildReport = ({
32
+ fixture,
33
+ records,
34
+ harnessFailures = [],
35
+ recoveredFailures = 0,
36
+ coverage = null,
37
+ judge,
38
+ settings,
39
+ sessionResults = [],
40
+ timing,
41
+ }) => {
42
+ const toolRecords = records.filter((record) => record.kind === 'tool');
43
+ const controlRecords = records.filter((record) => record.kind === 'control');
44
+
45
+ const toolNames = [...new Set(toolRecords.map((record) => record.expectedTool))];
46
+ const tools = toolNames.map((tool) =>
47
+ rollUpTool({ tool, records: toolRecords.filter((record) => record.expectedTool === tool) })
48
+ );
49
+
50
+ const byTag = {};
51
+ for (const record of toolRecords) {
52
+ const tag = record.tag ?? 'untagged';
53
+ byTag[tag] ??= { trials: 0, ok: 0 };
54
+ byTag[tag].trials += 1;
55
+ if (record.outcome === 'ok') byTag[tag].ok += 1;
56
+ }
57
+ for (const tag of Object.keys(byTag)) {
58
+ byTag[tag].rate = byTag[tag].ok / byTag[tag].trials;
59
+ }
60
+
61
+ const sessionMetas = [];
62
+ for (const record of records) {
63
+ const session = record.session ?? 1;
64
+ if (sessionMetas.some((entry) => entry.session === session)) continue;
65
+ sessionMetas.push({ session, ...(record.sessionMeta ?? {}) });
66
+ }
67
+
68
+ return {
69
+ schema: 'webmcp-gauge/report/3',
70
+ generatedAt: new Date().toISOString(),
71
+ /**
72
+ * The subject label is explicit because the utterance set and the page under
73
+ * test are separable: the frozen Airlock set also drives a deliberately
74
+ * mis-described local twin, and a report that inherited the fixture's name
75
+ * would claim to have measured Airlock.
76
+ */
77
+ subject: {
78
+ url: settings.url,
79
+ name: settings.subjectName ?? fixture.subject?.name ?? null,
80
+ servedFrom: settings.servedFrom ?? null,
81
+ },
82
+ stamps: {
83
+ utteranceSet: {
84
+ version: fixture.version,
85
+ frozen: fixture.frozen === true,
86
+ authoringModel: fixture.authoring?.modelId ?? null,
87
+ },
88
+ judge,
89
+ harness: {
90
+ sessions: settings.sessions,
91
+ repeatsPerSession: settings.repeatsPerSession,
92
+ concurrency: settings.concurrency,
93
+ gapSeconds: settings.gapSeconds ?? 0,
94
+ isolatedSessions: settings.isolatedSessions !== false,
95
+ },
96
+ browsers: sessionMetas,
97
+ },
98
+ /**
99
+ * What a session does not isolate. Stated in the artifact rather than in a
100
+ * commit message, because someone reading the number a month from now is the
101
+ * person who needs it.
102
+ */
103
+ isolationCaveats: [
104
+ 'Sessions share the machine, the OS network stack and the route to the provider.',
105
+ 'Provider-side state (routing, caches, rate-limit counters, model version behind a slug) is not controlled.',
106
+ settings.gapSeconds > 0
107
+ ? `Sessions were spaced ${settings.gapSeconds}s apart, which is not the same as spanning days.`
108
+ : 'Sessions ran back to back, so this measures process and browser independence, not day-to-day drift.',
109
+ settings.isolatedSessions === false
110
+ ? 'This run attached to a pre-existing browser (--port), so sessions shared a browser process and page cache: between-session sigma here is not isolated.'
111
+ : 'Each session ran in its own OS process with its own browser and a cold profile, so no HTTP connection pool, renderer or page cache was shared.',
112
+ ],
113
+ invocation: {
114
+ overall: {
115
+ trials: toolRecords.length,
116
+ ok: toolRecords.filter((record) => record.outcome === 'ok').length,
117
+ },
118
+ perTool: tools,
119
+ perTag: byTag,
120
+ },
121
+ controls: controlRecords.length > 0 ? rollUpControls({ records: controlRecords }) : null,
122
+ /**
123
+ * Whether the run measured what it planned to measure, derived from the plan
124
+ * against the checkpoint rather than from the failure log. A log only knows
125
+ * about trials that failed loudly: a session killed mid-plan leaves no entry
126
+ * and would otherwise report as complete.
127
+ */
128
+ coverage,
129
+ harnessFailures,
130
+ recoveredFailures,
131
+ sessionResults,
132
+ timing,
133
+ };
134
+ };
135
+
136
+ export const toMarkdown = (report) => {
137
+ const lines = [];
138
+ const { harness } = report.stamps;
139
+
140
+ lines.push(`# webmcp-gauge — ${report.subject.name ?? report.subject.url}`);
141
+ lines.push('');
142
+ if (report.subject.servedFrom) {
143
+ lines.push(
144
+ `Subject: \`${report.subject.url}\`, served locally from \`${report.subject.servedFrom}\` — a fixture page in this repository, not a deployed site.`
145
+ );
146
+ lines.push('');
147
+ }
148
+ lines.push(
149
+ `Utterance set \`${report.stamps.utteranceSet.version}\`${report.stamps.utteranceSet.frozen ? ' (frozen)' : ' (DRAFT — numbers are not comparable)'} · judge \`${report.stamps.judge.model}\` · ${harness.sessions} session${harness.sessions === 1 ? '' : 's'} × ${harness.repeatsPerSession} repeat${harness.repeatsPerSession === 1 ? '' : 's'} · concurrency ${harness.concurrency}${harness.isolatedSessions ? '' : ' · **shared browser, sessions not isolated**'}`
150
+ );
151
+ lines.push('');
152
+ lines.push(
153
+ `Authored by \`${report.stamps.utteranceSet.authoringModel ?? 'unrecorded'}\`, which is disqualified as a judge for these numbers.`
154
+ );
155
+ lines.push('');
156
+
157
+ if (report.gate) {
158
+ lines.push(`**Gate:** ${report.gate.summary} _(exit ${report.gate.code})_`);
159
+ lines.push('');
160
+ }
161
+
162
+ lines.push('## Invocation rate');
163
+ lines.push('');
164
+ lines.push('| Tool | Rate (95% Wilson) | σ between sessions | σ within session | Trials | Outcomes |');
165
+ lines.push('|---|---|---|---|---|---|');
166
+ for (const tool of [...report.invocation.perTool].sort(
167
+ (a, b) => (b.invocation.rate ?? 0) - (a.invocation.rate ?? 0)
168
+ )) {
169
+ const outcomes = Object.entries(tool.outcomes)
170
+ .sort((a, b) => b[1] - a[1])
171
+ .map(([outcome, count]) => `${outcome} ${count}`)
172
+ .join(', ');
173
+ lines.push(
174
+ `| \`${tool.tool}\` | ${interval(tool.invocation)} | ${sigma(tool.betweenSession.sigma)} | ${sigma(tool.withinSession.sigma)} | ${tool.trials} | ${outcomes} |`
175
+ );
176
+ }
177
+ lines.push('');
178
+ lines.push(
179
+ '**σ between sessions** compares whole sessions, each with its own process, browser and cold profile — the only figure that speaks to reproducibility. **σ within session** compares repeats that shared a warm page and one provider connection, so it is a floor. Where the within-session column reads `—`, only one repeat per session was run.'
180
+ );
181
+ lines.push('');
182
+
183
+ lines.push('## By phrasing difficulty');
184
+ lines.push('');
185
+ lines.push('| Tag | Rate | Trials |');
186
+ lines.push('|---|---|---|');
187
+ for (const tag of ['plain', 'paraphrase', 'oblique']) {
188
+ const entry = report.invocation.perTag[tag];
189
+ if (!entry) continue;
190
+ lines.push(`| ${tag} | ${percent(entry.rate)} | ${entry.trials} |`);
191
+ }
192
+ lines.push('');
193
+
194
+ if (report.controls) {
195
+ lines.push('## Control false positives');
196
+ lines.push('');
197
+ lines.push(
198
+ 'A control utterance is one no registered tool can serve, so **not selecting anything is the pass**. This rate is never pooled with invocation rate.'
199
+ );
200
+ lines.push('');
201
+ lines.push(
202
+ `False positive rate: **${interval(report.controls.falsePositiveRate)}** over ${report.controls.trials} trials · σ between sessions ${sigma(report.controls.betweenSession.sigma)}.`
203
+ );
204
+ lines.push('');
205
+ lines.push('| Control class | False positives | Rate |');
206
+ lines.push('|---|---|---|');
207
+ for (const [tag, entry] of Object.entries(report.controls.byClass)) {
208
+ lines.push(`| ${tag} | ${entry.falsePositives}/${entry.trials} | ${interval(entry.rate)} |`);
209
+ }
210
+ lines.push('');
211
+ if (report.controls.injectionFailures.length > 0) {
212
+ lines.push(
213
+ `⚠️ **Injection-class false positives — a safety finding, not a scoring miss:** ${report.controls.injectionFailures
214
+ .map((failure) => `${failure.id} (session ${failure.session}) → \`${failure.selected}\``)
215
+ .join(', ')}`
216
+ );
217
+ lines.push('');
218
+ }
219
+ }
220
+
221
+ lines.push('## What a session does not isolate');
222
+ lines.push('');
223
+ for (const caveat of report.isolationCaveats) lines.push(`- ${caveat}`);
224
+ lines.push('');
225
+
226
+ if (report.stamps.browsers.length > 0) {
227
+ lines.push('| Session | Browser | Headless | Profile |');
228
+ lines.push('|---|---|---|---|');
229
+ for (const entry of report.stamps.browsers) {
230
+ lines.push(
231
+ `| ${entry.session} | ${entry.build ?? 'attached'} | ${entry.headless === undefined ? '—' : String(entry.headless)} | ${entry.profileDir ?? entry.note ?? '—'} |`
232
+ );
233
+ }
234
+ lines.push('');
235
+ }
236
+
237
+ if (
238
+ report.harnessFailures.length > 0 ||
239
+ report.recoveredFailures > 0 ||
240
+ (report.coverage?.missingTrials ?? 0) > 0
241
+ ) {
242
+ lines.push('## Harness failures');
243
+ lines.push('');
244
+ lines.push(
245
+ 'Trials that produced no measurement at all — an unreachable or truncated judge, or a page that never loaded, says nothing about the page under test. These are excluded from every rate above rather than counted as outcomes, and `--resume` retries them.'
246
+ );
247
+ lines.push('');
248
+ if (report.recoveredFailures > 0) {
249
+ lines.push(
250
+ `**${report.recoveredFailures} earlier failure${report.recoveredFailures === 1 ? '' : 's'} recovered by \`--resume\`** and are counted in the rates above.`
251
+ );
252
+ lines.push('');
253
+ }
254
+ if ((report.coverage?.missingTrials ?? 0) > 0) {
255
+ lines.push(
256
+ `**${report.coverage.missingTrials} of ${report.coverage.expectedTrials} planned trials have no measurement**, so every rate above is over a denominator this run did not choose. Re-run with \`--resume\`.`
257
+ );
258
+ if (report.coverage.missing?.length > 0) {
259
+ lines.push('');
260
+ lines.push(
261
+ `Missing (session:repeat:utterance, first ${report.coverage.missing.length}): ${report.coverage.missing.map((key) => `\`${key}\``).join(', ')}`
262
+ );
263
+ }
264
+ lines.push('');
265
+ } else if (report.harnessFailures.length === 0) {
266
+ lines.push('No outstanding gaps: every planned trial has a measurement.');
267
+ lines.push('');
268
+ }
269
+ for (const failure of report.harnessFailures) {
270
+ lines.push(
271
+ `- \`${failure.utteranceId}\` (session ${failure.session ?? '?'}, repeat ${failure.repeat}, ${failure.kind ?? 'unknown'}): ${failure.error}`
272
+ );
273
+ }
274
+ lines.push('');
275
+ }
276
+
277
+ lines.push(
278
+ `_${report.invocation.overall.ok}/${report.invocation.overall.trials} tool trials returned \`ok\`${report.coverage ? ` · ${report.coverage.expectedTrials - report.coverage.missingTrials}/${report.coverage.expectedTrials} planned trials measured` : ''}. Generated ${report.generatedAt} in ${(report.timing.elapsedMs / 1000).toFixed(0)}s._`
279
+ );
280
+
281
+ return `${lines.join('\n')}\n`;
282
+ };