@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.
- package/README.md +26 -0
- package/dist/adversarial.d.ts +14 -1
- package/dist/adversarial.d.ts.map +1 -1
- package/dist/adversarial.js +113 -25
- package/dist/adversarial.js.map +1 -1
- package/dist/agent-eval.d.ts.map +1 -1
- package/dist/agent-eval.js +3 -1
- package/dist/agent-eval.js.map +1 -1
- package/dist/certification.d.ts +28 -1
- package/dist/certification.d.ts.map +1 -1
- package/dist/certification.js +119 -51
- package/dist/certification.js.map +1 -1
- package/dist/contract-lab.js +1 -1
- package/dist/contract-lab.js.map +1 -1
- package/dist/data-conformance.d.ts +72 -0
- package/dist/data-conformance.d.ts.map +1 -0
- package/dist/data-conformance.js +198 -0
- package/dist/data-conformance.js.map +1 -0
- package/dist/e2e.d.ts +63 -0
- package/dist/e2e.d.ts.map +1 -0
- package/dist/e2e.js +56 -0
- package/dist/e2e.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/prediction-lab.d.ts +65 -0
- package/dist/prediction-lab.d.ts.map +1 -0
- package/dist/prediction-lab.js +530 -0
- package/dist/prediction-lab.js.map +1 -0
- package/package.json +20 -7
- package/src/adversarial.ts +144 -28
- package/src/agent-eval.ts +4 -3
- package/src/certification.ts +138 -45
- package/src/contract-lab.ts +1 -1
- package/src/data-conformance.ts +297 -0
- package/src/e2e.ts +112 -0
- package/src/index.ts +28 -0
- package/src/prediction-lab.ts +692 -0
|
@@ -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
|
+
}
|