feat(rete): add WorkingMemory with deterministic iteration (P1.2)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 13:37:47 -06:00
commit 72a1723522
No known key found for this signature in database
3 changed files with 212 additions and 0 deletions

View file

@ -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";

View file

@ -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();
});
});

91
packages/rete/src/wm.ts Normal file
View file

@ -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<entityId, Map<attrKey, value>>
readonly #store = new Map<EntityId, Map<AttrKey, FactValue>>();
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);
}
}