feat(rete): add Immer snapshots at tick boundaries (P3.2)
This commit is contained in:
parent
c5c00153ab
commit
35e270e3b5
3 changed files with 299 additions and 0 deletions
|
|
@ -17,6 +17,7 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"immer": "^10.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
163
packages/rete/src/snapshot.test.ts
Normal file
163
packages/rete/src/snapshot.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
135
packages/rete/src/snapshot.ts
Normal file
135
packages/rete/src/snapshot.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue