feat(rete): add WorkingMemory with deterministic iteration (P1.2)
This commit is contained in:
parent
a027635d27
commit
72a1723522
3 changed files with 212 additions and 0 deletions
|
|
@ -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";
|
||||
|
|
|
|||
115
packages/rete/src/wm.test.ts
Normal file
115
packages/rete/src/wm.test.ts
Normal 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
91
packages/rete/src/wm.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue