feat(rete): add AlphaNetwork with inverted-index dispatch (P1.3)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 13:38:24 -06:00
commit 2501edd886
No known key found for this signature in database
2 changed files with 369 additions and 0 deletions

View file

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

198
packages/rete/src/alpha.ts Normal file
View file

@ -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<AttrKey, AlphaNode[]>();
/** Memoisation cache: canonical condition key → node. */
readonly #nodeCache = new Map<string, AlphaNode>();
/** 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);
}
}
}
}