houserules/scripts/replay-determinism.ts

107 lines
3 KiB
TypeScript

#!/usr/bin/env bun
/**
* CI replay-determinism verifier.
*
* Drives a handful of canonical event sequences through a live Session
* (capturing to an EventLog) and re-plays each log on a fresh Session,
* asserting that the state hashes match byte-for-byte. See
* packages/rete/SPEC.md §Replay Determinism and P3.3 in docs/PHASES.md.
*
* Intentionally imports engine internals via relative paths so the
* script is runnable without first building the package — Bun executes
* TypeScript sources directly.
*
* Usage:
* bun run scripts/replay-determinism.ts
*
* Exit status:
* 0 — all sequences produced matching hashes
* 1 — at least one mismatch (replay is non-deterministic)
*/
import { Session } from "../packages/rete/src/session.js";
import { EventLog } from "../packages/rete/src/eventlog.js";
import { stateHash, replayFromLog } from "../packages/rete/src/replay.js";
import type { EntityId } from "../packages/rete/src/schema.js";
type Op =
| { op: "insert"; id: number; attr: string; value: unknown }
| { op: "retract"; id: number; attr: string };
interface TestCase {
name: string;
operations: Op[];
}
const TEST_CASES: TestCase[] = [
{
name: "basic-inserts",
operations: [
{ op: "insert", id: 1, attr: "X", value: 42 },
{ op: "insert", id: 2, attr: "Y", value: "hello" },
{ op: "insert", id: 3, attr: "Z", value: true },
],
},
{
name: "with-retracts",
operations: [
{ op: "insert", id: 1, attr: "X", value: 100 },
{ op: "insert", id: 2, attr: "Y", value: 200 },
{ op: "retract", id: 1, attr: "X" },
{ op: "insert", id: 1, attr: "X", value: 300 },
],
},
{
name: "nested-values",
operations: [
{ op: "insert", id: 1, attr: "pos", value: { x: 1, y: 2 } },
{ op: "insert", id: 2, attr: "tags", value: ["a", "b", "c"] },
],
},
{
name: "large-set",
operations: Array.from({ length: 100 }, (_, i) => ({
op: "insert" as const,
id: i + 1,
attr: "N",
value: i * 7,
})),
},
];
function runCase(tc: TestCase): boolean {
const log = new EventLog();
const original = new Session({ autoFire: false, eventLog: log });
for (const op of tc.operations) {
const id = op.id as unknown as EntityId;
if (op.op === "insert") {
original.insert(id, op.attr, op.value);
} else {
original.retract(id, op.attr);
}
}
const originalHash = stateHash(original);
const replayed = replayFromLog(log.getAll());
const replayedHash = stateHash(replayed);
const match = originalHash === replayedHash;
const status = match ? "MATCH " : "MISMATCH";
console.log(
`${status} [${tc.name}] original=${originalHash} replayed=${replayedHash} events=${log.length}`,
);
return match;
}
let allMatch = true;
for (const tc of TEST_CASES) {
if (!runCase(tc)) allMatch = false;
}
if (allMatch) {
console.log("\nAll replay hashes match.");
process.exit(0);
} else {
console.error("\nOne or more replay hashes diverged — replay is non-deterministic.");
process.exit(1);
}