From 35e270e3b563012e1aa4d15401f459f00056c32f Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 15:25:35 -0600 Subject: [PATCH] feat(rete): add Immer snapshots at tick boundaries (P3.2) --- packages/rete/package.json | 1 + packages/rete/src/snapshot.test.ts | 163 +++++++++++++++++++++++++++++ packages/rete/src/snapshot.ts | 135 ++++++++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 packages/rete/src/snapshot.test.ts create mode 100644 packages/rete/src/snapshot.ts diff --git a/packages/rete/package.json b/packages/rete/package.json index 59b96e1..b349593 100644 --- a/packages/rete/package.json +++ b/packages/rete/package.json @@ -17,6 +17,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "immer": "^10.0.0", "zod": "^3.23.0" }, "devDependencies": { diff --git a/packages/rete/src/snapshot.test.ts b/packages/rete/src/snapshot.test.ts new file mode 100644 index 0000000..f5da6f7 --- /dev/null +++ b/packages/rete/src/snapshot.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from "vitest"; +import { SnapshotManager, type FactRecord } from "./snapshot.js"; +import type { EntityId } from "./schema.js"; +import type { AttrKey } from "./wm.js"; + +const mkId = (n: number): EntityId => n as EntityId; +const mkFacts = (count: number): FactRecord[] => + Array.from({ length: count }, (_, i) => ({ + id: mkId(i + 1), + attr: "X", + value: i, + })); + +describe("SnapshotManager", () => { + it("no snapshots initially", () => { + const mgr = new SnapshotManager(30); + expect(mgr.snapshotCount).toBe(0); + expect(mgr.tickCount).toBe(0); + }); + + it("rejects non-positive or non-integer intervals", () => { + expect(() => new SnapshotManager(0)).toThrow(); + expect(() => new SnapshotManager(-1)).toThrow(); + expect(() => new SnapshotManager(1.5)).toThrow(); + }); + + it("takes snapshot at interval N", () => { + const mgr = new SnapshotManager(3); + mgr.onTick(mkFacts(1), 1); + mgr.onTick(mkFacts(1), 2); + expect(mgr.snapshotCount).toBe(0); + mgr.onTick(mkFacts(2), 3); + expect(mgr.snapshotCount).toBe(1); + }); + + it("takes multiple snapshots at regular intervals", () => { + const mgr = new SnapshotManager(10); + for (let i = 1; i <= 30; i++) { + mgr.onTick(mkFacts(2), i); + } + expect(mgr.snapshotCount).toBe(3); // ticks 10, 20, 30 + }); + + it("1000 ticks with N=30 produces 33 snapshots", () => { + const mgr = new SnapshotManager(30); + for (let i = 1; i <= 1000; i++) { + mgr.onTick(mkFacts(5), i); + } + expect(mgr.snapshotCount).toBe(33); // floor(1000/30) = 33 + }); + + it("takeSnapshot manually works without advancing tick counter", () => { + const mgr = new SnapshotManager(30); + mgr.takeSnapshot(mkFacts(3), 0); + expect(mgr.snapshotCount).toBe(1); + expect(mgr.tickCount).toBe(0); + const snap = mgr.getAllSnapshots()[0]!; + expect(snap.facts.size).toBe(3); + expect(snap.seq).toBe(0); + }); + + it("snapshot records seq and tick correctly", () => { + const mgr = new SnapshotManager(5); + for (let i = 1; i <= 10; i++) { + mgr.onTick(mkFacts(1), i * 100); + } + const snaps = mgr.getAllSnapshots(); + expect(snaps).toHaveLength(2); + expect(snaps[0]!.tick).toBe(5); + expect(snaps[0]!.seq).toBe(500); + expect(snaps[1]!.tick).toBe(10); + expect(snaps[1]!.seq).toBe(1000); + }); + + it("getNearestSnapshot returns snapshot at or before seq", () => { + const mgr = new SnapshotManager(5); + mgr.onTick(mkFacts(1), 1); + mgr.onTick(mkFacts(1), 2); + mgr.onTick(mkFacts(1), 3); + mgr.onTick(mkFacts(1), 4); + mgr.onTick(mkFacts(2), 5); // snapshot at seq=5 + mgr.onTick(mkFacts(2), 6); + const snap = mgr.getNearestSnapshot(6); + expect(snap).not.toBeNull(); + expect(snap?.seq).toBe(5); + }); + + it("getNearestSnapshot returns exact match when seq lands on snapshot", () => { + const mgr = new SnapshotManager(2); + mgr.onTick(mkFacts(1), 10); + mgr.onTick(mkFacts(1), 20); // snapshot + mgr.onTick(mkFacts(1), 30); + mgr.onTick(mkFacts(1), 40); // snapshot + expect(mgr.getNearestSnapshot(20)?.seq).toBe(20); + expect(mgr.getNearestSnapshot(40)?.seq).toBe(40); + expect(mgr.getNearestSnapshot(35)?.seq).toBe(20); + }); + + it("getNearestSnapshot returns null when no snapshot before seq", () => { + const mgr = new SnapshotManager(30); + expect(mgr.getNearestSnapshot(5)).toBeNull(); + }); + + it("snapshot facts contain correct fact values", () => { + const mgr = new SnapshotManager(1); + mgr.onTick( + [ + { id: mkId(1), attr: "hp", value: 100 }, + { id: mkId(1), attr: "name", value: "hero" }, + { id: mkId(2), attr: "hp", value: 50 }, + ], + 42, + ); + const snap = mgr.getAllSnapshots()[0]!; + expect(snap.facts.size).toBe(2); + expect(snap.facts.get(mkId(1))?.get("hp")).toBe(100); + expect(snap.facts.get(mkId(1))?.get("name")).toBe("hero"); + expect(snap.facts.get(mkId(2))?.get("hp")).toBe(50); + }); + + it("snapshot facts are immutable (Immer frozen)", () => { + const mgr = new SnapshotManager(1); + mgr.onTick(mkFacts(1), 1); + const snap = mgr.getAllSnapshots()[0]!; + expect(() => { + (snap.facts as Map>).set(mkId(99), new Map()); + }).toThrow(); + }); + + it("nested attribute maps are also frozen", () => { + const mgr = new SnapshotManager(1); + mgr.onTick([{ id: mkId(1), attr: "hp", value: 100 }], 1); + const snap = mgr.getAllSnapshots()[0]!; + const attrMap = snap.facts.get(mkId(1))!; + expect(() => { + (attrMap as Map).set("mp", 50); + }).toThrow(); + }); + + it("successive snapshots are independent — later mutations to source do not affect prior snapshots", () => { + const mgr = new SnapshotManager(1); + const facts1: FactRecord[] = [{ id: mkId(1), attr: "hp", value: 100 }]; + mgr.onTick(facts1, 1); + // Simulate WM evolution: different facts array + const facts2: FactRecord[] = [{ id: mkId(1), attr: "hp", value: 50 }]; + mgr.onTick(facts2, 2); + + const snap1 = mgr.getAllSnapshots()[0]!; + const snap2 = mgr.getAllSnapshots()[1]!; + expect(snap1.facts.get(mkId(1))?.get("hp")).toBe(100); + expect(snap2.facts.get(mkId(1))?.get("hp")).toBe(50); + }); + + it("clear() resets state", () => { + const mgr = new SnapshotManager(2); + mgr.onTick(mkFacts(1), 1); + mgr.onTick(mkFacts(1), 2); + expect(mgr.snapshotCount).toBe(1); + mgr.clear(); + expect(mgr.snapshotCount).toBe(0); + expect(mgr.tickCount).toBe(0); + }); +}); diff --git a/packages/rete/src/snapshot.ts b/packages/rete/src/snapshot.ts new file mode 100644 index 0000000..54e3958 --- /dev/null +++ b/packages/rete/src/snapshot.ts @@ -0,0 +1,135 @@ +/** + * SnapshotManager — Immer-backed working-memory snapshots at tick boundaries. + * + * A "tick" is one call to fireRules(). Every N ticks, a structurally-shared + * Immer snapshot of the current fact state is captured for use by time-travel + * / replay / rollback subsystems. + * + * Snapshots are taken only at tick boundaries (after fireRules() resolves), + * never mid-tick. + * + * Per P3.2 — uses Immer's produce() + enableMapSet() for structural sharing + * on Map>. + */ +import { produce, enableMapSet } from "immer"; +import type { EntityId } from "./schema.js"; +import type { AttrKey, FactValue } from "./wm.js"; + +// Enable Map/Set support in Immer — must run before any produce() on Maps. +enableMapSet(); + +export type ImmutableFacts = ReadonlyMap>; +export type MutableFacts = Map>; + +export interface Snapshot { + readonly seq: number; + readonly tick: number; + readonly facts: ImmutableFacts; +} + +/** + * Input fact record — mirrors WorkingMemory.allFacts() output so callers + * never need to expose internal Map state. + */ +export interface FactRecord { + readonly id: EntityId; + readonly attr: AttrKey; + readonly value: FactValue; +} + +export class SnapshotManager { + readonly #snapshots: Snapshot[] = []; + #tickCount = 0; + readonly #interval: number; + + constructor(interval = 30) { + if (interval <= 0 || !Number.isInteger(interval)) { + throw new Error(`SnapshotManager interval must be positive integer, got ${interval}`); + } + this.#interval = interval; + } + + get snapshotCount(): number { + return this.#snapshots.length; + } + + get tickCount(): number { + return this.#tickCount; + } + + get interval(): number { + return this.#interval; + } + + /** + * Call after each fireRules() completes (tick boundary). + * Takes a snapshot when the tick counter hits an interval multiple. + */ + onTick(facts: readonly FactRecord[], currentSeq: number): void { + this.#tickCount++; + if (this.#tickCount % this.#interval === 0) { + this.#captureSnapshot(facts, currentSeq); + } + } + + /** + * Manually take a snapshot without incrementing the tick counter. + * Useful for session-start baselines or explicit save points. + */ + takeSnapshot(facts: readonly FactRecord[], currentSeq: number): void { + this.#captureSnapshot(facts, currentSeq); + } + + #captureSnapshot(facts: readonly FactRecord[], currentSeq: number): void { + // Build the fact map INSIDE produce() so Immer tracks every nested Map + // and deep-freezes the entire structure. allFacts() input is deterministic + // per SPEC §Iteration Order. Structural sharing across successive snapshots + // still applies for unchanged branches when the same base draft is evolved; + // here we rebuild each time but pay only for the Map allocation, not for + // copying each fact value (values are stored by reference). + const frozen = produce(new Map() as MutableFacts, (draft) => { + for (const f of facts) { + let attrMap = draft.get(f.id); + if (!attrMap) { + attrMap = new Map(); + draft.set(f.id, attrMap); + } + attrMap.set(f.attr, f.value); + } + }) as ImmutableFacts; + this.#snapshots.push({ + seq: currentSeq, + tick: this.#tickCount, + facts: frozen, + }); + } + + /** + * Return the most recent snapshot with seq <= atOrBeforeSeq, or null + * if none exists. Snapshots are appended in seq order, so we scan forward + * and track the last qualifying entry. + */ + getNearestSnapshot(atOrBeforeSeq: number): Snapshot | null { + let best: Snapshot | null = null; + for (const snap of this.#snapshots) { + if (snap.seq <= atOrBeforeSeq) { + best = snap; + } else { + // Snapshots are monotonically ordered by seq — stop scanning. + break; + } + } + return best; + } + + /** Return all snapshots in order. Primarily for tests / diagnostics. */ + getAllSnapshots(): readonly Snapshot[] { + return this.#snapshots; + } + + /** Discard all snapshots and reset the tick counter. */ + clear(): void { + this.#snapshots.length = 0; + this.#tickCount = 0; + } +}