From 2501edd886abb6264357df463c5817df635bae61 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 13:38:24 -0600 Subject: [PATCH] feat(rete): add AlphaNetwork with inverted-index dispatch (P1.3) --- packages/rete/src/alpha.test.ts | 171 +++++++++++++++++++++++++++ packages/rete/src/alpha.ts | 198 ++++++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 packages/rete/src/alpha.test.ts create mode 100644 packages/rete/src/alpha.ts diff --git a/packages/rete/src/alpha.test.ts b/packages/rete/src/alpha.test.ts new file mode 100644 index 0000000..6996dcf --- /dev/null +++ b/packages/rete/src/alpha.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi } from "vitest"; +import { AlphaNetwork, type AlphaCondition } from "./alpha.js"; +import type { EntityId } from "./schema.js"; + +const mkId = (n: number) => n as EntityId; + +describe("AlphaNetwork", () => { + it("dispatches an inserted fact to a matching node (exact id + attr)", () => { + const net = new AlphaNetwork(); + const cond: AlphaCondition = { id: mkId(1), attr: "Health" }; + const node = net.buildNode(cond); + const listener = vi.fn(); + node.onActivate(listener); + + net.notifyInsert(mkId(1), "Health", 100); + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(mkId(1), "Health", 100); + }); + + it("does NOT dispatch to a node with a different id", () => { + const net = new AlphaNetwork(); + const node = net.buildNode({ id: mkId(2), attr: "Health" }); + const listener = vi.fn(); + node.onActivate(listener); + + net.notifyInsert(mkId(1), "Health", 100); // different id + expect(listener).not.toHaveBeenCalled(); + }); + + it("dispatches to wildcard-id node (id: null) regardless of entity", () => { + const net = new AlphaNetwork(); + const node = net.buildNode({ id: null, attr: "Health" }); + const listener = vi.fn(); + node.onActivate(listener); + + net.notifyInsert(mkId(1), "Health", 10); + net.notifyInsert(mkId(2), "Health", 20); + net.notifyInsert(mkId(3), "Name", "Alice"); // different attr — no match + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("does NOT dispatch fact with wrong attr even if id matches", () => { + const net = new AlphaNetwork(); + const node = net.buildNode({ id: mkId(1), attr: "Health" }); + const listener = vi.fn(); + node.onActivate(listener); + + net.notifyInsert(mkId(1), "Position", 42); // wrong attr + expect(listener).not.toHaveBeenCalled(); + }); + + it("notifyRetract removes fact from memory and calls deactivate", () => { + const net = new AlphaNetwork(); + const node = net.buildNode({ id: null, attr: "Health" }); + const deactivateFn = vi.fn(); + node.onDeactivate(deactivateFn); + + net.notifyInsert(mkId(1), "Health", 10); + net.notifyRetract(mkId(1), "Health", 10); + + expect(deactivateFn).toHaveBeenCalledOnce(); + expect(deactivateFn).toHaveBeenCalledWith(mkId(1), "Health", 10); + expect(node.memory.facts).toHaveLength(0); + }); + + it("buildNode returns same node for same condition (memoized)", () => { + const net = new AlphaNetwork(); + const cond: AlphaCondition = { id: null, attr: "X" }; + const node1 = net.buildNode(cond); + const node2 = net.buildNode(cond); + expect(node1).toBe(node2); + }); + + it("buildNode returns different nodes for different conditions", () => { + const net = new AlphaNetwork(); + const nodeA = net.buildNode({ id: null, attr: "A" }); + const nodeB = net.buildNode({ id: null, attr: "B" }); + expect(nodeA).not.toBe(nodeB); + }); + + it("buildNode distinguishes wildcard from specific id on same attr", () => { + const net = new AlphaNetwork(); + const wildcard = net.buildNode({ id: null, attr: "X" }); + const specific = net.buildNode({ id: mkId(1), attr: "X" }); + expect(wildcard).not.toBe(specific); + + const wildFn = vi.fn(); + const specFn = vi.fn(); + wildcard.onActivate(wildFn); + specific.onActivate(specFn); + + net.notifyInsert(mkId(1), "X", 1); + net.notifyInsert(mkId(2), "X", 2); + + expect(wildFn).toHaveBeenCalledTimes(2); + expect(specFn).toHaveBeenCalledTimes(1); + expect(specFn).toHaveBeenCalledWith(mkId(1), "X", 1); + }); + + it("stores all matching facts in alpha memory, sorted [id asc, attr asc]", () => { + const net = new AlphaNetwork(); + const node = net.buildNode({ id: null, attr: "Hp" }); + + net.notifyInsert(mkId(2), "Hp", 5); + net.notifyInsert(mkId(1), "Hp", 3); + net.notifyInsert(mkId(3), "Other", 0); // no match + + // Memory should have sorted facts + expect(node.memory.facts).toHaveLength(2); + expect(node.memory.facts[0]).toMatchObject({ id: 1, attr: "Hp", value: 3 }); + expect(node.memory.facts[1]).toMatchObject({ id: 2, attr: "Hp", value: 5 }); + }); + + it("notifyRetract on non-indexed attr is a no-op", () => { + const net = new AlphaNetwork(); + const node = net.buildNode({ id: null, attr: "A" }); + const fn = vi.fn(); + node.onDeactivate(fn); + + // No node for "Unknown" — retract should not throw or affect our node + net.notifyRetract(mkId(1), "Unknown", 0); + expect(fn).not.toHaveBeenCalled(); + }); + + it("notifyInsert on non-indexed attr is a no-op", () => { + const net = new AlphaNetwork(); + const node = net.buildNode({ id: null, attr: "A" }); + const fn = vi.fn(); + node.onActivate(fn); + + net.notifyInsert(mkId(1), "Unknown", 0); + expect(fn).not.toHaveBeenCalled(); + }); + + it("supports multiple alpha nodes for the same attr (wildcard + specific)", () => { + const net = new AlphaNetwork(); + const wild = net.buildNode({ id: null, attr: "Hp" }); + const spec = net.buildNode({ id: mkId(7), attr: "Hp" }); + const wildFn = vi.fn(); + const specFn = vi.fn(); + wild.onActivate(wildFn); + spec.onActivate(specFn); + + net.notifyInsert(mkId(7), "Hp", 99); + + // Both should activate for this fact + expect(wildFn).toHaveBeenCalledOnce(); + expect(specFn).toHaveBeenCalledOnce(); + }); + + it("handles 10,000 inserts without error (scalability / inverted index O(1))", () => { + const net = new AlphaNetwork(); + let count = 0; + const node = net.buildNode({ id: null, attr: "X" }); + node.onActivate(() => { + count++; + }); + + // Create a decoy node on another attr to ensure we're not scanning all nodes + const decoy = net.buildNode({ id: null, attr: "Y" }); + const decoyFn = vi.fn(); + decoy.onActivate(decoyFn); + + for (let i = 0; i < 10_000; i++) { + net.notifyInsert(i as EntityId, "X", i); + } + expect(count).toBe(10_000); + expect(node.memory.facts).toHaveLength(10_000); + expect(decoyFn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rete/src/alpha.ts b/packages/rete/src/alpha.ts new file mode 100644 index 0000000..41de997 --- /dev/null +++ b/packages/rete/src/alpha.ts @@ -0,0 +1,198 @@ +/** + * AlphaNetwork — indexes and dispatches facts by (id?, attr) pattern. + * + * Per `packages/rete/SPEC.md §Rete II Reference Target` this module implements + * the alpha side of the Doorenbos network: {@link AlphaNode} (constant tests) + * and {@link AlphaMemory} (set of facts that passed those tests). + * + * Dispatch is amortised O(1) per fact: an inverted index maps attribute keys + * to the alpha nodes that care about them, so we only visit the small subset + * of nodes registered for a given attr rather than scanning every node. The + * id dimension is a final compare in the node's match step. + * + * AlphaMemory keeps its facts sorted by `[id asc, attr asc]` to satisfy + * §Iteration Order — downstream beta joins iterate alpha memories in a + * deterministic order. + */ +import type { EntityId } from "./schema.js"; +import type { AttrKey, FactValue } from "./wm.js"; + +/** + * Constant-test condition recognised by a single {@link AlphaNode}. + * + * `id` is either a specific {@link EntityId} (the node only accepts facts for + * that entity) or `null` (wildcard — accepts any entity). `attr` must always + * be a concrete attribute key; attribute wildcards are not supported at the + * alpha layer per SPEC.md. + */ +export interface AlphaCondition { + /** Specific entity ID to match, or `null` for a wildcard over entities. */ + readonly id: EntityId | null; + /** Attribute key the node filters on — required (no attr wildcards). */ + readonly attr: AttrKey; +} + +type ActivateListener = (id: EntityId, attr: AttrKey, value: FactValue) => void; +type DeactivateListener = ( + id: EntityId, + attr: AttrKey, + value: FactValue +) => void; + +/** + * Sorted working-set of facts that have passed an {@link AlphaNode}'s tests. + * + * The `facts` array is maintained in `[id asc, attr asc]` order on every + * mutation so that iteration is deterministic without a per-read sort. + */ +export class AlphaMemory { + /** Facts currently in memory, sorted by `[id asc, attr asc]`. */ + readonly facts: Array<{ id: EntityId; attr: AttrKey; value: FactValue }> = []; + + /** Insert a fact; keeps `facts` sorted. O(n) via insertion-point search. */ + addFact(id: EntityId, attr: AttrKey, value: FactValue): void { + const fact = { id, attr, value }; + // Find insertion index to keep sorted: [id asc, attr asc] + let lo = 0; + let hi = this.facts.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + const m = this.facts[mid]!; + const cmp = + m.id !== id + ? (m.id as number) - (id as number) + : m.attr < attr + ? -1 + : m.attr > attr + ? 1 + : 0; + if (cmp < 0) lo = mid + 1; + else hi = mid; + } + this.facts.splice(lo, 0, fact); + } + + /** Remove the fact matching `(id, attr)`. Returns the removed value, or undefined. */ + removeFact(id: EntityId, attr: AttrKey): FactValue | undefined { + const idx = this.facts.findIndex((f) => f.id === id && f.attr === attr); + if (idx === -1) return undefined; + const removed = this.facts[idx]!; + this.facts.splice(idx, 1); + return removed.value; + } +} + +/** + * A single alpha-network node: owns one {@link AlphaCondition}, its + * {@link AlphaMemory}, and activate/deactivate listener lists. + * + * Listeners are invoked synchronously after the memory mutation, so observers + * (typically join-nodes in Phase 1 and the agenda in later phases) see a + * consistent memory view. + */ +export class AlphaNode { + readonly condition: AlphaCondition; + readonly memory: AlphaMemory; + readonly #activateListeners: ActivateListener[] = []; + readonly #deactivateListeners: DeactivateListener[] = []; + + constructor(condition: AlphaCondition) { + this.condition = condition; + this.memory = new AlphaMemory(); + } + + /** Register a listener fired after a fact is added to this node's memory. */ + onActivate(listener: ActivateListener): void { + this.#activateListeners.push(listener); + } + + /** Register a listener fired after a fact is removed from this node's memory. */ + onDeactivate(listener: DeactivateListener): void { + this.#deactivateListeners.push(listener); + } + + /** Internal: add to memory, then notify activate listeners. */ + activate(id: EntityId, attr: AttrKey, value: FactValue): void { + this.memory.addFact(id, attr, value); + for (const l of this.#activateListeners) l(id, attr, value); + } + + /** Internal: remove from memory, then notify deactivate listeners. */ + deactivate(id: EntityId, attr: AttrKey, value: FactValue): void { + this.memory.removeFact(id, attr); + for (const l of this.#deactivateListeners) l(id, attr, value); + } +} + +/** + * Dispatch layer for alpha nodes. + * + * The network owns all alpha nodes and exposes `notifyInsert` / `notifyRetract` + * that the {@link import("./wm.js").WorkingMemory} fires on every triple + * change. Lookup is performed via an inverted index `attr → AlphaNode[]`, so + * dispatch cost scales with the number of nodes interested in that attr + * rather than the total node count. + * + * {@link buildNode} is memoised: identical conditions collapse to one node so + * that rules sharing a constant-test share the same alpha memory (the usual + * Rete sharing optimisation). + */ +export class AlphaNetwork { + /** Inverted index: attribute key → nodes that filter on it. */ + readonly #index = new Map(); + /** Memoisation cache: canonical condition key → node. */ + readonly #nodeCache = new Map(); + + /** Canonical key for an {@link AlphaCondition}. */ + #conditionKey(cond: AlphaCondition): string { + // "*" is safe as a sentinel because EntityId is numeric at runtime. + return `${cond.id === null ? "*" : String(cond.id)}:${cond.attr}`; + } + + /** + * Get or create the {@link AlphaNode} for a given condition. + * + * Two calls with the same `(id, attr)` pair return the same instance, so + * rules that share constant tests share alpha memories automatically. + */ + buildNode(cond: AlphaCondition): AlphaNode { + const key = this.#conditionKey(cond); + const cached = this.#nodeCache.get(key); + if (cached) return cached; + + const node = new AlphaNode({ id: cond.id, attr: cond.attr }); + this.#nodeCache.set(key, node); + + let attrNodes = this.#index.get(cond.attr); + if (!attrNodes) { + attrNodes = []; + this.#index.set(cond.attr, attrNodes); + } + attrNodes.push(node); + + return node; + } + + /** Called by `WorkingMemory` (via Session) when a fact is inserted. */ + notifyInsert(id: EntityId, attr: AttrKey, value: FactValue): void { + const nodes = this.#index.get(attr); + if (!nodes) return; + for (const node of nodes) { + // Wildcard (null) always matches; otherwise require exact id. + if (node.condition.id === null || node.condition.id === id) { + node.activate(id, attr, value); + } + } + } + + /** Called by `WorkingMemory` (via Session) when a fact is retracted. */ + notifyRetract(id: EntityId, attr: AttrKey, value: FactValue): void { + const nodes = this.#index.get(attr); + if (!nodes) return; + for (const node of nodes) { + if (node.condition.id === null || node.condition.id === id) { + node.deactivate(id, attr, value); + } + } + } +}