@nifrajs/testing 3.3.0 → 4.0.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.
@@ -0,0 +1,692 @@
1
+ /**
2
+ * Seeded hostile corpus for predictions and projections - the prediction-side sibling of
3
+ * `adversarial.ts`.
4
+ *
5
+ * `adversarial.ts` proves HTTP body/query/response contracts with validator-proven hostile
6
+ * mutations. This module proves the same shaped guarantees one layer up: the `@nifrajs/webmcp`
7
+ * prediction store (stale versions, expiry, prototype-grafting patches, non-atomic patches,
8
+ * commit conflicts, reconcile/tool rollback) and the WebMCP-plus-MCP projection of one
9
+ * capability (the same witness accepted or rejected identically on both surfaces).
10
+ *
11
+ * Every case has a stable ID, every run carries its replay seed, and every clock is injected -
12
+ * nothing reads `Date.now` or `crypto.randomUUID` implicitly. `only` replays a subset by ID.
13
+ *
14
+ * Deliberately out of scope: A2A and AG-UI projections. Neither package exposes a same-contract
15
+ * projection from a tool/capability (`a2a` has `agentCard`/`mountA2A` at the protocol level; there
16
+ * is no `capability -> a2a/ag-ui` seam), so a parity case here would invent the seam it claims to
17
+ * test. Proposing such a seam needs consumer evidence first (N7), not a lab that assumes it.
18
+ */
19
+
20
+ import type { McpToolContext } from "@nifrajs/mcp/protocol"
21
+ import { toMcpTool } from "@nifrajs/mcp/tool-contract"
22
+ import {
23
+ type AgentCapability,
24
+ type AgentSurfacePrediction,
25
+ createPredictionStore,
26
+ defineAgentCapability,
27
+ executePredicted,
28
+ type PredictionStore,
29
+ type PredictionStoreSnapshot,
30
+ toWebMcpTool,
31
+ } from "@nifrajs/webmcp"
32
+
33
+ export type PredictionLabTarget = "prediction" | "projection"
34
+
35
+ export interface PredictionLabContext {
36
+ readonly seed: number
37
+ readonly caseId: string
38
+ readonly target: PredictionLabTarget
39
+ readonly mutation?: string
40
+ }
41
+
42
+ export interface PredictionLabReplay {
43
+ readonly seed: number
44
+ readonly caseId: string
45
+ }
46
+
47
+ export interface PredictionLabResult {
48
+ readonly id: string
49
+ readonly target: PredictionLabTarget
50
+ readonly mutation?: string
51
+ readonly ok: boolean
52
+ readonly message: string
53
+ readonly replay: PredictionLabReplay
54
+ }
55
+
56
+ export interface PredictionLabReport {
57
+ readonly ok: boolean
58
+ readonly seed: number
59
+ readonly results: readonly PredictionLabResult[]
60
+ readonly failures: readonly PredictionLabResult[]
61
+ readonly counts: { readonly passed: number; readonly failed: number }
62
+ }
63
+
64
+ export interface PredictionLabOptions {
65
+ /** Replayable seed. Prediction IDs and clocks derive from it deterministically. */
66
+ readonly seed?: number
67
+ /** Replay only these stable case IDs. */
68
+ readonly only?: string | readonly string[]
69
+ }
70
+
71
+ export const PREDICTION_LAB_SEED = 0x50_52_45_44
72
+
73
+ interface CartItem {
74
+ readonly sku: string
75
+ readonly quantity: number
76
+ }
77
+
78
+ interface CartState {
79
+ readonly items: readonly CartItem[]
80
+ }
81
+
82
+ interface AddInput {
83
+ readonly sku: string
84
+ readonly quantity: number
85
+ }
86
+
87
+ interface AddOutput {
88
+ readonly cartVersion: string
89
+ }
90
+
91
+ type LabSchema<Input, Output = Input> = {
92
+ readonly jsonSchema: Readonly<Record<string, unknown>>
93
+ readonly "~standard": {
94
+ readonly version: 1
95
+ readonly vendor: string
96
+ readonly types: { readonly input: Input; readonly output: Output }
97
+ readonly validate: (
98
+ value: unknown,
99
+ ) => { readonly value: Output } | { readonly issues: readonly { readonly message: string }[] }
100
+ }
101
+ }
102
+
103
+ function issue(message: string): { readonly issues: readonly { readonly message: string }[] } {
104
+ return { issues: [{ message }] }
105
+ }
106
+
107
+ const mcpContext: McpToolContext = {
108
+ signal: new AbortController().signal,
109
+ requestId: null,
110
+ reportProgress: () => {},
111
+ }
112
+
113
+ function isRecord(value: unknown): value is Record<string, unknown> {
114
+ return typeof value === "object" && value !== null && !Array.isArray(value)
115
+ }
116
+
117
+ /** Strict cart-add input: unknown fields rejected, so hostile extras are provably invalid. */
118
+ const addInputSchema: LabSchema<AddInput> = {
119
+ jsonSchema: {
120
+ type: "object",
121
+ properties: {
122
+ sku: { type: "string", minLength: 1 },
123
+ quantity: { type: "integer", minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
124
+ },
125
+ required: ["sku", "quantity"],
126
+ additionalProperties: false,
127
+ },
128
+ "~standard": {
129
+ version: 1,
130
+ vendor: "prediction-lab",
131
+ types: {
132
+ input: { sku: "example", quantity: 1 },
133
+ output: { sku: "example", quantity: 1 },
134
+ },
135
+ validate(value: unknown) {
136
+ if (typeof value !== "object" || value === null) return issue("input must be an object")
137
+ const record = value as Record<string, unknown>
138
+ const keys = Object.keys(record).sort()
139
+ if (keys.join(",") !== "quantity,sku") return issue("input must be exactly { sku, quantity }")
140
+ if (typeof record.sku !== "string" || record.sku === "")
141
+ return issue("sku must be a non-empty string")
142
+ if (!Number.isSafeInteger(record.quantity) || (record.quantity as number) <= 0)
143
+ return issue("quantity must be a positive safe integer")
144
+ return { value: { sku: record.sku as string, quantity: record.quantity as number } }
145
+ },
146
+ },
147
+ }
148
+
149
+ const addOutputSchema: LabSchema<AddOutput> = {
150
+ jsonSchema: {
151
+ type: "object",
152
+ properties: { cartVersion: { type: "string" } },
153
+ required: ["cartVersion"],
154
+ additionalProperties: false,
155
+ },
156
+ "~standard": {
157
+ version: 1,
158
+ vendor: "prediction-lab",
159
+ types: {
160
+ input: { cartVersion: "example" },
161
+ output: { cartVersion: "example" },
162
+ },
163
+ validate(value: unknown) {
164
+ if (
165
+ typeof value !== "object" ||
166
+ value === null ||
167
+ typeof (value as Record<string, unknown>).cartVersion !== "string"
168
+ )
169
+ return issue("output must carry cartVersion")
170
+ return { value: { cartVersion: (value as Record<string, unknown>).cartVersion as string } }
171
+ },
172
+ },
173
+ }
174
+
175
+ function labCapability(options: {
176
+ readonly execute: (input: AddInput) => AddOutput | Promise<AddOutput>
177
+ readonly reconcileThrows?: boolean
178
+ readonly onExecute?: () => void
179
+ readonly onReconcile?: () => void
180
+ }): AgentCapability<AddInput, AddOutput, CartState> {
181
+ return defineAgentCapability({
182
+ name: "cart.add",
183
+ description: "Add a product to the current cart.",
184
+ input: addInputSchema,
185
+ output: addOutputSchema,
186
+ writes: ["cart"],
187
+ execute: (input) => {
188
+ options.onExecute?.()
189
+ return options.execute(input)
190
+ },
191
+ predict: ({ input, version }) => ({
192
+ baseVersion: version,
193
+ patch: [{ op: "add", path: "/items/-", value: { sku: input.sku, quantity: input.quantity } }],
194
+ }),
195
+ reconcile: ({ input, output }) => {
196
+ options.onReconcile?.()
197
+ if (options.reconcileThrows === true) throw new Error("lab reconcile failure")
198
+ void input
199
+ return {
200
+ state: { items: [{ sku: "server-item", quantity: 1 }] },
201
+ version: (output as AddOutput).cartVersion,
202
+ }
203
+ },
204
+ })
205
+ }
206
+
207
+ function emptyCart(version: string): { readonly state: CartState; readonly version: string } {
208
+ return { state: { items: [] }, version }
209
+ }
210
+
211
+ function cartAddPrediction(version: string, sku = "lab-sku"): AgentSurfacePrediction {
212
+ return {
213
+ baseVersion: version,
214
+ patch: [{ op: "add", path: "/items/-", value: { sku, quantity: 1 } }],
215
+ }
216
+ }
217
+ function deterministicExecutionOptions(predictionId: string, now: number) {
218
+ return {
219
+ predictionId,
220
+ now: () => now,
221
+ effectId: `${predictionId}-effect`,
222
+ clock: () => now,
223
+ }
224
+ }
225
+
226
+ type CaseRun = (ctx: {
227
+ readonly seed: number
228
+ readonly now: number
229
+ readonly predictionId: (index: number) => string
230
+ }) =>
231
+ | Promise<{ readonly ok: boolean; readonly message: string }>
232
+ | { readonly ok: boolean; readonly message: string }
233
+
234
+ interface LabCase {
235
+ readonly id: string
236
+ readonly target: PredictionLabTarget
237
+ readonly mutation?: string
238
+ readonly run: CaseRun
239
+ }
240
+
241
+ function snapshotItems(snapshot: PredictionStoreSnapshot<CartState>): readonly CartItem[] {
242
+ return snapshot.state.items
243
+ }
244
+
245
+ const LAB_CASES: readonly LabCase[] = [
246
+ {
247
+ id: "prediction-stale-version",
248
+ target: "prediction",
249
+ mutation: "baseVersion v0 against authoritative v1",
250
+ run: ({ now, predictionId }) => {
251
+ const store = createPredictionStore<CartState>(emptyCart("v1"))
252
+ const result = store.predict(predictionId(0), { ...cartAddPrediction("v0") }, now)
253
+ const stateKept = snapshotItems(result.snapshot).length === 0
254
+ const ok = result.outcome === "stale" && stateKept
255
+ return {
256
+ ok,
257
+ message: ok
258
+ ? "stale baseVersion rejected without touching state"
259
+ : `expected stale + empty state, got ${result.outcome} + ${snapshotItems(result.snapshot).length} items`,
260
+ }
261
+ },
262
+ },
263
+ {
264
+ id: "prediction-expired-at-predict",
265
+ target: "prediction",
266
+ mutation: "expiresAt at or before now",
267
+ run: ({ now, predictionId }) => {
268
+ const store = createPredictionStore<CartState>(emptyCart("v1"))
269
+ const result = store.predict(
270
+ predictionId(0),
271
+ { ...cartAddPrediction("v1"), expiresAt: now },
272
+ now,
273
+ )
274
+ const ok = result.outcome === "expired" && snapshotItems(result.snapshot).length === 0
275
+ return {
276
+ ok,
277
+ message: ok
278
+ ? "already-expired prediction refused"
279
+ : `expected expired, got ${result.outcome}`,
280
+ }
281
+ },
282
+ },
283
+ {
284
+ id: "prediction-expire-sweep",
285
+ target: "prediction",
286
+ mutation: "clock advances past expiresAt",
287
+ run: ({ now, predictionId }) => {
288
+ const store = createPredictionStore<CartState>(emptyCart("v1"))
289
+ const events: string[] = []
290
+ store.subscribe((event) => {
291
+ events.push(event.type)
292
+ })
293
+ const predicted = store.predict(
294
+ predictionId(0),
295
+ { ...cartAddPrediction("v1"), expiresAt: now + 1000 },
296
+ now,
297
+ )
298
+ if (predicted.outcome !== "predicted")
299
+ return { ok: false, message: `setup failed: ${predicted.outcome}` }
300
+ const swept = store.expire(now + 2000)
301
+ const after = store.snapshot()
302
+ const ok =
303
+ swept.length === 1 &&
304
+ swept[0] === predictionId(0) &&
305
+ after.activePredictionIds.length === 0 &&
306
+ snapshotItems(after).length === 0 &&
307
+ events.includes("expired")
308
+ return {
309
+ ok,
310
+ message: ok ? "expiry swept the overlay with an event" : "expiry sweep left residue",
311
+ }
312
+ },
313
+ },
314
+ {
315
+ id: "prediction-prototype-path",
316
+ target: "prediction",
317
+ mutation: "patch path traverses forbidden prototype segments",
318
+ run: ({ now, predictionId }) => {
319
+ const store = createPredictionStore<CartState>(emptyCart("v1"))
320
+ const forbidden = ["__proto__", "constructor", "prototype"] as const
321
+ // Each forbidden pointer must fail before an overlay is stored; checking all three keeps the
322
+ // corpus aligned with the store's complete prototype-pollution guard.
323
+ let allThrew = true
324
+ for (const [index, segment] of forbidden.entries()) {
325
+ let threw = false
326
+ try {
327
+ store.predict(
328
+ predictionId(index),
329
+ {
330
+ baseVersion: "v1",
331
+ patch: [
332
+ { op: "add", path: `/${segment}/polluted`, value: { sku: "x", quantity: 1 } },
333
+ ],
334
+ },
335
+ now,
336
+ )
337
+ } catch {
338
+ threw = true
339
+ }
340
+ allThrew = allThrew && threw
341
+ }
342
+ const after = store.snapshot()
343
+ const ok =
344
+ allThrew &&
345
+ after.activePredictionIds.length === 0 &&
346
+ snapshotItems(after).length === 0 &&
347
+ (Object.prototype as Record<string, unknown>).polluted === undefined
348
+ return {
349
+ ok,
350
+ message: ok
351
+ ? "prototype-grafting patches threw without storing overlays"
352
+ : "prototype patch was not fail-closed",
353
+ }
354
+ },
355
+ },
356
+ {
357
+ id: "prediction-non-atomic-patch",
358
+ target: "prediction",
359
+ mutation: "second op removes a missing path",
360
+ run: ({ now, predictionId }) => {
361
+ const store = createPredictionStore<CartState>(emptyCart("v1"))
362
+ // A patch whose second op cannot apply throws during candidate application - before the
363
+ // overlay is stored - so the first op must not survive either. Assert throw + empty state.
364
+ let threw = false
365
+ try {
366
+ store.predict(
367
+ predictionId(0),
368
+ {
369
+ baseVersion: "v1",
370
+ patch: [
371
+ { op: "add", path: "/items/-", value: { sku: "a", quantity: 1 } },
372
+ { op: "remove", path: "/items/7" },
373
+ ],
374
+ },
375
+ now,
376
+ )
377
+ } catch {
378
+ threw = true
379
+ }
380
+ const after = store.snapshot()
381
+ const ok =
382
+ threw && after.activePredictionIds.length === 0 && snapshotItems(after).length === 0
383
+ return {
384
+ ok,
385
+ message: ok
386
+ ? "partial patch threw - no half-applied overlay"
387
+ : `non-atomic patch left ${snapshotItems(after).length} items`,
388
+ }
389
+ },
390
+ },
391
+ {
392
+ id: "prediction-duplicate-id-invalid",
393
+ target: "prediction",
394
+ mutation: "same prediction id predicted twice",
395
+ run: ({ now, predictionId }) => {
396
+ const store = createPredictionStore<CartState>(emptyCart("v1"))
397
+ const first = store.predict(predictionId(0), cartAddPrediction("v1"), now)
398
+ const second = store.predict(predictionId(0), cartAddPrediction("v1"), now)
399
+ const ok = first.outcome === "predicted" && second.outcome === "invalid"
400
+ return {
401
+ ok,
402
+ message: ok
403
+ ? "duplicate prediction id rejected"
404
+ : `${first.outcome} then ${second.outcome}`,
405
+ }
406
+ },
407
+ },
408
+ {
409
+ id: "prediction-commit-conflict",
410
+ target: "prediction",
411
+ mutation: "authoritative version advances before commit",
412
+ run: ({ now, predictionId }) => {
413
+ const store: PredictionStore<CartState> = createPredictionStore<CartState>(emptyCart("v1"))
414
+ const first = store.predict(predictionId(0), cartAddPrediction("v1", "first"), now)
415
+ const second = store.predict(predictionId(1), cartAddPrediction("v1", "second"), now)
416
+ if (first.outcome !== "predicted" || second.outcome !== "predicted")
417
+ return { ok: false, message: "setup failed" }
418
+ const committed = store.commit(predictionId(0), emptyCart("v2"), now)
419
+ if (committed.outcome !== "committed") return { ok: false, message: "setup commit failed" }
420
+ const late = store.commit(predictionId(1), emptyCart("v3"), now)
421
+ const after = store.snapshot()
422
+ const ok =
423
+ late.outcome === "conflicted" &&
424
+ after.version === "v2" &&
425
+ after.activePredictionIds.length === 0
426
+ return {
427
+ ok,
428
+ message: ok ? "late commit conflicted without clobbering v2" : `got ${late.outcome}`,
429
+ }
430
+ },
431
+ },
432
+ {
433
+ id: "prediction-tool-failure-rolls-back",
434
+ target: "prediction",
435
+ mutation: "tool execute throws after predict",
436
+ run: async ({ now, predictionId }) => {
437
+ let executed = false
438
+ const capability = labCapability({
439
+ execute: () => {
440
+ executed = true
441
+ throw new Error("lab tool failure")
442
+ },
443
+ })
444
+ const store = createPredictionStore<CartState>(emptyCart("v1"))
445
+ const before = store.snapshot()
446
+ const result = await executePredicted(
447
+ capability,
448
+ { sku: "lab-sku", quantity: 1 },
449
+ store,
450
+ deterministicExecutionOptions(predictionId(0), now),
451
+ )
452
+ const after = store.snapshot()
453
+ const ok =
454
+ executed &&
455
+ result.tool?.ok === false &&
456
+ result.outcome === "rolled-back" &&
457
+ after.activePredictionIds.length === 0 &&
458
+ snapshotItems(after).length === snapshotItems(before).length
459
+ return {
460
+ ok,
461
+ message: ok ? "tool failure rolled the overlay back" : `got ${result.outcome}`,
462
+ }
463
+ },
464
+ },
465
+ {
466
+ id: "prediction-reconcile-failure-rolls-back",
467
+ target: "prediction",
468
+ mutation: "reconcile throws after a successful tool call",
469
+ run: async ({ now, predictionId }) => {
470
+ let executed = false
471
+ let reconciled = false
472
+ const capability = labCapability({
473
+ execute: () => {
474
+ executed = true
475
+ return { cartVersion: "v2" }
476
+ },
477
+ reconcileThrows: true,
478
+ onReconcile: () => {
479
+ reconciled = true
480
+ },
481
+ })
482
+ const store = createPredictionStore<CartState>(emptyCart("v1"))
483
+ const result = await executePredicted(
484
+ capability,
485
+ { sku: "lab-sku", quantity: 1 },
486
+ store,
487
+ deterministicExecutionOptions(predictionId(0), now),
488
+ )
489
+ const after = store.snapshot()
490
+ const ok =
491
+ executed &&
492
+ reconciled &&
493
+ result.tool?.ok === true &&
494
+ result.outcome === "rolled-back" &&
495
+ after.version === "v1" &&
496
+ snapshotItems(after).length === 0
497
+ return {
498
+ ok,
499
+ message: ok ? "reconcile failure kept authoritative v1" : `got ${result.outcome}`,
500
+ }
501
+ },
502
+ },
503
+ {
504
+ id: "prediction-commit-accepts-server",
505
+ target: "prediction",
506
+ mutation: "happy path - predict, execute, reconcile, commit",
507
+ run: async ({ now, predictionId }) => {
508
+ let executed = false
509
+ let reconciled = false
510
+ const capability = labCapability({
511
+ execute: () => {
512
+ executed = true
513
+ return { cartVersion: "v2" }
514
+ },
515
+ onReconcile: () => {
516
+ reconciled = true
517
+ },
518
+ })
519
+ const store = createPredictionStore<CartState>(emptyCart("v1"))
520
+ const result = await executePredicted(
521
+ capability,
522
+ { sku: "lab-sku", quantity: 1 },
523
+ store,
524
+ deterministicExecutionOptions(predictionId(0), now),
525
+ )
526
+ const after = store.snapshot()
527
+ const item = snapshotItems(after)[0]
528
+ const ok =
529
+ executed &&
530
+ reconciled &&
531
+ result.tool?.ok === true &&
532
+ result.outcome === "committed" &&
533
+ after.version === "v2" &&
534
+ item?.sku === "server-item" &&
535
+ item.quantity === 1
536
+ return {
537
+ ok,
538
+ message: ok ? "predicted execution committed at v2" : `got ${result.outcome}`,
539
+ }
540
+ },
541
+ },
542
+ {
543
+ id: "projection-webmcp-mcp-parity",
544
+ target: "projection",
545
+ mutation: "same witness across toWebMcpTool and toMcpTool",
546
+ run: async ({ now, predictionId }) => {
547
+ const capability = labCapability({
548
+ execute: (input) => ({ cartVersion: `v-${input.sku}` }),
549
+ })
550
+ const webmcp = toWebMcpTool(capability, {
551
+ effectId: `${predictionId(0)}-webmcp-effect`,
552
+ clock: () => now,
553
+ })
554
+ // The WebMCP adapter grants the tool's own capability itself; the MCP adapter takes the
555
+ // host's grant at construction. Both grants name the same token, so the boundary stays one.
556
+ const mcp = toMcpTool(capability.tool, {
557
+ capabilities: [capability.tool.capability],
558
+ effectId: `${predictionId(0)}-mcp-effect`,
559
+ clock: () => now,
560
+ })
561
+ const valid = { sku: "lab-sku", quantity: 2 }
562
+ const webValid = (await webmcp.execute(valid)) as { readonly ok: boolean }
563
+ const mcpValid = (await mcp.handler(valid, mcpContext)) as { readonly isError?: true }
564
+ const hostiles: readonly { readonly name: string; readonly input: unknown }[] = [
565
+ { name: "wrong-type", input: { sku: "lab-sku", quantity: "two" } },
566
+ { name: "missing-field", input: { sku: "lab-sku" } },
567
+ { name: "unknown-field", input: { sku: "lab-sku", quantity: 1, admin: true } },
568
+ { name: "empty-sku", input: { sku: "", quantity: 1 } },
569
+ { name: "non-object", input: "add-to-cart" },
570
+ ]
571
+ const mismatches: string[] = []
572
+ if (webValid.ok !== true) mismatches.push("webmcp rejected the valid witness")
573
+ if (mcpValid.isError === true) mismatches.push("mcp rejected the valid witness")
574
+ if (webmcp.name !== mcp.name) mismatches.push("projected tool names differ")
575
+ if (webmcp.description !== mcp.description) mismatches.push("projected descriptions differ")
576
+ if (JSON.stringify(webmcp.inputSchema) !== JSON.stringify(mcp.inputSchema))
577
+ mismatches.push("projected input schemas differ")
578
+ const properties = webmcp.inputSchema.properties
579
+ if (
580
+ !isRecord(properties) ||
581
+ !Object.hasOwn(properties, "sku") ||
582
+ !Object.hasOwn(properties, "quantity")
583
+ )
584
+ mismatches.push("projected input schema omits the valid witness fields")
585
+ for (const hostile of hostiles) {
586
+ const web = (await webmcp.execute(hostile.input)) as { readonly ok: boolean }
587
+ const viaMcp = (await mcp.handler(
588
+ hostile.input as unknown as Record<string, unknown>,
589
+ mcpContext,
590
+ )) as { readonly isError?: true }
591
+ if (web.ok !== false) mismatches.push(`webmcp accepted ${hostile.name}`)
592
+ if (viaMcp.isError !== true) mismatches.push(`mcp accepted ${hostile.name}`)
593
+ }
594
+ const ok = mismatches.length === 0
595
+ return {
596
+ ok,
597
+ message: ok ? "both projections share one validation boundary" : mismatches.join("; "),
598
+ }
599
+ },
600
+ },
601
+ ]
602
+
603
+ export async function runPredictionLab(
604
+ options: PredictionLabOptions = {},
605
+ ): Promise<PredictionLabReport> {
606
+ const seed = options.seed ?? PREDICTION_LAB_SEED
607
+ if (!Number.isSafeInteger(seed))
608
+ throw new TypeError("prediction lab: seed must be a finite safe integer")
609
+ const requested =
610
+ options.only === undefined
611
+ ? undefined
612
+ : Array.isArray(options.only)
613
+ ? [...options.only]
614
+ : [options.only]
615
+ let only: Set<string> | undefined
616
+ if (requested !== undefined) {
617
+ if (requested.length === 0)
618
+ throw new TypeError("prediction lab: only must select at least one case ID")
619
+ const known = new Set(LAB_CASES.map((labCase) => labCase.id))
620
+ const unknown = requested.filter((id) => typeof id !== "string" || !known.has(id))
621
+ if (unknown.length > 0)
622
+ throw new RangeError(`prediction lab: unknown case ID(s): ${unknown.join(", ")}`)
623
+ only = new Set(requested)
624
+ }
625
+ const now = 1_700_000_000_000 + (seed % 1000)
626
+ const predictionId = (index: number): string => `pl-${seed.toString(16)}-${index}`
627
+ const results: PredictionLabResult[] = []
628
+ for (const labCase of LAB_CASES) {
629
+ if (only !== undefined && !only.has(labCase.id)) continue
630
+ let outcome: { readonly ok: boolean; readonly message: string }
631
+ try {
632
+ outcome = await labCase.run({ seed, now, predictionId })
633
+ } catch (error) {
634
+ outcome = {
635
+ ok: false,
636
+ message: error instanceof Error ? `threw: ${error.message}` : "threw an unknown value",
637
+ }
638
+ }
639
+ results.push(
640
+ Object.freeze({
641
+ id: labCase.id,
642
+ target: labCase.target,
643
+ ...(labCase.mutation === undefined ? {} : { mutation: labCase.mutation }),
644
+ ok: outcome.ok,
645
+ message: outcome.message,
646
+ replay: Object.freeze({ seed, caseId: labCase.id }),
647
+ }),
648
+ )
649
+ }
650
+ const failures = Object.freeze(results.filter((result) => !result.ok))
651
+ return Object.freeze({
652
+ ok: failures.length === 0,
653
+ seed,
654
+ results: Object.freeze(results),
655
+ failures,
656
+ counts: Object.freeze({ passed: results.length - failures.length, failed: failures.length }),
657
+ })
658
+ }
659
+
660
+ /** Run the lab and throw a joined error on failure - the `assertAdversarialContract` shape. */
661
+ export async function assertPredictionLab(
662
+ options: PredictionLabOptions = {},
663
+ ): Promise<PredictionLabReport> {
664
+ const report = await runPredictionLab(options)
665
+ if (!report.ok) {
666
+ throw new Error(
667
+ `Prediction lab failed: ${report.failures.map((failure) => `${failure.id} - ${failure.message} (replay seed ${report.seed})`).join("\n")}`,
668
+ )
669
+ }
670
+ return report
671
+ }
672
+
673
+ /** Stable case IDs in run order. */
674
+ export const predictionLabCaseIds: readonly string[] = Object.freeze(
675
+ LAB_CASES.map((labCase) => labCase.id),
676
+ )
677
+
678
+ export function toPredictionLabContext(
679
+ seed: number,
680
+ labCase: {
681
+ readonly id: string
682
+ readonly target: PredictionLabTarget
683
+ readonly mutation?: string
684
+ },
685
+ ): PredictionLabContext {
686
+ return {
687
+ seed,
688
+ caseId: labCase.id,
689
+ target: labCase.target,
690
+ ...(labCase.mutation === undefined ? {} : { mutation: labCase.mutation }),
691
+ }
692
+ }