diff --git a/package.json b/package.json index b707928..578d9b9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index 52161b8..483ce12 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -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"; diff --git a/packages/rete/src/replay.test.ts b/packages/rete/src/replay.test.ts new file mode 100644 index 0000000..6175204 --- /dev/null +++ b/packages/rete/src/replay.test.ts @@ -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); + }); +}); diff --git a/packages/rete/src/replay.ts b/packages/rete/src/replay.ts new file mode 100644 index 0000000..b2a3659 --- /dev/null +++ b/packages/rete/src/replay.ts @@ -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; + +/** + * 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; +} diff --git a/scripts/replay-determinism.ts b/scripts/replay-determinism.ts new file mode 100644 index 0000000..c851a9b --- /dev/null +++ b/scripts/replay-determinism.ts @@ -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); +}