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