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
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue