@openwop/openwop-conformance 2.46.7 → 2.46.9

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,258 @@
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`, `passed: false` and `score: 0` (summary and
29
+ * every `eval.scored`); the served one passes.
30
+ * That no live tool ran is seam-gated (RFC 0244 G2) and not asserted.
31
+ * 6. Refusal — a host advertising `agents.evalSuite` without `trials` that
32
+ * lists `conformance-eval-trials` refuses it at createRun with
33
+ * `400 validation_error`.
34
+ *
35
+ * @see RFCS/0244-eval-trajectories-trials-and-recorded-fixtures.md
36
+ */
37
+
38
+ import { describe, it, expect } from 'vitest';
39
+ import { readFileSync } from 'node:fs';
40
+ import { join } from 'node:path';
41
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
42
+ import { v2Discovery, v2Validator, v2RefValidator } from '../lib/v2.js';
43
+ import { softSkip } from '../lib/soft-skip.js';
44
+ import { req } from '../lib/requirement-ids.js';
45
+ import { readErrorCode } from '../lib/error-envelope.js';
46
+ import { majorProfile } from '../lib/major-profile.js';
47
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
48
+ import { FIXTURES_DIR } from '../lib/paths.js';
49
+ import { pollTerminal, readEvents, isRecord, type Ev } from '../lib/fixture-run-observer.js';
50
+ import { gradeTrajectory, passAtK, passHatK, scoreStderr, withinTolerance, type TrajectorySpec } from '../lib/eval-trajectory.js';
51
+
52
+ const PROFILE = majorProfile(2);
53
+ const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
54
+ const DOC = 'agents.schema.json §evalSuite (RFC 0244)';
55
+ const AGENT = 'core.conformance.agents.scripted';
56
+ const SUITE_URI = (id: string) => `https://conformance.openwop.dev/eval-suites/${id}`;
57
+ const ID_SHAPE = 'openwop.requirement.0244.wire-shape';
58
+ const ID_CONTENT_FREE = 'openwop.requirement.0244.content-free';
59
+ const ID_FIXTURES = 'openwop.requirement.0244.fixtures-valid';
60
+ const ID_TRAJECTORY = 'openwop.requirement.0244.trajectory-graded';
61
+ const ID_TRIALS = 'openwop.requirement.0244.trials-per-task';
62
+ const ID_TRIAL_STATS = 'openwop.requirement.0244.trial-stats-formula';
63
+ const ID_MISS = 'openwop.requirement.0244.fixture-miss-fails';
64
+ const ID_REFUSED = 'openwop.requirement.0244.unadvertised-feature-refused';
65
+
66
+ interface Task { taskId: string; input: { script: Array<{ tool: string; arguments?: unknown }>; output: string }; expected: { kind: string; trajectory?: TrajectorySpec } }
67
+ interface Suite { tasks: Task[]; trials?: { count: number; k?: number; passRule?: string } }
68
+ const fixture = (id: string): Suite => JSON.parse(readFileSync(join(FIXTURES_DIR, 'eval-suites', `${id}.json`), 'utf8')) as Suite;
69
+
70
+ const suiteSchema = v2Validator('agent-eval-suite');
71
+ const summarySchema = v2Validator('eval-summary');
72
+ const scored = v2RefValidator('run-event-payloads.schema.json#/$defs/evalScored');
73
+ const started = v2RefValidator('run-event-payloads.schema.json#/$defs/evalStarted');
74
+
75
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
76
+ try { return await fn(); } catch { return null; }
77
+ }
78
+
79
+ async function evalSuiteFacet(): Promise<{ doc: Record<string, unknown> | null; facet: Record<string, unknown> | null }> {
80
+ let doc: Record<string, unknown> | null;
81
+ try { doc = await v2Discovery(); } catch { doc = null; }
82
+ const agents = doc?.['agents'];
83
+ const facet = isRecord(agents) && isRecord(agents['evalSuite']) ? agents['evalSuite'] : null;
84
+ return { doc, facet };
85
+ }
86
+
87
+ type Observed = { kind: 'skip'; disposition: 'inapplicable' | 'blocked'; reason: string } | { kind: 'ok'; events: Ev[]; summary: Record<string, unknown> };
88
+
89
+ const runs = new Map<string, Promise<Observed>>();
90
+ /** One eval run per fixture per worker: two legs that read the same run share it. */
91
+ function runSuite(id: string): Promise<Observed> {
92
+ let p = runs.get(id);
93
+ if (p === undefined) { p = runSuiteOnce(id); runs.set(id, p); }
94
+ return p;
95
+ }
96
+
97
+ async function runSuiteOnce(id: string): Promise<Observed> {
98
+ const res = await http(() => driver.post(PROFILE.runsPath, { mode: 'eval', evalSuiteRef: SUITE_URI(id), agentId: AGENT }));
99
+ if (res === null) return { kind: 'skip', disposition: 'blocked', reason: 'POST /runs unreachable (fetch failed)' };
100
+ if (res.status === 429) return { kind: 'skip', disposition: 'blocked', reason: 'POST /runs answered 429 — the run budget, not the wire' };
101
+ const runId = isRecord(res.json) && typeof res.json['runId'] === 'string' ? res.json['runId'] : undefined;
102
+ 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() };
103
+ const terminal = await pollTerminal(PROFILE, runId, { timeoutMs: 60_000 });
104
+ if (terminal.snapshot['status'] !== 'completed') return { kind: 'skip', disposition: 'blocked', reason: `eval run ${runId} ended ${String(terminal.snapshot['status'])}, not completed` };
105
+ const s = await http(() => driver.get(`${PROFILE.runsPath}/${PROFILE.idSegment(runId)}/eval-summary`));
106
+ if (s === null || s.status !== 200 || !isRecord(s.json)) return { kind: 'skip', disposition: 'blocked', reason: `GET …/eval-summary answered ${s?.status ?? 'nothing'}` };
107
+ return { kind: 'ok', events: await readEvents(PROFILE, runId), summary: s.json };
108
+ }
109
+
110
+ const taskEntry = (summary: Record<string, unknown>, taskId: string): Record<string, unknown> | undefined =>
111
+ (Array.isArray(summary['tasks']) ? (summary['tasks'] as unknown[]) : []).filter(isRecord).find((t) => t['taskId'] === taskId);
112
+
113
+ describe('v2 RFC 0244 — wire shape (server-free)', () => {
114
+ it('the RFC 0244 suite, summary and event fields validate and the negatives are refused', () => {
115
+ const traj = fixture('conformance-eval-trajectory');
116
+ const bad = (mut: (s: Suite) => void): boolean => { const s = structuredClone(traj) as Suite; mut(s); return suiteSchema(s).ok; };
117
+ 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);
118
+ expect(bad((s) => { s.trials = { count: 0 }; }), req(ID_SHAPE, DOC, 'trials.count 0 MUST be refused')).toBe(false);
119
+ 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);
120
+
121
+ const good = { taskId: 't', score: 1, passed: true, trialIndex: 2, scoreLabel: 'pass', evaluatorType: 'deterministic' };
122
+ expect(scored(good).ok, req(ID_SHAPE, DOC, `eval.scored with trialIndex/scoreLabel/evaluatorType MUST validate: ${scored(good).errors}`)).toBe(true);
123
+ 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);
124
+ expect(scored({ ...good, evaluatorType: 'gpt' }).ok, req(ID_SHAPE, DOC, 'an unknown evaluatorType MUST be refused')).toBe(false);
125
+
126
+ const summary = {
127
+ suiteId: 'core.openwop.evals.x', suiteVersion: '1.0.0', aggregateScore: 0.5, passed: false, taskCount: 1, passedCount: 0,
128
+ tasks: [{ taskId: 't', score: 0.8, passed: false,
129
+ trajectory: { mode: 'in-order', matched: false, expectedCount: 2, observedCount: 3, matchedCount: 1, forbiddenCount: 1 },
130
+ trials: { n: 5, passedCount: 4, meanScore: 0.8, passAtK: 1, passHatK: 0.4 },
131
+ efficiency: { toolCallCount: 3, modelCallCount: 1, inputTokens: 10, outputTokens: 5 }, fixtureMissCount: 0 }],
132
+ trialStats: { k: 3, passAtK: 1, passHatK: 0.4 },
133
+ };
134
+ expect(summarySchema(summary).ok, req(ID_SHAPE, DOC, `an RFC 0244 EvalSummary MUST validate: ${summarySchema(summary).errors}`)).toBe(true);
135
+ });
136
+
137
+ it('the RFC 0244 additions are content-free (eval-summary-no-content-leak)', () => {
138
+ const base = { suiteId: 'core.openwop.evals.x', suiteVersion: '1.0.0', aggregateScore: 0, passed: false, taskCount: 1, passedCount: 0 };
139
+ 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'] } }] };
140
+ expect(summarySchema(leak).ok, req(ID_CONTENT_FREE, DOC, 'a trajectory block naming tools MUST be refused')).toBe(false);
141
+ 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);
142
+ });
143
+
144
+ it('the eval-suite fixtures validate against the v2 suite schema', () => {
145
+ for (const id of ['conformance-eval-trajectory', 'conformance-eval-trials', 'conformance-eval-fixture-miss']) {
146
+ const r = suiteSchema(fixture(id));
147
+ expect(r.ok, req(ID_FIXTURES, 'fixtures.md §eval-suites', `${id} MUST validate: ${r.errors}`)).toBe(true);
148
+ }
149
+ });
150
+ });
151
+
152
+ describe('v2 RFC 0244 — host behaviour', () => {
153
+ it('a trajectory suite is graded per the reference grader', async () => {
154
+ const { doc, facet } = await evalSuiteFacet();
155
+ if (!doc) return softSkip('blocked', UNREADABLE);
156
+ if (facet?.['trajectory'] !== true) return softSkip('inapplicable', 'the host does not advertise agents.evalSuite.trajectory');
157
+ const id = 'conformance-eval-trajectory';
158
+ if (!isFixtureAdvertised(id)) return softSkip('inapplicable', `the host does not advertise fixture ${id}`);
159
+ const o = await runSuite(id);
160
+ if (o.kind === 'skip') return softSkip(o.disposition, o.reason);
161
+ const suite = fixture(id);
162
+ for (const t of suite.tasks) {
163
+ const want = gradeTrajectory(t.expected.trajectory!, t.input.script.map((c) => ({ tool: c.tool, inputs: c.arguments })));
164
+ const entry = taskEntry(o.summary, t.taskId);
165
+ expect(entry, req(ID_TRAJECTORY, DOC, `the summary MUST carry task ${t.taskId}`)).toBeDefined();
166
+ const got = isRecord(entry?.['trajectory']) ? entry['trajectory'] : {};
167
+ expect(got, req(ID_TRAJECTORY, DOC, `task ${t.taskId}: the trajectory counts MUST equal the reference grade`)).toEqual(want);
168
+ if (!want.matched) expect(entry?.['passed'], req(ID_TRAJECTORY, DOC, `task ${t.taskId}: an unmatched trajectory MUST NOT pass`)).toBe(false);
169
+ 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);
170
+ }
171
+ const calls = o.events.filter((e) => e.type === 'agent.tool-called' && e.payload['agentId'] === AGENT).length;
172
+ const scripted = suite.tasks.reduce((a, t) => a + t.input.script.length, 0);
173
+ expect(calls, req(ID_TRAJECTORY, DOC, `the log MUST carry one agent.tool-called per scripted call (${scripted})`)).toBe(scripted);
174
+ });
175
+
176
+ async function trialsRun(): Promise<{ skip: [Parameters<typeof softSkip>[0], string] } | { o: Extract<Observed, { kind: 'ok' }>; suite: Suite; n: number; k: number }> {
177
+ const { doc, facet } = await evalSuiteFacet();
178
+ if (!doc) return { skip: ['blocked', UNREADABLE] };
179
+ if (!isRecord(facet?.['trials'])) return { skip: ['inapplicable', 'the host does not advertise agents.evalSuite.trials'] };
180
+ const id = 'conformance-eval-trials';
181
+ if (!isFixtureAdvertised(id)) return { skip: ['inapplicable', `the host does not advertise fixture ${id}`] };
182
+ const o = await runSuite(id);
183
+ if (o.kind === 'skip') return { skip: [o.disposition, o.reason] };
184
+ const suite = fixture(id);
185
+ const n = suite.trials!.count;
186
+ return { o, suite, n, k: suite.trials!.k ?? n };
187
+ }
188
+
189
+ it('repeated trials emit one eval.scored per task per trial', async () => {
190
+ const r = await trialsRun();
191
+ if ('skip' in r) return softSkip(...r.skip);
192
+ const { o, suite, n } = r;
193
+ expect(o.events.find((e) => e.type === 'eval.started')?.payload['trialCount'], req(ID_TRIALS, DOC, 'eval.started MUST carry trialCount')).toBe(n);
194
+ for (const t of suite.tasks) {
195
+ const rows = o.events.filter((e) => e.type === 'eval.scored' && e.payload['taskId'] === t.taskId);
196
+ 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()]);
197
+ }
198
+ });
199
+
200
+ it('the per-task trials block and trialStats follow the §B formulas', async () => {
201
+ const r = await trialsRun();
202
+ if ('skip' in r) return softSkip(...r.skip);
203
+ const { o, suite, n, k } = r;
204
+ const means: number[] = [];
205
+ for (const t of suite.tasks) {
206
+ const rows = o.events.filter((e) => e.type === 'eval.scored' && e.payload['taskId'] === t.taskId);
207
+ const c = rows.filter((x) => x.payload['passed'] === true).length;
208
+ const mean = rows.length === 0 ? 0 : rows.reduce((a, x) => a + Number(x.payload['score']), 0) / rows.length;
209
+ means.push(mean);
210
+ const trials = taskEntry(o.summary, t.taskId)?.['trials'];
211
+ const got = isRecord(trials) ? trials : {};
212
+ expect(got['n'], req(ID_TRIAL_STATS, DOC, `task ${t.taskId}: trials.n`)).toBe(n);
213
+ expect(got['passedCount'], req(ID_TRIAL_STATS, DOC, `task ${t.taskId}: trials.passedCount MUST count the passed trials`)).toBe(c);
214
+ 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);
215
+ 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);
216
+ 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);
217
+ }
218
+ const stats = isRecord(o.summary['trialStats']) ? o.summary['trialStats'] : {};
219
+ const se = scoreStderr(means);
220
+ 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);
221
+ });
222
+
223
+ it('a fixture miss under onMiss "fail" fails the trial and is counted', async () => {
224
+ const { doc, facet } = await evalSuiteFacet();
225
+ if (!doc) return softSkip('blocked', UNREADABLE);
226
+ if (facet?.['fixtureMatching'] !== true) return softSkip('inapplicable', 'the host does not advertise agents.evalSuite.fixtureMatching');
227
+ const id = 'conformance-eval-fixture-miss';
228
+ if (!isFixtureAdvertised(id)) return softSkip('inapplicable', `the host does not advertise fixture ${id}`);
229
+ const o = await runSuite(id);
230
+ if (o.kind === 'skip') return softSkip(o.disposition, o.reason);
231
+ const missed = taskEntry(o.summary, 'missed');
232
+ const served = taskEntry(o.summary, 'served');
233
+ expect(missed?.['fixtureMissCount'], req(ID_MISS, DOC, 'the missed task MUST report fixtureMissCount 1')).toBe(1);
234
+ expect(missed?.['passed'], req(ID_MISS, DOC, 'a miss under onMiss "fail" MUST fail the trial')).toBe(false);
235
+ expect(missed?.['score'], req(ID_MISS, DOC, 'a trial that fails closed MUST report score 0 in the summary')).toBe(0);
236
+ const missedScored = o.events.filter((e) => e.type === 'eval.scored' && e.payload['taskId'] === 'missed');
237
+ expect(missedScored.length, req(ID_MISS, DOC, 'the missed task MUST emit eval.scored')).toBeGreaterThan(0);
238
+ for (const e of missedScored) expect(e.payload['score'], req(ID_MISS, DOC, 'eval.scored for a trial that fails closed MUST carry score 0')).toBe(0);
239
+ expect(served?.['passed'], req(ID_MISS, DOC, 'a served task MUST still pass')).toBe(true);
240
+ expect(served?.['fixtureMissCount'] ?? 0, req(ID_MISS, DOC, 'a served task MUST NOT report a miss')).toBe(0);
241
+ });
242
+
243
+ it('a suite using an unadvertised RFC 0244 feature is refused at createRun', async () => {
244
+ const { doc, facet } = await evalSuiteFacet();
245
+ if (!doc) return softSkip('blocked', UNREADABLE);
246
+ if (facet === null) return softSkip('inapplicable', 'the host does not advertise agents.evalSuite (v2-eval-mode-unadvertised-refused covers it)');
247
+ if (isRecord(facet['trials'])) return softSkip('inapplicable', 'the host advertises agents.evalSuite.trials, so the trials suite is served');
248
+ const id = 'conformance-eval-trials';
249
+ if (!isFixtureAdvertised(id)) return softSkip('inapplicable', `the host does not advertise fixture ${id}`);
250
+ const res = await http(() => driver.post(PROFILE.runsPath, { mode: 'eval', evalSuiteRef: SUITE_URI(id), agentId: AGENT }));
251
+ if (res === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
252
+ if (res.status === 429) return softSkip('blocked', 'POST /runs answered 429 — the run budget, not the wire');
253
+ const runId = isRecord(res.json) && typeof res.json['runId'] === 'string' ? res.json['runId'] : undefined;
254
+ if (res.status === 201 && runId !== undefined) await http(() => driver.post(`${PROFILE.runsPath}/${PROFILE.idSegment(runId)}/cancel`, {}));
255
+ 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);
256
+ expect(readErrorCode(res.json), req(ID_REFUSED, DOC, 'the refusal MUST carry validation_error')).toBe('validation_error');
257
+ });
258
+ });