@openwop/openwop-conformance 2.46.6 → 2.46.8

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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * RFC 0244 — the suite's reference grader for eval trajectories and trials.
3
+ *
4
+ * Pure functions, no I/O. `v2-eval-trajectory-trials.test.ts` uses them to
5
+ * recompute what a host must report, so a host's grade is checked against the
6
+ * normative definition rather than against itself:
7
+ *
8
+ * gradeTrajectory §A — the four match modes, `forbidden`, `argsMatch`,
9
+ * and the mode-independent `matchedCount` (a maximum
10
+ * one-to-one matching, ignoring order).
11
+ * passAtK / passHatK / scoreStderr §B — the normative formulas.
12
+ *
13
+ * @see RFCS/0244-eval-trajectories-trials-and-recorded-fixtures.md
14
+ * @see schemas/v2/agent-eval-suite.schema.json, schemas/v2/eval-summary.schema.json
15
+ */
16
+
17
+ export type ArgsMatch = 'ignore' | 'exact' | 'subset';
18
+ export type MatchMode = 'exact' | 'in-order' | 'any-order' | 'subset';
19
+ export interface ExpectedCall { readonly tool: string; readonly args?: unknown; readonly argsMatch?: ArgsMatch }
20
+ export interface ObservedCall { readonly tool: string; readonly inputs?: unknown }
21
+ export interface TrajectorySpec { readonly match: MatchMode; readonly calls: readonly ExpectedCall[]; readonly forbidden?: readonly string[] }
22
+ export interface TrajectoryGrade {
23
+ readonly mode: MatchMode;
24
+ readonly matched: boolean;
25
+ readonly expectedCount: number;
26
+ readonly observedCount: number;
27
+ readonly matchedCount: number;
28
+ readonly forbiddenCount: number;
29
+ }
30
+
31
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
32
+
33
+ /** JSON deep equality, key-order insensitive. */
34
+ export function jsonEqual(a: unknown, b: unknown): boolean {
35
+ if (a === b) return true;
36
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((x, i) => jsonEqual(x, b[i]));
37
+ if (isRecord(a) && isRecord(b)) {
38
+ const ka = Object.keys(a);
39
+ return ka.length === Object.keys(b).length && ka.every((k) => k in b && jsonEqual(a[k], b[k]));
40
+ }
41
+ return false;
42
+ }
43
+
44
+ /** `subset`: every key path in `expected` is present in `actual` with a JSON-deep-equal value. */
45
+ export function jsonSubset(expected: unknown, actual: unknown): boolean {
46
+ if (isRecord(expected)) return isRecord(actual) && Object.keys(expected).every((k) => k in actual && jsonSubset(expected[k], actual[k]));
47
+ return jsonEqual(expected, actual);
48
+ }
49
+
50
+ export function callMatches(e: ExpectedCall, o: ObservedCall): boolean {
51
+ if (e.tool !== o.tool) return false;
52
+ const mode = e.argsMatch ?? 'ignore';
53
+ if (mode === 'ignore' || e.args === undefined) return true;
54
+ return mode === 'exact' ? jsonEqual(e.args, o.inputs) : jsonSubset(e.args, o.inputs);
55
+ }
56
+
57
+ /** Size of a maximum one-to-one matching (Kuhn's augmenting paths). */
58
+ function maxMatching(expected: readonly ExpectedCall[], observed: readonly ObservedCall[]): number {
59
+ const owner: (number | undefined)[] = new Array(observed.length).fill(undefined);
60
+ const augment = (i: number, seen: boolean[]): boolean => {
61
+ for (let j = 0; j < observed.length; j++) {
62
+ if (seen[j] || !callMatches(expected[i]!, observed[j]!)) continue;
63
+ seen[j] = true;
64
+ if (owner[j] === undefined || augment(owner[j]!, seen)) { owner[j] = i; return true; }
65
+ }
66
+ return false;
67
+ };
68
+ let n = 0;
69
+ for (let i = 0; i < expected.length; i++) if (augment(i, new Array(observed.length).fill(false))) n++;
70
+ return n;
71
+ }
72
+
73
+ function inOrder(expected: readonly ExpectedCall[], observed: readonly ObservedCall[]): boolean {
74
+ let j = 0;
75
+ for (const e of expected) {
76
+ while (j < observed.length && !callMatches(e, observed[j]!)) j++;
77
+ if (j === observed.length) return false;
78
+ j++;
79
+ }
80
+ return true;
81
+ }
82
+
83
+ export function gradeTrajectory(spec: TrajectorySpec, observed: readonly ObservedCall[]): TrajectoryGrade {
84
+ const forbidden = new Set(spec.forbidden ?? []);
85
+ const forbiddenCount = observed.filter((o) => forbidden.has(o.tool)).length;
86
+ const matchedCount = maxMatching(spec.calls, observed);
87
+ let modeOk: boolean;
88
+ switch (spec.match) {
89
+ case 'exact': modeOk = observed.length === spec.calls.length && spec.calls.every((e, i) => callMatches(e, observed[i]!)); break;
90
+ case 'in-order': modeOk = inOrder(spec.calls, observed); break;
91
+ case 'any-order': modeOk = matchedCount === spec.calls.length; break;
92
+ case 'subset': modeOk = observed.every((o) => spec.calls.some((e) => callMatches(e, o))); break;
93
+ }
94
+ return { mode: spec.match, matched: modeOk && forbiddenCount === 0, expectedCount: spec.calls.length, observedCount: observed.length, matchedCount, forbiddenCount };
95
+ }
96
+
97
+ /**
98
+ * C(a, k) / C(b, k) as an exact rational, converted to a double once. The
99
+ * ratio telescopes to Π (a−i)/(b−i) for i < k, so no factorial is formed and
100
+ * the result is stable for large n (pass^k(200, 100, 50) ≈ 2.2e-19).
101
+ */
102
+ function binomRatio(a: number, b: number, k: number): number {
103
+ if (k < 0 || k > b) throw new RangeError(`k=${k} outside 0..${b}`);
104
+ if (k > a) return 0;
105
+ let num = 1n;
106
+ let den = 1n;
107
+ for (let i = 0; i < k; i++) { num *= BigInt(a - i); den *= BigInt(b - i); }
108
+ return ratioToNumber(num, den);
109
+ }
110
+
111
+ /** num/den (num ≤ den, both > 0) to the nearest double, without overflowing either. */
112
+ function ratioToNumber(num: bigint, den: bigint): number {
113
+ if (num === 0n) return 0;
114
+ const shift = den.toString(2).length - num.toString(2).length + 64;
115
+ const q = (num << BigInt(shift)) / den;
116
+ let r = Number(q);
117
+ let e = shift;
118
+ while (e > 1000) { r *= 2 ** -1000; e -= 1000; }
119
+ return r * 2 ** -e;
120
+ }
121
+
122
+ /** pass@k = 1 − C(n−c, k) / C(n, k); 0 when c = 0. */
123
+ export function passAtK(n: number, c: number, k: number): number {
124
+ if (c === 0) return 0;
125
+ return 1 - binomRatio(n - c, n, k);
126
+ }
127
+
128
+ /** pass^k = C(c, k) / C(n, k); 0 when c < k. */
129
+ export function passHatK(n: number, c: number, k: number): number {
130
+ if (c < k) return 0;
131
+ return binomRatio(c, n, k);
132
+ }
133
+
134
+ /** RFC 0244 §B tolerance: a host value agrees with the formula when |host − ref| ≤ 1e-9 · max(1, |ref|). */
135
+ export function withinTolerance(host: number, ref: number): boolean {
136
+ return Number.isFinite(host) && Math.abs(host - ref) <= 1e-9 * Math.max(1, Math.abs(ref));
137
+ }
138
+
139
+ /** Sample sd of the per-task mean scores over √T; undefined when T < 2. */
140
+ export function scoreStderr(taskMeans: readonly number[]): number | undefined {
141
+ const t = taskMeans.length;
142
+ if (t < 2) return undefined;
143
+ const mean = taskMeans.reduce((a, b) => a + b, 0) / t;
144
+ const variance = taskMeans.reduce((a, b) => a + (b - mean) ** 2, 0) / (t - 1);
145
+ return Math.sqrt(variance) / Math.sqrt(t);
146
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * v2 — eval suites grade the tool path, run repeated trials, and fail closed
3
+ * on a fixture miss (RFC 0244; the rules are the `agents.evalSuite` facet
4
+ * description in `spec/v2/facets/agents.schema.json` and the
5
+ * `agent-eval-suite` / `eval-summary` / `run-event-payloads` descriptions).
6
+ *
7
+ * Server-free legs (always on):
8
+ * 1. The RFC 0244 suite, summary and `eval.scored` / `eval.started` fields
9
+ * validate, and the RFC's negatives are refused — including the
10
+ * content-free ones that are the v2 public test for SECURITY invariant
11
+ * `eval-summary-no-content-leak` (a trajectory block naming a tool, a
12
+ * prose `scoreLabel`).
13
+ * 2. The three eval-suite fixtures validate against the v2 suite schema.
14
+ *
15
+ * Host legs (gated on the facet member AND the fixture in discovery
16
+ * `fixtures`). Each fixture is an `AgentEvalSuite` under
17
+ * `fixtures/eval-suites/`, run against the scripted agent
18
+ * `core.conformance.agents.scripted` (contract in `fixtures.md`): every task's
19
+ * `input.script` is replayed as tool calls, so the trajectory is known to the
20
+ * suite in advance and the host's grade is checked against the reference
21
+ * grader in `lib/eval-trajectory.ts`, not against itself.
22
+ * 3. `conformance-eval-trajectory` — per-task `passed` and the trajectory
23
+ * counts equal the reference grade; the log carries the scripted calls.
24
+ * 4. `conformance-eval-trials` — `trialCount` on `eval.started`, one
25
+ * `eval.scored` per task per trial with contiguous `trialIndex`, and the
26
+ * per-task `trials` block and `trialStats` equal the §B formulas.
27
+ * 5. `conformance-eval-fixture-miss` — the missed task reports
28
+ * `fixtureMissCount: 1` and `passed: false`; the served one passes.
29
+ * That no live tool ran is seam-gated (RFC 0244 G2) and not asserted.
30
+ * 6. Refusal — a host advertising `agents.evalSuite` without `trials` that
31
+ * lists `conformance-eval-trials` refuses it at createRun with
32
+ * `400 validation_error`.
33
+ *
34
+ * @see RFCS/0244-eval-trajectories-trials-and-recorded-fixtures.md
35
+ */
36
+
37
+ import { describe, it, expect } from 'vitest';
38
+ import { readFileSync } from 'node:fs';
39
+ import { join } from 'node:path';
40
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
41
+ import { v2Discovery, v2Validator, v2RefValidator } from '../lib/v2.js';
42
+ import { softSkip } from '../lib/soft-skip.js';
43
+ import { req } from '../lib/requirement-ids.js';
44
+ import { readErrorCode } from '../lib/error-envelope.js';
45
+ import { majorProfile } from '../lib/major-profile.js';
46
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
47
+ import { FIXTURES_DIR } from '../lib/paths.js';
48
+ import { pollTerminal, readEvents, isRecord, type Ev } from '../lib/fixture-run-observer.js';
49
+ import { gradeTrajectory, passAtK, passHatK, scoreStderr, withinTolerance, type TrajectorySpec } from '../lib/eval-trajectory.js';
50
+
51
+ const PROFILE = majorProfile(2);
52
+ const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
53
+ const DOC = 'agents.schema.json §evalSuite (RFC 0244)';
54
+ const AGENT = 'core.conformance.agents.scripted';
55
+ const SUITE_URI = (id: string) => `https://conformance.openwop.dev/eval-suites/${id}`;
56
+ const ID_SHAPE = 'openwop.requirement.0244.wire-shape';
57
+ const ID_CONTENT_FREE = 'openwop.requirement.0244.content-free';
58
+ const ID_FIXTURES = 'openwop.requirement.0244.fixtures-valid';
59
+ const ID_TRAJECTORY = 'openwop.requirement.0244.trajectory-graded';
60
+ const ID_TRIALS = 'openwop.requirement.0244.trials-per-task';
61
+ const ID_TRIAL_STATS = 'openwop.requirement.0244.trial-stats-formula';
62
+ const ID_MISS = 'openwop.requirement.0244.fixture-miss-fails';
63
+ const ID_REFUSED = 'openwop.requirement.0244.unadvertised-feature-refused';
64
+
65
+ interface Task { taskId: string; input: { script: Array<{ tool: string; arguments?: unknown }>; output: string }; expected: { kind: string; trajectory?: TrajectorySpec } }
66
+ interface Suite { tasks: Task[]; trials?: { count: number; k?: number; passRule?: string } }
67
+ const fixture = (id: string): Suite => JSON.parse(readFileSync(join(FIXTURES_DIR, 'eval-suites', `${id}.json`), 'utf8')) as Suite;
68
+
69
+ const suiteSchema = v2Validator('agent-eval-suite');
70
+ const summarySchema = v2Validator('eval-summary');
71
+ const scored = v2RefValidator('run-event-payloads.schema.json#/$defs/evalScored');
72
+ const started = v2RefValidator('run-event-payloads.schema.json#/$defs/evalStarted');
73
+
74
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
75
+ try { return await fn(); } catch { return null; }
76
+ }
77
+
78
+ async function evalSuiteFacet(): Promise<{ doc: Record<string, unknown> | null; facet: Record<string, unknown> | null }> {
79
+ let doc: Record<string, unknown> | null;
80
+ try { doc = await v2Discovery(); } catch { doc = null; }
81
+ const agents = doc?.['agents'];
82
+ const facet = isRecord(agents) && isRecord(agents['evalSuite']) ? agents['evalSuite'] : null;
83
+ return { doc, facet };
84
+ }
85
+
86
+ type Observed = { kind: 'skip'; disposition: 'inapplicable' | 'blocked'; reason: string } | { kind: 'ok'; events: Ev[]; summary: Record<string, unknown> };
87
+
88
+ const runs = new Map<string, Promise<Observed>>();
89
+ /** One eval run per fixture per worker: two legs that read the same run share it. */
90
+ function runSuite(id: string): Promise<Observed> {
91
+ let p = runs.get(id);
92
+ if (p === undefined) { p = runSuiteOnce(id); runs.set(id, p); }
93
+ return p;
94
+ }
95
+
96
+ async function runSuiteOnce(id: string): Promise<Observed> {
97
+ const res = await http(() => driver.post(PROFILE.runsPath, { mode: 'eval', evalSuiteRef: SUITE_URI(id), agentId: AGENT }));
98
+ if (res === null) return { kind: 'skip', disposition: 'blocked', reason: 'POST /runs unreachable (fetch failed)' };
99
+ if (res.status === 429) return { kind: 'skip', disposition: 'blocked', reason: 'POST /runs answered 429 — the run budget, not the wire' };
100
+ const runId = isRecord(res.json) && typeof res.json['runId'] === 'string' ? res.json['runId'] : undefined;
101
+ if ((res.status !== 201 && res.status !== 202) || runId === undefined) return { kind: 'skip', disposition: 'blocked', reason: `createRun of eval suite ${id} answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
102
+ const terminal = await pollTerminal(PROFILE, runId, { timeoutMs: 60_000 });
103
+ if (terminal.snapshot['status'] !== 'completed') return { kind: 'skip', disposition: 'blocked', reason: `eval run ${runId} ended ${String(terminal.snapshot['status'])}, not completed` };
104
+ const s = await http(() => driver.get(`${PROFILE.runsPath}/${PROFILE.idSegment(runId)}/eval-summary`));
105
+ if (s === null || s.status !== 200 || !isRecord(s.json)) return { kind: 'skip', disposition: 'blocked', reason: `GET …/eval-summary answered ${s?.status ?? 'nothing'}` };
106
+ return { kind: 'ok', events: await readEvents(PROFILE, runId), summary: s.json };
107
+ }
108
+
109
+ const taskEntry = (summary: Record<string, unknown>, taskId: string): Record<string, unknown> | undefined =>
110
+ (Array.isArray(summary['tasks']) ? (summary['tasks'] as unknown[]) : []).filter(isRecord).find((t) => t['taskId'] === taskId);
111
+
112
+ describe('v2 RFC 0244 — wire shape (server-free)', () => {
113
+ it('the RFC 0244 suite, summary and event fields validate and the negatives are refused', () => {
114
+ const traj = fixture('conformance-eval-trajectory');
115
+ const bad = (mut: (s: Suite) => void): boolean => { const s = structuredClone(traj) as Suite; mut(s); return suiteSchema(s).ok; };
116
+ expect(bad((s) => { (s.tasks[0]!.expected.trajectory as { match: string }).match = 'superset'; }), req(ID_SHAPE, DOC, 'trajectory.match "superset" MUST be refused (use any-order)')).toBe(false);
117
+ expect(bad((s) => { s.trials = { count: 0 }; }), req(ID_SHAPE, DOC, 'trials.count 0 MUST be refused')).toBe(false);
118
+ expect(bad((s) => { (s.tasks[0]!.expected.trajectory as unknown as Record<string, unknown>)['weight'] = 0.5; }), req(ID_SHAPE, DOC, 'an unknown trajectory member MUST be refused')).toBe(false);
119
+
120
+ const good = { taskId: 't', score: 1, passed: true, trialIndex: 2, scoreLabel: 'pass', evaluatorType: 'deterministic' };
121
+ expect(scored(good).ok, req(ID_SHAPE, DOC, `eval.scored with trialIndex/scoreLabel/evaluatorType MUST validate: ${scored(good).errors}`)).toBe(true);
122
+ expect(started({ suiteId: 'core.openwop.evals.x', suiteVersion: '1.0.0', taskCount: 2, modes: ['golden'], trialCount: 3 }).ok, req(ID_SHAPE, DOC, 'eval.started.trialCount MUST validate')).toBe(true);
123
+ expect(scored({ ...good, evaluatorType: 'gpt' }).ok, req(ID_SHAPE, DOC, 'an unknown evaluatorType MUST be refused')).toBe(false);
124
+
125
+ const summary = {
126
+ suiteId: 'core.openwop.evals.x', suiteVersion: '1.0.0', aggregateScore: 0.5, passed: false, taskCount: 1, passedCount: 0,
127
+ tasks: [{ taskId: 't', score: 0.8, passed: false,
128
+ trajectory: { mode: 'in-order', matched: false, expectedCount: 2, observedCount: 3, matchedCount: 1, forbiddenCount: 1 },
129
+ trials: { n: 5, passedCount: 4, meanScore: 0.8, passAtK: 1, passHatK: 0.4 },
130
+ efficiency: { toolCallCount: 3, modelCallCount: 1, inputTokens: 10, outputTokens: 5 }, fixtureMissCount: 0 }],
131
+ trialStats: { k: 3, passAtK: 1, passHatK: 0.4 },
132
+ };
133
+ expect(summarySchema(summary).ok, req(ID_SHAPE, DOC, `an RFC 0244 EvalSummary MUST validate: ${summarySchema(summary).errors}`)).toBe(true);
134
+ });
135
+
136
+ it('the RFC 0244 additions are content-free (eval-summary-no-content-leak)', () => {
137
+ const base = { suiteId: 'core.openwop.evals.x', suiteVersion: '1.0.0', aggregateScore: 0, passed: false, taskCount: 1, passedCount: 0 };
138
+ const leak = { ...base, tasks: [{ taskId: 't', score: 0, passed: false, trajectory: { mode: 'exact', matched: false, expectedCount: 1, observedCount: 1, matchedCount: 0, forbiddenCount: 1, tools: ['billing:refund'] } }] };
139
+ expect(summarySchema(leak).ok, req(ID_CONTENT_FREE, DOC, 'a trajectory block naming tools MUST be refused')).toBe(false);
140
+ expect(scored({ taskId: 't', score: 1, passed: true, scoreLabel: 'Looks good to me' }).ok, req(ID_CONTENT_FREE, DOC, 'a prose scoreLabel MUST be refused')).toBe(false);
141
+ });
142
+
143
+ it('the eval-suite fixtures validate against the v2 suite schema', () => {
144
+ for (const id of ['conformance-eval-trajectory', 'conformance-eval-trials', 'conformance-eval-fixture-miss']) {
145
+ const r = suiteSchema(fixture(id));
146
+ expect(r.ok, req(ID_FIXTURES, 'fixtures.md §eval-suites', `${id} MUST validate: ${r.errors}`)).toBe(true);
147
+ }
148
+ });
149
+ });
150
+
151
+ describe('v2 RFC 0244 — host behaviour', () => {
152
+ it('a trajectory suite is graded per the reference grader', async () => {
153
+ const { doc, facet } = await evalSuiteFacet();
154
+ if (!doc) return softSkip('blocked', UNREADABLE);
155
+ if (facet?.['trajectory'] !== true) return softSkip('inapplicable', 'the host does not advertise agents.evalSuite.trajectory');
156
+ const id = 'conformance-eval-trajectory';
157
+ if (!isFixtureAdvertised(id)) return softSkip('inapplicable', `the host does not advertise fixture ${id}`);
158
+ const o = await runSuite(id);
159
+ if (o.kind === 'skip') return softSkip(o.disposition, o.reason);
160
+ const suite = fixture(id);
161
+ for (const t of suite.tasks) {
162
+ const want = gradeTrajectory(t.expected.trajectory!, t.input.script.map((c) => ({ tool: c.tool, inputs: c.arguments })));
163
+ const entry = taskEntry(o.summary, t.taskId);
164
+ expect(entry, req(ID_TRAJECTORY, DOC, `the summary MUST carry task ${t.taskId}`)).toBeDefined();
165
+ const got = isRecord(entry?.['trajectory']) ? entry['trajectory'] : {};
166
+ expect(got, req(ID_TRAJECTORY, DOC, `task ${t.taskId}: the trajectory counts MUST equal the reference grade`)).toEqual(want);
167
+ if (!want.matched) expect(entry?.['passed'], req(ID_TRAJECTORY, DOC, `task ${t.taskId}: an unmatched trajectory MUST NOT pass`)).toBe(false);
168
+ if (t.expected.kind === 'trajectory') expect(entry?.['score'], req(ID_TRAJECTORY, DOC, `task ${t.taskId}: a trajectory-only task scores 1 when matched, else 0`)).toBe(want.matched ? 1 : 0);
169
+ }
170
+ const calls = o.events.filter((e) => e.type === 'agent.tool-called' && e.payload['agentId'] === AGENT).length;
171
+ const scripted = suite.tasks.reduce((a, t) => a + t.input.script.length, 0);
172
+ expect(calls, req(ID_TRAJECTORY, DOC, `the log MUST carry one agent.tool-called per scripted call (${scripted})`)).toBe(scripted);
173
+ });
174
+
175
+ async function trialsRun(): Promise<{ skip: [Parameters<typeof softSkip>[0], string] } | { o: Extract<Observed, { kind: 'ok' }>; suite: Suite; n: number; k: number }> {
176
+ const { doc, facet } = await evalSuiteFacet();
177
+ if (!doc) return { skip: ['blocked', UNREADABLE] };
178
+ if (!isRecord(facet?.['trials'])) return { skip: ['inapplicable', 'the host does not advertise agents.evalSuite.trials'] };
179
+ const id = 'conformance-eval-trials';
180
+ if (!isFixtureAdvertised(id)) return { skip: ['inapplicable', `the host does not advertise fixture ${id}`] };
181
+ const o = await runSuite(id);
182
+ if (o.kind === 'skip') return { skip: [o.disposition, o.reason] };
183
+ const suite = fixture(id);
184
+ const n = suite.trials!.count;
185
+ return { o, suite, n, k: suite.trials!.k ?? n };
186
+ }
187
+
188
+ it('repeated trials emit one eval.scored per task per trial', async () => {
189
+ const r = await trialsRun();
190
+ if ('skip' in r) return softSkip(...r.skip);
191
+ const { o, suite, n } = r;
192
+ expect(o.events.find((e) => e.type === 'eval.started')?.payload['trialCount'], req(ID_TRIALS, DOC, 'eval.started MUST carry trialCount')).toBe(n);
193
+ for (const t of suite.tasks) {
194
+ const rows = o.events.filter((e) => e.type === 'eval.scored' && e.payload['taskId'] === t.taskId);
195
+ expect(rows.map((x) => x.payload['trialIndex']).sort(), req(ID_TRIALS, DOC, `task ${t.taskId}: trialIndex MUST be 0..${n - 1}, once each`)).toEqual([...Array(n).keys()]);
196
+ }
197
+ });
198
+
199
+ it('the per-task trials block and trialStats follow the §B formulas', async () => {
200
+ const r = await trialsRun();
201
+ if ('skip' in r) return softSkip(...r.skip);
202
+ const { o, suite, n, k } = r;
203
+ const means: number[] = [];
204
+ for (const t of suite.tasks) {
205
+ const rows = o.events.filter((e) => e.type === 'eval.scored' && e.payload['taskId'] === t.taskId);
206
+ const c = rows.filter((x) => x.payload['passed'] === true).length;
207
+ const mean = rows.length === 0 ? 0 : rows.reduce((a, x) => a + Number(x.payload['score']), 0) / rows.length;
208
+ means.push(mean);
209
+ const trials = taskEntry(o.summary, t.taskId)?.['trials'];
210
+ const got = isRecord(trials) ? trials : {};
211
+ expect(got['n'], req(ID_TRIAL_STATS, DOC, `task ${t.taskId}: trials.n`)).toBe(n);
212
+ expect(got['passedCount'], req(ID_TRIAL_STATS, DOC, `task ${t.taskId}: trials.passedCount MUST count the passed trials`)).toBe(c);
213
+ expect(withinTolerance(Number(got['passAtK']), passAtK(n, c, k)), req(ID_TRIAL_STATS, DOC, `task ${t.taskId}: passAtK MUST equal 1 − C(n−c,k)/C(n,k) within 1e-9 relative (got ${String(got['passAtK'])}, want ${passAtK(n, c, k)})`)).toBe(true);
214
+ expect(withinTolerance(Number(got['passHatK']), passHatK(n, c, k)), req(ID_TRIAL_STATS, DOC, `task ${t.taskId}: passHatK MUST equal C(c,k)/C(n,k) within 1e-9 relative (got ${String(got['passHatK'])}, want ${passHatK(n, c, k)})`)).toBe(true);
215
+ expect(withinTolerance(Number(got['meanScore']), mean), req(ID_TRIAL_STATS, DOC, `task ${t.taskId}: meanScore MUST equal the mean trial score within 1e-9 relative (got ${String(got['meanScore'])}, want ${mean})`)).toBe(true);
216
+ }
217
+ const stats = isRecord(o.summary['trialStats']) ? o.summary['trialStats'] : {};
218
+ const se = scoreStderr(means);
219
+ if (se !== undefined) expect(withinTolerance(Number(stats['scoreStderr']), se), req(ID_TRIAL_STATS, DOC, `trialStats.scoreStderr MUST equal sd(per-task means)/√T within 1e-9 relative (got ${String(stats['scoreStderr'])}, want ${se})`)).toBe(true);
220
+ });
221
+
222
+ it('a fixture miss under onMiss "fail" fails the trial and is counted', async () => {
223
+ const { doc, facet } = await evalSuiteFacet();
224
+ if (!doc) return softSkip('blocked', UNREADABLE);
225
+ if (facet?.['fixtureMatching'] !== true) return softSkip('inapplicable', 'the host does not advertise agents.evalSuite.fixtureMatching');
226
+ const id = 'conformance-eval-fixture-miss';
227
+ if (!isFixtureAdvertised(id)) return softSkip('inapplicable', `the host does not advertise fixture ${id}`);
228
+ const o = await runSuite(id);
229
+ if (o.kind === 'skip') return softSkip(o.disposition, o.reason);
230
+ const missed = taskEntry(o.summary, 'missed');
231
+ const served = taskEntry(o.summary, 'served');
232
+ expect(missed?.['fixtureMissCount'], req(ID_MISS, DOC, 'the missed task MUST report fixtureMissCount 1')).toBe(1);
233
+ expect(missed?.['passed'], req(ID_MISS, DOC, 'a miss under onMiss "fail" MUST fail the trial')).toBe(false);
234
+ expect(served?.['passed'], req(ID_MISS, DOC, 'a served task MUST still pass')).toBe(true);
235
+ expect(served?.['fixtureMissCount'] ?? 0, req(ID_MISS, DOC, 'a served task MUST NOT report a miss')).toBe(0);
236
+ });
237
+
238
+ it('a suite using an unadvertised RFC 0244 feature is refused at createRun', async () => {
239
+ const { doc, facet } = await evalSuiteFacet();
240
+ if (!doc) return softSkip('blocked', UNREADABLE);
241
+ if (facet === null) return softSkip('inapplicable', 'the host does not advertise agents.evalSuite (v2-eval-mode-unadvertised-refused covers it)');
242
+ if (isRecord(facet['trials'])) return softSkip('inapplicable', 'the host advertises agents.evalSuite.trials, so the trials suite is served');
243
+ const id = 'conformance-eval-trials';
244
+ if (!isFixtureAdvertised(id)) return softSkip('inapplicable', `the host does not advertise fixture ${id}`);
245
+ const res = await http(() => driver.post(PROFILE.runsPath, { mode: 'eval', evalSuiteRef: SUITE_URI(id), agentId: AGENT }));
246
+ if (res === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
247
+ if (res.status === 429) return softSkip('blocked', 'POST /runs answered 429 — the run budget, not the wire');
248
+ const runId = isRecord(res.json) && typeof res.json['runId'] === 'string' ? res.json['runId'] : undefined;
249
+ if (res.status === 201 && runId !== undefined) await http(() => driver.post(`${PROFILE.runsPath}/${PROFILE.idSegment(runId)}/cancel`, {}));
250
+ expect(res.status, req(ID_REFUSED, DOC, `a trials suite on a host without agents.evalSuite.trials MUST be refused with 400 (got ${res.status})`)).toBe(400);
251
+ expect(readErrorCode(res.json), req(ID_REFUSED, DOC, 'the refusal MUST carry validation_error')).toBe('validation_error');
252
+ });
253
+ });