diff --git a/pkg/spec/test/fixtures/parity-golden.json b/pkg/spec/test/fixtures/parity-golden.json new file mode 100644 index 0000000..b9865d4 --- /dev/null +++ b/pkg/spec/test/fixtures/parity-golden.json @@ -0,0 +1,127 @@ +[ + { + "kind": "Tap", + "x": 250, + "y": 260, + "selector": "id:gamma" + }, + { + "kind": "Tap", + "x": 150, + "y": 160, + "selector": "id:beta" + }, + { + "kind": "Tap", + "x": 50, + "y": 60, + "selector": "id:alpha" + }, + { + "kind": "InputText", + "x": 150, + "y": 160, + "text": "a", + "selector": "id:beta" + }, + { + "kind": "InputText", + "x": 50, + "y": 60, + "text": "\t\n", + "selector": "id:alpha" + }, + { + "kind": "Tap", + "x": 250, + "y": 260, + "selector": "id:gamma" + }, + { + "kind": "Tap", + "x": 150, + "y": 160, + "selector": "id:beta" + }, + { + "kind": "Tap", + "x": 250, + "y": 260, + "selector": "id:gamma" + }, + { + "kind": "Tap", + "x": 150, + "y": 160, + "selector": "id:beta" + }, + { + "kind": "Tap", + "x": 250, + "y": 260, + "selector": "id:gamma" + }, + { + "kind": "Tap", + "x": 250, + "y": 260, + "selector": "id:gamma" + }, + { + "kind": "Tap", + "x": 150, + "y": 160, + "selector": "id:beta" + }, + { + "kind": "Tap", + "x": 250, + "y": 260, + "selector": "id:gamma" + }, + { + "kind": "Tap", + "x": 50, + "y": 60, + "selector": "id:alpha" + }, + { + "kind": "InputText", + "x": 150, + "y": 160, + "text": "999999999999999999999", + "selector": "id:beta" + }, + { + "kind": "InputText", + "x": 150, + "y": 160, + "text": "-1", + "selector": "id:beta" + }, + { + "kind": "InputText", + "x": 250, + "y": 260, + "text": "-1", + "selector": "id:gamma" + }, + { + "kind": "Tap", + "x": 50, + "y": 60, + "selector": "id:alpha" + }, + { + "kind": "Tap", + "x": 250, + "y": 260, + "selector": "id:gamma" + }, + { + "kind": "Tap", + "x": 50, + "y": 60, + "selector": "id:alpha" + } +] diff --git a/pkg/spec/test/parity-harness.ts b/pkg/spec/test/parity-harness.ts index 1de2872..fba8f96 100644 --- a/pkg/spec/test/parity-harness.ts +++ b/pkg/spec/test/parity-harness.ts @@ -1,37 +1,52 @@ -// Cross-runtime parity harness. Run under node (tsx) by the Go test -// internal/verifier/parity_test.go, which feeds the SAME seed and candidate -// list into the goja verifier and asserts an identical action stream. Both -// engines run the SAME pick.ts over the SAME Pcg, so any divergence here is an -// engine-level (number/bigint) parity bug. +// Shared cross-runtime parity scenario: the FIXED seed, FIXED candidate list, +// and FIXED action root that both the node test (parity.test.ts) and the goja +// test (internal/verifier/parity_test.go) drive. Each side runs the SAME +// pick.ts over the SAME Pcg and asserts the SAME committed golden +// (fixtures/parity-golden.json); matching one golden on both sides proves the +// two engines agree without either invoking the other. // -// Inputs come from env so the Go side controls them: -// SANDERLING_PARITY_SEED decimal uint64 seed -// SANDERLING_PARITY_STEPS action count to emit -// SANDERLING_PARITY_CANDIDATES JSON array of {x,y,selector,width,height} -// -// Output: one JSON array of serialized actions on stdout. +// The candidate ORDER and the per-tick PCG draw order are the parity contract. +// The weighted root mixes a tap branch (1 candidate draw) with a typing branch +// (1 candidate draw + 1 corpus draw), so a tick exercises weighted selection, a +// builtin, and the input corpus together; reordering candidates or adding or +// dropping a draw on either side shifts the stream and fails the golden. -import { installRuntime } from "../src/runtime-entry.ts"; +import { Pcg } from "../src/pcg.ts"; +import { nextAction } from "../src/pick.ts"; +import { serializeAction, type SerializedAction } from "../src/runtime-entry.ts"; +import { taps, typing, weighted } from "../src/actions.ts"; import type { BuiltinVerb, Candidate, GeneratorNode, Host } from "../src/action-tree.ts"; -const seed = BigInt(process.env.SANDERLING_PARITY_SEED ?? "0"); -const steps = Number(process.env.SANDERLING_PARITY_STEPS ?? "0"); -const candidates = JSON.parse(process.env.SANDERLING_PARITY_CANDIDATES ?? "[]") as Candidate[]; +export const PARITY_SEED_HI = 0x9e3779b97f4a7c15n; +export const PARITY_STEPS = 20; -const host: Host = { +export const PARITY_CANDIDATES: Candidate[] = [ + { x: 50, y: 60, selector: "id:alpha", width: 100, height: 40 }, + { x: 150, y: 160, selector: "id:beta", width: 120, height: 48 }, + { x: 250, y: 260, selector: "id:gamma", width: 80, height: 32 }, +]; + +// A 3:1 weighted split over taps and typing. The stub host returns the same +// candidate list for every verb so the only variables are the draw order and +// the JS engine's number/bigint behavior. +export const PARITY_ROOT: GeneratorNode = weighted([3, taps], [1, typing]); + +const HOST: Host = { platform: () => "android", - queryCandidates: (_verb: BuiltinVerb) => candidates, + queryCandidates: (_verb: BuiltinVerb) => PARITY_CANDIDATES, reportUnsupported: () => {}, - seedHi: () => seed, + seedHi: () => PARITY_SEED_HI, seedLo: () => 0n, }; -const root: GeneratorNode = { kind: "builtin", verb: "taps" }; - -installRuntime(host, root, () => ({})); - -const nextAction = (globalThis as { __sanderlingNextAction__: () => unknown }) - .__sanderlingNextAction__; -const stream = []; -for (let i = 0; i < steps; i++) stream.push(nextAction()); -process.stdout.write(JSON.stringify(stream)); +// runParity emits the parity scenario's action stream from a fresh Pcg. It +// drives pick.ts directly (not installRuntime, whose globals are locked once) +// so the caller can run it repeatedly to assert determinism. +export function runParity(): (SerializedAction | null)[] { + const rng = new Pcg(PARITY_SEED_HI, 0n); + const stream: (SerializedAction | null)[] = []; + for (let i = 0; i < PARITY_STEPS; i++) { + stream.push(serializeAction(nextAction(PARITY_ROOT, rng, HOST))); + } + return stream; +} diff --git a/pkg/spec/test/parity.test.ts b/pkg/spec/test/parity.test.ts new file mode 100644 index 0000000..cc39620 --- /dev/null +++ b/pkg/spec/test/parity.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +import { PARITY_STEPS, runParity } from "./parity-harness.ts"; + +const goldenPath = fileURLToPath(new URL("./fixtures/parity-golden.json", import.meta.url)); +const golden = JSON.parse(readFileSync(goldenPath, "utf8")); + +// The node side of the W2 acceptance gate: the shared picker's stream for the +// fixed parity scenario must match the committed golden byte-for-byte. The goja +// test asserts the SAME golden, so a match on both sides proves cross-runtime +// parity. A drift here means pick.ts, the corpus, or the Pcg changed. +test("node picker matches the cross-runtime golden", () => { + const stream = runParity(); + assert.equal(stream.length, PARITY_STEPS); + assert.deepEqual(stream, golden); +}); + +// Determinism: a second run from the same seed yields the identical stream. +test("node picker is deterministic across repeated runs", () => { + assert.deepEqual(runParity(), runParity()); +});