feat(rete): add AlphaNetwork with inverted-index dispatch (P1.3)
This commit is contained in:
parent
72a1723522
commit
2501edd886
2 changed files with 369 additions and 0 deletions
171
packages/rete/src/alpha.test.ts
Normal file
171
packages/rete/src/alpha.test.ts
Normal 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
198
packages/rete/src/alpha.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue