feat(rete): add Immer snapshots at tick boundaries (P3.2)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 15:25:35 -06:00
commit 35e270e3b5
No known key found for this signature in database
3 changed files with 299 additions and 0 deletions

View file

@ -17,6 +17,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"immer": "^10.0.0",
"zod": "^3.23.0"
},
"devDependencies": {

View file

@ -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<EntityId, Map<AttrKey, unknown>>).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<AttrKey, unknown>).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);
});
});

View file

@ -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<EntityId, Map<AttrKey, FactValue>>.
*/
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<EntityId, ReadonlyMap<AttrKey, FactValue>>;
export type MutableFacts = Map<EntityId, Map<AttrKey, FactValue>>;
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;
}
}