From 72a172352262eec92712433835613fbd63574b90 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 13:37:47 -0600 Subject: [PATCH] feat(rete): add WorkingMemory with deterministic iteration (P1.2) --- packages/rete/src/index.ts | 6 ++ packages/rete/src/wm.test.ts | 115 +++++++++++++++++++++++++++++++++++ packages/rete/src/wm.ts | 91 +++++++++++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 packages/rete/src/wm.test.ts create mode 100644 packages/rete/src/wm.ts diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index ab09de7..b572c9a 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -7,3 +7,9 @@ export type { DefineSchemaOptions, } from "./schema.js"; export { defineSchema, fact } from "./schema.js"; + +export type { AttrKey, FactValue } from "./wm.js"; +export { WorkingMemory } from "./wm.js"; + +export type { AlphaCondition } from "./alpha.js"; +export { AlphaNetwork, AlphaNode, AlphaMemory } from "./alpha.js"; diff --git a/packages/rete/src/wm.test.ts b/packages/rete/src/wm.test.ts new file mode 100644 index 0000000..769b080 --- /dev/null +++ b/packages/rete/src/wm.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from "vitest"; +import { WorkingMemory } from "./wm.js"; +import type { EntityId } from "./schema.js"; + +describe("WorkingMemory", () => { + const mkId = (n: number) => n as EntityId; + + it("inserts a fact and retrieves it with get()", () => { + const wm = new WorkingMemory(); + wm.insert(mkId(1), "Health", 100); + expect(wm.get(mkId(1), "Health")).toBe(100); + }); + + it("insert with same id+attr replaces existing value (update semantics)", () => { + const wm = new WorkingMemory(); + wm.insert(mkId(1), "Health", 100); + wm.insert(mkId(1), "Health", 50); + expect(wm.get(mkId(1), "Health")).toBe(50); + }); + + it("get() returns undefined for missing fact", () => { + const wm = new WorkingMemory(); + expect(wm.get(mkId(1), "Health")).toBeUndefined(); + }); + + it("contains() returns true when fact exists", () => { + const wm = new WorkingMemory(); + wm.insert(mkId(2), "Active", true); + expect(wm.contains(mkId(2), "Active")).toBe(true); + }); + + it("contains() returns false when fact missing", () => { + const wm = new WorkingMemory(); + expect(wm.contains(mkId(2), "Active")).toBe(false); + }); + + it("retract() removes a fact, returns true", () => { + const wm = new WorkingMemory(); + wm.insert(mkId(3), "Name", "Alice"); + const result = wm.retract(mkId(3), "Name"); + expect(result).toBe(true); + expect(wm.get(mkId(3), "Name")).toBeUndefined(); + }); + + it("retract() returns false for non-existent fact", () => { + const wm = new WorkingMemory(); + const result = wm.retract(mkId(99), "Health"); + expect(result).toBe(false); + }); + + it("allFacts() returns sorted [id asc, attr asc] array", () => { + const wm = new WorkingMemory(); + wm.insert(mkId(2), "Z", "z"); + wm.insert(mkId(1), "B", "b"); + wm.insert(mkId(1), "A", "a"); + wm.insert(mkId(2), "A", "a2"); + const facts = wm.allFacts(); + expect(facts).toHaveLength(4); + expect(facts[0]).toMatchObject({ id: 1, attr: "A" }); + expect(facts[1]).toMatchObject({ id: 1, attr: "B" }); + expect(facts[2]).toMatchObject({ id: 2, attr: "A" }); + expect(facts[3]).toMatchObject({ id: 2, attr: "Z" }); + }); + + it("allFacts() is deterministic across multiple calls (same insertion order)", () => { + const wm = new WorkingMemory(); + wm.insert(mkId(5), "X", 1); + wm.insert(mkId(3), "Y", 2); + const first = wm.allFacts().map((f) => `${f.id}:${String(f.attr)}`); + const second = wm.allFacts().map((f) => `${f.id}:${String(f.attr)}`); + expect(first).toEqual(second); + }); + + it("calls insert listener when a fact is inserted", () => { + const wm = new WorkingMemory(); + const listener = vi.fn(); + wm.onInsert(listener); + wm.insert(mkId(1), "Health", 10); + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(mkId(1), "Health", 10); + }); + + it("calls retract listener when a fact is retracted", () => { + const wm = new WorkingMemory(); + const listener = vi.fn(); + wm.onRetract(listener); + wm.insert(mkId(1), "Health", 10); + wm.retract(mkId(1), "Health"); + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(mkId(1), "Health", 10); + }); + + it("calls insert listener on update (retract+insert of same id+attr)", () => { + const wm = new WorkingMemory(); + const retractFn = vi.fn(); + const insertFn = vi.fn(); + wm.onRetract(retractFn); + wm.onInsert(insertFn); + wm.insert(mkId(1), "Health", 10); + wm.insert(mkId(1), "Health", 20); // update + // First insert: insertFn called with 10 + // Update: retractFn called with old value 10, then insertFn called with 20 + expect(insertFn).toHaveBeenCalledTimes(2); + expect(retractFn).toHaveBeenCalledTimes(1); + expect(retractFn).toHaveBeenCalledWith(mkId(1), "Health", 10); + }); + + it("does not call retract listener when retracting non-existent fact", () => { + const wm = new WorkingMemory(); + const listener = vi.fn(); + wm.onRetract(listener); + wm.retract(mkId(99), "Health"); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rete/src/wm.ts b/packages/rete/src/wm.ts new file mode 100644 index 0000000..2fd94f1 --- /dev/null +++ b/packages/rete/src/wm.ts @@ -0,0 +1,91 @@ +/** + * WorkingMemory — stores EAV facts. + * Per SPEC.md §Fact Model and §Iteration Order. + */ +import type { EntityId } from "./schema.js"; + +export type AttrKey = string; +export type FactValue = unknown; + +type InsertListener = (id: EntityId, attr: AttrKey, value: FactValue) => void; +type RetractListener = (id: EntityId, attr: AttrKey, oldValue: FactValue) => void; + +export class WorkingMemory { + // Map> + readonly #store = new Map>(); + readonly #insertListeners: InsertListener[] = []; + readonly #retractListeners: RetractListener[] = []; + + /** Insert (or update) a fact. On update, fires retract listener for old value first. */ + insert(id: EntityId, attr: AttrKey, value: FactValue): void { + let attrMap = this.#store.get(id); + if (!attrMap) { + attrMap = new Map(); + this.#store.set(id, attrMap); + } + // If existing, fire retract for old value (update semantics) + if (attrMap.has(attr)) { + const oldValue = attrMap.get(attr); + for (const listener of this.#retractListeners) { + listener(id, attr, oldValue); + } + } + attrMap.set(attr, value); + for (const listener of this.#insertListeners) { + listener(id, attr, value); + } + } + + /** Retract a fact. Returns true if fact existed, false otherwise. */ + retract(id: EntityId, attr: AttrKey): boolean { + const attrMap = this.#store.get(id); + if (!attrMap?.has(attr)) return false; + const oldValue = attrMap.get(attr); + attrMap.delete(attr); + if (attrMap.size === 0) this.#store.delete(id); + for (const listener of this.#retractListeners) { + listener(id, attr, oldValue); + } + return true; + } + + /** Get the value of a fact, or undefined if not present. */ + get(id: EntityId, attr: AttrKey): FactValue | undefined { + return this.#store.get(id)?.get(attr); + } + + /** Check if a fact exists. */ + contains(id: EntityId, attr: AttrKey): boolean { + return this.#store.get(id)?.has(attr) ?? false; + } + + /** + * Return all facts as a sorted array. + * Sort order: id asc (numeric), then attr asc (lexicographic). + * Per SPEC.md §Iteration Order — deterministic, never raw Map iteration in hot paths. + */ + allFacts(): Array<{ id: EntityId; attr: AttrKey; value: FactValue }> { + const result: Array<{ id: EntityId; attr: AttrKey; value: FactValue }> = []; + for (const [id, attrMap] of this.#store) { + for (const [attr, value] of attrMap) { + result.push({ id, attr, value }); + } + } + // Sort: id asc (numeric), then attr asc (lexicographic). + result.sort((a, b) => { + if (a.id !== b.id) return (a.id as number) - (b.id as number); + return a.attr < b.attr ? -1 : a.attr > b.attr ? 1 : 0; + }); + return result; + } + + /** Register a listener called after each insert (including update inserts). */ + onInsert(listener: InsertListener): void { + this.#insertListeners.push(listener); + } + + /** Register a listener called before each retract (including update retracts). */ + onRetract(listener: RetractListener): void { + this.#retractListeners.push(listener); + } +}