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
package/core/stats.mjs ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The statistics a reported rate has to carry.
3
+ *
4
+ * K is small and outcomes are binomial, so a bare percentage is not defensible:
5
+ * 7/20 and 70/200 are the same fraction and not the same claim. Every rate here
6
+ * comes with a Wilson score interval, which stays inside [0,1] and stays sane at
7
+ * the edges where the normal approximation produces intervals that include
8
+ * impossible values.
9
+ */
10
+
11
+ /** 95% two-sided. Kept explicit so a report can say which z it used. */
12
+ export const Z_95 = 1.959963984540054;
13
+
14
+ export const wilson = (successes, trials, z = Z_95) => {
15
+ if (!Number.isInteger(successes) || !Number.isInteger(trials)) {
16
+ throw new TypeError('wilson takes integer counts');
17
+ }
18
+ if (trials < 0 || successes < 0 || successes > trials) {
19
+ throw new RangeError(`nonsensical counts: ${successes}/${trials}`);
20
+ }
21
+ if (trials === 0) return { rate: null, low: null, high: null, n: 0, z };
22
+
23
+ const p = successes / trials;
24
+ const z2 = z * z;
25
+ const denominator = 1 + z2 / trials;
26
+ const centre = p + z2 / (2 * trials);
27
+ const spread = z * Math.sqrt((p * (1 - p) + z2 / (4 * trials)) / trials);
28
+
29
+ return {
30
+ rate: p,
31
+ low: Math.max(0, (centre - spread) / denominator),
32
+ high: Math.min(1, (centre + spread) / denominator),
33
+ n: trials,
34
+ z,
35
+ };
36
+ };
37
+
38
+ /**
39
+ * Run-to-run spread across repeats. Agent behaviour is non-deterministic, and a
40
+ * number without this is the first thing a sceptic attacks - rightly, because
41
+ * variance that swamps the signal means the metric does not exist yet.
42
+ */
43
+ export const spread = (rates) => {
44
+ const values = rates.filter((value) => typeof value === 'number');
45
+ if (values.length === 0) return { mean: null, sigma: null, min: null, max: null, runs: 0 };
46
+
47
+ const mean = values.reduce((total, value) => total + value, 0) / values.length;
48
+ // Population sigma: these are all the runs, not a sample from more of them.
49
+ const variance =
50
+ values.reduce((total, value) => total + (value - mean) ** 2, 0) / values.length;
51
+
52
+ return {
53
+ mean,
54
+ sigma: Math.sqrt(variance),
55
+ min: Math.min(...values),
56
+ max: Math.max(...values),
57
+ runs: values.length,
58
+ };
59
+ };
60
+
61
+ export const countOutcomes = (records) =>
62
+ records.reduce((counts, record) => {
63
+ counts[record.outcome] = (counts[record.outcome] ?? 0) + 1;
64
+ return counts;
65
+ }, {});
66
+
67
+ /**
68
+ * Invocation rate: of the trials for one tool, the fraction that selected that
69
+ * tool with valid arguments and executed. Only `ok` counts - `bad_args` is a
70
+ * selection with the wrong arguments, and calling that a success would hide the
71
+ * failure the taxonomy exists to name.
72
+ *
73
+ * Two spreads are reported, and conflating them was the flaw in the first two
74
+ * sweeps: `betweenSession` compares whole sessions, each with its own browser
75
+ * process and cold cache, and is the only figure that speaks to reproducibility.
76
+ * `withinSession` compares repeats inside one session, which share a warm page
77
+ * and one provider connection, so it is a floor rather than a stability claim.
78
+ */
79
+ const groupRates = (records, keyOf) => {
80
+ const buckets = new Map();
81
+ for (const record of records) {
82
+ const key = keyOf(record);
83
+ if (key === undefined || key === null) continue;
84
+ const bucket = buckets.get(key) ?? { trials: 0, ok: 0 };
85
+ bucket.trials += 1;
86
+ if (record.outcome === 'ok') bucket.ok += 1;
87
+ buckets.set(key, bucket);
88
+ }
89
+ return [...buckets.values()].map((bucket) => bucket.ok / bucket.trials);
90
+ };
91
+
92
+ export const rollUpTool = ({ tool, records }) => {
93
+ const successes = records.filter((record) => record.outcome === 'ok').length;
94
+ const sessions = [...new Set(records.map((record) => record.session ?? record.repeat))];
95
+
96
+ const withinSessionSigmas = sessions
97
+ .map((session) =>
98
+ spread(
99
+ groupRates(
100
+ records.filter((record) => (record.session ?? record.repeat) === session),
101
+ (record) => record.repeat
102
+ )
103
+ ).sigma
104
+ )
105
+ .filter((sigma) => typeof sigma === 'number');
106
+
107
+ return {
108
+ tool,
109
+ trials: records.length,
110
+ ok: successes,
111
+ invocation: wilson(successes, records.length),
112
+ betweenSession: spread(groupRates(records, (record) => record.session ?? record.repeat)),
113
+ withinSession: {
114
+ sigma:
115
+ withinSessionSigmas.length > 0
116
+ ? withinSessionSigmas.reduce((total, value) => total + value, 0) /
117
+ withinSessionSigmas.length
118
+ : null,
119
+ sessions: withinSessionSigmas.length,
120
+ },
121
+ outcomes: countOutcomes(records),
122
+ };
123
+ };
124
+
125
+ /**
126
+ * Controls invert the taxonomy: not_selected is the pass, and any tool call is a
127
+ * false positive. This rate is never pooled with invocation rate - an agent that
128
+ * fires a tool at everything would otherwise look excellent.
129
+ */
130
+ export const rollUpControls = ({ records }) => {
131
+ const falsePositives = records.filter((record) => record.outcome !== 'not_selected');
132
+
133
+ const byClass = {};
134
+ for (const record of records) {
135
+ const tag = record.tag ?? 'untagged';
136
+ byClass[tag] ??= { trials: 0, falsePositives: 0 };
137
+ byClass[tag].trials += 1;
138
+ if (record.outcome !== 'not_selected') byClass[tag].falsePositives += 1;
139
+ }
140
+ for (const [tag, counts] of Object.entries(byClass)) {
141
+ byClass[tag].rate = wilson(counts.falsePositives, counts.trials);
142
+ }
143
+
144
+ const perSession = (() => {
145
+ const buckets = new Map();
146
+ for (const record of records) {
147
+ const key = record.session ?? record.repeat;
148
+ const bucket = buckets.get(key) ?? { trials: 0, bad: 0 };
149
+ bucket.trials += 1;
150
+ if (record.outcome !== 'not_selected') bucket.bad += 1;
151
+ buckets.set(key, bucket);
152
+ }
153
+ return [...buckets.values()].map((bucket) => bucket.bad / bucket.trials);
154
+ })();
155
+
156
+ return {
157
+ trials: records.length,
158
+ falsePositives: falsePositives.length,
159
+ falsePositiveRate: wilson(falsePositives.length, records.length),
160
+ betweenSession: spread(perSession),
161
+ byClass,
162
+ // An injection false positive is a safety finding, not a scoring miss, so it
163
+ // is surfaced on its own rather than averaged into the rest.
164
+ injectionFailures: falsePositives
165
+ .filter((record) => record.tag === 'injection')
166
+ .map((record) => ({
167
+ id: record.utteranceId,
168
+ session: record.session ?? record.repeat,
169
+ selected: record.selection?.tool ?? null,
170
+ })),
171
+ };
172
+ };
@@ -0,0 +1,156 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { countOutcomes, rollUpControls, rollUpTool, spread, wilson, Z_95 } from './stats.mjs';
4
+
5
+ const near = (actual, expected, tolerance = 1e-6) =>
6
+ assert.ok(
7
+ Math.abs(actual - expected) < tolerance,
8
+ `${actual} is not within ${tolerance} of ${expected}`
9
+ );
10
+
11
+ test('wilson matches a hand-derived interval for a small proportion', () => {
12
+ // 7/20, z=1.96, derived by hand rather than copied from the implementation:
13
+ // denominator = 1 + z^2/n = 1 + 3.8415/20 = 1.192074
14
+ // centre = p + z^2/(2n) = 0.35 + 0.0960375 = 0.4460375
15
+ // spread = z*sqrt((p(1-p) + z^2/(4n))/n)
16
+ // = 1.96*sqrt((0.2275 + 0.04801875)/20) = 0.230046
17
+ // low = (0.4460375 - 0.230046)/1.192074 = 0.181190
18
+ // high = (0.4460375 + 0.230046)/1.192074 = 0.567149
19
+ const interval = wilson(7, 20);
20
+ near(interval.rate, 0.35);
21
+ near(interval.low, 0.18119, 5e-5);
22
+ near(interval.high, 0.567149, 5e-5);
23
+ assert.equal(interval.n, 20);
24
+ assert.equal(interval.z, Z_95);
25
+ });
26
+
27
+ test('wilson stays inside [0,1] at the edges, where the normal approximation does not', () => {
28
+ const none = wilson(0, 20);
29
+ near(none.rate, 0);
30
+ assert.equal(none.low, 0);
31
+ assert.ok(none.high > 0 && none.high < 0.2, `implausible upper bound ${none.high}`);
32
+
33
+ const all = wilson(20, 20);
34
+ near(all.rate, 1);
35
+ assert.equal(all.high, 1);
36
+ assert.ok(all.low > 0.8 && all.low < 1, `implausible lower bound ${all.low}`);
37
+ });
38
+
39
+ test('wilson narrows as n grows for the same proportion', () => {
40
+ const small = wilson(7, 20);
41
+ const large = wilson(70, 200);
42
+ near(small.rate, large.rate);
43
+ assert.ok(
44
+ large.high - large.low < small.high - small.low,
45
+ 'the same fraction from more trials must be a tighter claim'
46
+ );
47
+ });
48
+
49
+ test('wilson refuses nonsensical counts rather than returning a number', () => {
50
+ assert.throws(() => wilson(3, 2), RangeError);
51
+ assert.throws(() => wilson(-1, 10), RangeError);
52
+ assert.throws(() => wilson(1.5, 10), TypeError);
53
+ });
54
+
55
+ test('an empty set has no rate at all, rather than zero', () => {
56
+ const interval = wilson(0, 0);
57
+ assert.equal(interval.rate, null);
58
+ assert.equal(interval.low, null);
59
+ assert.equal(interval.n, 0);
60
+ });
61
+
62
+ test('spread reports population sigma across repeats', () => {
63
+ const result = spread([0.4, 0.5, 0.6]);
64
+ near(result.mean, 0.5);
65
+ near(result.sigma, Math.sqrt(((0.1 ** 2) * 2) / 3));
66
+ near(result.min, 0.4);
67
+ near(result.max, 0.6);
68
+ assert.equal(result.runs, 3);
69
+ });
70
+
71
+ test('identical repeats have zero spread, and one repeat has no spread to report', () => {
72
+ near(spread([0.5, 0.5, 0.5]).sigma, 0);
73
+ near(spread([0.5]).sigma, 0);
74
+ assert.equal(spread([]).sigma, null);
75
+ });
76
+
77
+ test('outcome counts are exact, not bucketed', () => {
78
+ assert.deepEqual(
79
+ countOutcomes([{ outcome: 'ok' }, { outcome: 'ok' }, { outcome: 'bad_args' }]),
80
+ { ok: 2, bad_args: 1 }
81
+ );
82
+ });
83
+
84
+ test('only ok counts towards invocation rate — bad_args is not a partial success', () => {
85
+ const records = [
86
+ { session: 1, repeat: 1, outcome: 'ok' },
87
+ { session: 1, repeat: 1, outcome: 'bad_args' },
88
+ { session: 2, repeat: 1, outcome: 'ok' },
89
+ { session: 2, repeat: 1, outcome: 'wrong_tool' },
90
+ ];
91
+ const rollUp = rollUpTool({ tool: 'sum_by_category', records });
92
+
93
+ assert.equal(rollUp.trials, 4);
94
+ assert.equal(rollUp.ok, 2);
95
+ near(rollUp.invocation.rate, 0.5);
96
+ assert.deepEqual(rollUp.outcomes, { ok: 2, bad_args: 1, wrong_tool: 1 });
97
+ near(rollUp.betweenSession.mean, 0.5);
98
+ near(rollUp.betweenSession.sigma, 0);
99
+ assert.equal(rollUp.betweenSession.runs, 2);
100
+ });
101
+
102
+ test('between-session and within-session sigma are computed from different groupings', () => {
103
+ // Session 1 is internally stable at 1.0; session 2 is internally split 1.0/0.0.
104
+ // Between-session sigma compares 1.0 against 0.5; within-session averages 0 and 0.5.
105
+ const records = [
106
+ { session: 1, repeat: 1, outcome: 'ok' },
107
+ { session: 1, repeat: 2, outcome: 'ok' },
108
+ { session: 2, repeat: 1, outcome: 'ok' },
109
+ { session: 2, repeat: 2, outcome: 'wrong_tool' },
110
+ ];
111
+ const rollUp = rollUpTool({ tool: 'filter_rows', records });
112
+
113
+ near(rollUp.betweenSession.mean, 0.75);
114
+ near(rollUp.betweenSession.sigma, 0.25);
115
+ assert.equal(rollUp.betweenSession.runs, 2);
116
+ near(rollUp.withinSession.sigma, 0.25);
117
+ assert.equal(rollUp.withinSession.sessions, 2);
118
+ });
119
+
120
+ test('identical sessions report zero between-session sigma, which is a floor not a proof', () => {
121
+ const records = [
122
+ { session: 1, repeat: 1, outcome: 'wrong_tool' },
123
+ { session: 2, repeat: 1, outcome: 'wrong_tool' },
124
+ { session: 3, repeat: 1, outcome: 'wrong_tool' },
125
+ ];
126
+ const rollUp = rollUpTool({ tool: 'sum_by_category', records });
127
+ near(rollUp.invocation.rate, 0);
128
+ near(rollUp.betweenSession.sigma, 0);
129
+ });
130
+
131
+ test('controls invert the taxonomy: not_selected is the pass', () => {
132
+ const records = [
133
+ { session: 1, repeat: 1, outcome: 'not_selected', tag: 'off_topic', utteranceId: 'control-01' },
134
+ { session: 1, repeat: 1, outcome: 'ok', tag: 'out_of_scope', utteranceId: 'control-07' },
135
+ {
136
+ session: 2,
137
+ repeat: 1,
138
+ outcome: 'ok',
139
+ tag: 'injection',
140
+ utteranceId: 'control-19',
141
+ selection: { tool: 'describe_dataset' },
142
+ },
143
+ ];
144
+ const rollUp = rollUpControls({ records });
145
+
146
+ assert.equal(rollUp.trials, 3);
147
+ assert.equal(rollUp.falsePositives, 2);
148
+ near(rollUp.falsePositiveRate.rate, 2 / 3);
149
+ assert.equal(rollUp.byClass.off_topic.falsePositives, 0);
150
+ assert.equal(rollUp.byClass.out_of_scope.falsePositives, 1);
151
+ assert.deepEqual(rollUp.injectionFailures, [
152
+ { id: 'control-19', session: 2, selected: 'describe_dataset' },
153
+ ]);
154
+ // Session 1 was half bad, session 2 fully bad: sigma over [0.5, 1.0].
155
+ near(rollUp.betweenSession.sigma, 0.25);
156
+ });
package/core/sweep.mjs ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * One measurement session: every utterance, P repeats deep, one fresh tab per trial.
3
+ *
4
+ * A session owns its browser process and its cold profile, and runs in its own
5
+ * OS process, so nothing is shared with the sessions before or after it - not the
6
+ * page cache, not the renderer, not the judge's HTTP connection pool. That
7
+ * separation is the whole point: repeats inside a session are correlated by
8
+ * construction, and the first two sweeps reported their sigma as if it described
9
+ * reproducibility.
10
+ *
11
+ * A fresh tab per trial is the expensive choice and the correct one - reusing a
12
+ * tab means trial N's highlighting is trial N+1's starting state.
13
+ *
14
+ * Every completed trial is appended to a JSONL checkpoint before the next one
15
+ * starts, so a run that dies mid-session loses nothing and resumes.
16
+ */
17
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
18
+ import { dirname } from 'node:path';
19
+ import { openSession } from '../browser/session.mjs';
20
+ import { runTrial } from './trial.mjs';
21
+
22
+ /**
23
+ * Bounds one trial. Every wait inside a trial is bounded on its own now, but a
24
+ * sweep is a long-running unattended thing and a stall is its worst failure mode:
25
+ * the run that produced this comment sat on trial 160 of 160 for ninety minutes,
26
+ * printing nothing, because a CDP command never answered and nothing was watching.
27
+ * A trial that overruns is recorded as a non-measurement and retried by --resume,
28
+ * which is what every other unmeasurable trial already does.
29
+ */
30
+ const withDeadline = (promise, ms, label) => {
31
+ let timer;
32
+ const guard = new Promise((_, reject) => {
33
+ timer = setTimeout(() => {
34
+ const error = new Error(`${label} did not finish within ${ms}ms`);
35
+ error.code = 'DEADLINE';
36
+ reject(error);
37
+ }, ms);
38
+ });
39
+ return Promise.race([promise, guard]).finally(() => clearTimeout(timer));
40
+ };
41
+
42
+ /** Exported for tests: the deadline is the invariant, not an implementation detail. */
43
+ export const __withDeadline = withDeadline;
44
+
45
+ export const trialKey = (session, repeat, utteranceId) => `${session}:${repeat}:${utteranceId}`;
46
+
47
+ /**
48
+ * Appends harness failures as they happen rather than at the end of a session.
49
+ *
50
+ * This was written at session end once, and a session killed mid-plan then took its
51
+ * failure kinds with it: the run that produced `reports/discrimination-2026-08-30.md`
52
+ * lost three of them to an external timeout. Coverage still caught the missing
53
+ * trials, because that is computed from the plan rather than from this log - but the
54
+ * diagnosis was gone, and a diagnosis that only survives a clean exit is not much of
55
+ * a diagnosis.
56
+ */
57
+ export const appendFailures = async (path, failures) => {
58
+ if (!path || failures.length === 0) return;
59
+ await mkdir(dirname(path), { recursive: true });
60
+ await appendFile(path, `${failures.map((failure) => JSON.stringify(failure)).join('\n')}\n`, 'utf8');
61
+ };
62
+
63
+ /**
64
+ * Reads the failure log, keeping the newest entry per trial. The log accumulates
65
+ * across `--resume` attempts, so the same trial can appear more than once; a report
66
+ * wants "how many trials failed at least once", not "how many attempts failed".
67
+ */
68
+ export const readFailures = async (path) => {
69
+ try {
70
+ const text = await readFile(path, 'utf8');
71
+ const byTrial = new Map();
72
+ for (const line of text.split('\n')) {
73
+ if (line.trim().length === 0) continue;
74
+ const failure = JSON.parse(line);
75
+ byTrial.set(trialKey(failure.session ?? 1, failure.repeat, failure.utteranceId), failure);
76
+ }
77
+ return [...byTrial.values()];
78
+ } catch (error) {
79
+ if (error.code === 'ENOENT') return [];
80
+ throw error;
81
+ }
82
+ };
83
+
84
+ export const readCheckpoint = async (path) => {
85
+ try {
86
+ const text = await readFile(path, 'utf8');
87
+ const records = text
88
+ .split('\n')
89
+ .filter((line) => line.trim().length > 0)
90
+ .map((line) => JSON.parse(line));
91
+ return {
92
+ records,
93
+ keys: new Set(records.map((r) => trialKey(r.session ?? 1, r.repeat, r.utteranceId))),
94
+ };
95
+ } catch (error) {
96
+ if (error.code === 'ENOENT') return { records: [], keys: new Set() };
97
+ throw error;
98
+ }
99
+ };
100
+
101
+ /** Flattens the fixture into one session's trial plan, controls included. */
102
+ export const buildPlan = ({
103
+ fixture,
104
+ repeatsPerSession = 1,
105
+ tools = null,
106
+ includeControls = true,
107
+ }) => {
108
+ const plan = [];
109
+
110
+ for (let repeat = 1; repeat <= repeatsPerSession; repeat += 1) {
111
+ for (const toolBlock of fixture.tools) {
112
+ if (tools && !tools.includes(toolBlock.name)) continue;
113
+ for (const utterance of toolBlock.utterances) {
114
+ plan.push({
115
+ repeat,
116
+ kind: 'tool',
117
+ toolName: toolBlock.name,
118
+ utterance,
119
+ setup: toolBlock.setup ?? null,
120
+ });
121
+ }
122
+ }
123
+
124
+ if (includeControls && fixture.controls) {
125
+ for (const utterance of fixture.controls.utterances) {
126
+ plan.push({ repeat, kind: 'control', toolName: null, utterance, setup: null });
127
+ }
128
+ }
129
+ }
130
+
131
+ return plan;
132
+ };
133
+
134
+ const toCheckpointRecord = ({ item, record, session, sessionMeta }) => ({
135
+ session,
136
+ repeat: item.repeat,
137
+ kind: item.kind,
138
+ utteranceId: item.utterance.id,
139
+ tag: item.utterance.tag ?? null,
140
+ expectedTool: item.toolName,
141
+ outcome: record.outcome,
142
+ reason: record.reason,
143
+ selection: record.selection,
144
+ violations: record.violations ?? null,
145
+ execution: record.execution ?? null,
146
+ observationChanged: record.observation?.changed ?? null,
147
+ client: record.client,
148
+ judge: record.judge,
149
+ seed: record.seed ?? null,
150
+ startedAt: record.trial.startedAt,
151
+ sessionMeta,
152
+ });
153
+
154
+ export const runSessionSweep = async ({
155
+ fixture,
156
+ judge,
157
+ url,
158
+ session = 1,
159
+ sessionMeta = null,
160
+ repeatsPerSession = 1,
161
+ tools = null,
162
+ includeControls = true,
163
+ concurrency = 1,
164
+ port,
165
+ checkpointPath,
166
+ failureLogPath = null,
167
+ /**
168
+ * Generous on purpose: the judge alone may take 60s, and a trial is a navigation,
169
+ * a manifest settle, a judge call and an execution. The observed median is under
170
+ * ten seconds, so this catches stalls rather than slow work.
171
+ */
172
+ trialTimeoutMs = 180000,
173
+ onProgress = () => {},
174
+ }) => {
175
+ const plan = buildPlan({ fixture, repeatsPerSession, tools, includeControls });
176
+ const { keys: done } = checkpointPath
177
+ ? await readCheckpoint(checkpointPath)
178
+ : { keys: new Set() };
179
+ const pending = plan.filter(
180
+ (item) => !done.has(trialKey(session, item.repeat, item.utterance.id))
181
+ );
182
+
183
+ if (checkpointPath) await mkdir(dirname(checkpointPath), { recursive: true });
184
+
185
+ const written = [];
186
+ const failures = [];
187
+ let completed = 0;
188
+ let cursor = 0;
189
+
190
+ const worker = async () => {
191
+ while (cursor < pending.length) {
192
+ const item = pending[cursor];
193
+ cursor += 1;
194
+
195
+ // Recorded the moment it happens, not at the end of the session: a killed
196
+ // process must not take the reason with it.
197
+ const recordFailure = async (failure) => {
198
+ failures.push(failure);
199
+ await appendFailures(failureLogPath, [failure]).catch(() => {});
200
+ };
201
+
202
+ let tab;
203
+ try {
204
+ // The work is started as its own promise so the deadline can stop *waiting*
205
+ // for it. Abandoned work still settles later, and its rejection must not
206
+ // reach the process as an unhandled one, hence the bare catch.
207
+ const attempt = (async () => {
208
+ tab = await openSession({ port });
209
+ return runTrial({
210
+ session: tab,
211
+ judge,
212
+ url,
213
+ toolName: item.toolName,
214
+ utterance: item.utterance,
215
+ expectation: item.kind === 'tool' ? item.utterance : {},
216
+ setup: item.setup,
217
+ fixtureVersion: fixture.version,
218
+ controlMode: item.kind === 'control',
219
+ });
220
+ })();
221
+ attempt.catch(() => {});
222
+
223
+ const record = await withDeadline(
224
+ attempt,
225
+ trialTimeoutMs,
226
+ `trial ${item.utterance.id} (session ${session}, repeat ${item.repeat})`
227
+ );
228
+
229
+ if (record.outcome === null) {
230
+ // The trial ran but produced no measurement - an unreachable or truncated
231
+ // judge says nothing about the page. It stays out of the checkpoint so a
232
+ // later --resume retries it instead of baking a non-result into the rates.
233
+ await recordFailure({
234
+ session,
235
+ repeat: item.repeat,
236
+ utteranceId: item.utterance.id,
237
+ kind: record.harnessFailure?.kind ?? 'unknown',
238
+ error: record.harnessFailure?.detail ?? 'no outcome and no reason given',
239
+ });
240
+ } else {
241
+ const checkpointRecord = toCheckpointRecord({ item, record, session, sessionMeta });
242
+ written.push(checkpointRecord);
243
+ if (checkpointPath) {
244
+ await appendFile(checkpointPath, `${JSON.stringify(checkpointRecord)}\n`, 'utf8');
245
+ }
246
+ }
247
+ } catch (error) {
248
+ await recordFailure({
249
+ session,
250
+ repeat: item.repeat,
251
+ utteranceId: item.utterance.id,
252
+ kind: error.code === 'DEADLINE' ? 'trial_timeout' : 'trial_threw',
253
+ error: String(error.message ?? error),
254
+ });
255
+ } finally {
256
+ if (tab) await tab.close().catch(() => {});
257
+ }
258
+
259
+ completed += 1;
260
+ onProgress({ completed, total: pending.length, item, session, failures: failures.length });
261
+ }
262
+ };
263
+
264
+ const startedMs = Date.now();
265
+ await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
266
+
267
+ return {
268
+ session,
269
+ written,
270
+ failures,
271
+ plan: { total: plan.length, attempted: pending.length },
272
+ elapsedMs: Date.now() - startedMs,
273
+ };
274
+ };