feat(rete): add replay engine + state-hash determinism verifier (P3.3)
This commit is contained in:
parent
aa5105d1c7
commit
c5c00153ab
5 changed files with 383 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
170
packages/rete/src/replay.test.ts
Normal file
170
packages/rete/src/replay.test.ts
Normal 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
101
packages/rete/src/replay.ts
Normal 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;
|
||||
}
|
||||
107
scripts/replay-determinism.ts
Normal file
107
scripts/replay-determinism.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue