feat(rete): add replay engine + state-hash determinism verifier (P3.3)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 15:25:13 -06:00
commit c5c00153ab
No known key found for this signature in database
5 changed files with 383 additions and 1 deletions

View file

@ -11,7 +11,8 @@
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"build": "bun run --filter '*' build",
"size-limit": "echo 'size-limit: TODO wire after build'"
"size-limit": "echo 'size-limit: TODO wire after build'",
"replay-determinism": "bun run scripts/replay-determinism.ts"
},
"devDependencies": {
"@playwright/test": "^1.52.0",

View file

@ -40,6 +40,9 @@ export { Session } from "./session.js";
export type { EventKind, LogEvent } from "./eventlog.js";
export { EventLog } from "./eventlog.js";
export type { ReplayOptions } from "./replay.js";
export { stateHash, replayFromLog } from "./replay.js";
export { RecursionLimitExceededError } from "./cycle.js";
export type { SerializedRule } from "./serialize.js";

View file

@ -0,0 +1,170 @@
import { describe, it, expect } from "vitest";
import { stateHash, replayFromLog } from "./replay.js";
import { EventLog } from "./eventlog.js";
import { Session } from "./session.js";
import type { EntityId } from "./schema.js";
const mkId = (n: number) => n as EntityId;
describe("stateHash()", () => {
it("produces a zero-padded 8-char hex string", () => {
const session = new Session({ autoFire: false });
session.insert(mkId(1), "X", 42);
expect(stateHash(session)).toMatch(/^[0-9a-f]{8}$/);
});
it("empty session hashes to the djb2 empty-string seed", () => {
const session = new Session({ autoFire: false });
// djb2 seed = 5381 → 0x1505
expect(stateHash(session)).toBe("00001505");
});
it("identical facts → identical hash", () => {
const s1 = new Session({ autoFire: false });
s1.insert(mkId(1), "X", 42);
s1.insert(mkId(2), "Y", "hello");
const s2 = new Session({ autoFire: false });
s2.insert(mkId(1), "X", 42);
s2.insert(mkId(2), "Y", "hello");
expect(stateHash(s1)).toBe(stateHash(s2));
});
it("different values → different hash", () => {
const s1 = new Session({ autoFire: false });
s1.insert(mkId(1), "X", 42);
const s2 = new Session({ autoFire: false });
s2.insert(mkId(1), "X", 43);
expect(stateHash(s1)).not.toBe(stateHash(s2));
});
it("different attr → different hash", () => {
const s1 = new Session({ autoFire: false });
s1.insert(mkId(1), "X", 1);
const s2 = new Session({ autoFire: false });
s2.insert(mkId(1), "Y", 1);
expect(stateHash(s1)).not.toBe(stateHash(s2));
});
it("insertion order is irrelevant (allFacts sorts)", () => {
const s1 = new Session({ autoFire: false });
s1.insert(mkId(1), "A", 1);
s1.insert(mkId(2), "B", 2);
s1.insert(mkId(3), "C", 3);
const s2 = new Session({ autoFire: false });
s2.insert(mkId(3), "C", 3);
s2.insert(mkId(2), "B", 2);
s2.insert(mkId(1), "A", 1);
expect(stateHash(s1)).toBe(stateHash(s2));
});
it("nested object values hash deterministically across sessions", () => {
const s1 = new Session({ autoFire: false });
s1.insert(mkId(1), "pos", { x: 1, y: 2 });
const s2 = new Session({ autoFire: false });
s2.insert(mkId(1), "pos", { x: 1, y: 2 });
expect(stateHash(s1)).toBe(stateHash(s2));
});
});
describe("replayFromLog()", () => {
it("round-trips inserts to a state-hash match", () => {
const log = new EventLog();
const original = new Session({ autoFire: false, eventLog: log });
original.insert(mkId(1), "X", 42);
original.insert(mkId(2), "Y", "hello");
original.insert(mkId(3), "Z", true);
const replayed = replayFromLog(log.getAll());
expect(stateHash(replayed)).toBe(stateHash(original));
});
it("round-trips retracts", () => {
const log = new EventLog();
const original = new Session({ autoFire: false, eventLog: log });
original.insert(mkId(1), "X", 42);
original.insert(mkId(2), "Y", "hello");
original.retract(mkId(1), "X");
const replayed = replayFromLog(log.getAll());
expect(stateHash(replayed)).toBe(stateHash(original));
expect(replayed.contains(mkId(1), "X")).toBe(false);
expect(replayed.contains(mkId(2), "Y")).toBe(true);
});
it("round-trips updates (retract-then-insert sequences)", () => {
const log = new EventLog();
const original = new Session({ autoFire: false, eventLog: log });
original.insert(mkId(1), "X", 1);
original.insert(mkId(1), "X", 2); // update
original.insert(mkId(1), "X", 3); // update
const replayed = replayFromLog(log.getAll());
expect(stateHash(replayed)).toBe(stateHash(original));
expect(replayed.get(mkId(1), "X")).toBe(3);
});
it("skips derived (negative-id) events defensively", () => {
// Synthesize a log that contains a negative-id event even though
// the Session would never record one. Replay must ignore it.
const log = new EventLog();
const original = new Session({ autoFire: false, eventLog: log });
original.insert(mkId(1), "X", 42);
const rawEvents = [
...log.getAll(),
// Forged derived event — should be ignored.
{
seq: 999,
ts: 0,
kind: "insert" as const,
id: -1 as EntityId,
attr: "Derived",
value: "nope",
},
];
const replayed = replayFromLog(rawEvents);
expect(replayed.contains(-1 as EntityId, "Derived")).toBe(false);
expect(stateHash(replayed)).toBe(stateHash(original));
});
it("ignores unknown event kinds (forward-compatible)", () => {
const log = new EventLog();
const original = new Session({ autoFire: false, eventLog: log });
original.insert(mkId(1), "X", 42);
const rawEvents = [
...log.getAll(),
{ seq: 999, ts: 0, kind: "snapshot" as unknown as "fire" },
];
const replayed = replayFromLog(rawEvents);
expect(stateHash(replayed)).toBe(stateHash(original));
});
it("fuzz: 10 replays of the same operation sequence yield identical hashes", () => {
const hashes: string[] = [];
for (let run = 0; run < 10; run++) {
const log = new EventLog();
const session = new Session({ autoFire: false, eventLog: log });
session.insert(mkId(1), "A", 100);
session.insert(mkId(2), "B", "test");
session.insert(mkId(3), "C", true);
session.retract(mkId(1), "A");
session.insert(mkId(1), "A", 200); // re-insert
session.insert(mkId(4), "D", { nested: [1, 2, 3] });
const replayed = replayFromLog(log.getAll());
hashes.push(stateHash(replayed));
}
expect(new Set(hashes).size).toBe(1);
});
it("replay is isolated: new Session has its own id counter and no shared state", () => {
const log = new EventLog();
const original = new Session({ autoFire: false, eventLog: log });
original.insert(mkId(1), "X", 1);
const replayed = replayFromLog(log.getAll());
// Replay does not mutate the original.
original.insert(mkId(2), "Y", 2);
expect(replayed.contains(mkId(2), "Y")).toBe(false);
});
});

101
packages/rete/src/replay.ts Normal file
View file

@ -0,0 +1,101 @@
/**
* Replay engine — reconstructs {@link Session} state from an
* {@link EventLog}, and produces a deterministic hash of a session's
* current fact state for equivalence checking.
*
* Per SPEC.md §Replay Determinism: given the same schema, rules, and
* event log, replay produces byte-identical working memory (verified
* via {@link stateHash}). Derived facts (negative {@link EntityId}s)
* are never logged — they are re-derived naturally when rules fire
* during replay.
*
* The hash is djb2 (a simple, deterministic, non-cryptographic
* algorithm). Collision resistance is not required — this is a
* fingerprint for determinism regression tests, not a security primitive.
*/
import { Session, type SessionOptions } from "./session.js";
import type { LogEvent } from "./eventlog.js";
import type { EntityId } from "./schema.js";
/**
* Produce a deterministic hash of a session's fact state.
*
* Relies on {@link Session.allFacts} returning facts sorted by
* `[id asc, attr asc]` (see wm.ts). Values are serialized with
* `JSON.stringify`, which is stable for plain JSON-compatible values —
* callers that store non-JSON values in working memory must ensure
* their serialization is order-independent.
*
* Output is a zero-padded 8-character hex string (32-bit djb2).
*/
export function stateHash(session: Session): string {
const facts = session.allFacts();
const canonical = facts
.map((f) => `${f.id as number}:${f.attr}=${JSON.stringify(f.value)}`)
.join("|");
// djb2 — deterministic, no crypto dependency, sufficient for
// fingerprinting fact state in tests and CI.
let hash = 5381;
for (let i = 0; i < canonical.length; i++) {
hash = ((hash << 5) + hash) ^ canonical.charCodeAt(i);
hash = hash >>> 0; // coerce to unsigned 32-bit
}
return hash.toString(16).padStart(8, "0");
}
/**
* Options accepted by {@link replayFromLog}. Mirrors
* {@link SessionOptions} minus `autoFire` and `eventLog`, which are
* always forced to deterministic values: `autoFire: false` (the log
* already records explicit fires via "fire" events) and no attached
* log (replay must not re-record into a live log).
*/
export type ReplayOptions = Omit<SessionOptions, "autoFire" | "eventLog">;
/**
* Replay a sequence of log events on a fresh {@link Session} and
* return it. The caller is responsible for registering rules on the
* session *before* replay if rule firings should re-derive facts.
*
* Events recorded against derived ids (negative {@link EntityId}s)
* are defensively skipped — the EventLog contract already excludes
* them at record time, but guarding here keeps replay correct against
* externally-sourced logs.
*
* Unknown event kinds are ignored rather than throwing: the log
* format may gain new kinds (e.g. `snapshot`) without breaking older
* replayers.
*/
export function replayFromLog(
events: readonly LogEvent[],
opts: ReplayOptions = {},
): Session {
const session = new Session({ ...opts, autoFire: false });
for (const event of events) {
switch (event.kind) {
case "insert": {
if (event.id === undefined || event.attr === undefined) continue;
if ((event.id as number) < 0) continue;
session.insert(event.id as EntityId, event.attr, event.value);
break;
}
case "retract": {
if (event.id === undefined || event.attr === undefined) continue;
if ((event.id as number) < 0) continue;
session.retract(event.id as EntityId, event.attr);
break;
}
case "fire": {
session.fireRules();
break;
}
default:
// Forward-compatible: ignore unknown kinds.
break;
}
}
return session;
}

View file

@ -0,0 +1,107 @@
#!/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);
}