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": "vitest run --passWithNoTests",
|
||||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||||
"build": "bun run --filter '*' build",
|
"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": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.52.0",
|
"@playwright/test": "^1.52.0",
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ export { Session } from "./session.js";
|
||||||
export type { EventKind, LogEvent } from "./eventlog.js";
|
export type { EventKind, LogEvent } from "./eventlog.js";
|
||||||
export { EventLog } 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 { RecursionLimitExceededError } from "./cycle.js";
|
||||||
|
|
||||||
export type { SerializedRule } from "./serialize.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