diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index 0175296..a2db880 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -17,6 +17,9 @@ export { AlphaNetwork, AlphaNode, AlphaMemory } from "./alpha.js"; export type { Bindings, TokenFact } from "./beta.js"; export { Token, BetaMemory, BetaMemoryNode } from "./beta.js"; +export type { JoinTest } from "./join.js"; +export { JoinNode } from "./join.js"; + export type { VariableDescriptor, RuleCondition, RuleDefinition, DefineRuleOpts } from "./builder.js"; export { v, defineRule } from "./builder.js"; export type { HandlerFn, PredicateFn } from "./registry.js"; diff --git a/packages/rete/src/join.test.ts b/packages/rete/src/join.test.ts new file mode 100644 index 0000000..f157ee7 --- /dev/null +++ b/packages/rete/src/join.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from "vitest"; +import { JoinNode, type JoinTest } from "./join.js"; +import { AlphaMemory } from "./alpha.js"; +import { Token, BetaMemory } from "./beta.js"; +import type { EntityId } from "./schema.js"; +import type { AttrKey, FactValue } from "./wm.js"; + +const mkId = (n: number) => n as EntityId; + +function mkFact(id: number, attr: string, value: unknown) { + return { + id: mkId(id), + attr: attr as AttrKey, + value: value as FactValue, + }; +} + +describe("JoinNode", () => { + it("right-activates: when a fact is added to alpha, combines with existing tokens", () => { + const leftMem = new BetaMemory(); + const rightMem = new AlphaMemory(); + const outMem = new BetaMemory(); + const join = new JoinNode(leftMem, rightMem, [], "hp", "eid"); + + // Seed left memory with one token + const rootToken = new Token(null, mkFact(1, "Health", 100), { hp: 100 }); + leftMem.leftActivate(rootToken); + join.addDownstreamActivate((t) => outMem.leftActivate(t)); + join.addDownstreamDeactivate((t) => outMem.leftDeactivate(t)); + + // Right-activate: fact (1, "Health", 100) joins with token + join.rightActivate(mkId(1), "Health" as AttrKey, 100 as FactValue); + + expect(outMem.tokens).toHaveLength(1); + expect(outMem.tokens[0]?.bindings["hp"]).toBe(100); + expect(outMem.tokens[0]?.bindings["eid"]).toBe(mkId(1)); + }); + + it("left-activates: when a token arrives, combines with existing alpha facts", () => { + const leftMem = new BetaMemory(); + const rightMem = new AlphaMemory(); + const outMem = new BetaMemory(); + const join = new JoinNode(leftMem, rightMem, [], "pos", "pid"); + join.addDownstreamActivate((t) => outMem.leftActivate(t)); + + // Seed right memory (alpha) + rightMem.addFact(mkId(2), "Position" as AttrKey, "e4" as FactValue); + + // Left-activate: new token arrives + const token = new Token(null, mkFact(2, "Color", "white"), { + color: "white", + }); + join.leftActivate(token); + + expect(outMem.tokens).toHaveLength(1); + expect(outMem.tokens[0]?.bindings["pos"]).toBe("e4"); + expect(outMem.tokens[0]?.bindings["pid"]).toBe(mkId(2)); + }); + + it("join test: idEquality — only matches when token binding equals right fact id", () => { + const leftMem = new BetaMemory(); + const rightMem = new AlphaMemory(); + const outMem = new BetaMemory(); + + // Join test: token's 'eid' binding must equal the right fact's entity id + const joinTests: JoinTest[] = [{ type: "idEquality", leftVar: "eid" }]; + const join = new JoinNode(leftMem, rightMem, joinTests, "name", null); + join.addDownstreamActivate((t) => outMem.leftActivate(t)); + + // Token with eid=1 + const token = new Token(null, mkFact(1, "X", "x"), { eid: mkId(1) }); + leftMem.leftActivate(token); + + // Right facts: entity 1 matches, entity 2 does not + rightMem.addFact(mkId(1), "Name" as AttrKey, "Alice" as FactValue); + rightMem.addFact(mkId(2), "Name" as AttrKey, "Bob" as FactValue); + + // Left-activate with the token + join.leftActivate(token); + + // Only the fact with id=1 should match + expect(outMem.tokens).toHaveLength(1); + expect(outMem.tokens[0]?.bindings["name"]).toBe("Alice"); + }); + + it("join test: valueEquality — only matches when token binding equals right fact value", () => { + const leftMem = new BetaMemory(); + const rightMem = new AlphaMemory(); + const outMem = new BetaMemory(); + + // Token's 'target' binding must equal the right fact's value + const joinTests: JoinTest[] = [{ type: "valueEquality", leftVar: "target" }]; + const join = new JoinNode(leftMem, rightMem, joinTests, null, "owner"); + join.addDownstreamActivate((t) => outMem.leftActivate(t)); + + const token = new Token(null, mkFact(1, "Pick", 42), { target: 42 }); + leftMem.leftActivate(token); + + rightMem.addFact(mkId(7), "Holds" as AttrKey, 42 as FactValue); + rightMem.addFact(mkId(8), "Holds" as AttrKey, 99 as FactValue); + + join.leftActivate(token); + + expect(outMem.tokens).toHaveLength(1); + expect(outMem.tokens[0]?.bindings["owner"]).toBe(mkId(7)); + }); + + it("join test: no constraints — produces cartesian product", () => { + const leftMem = new BetaMemory(); + const rightMem = new AlphaMemory(); + const outMem = new BetaMemory(); + const join = new JoinNode(leftMem, rightMem, [], "v", null); + join.addDownstreamActivate((t) => outMem.leftActivate(t)); + + const t1 = new Token(null, mkFact(1, "A", "a"), { a: "a" }); + const t2 = new Token(null, mkFact(2, "A", "b"), { a: "b" }); + leftMem.leftActivate(t1); + leftMem.leftActivate(t2); + + rightMem.addFact(mkId(10), "B" as AttrKey, "z" as FactValue); + rightMem.addFact(mkId(11), "B" as AttrKey, "y" as FactValue); + + // Left-activate both tokens (triggers join against existing right facts) + join.leftActivate(t1); + join.leftActivate(t2); + + // 2 tokens × 2 right facts = 4 output tokens + expect(outMem.tokens).toHaveLength(4); + }); + + it("right-deactivate: removes tokens dependent on retracted right fact", () => { + const leftMem = new BetaMemory(); + const rightMem = new AlphaMemory(); + const outMem = new BetaMemory(); + const join = new JoinNode(leftMem, rightMem, [], "hp", "eid"); + join.addDownstreamActivate((t) => outMem.leftActivate(t)); + join.addDownstreamDeactivate((t) => outMem.leftDeactivate(t)); + + const token = new Token(null, mkFact(1, "X", "x"), {}); + leftMem.leftActivate(token); + join.rightActivate(mkId(1), "Health" as AttrKey, 100 as FactValue); + expect(outMem.tokens).toHaveLength(1); + + // Right-deactivate + join.rightDeactivate(mkId(1), "Health" as AttrKey, 100 as FactValue); + expect(outMem.tokens).toHaveLength(0); + }); + + it("left-deactivate: removes downstream tokens derived from a retracted left token", () => { + const leftMem = new BetaMemory(); + const rightMem = new AlphaMemory(); + const outMem = new BetaMemory(); + const join = new JoinNode(leftMem, rightMem, [], "v", "eid"); + join.addDownstreamActivate((t) => outMem.leftActivate(t)); + join.addDownstreamDeactivate((t) => outMem.leftDeactivate(t)); + + const tokenA = new Token(null, mkFact(1, "A", "a"), { a: "a" }); + const tokenB = new Token(null, mkFact(2, "A", "b"), { a: "b" }); + leftMem.leftActivate(tokenA); + leftMem.leftActivate(tokenB); + + rightMem.addFact(mkId(10), "B" as AttrKey, "z" as FactValue); + + join.leftActivate(tokenA); + join.leftActivate(tokenB); + expect(outMem.tokens).toHaveLength(2); + + // Retract tokenA — only its descendant should vanish + join.leftDeactivate(tokenA); + expect(outMem.tokens).toHaveLength(1); + expect(outMem.tokens[0]?.bindings["a"]).toBe("b"); + }); + + it("scalability: 100 entities, 2 conditions — produces correct token count", () => { + const leftMem = new BetaMemory(); + const rightMem = new AlphaMemory(); + const outMem = new BetaMemory(); + const joinTests: JoinTest[] = [{ type: "idEquality", leftVar: "eid" }]; + const join = new JoinNode(leftMem, rightMem, joinTests, "y", null); + join.addDownstreamActivate((t) => outMem.leftActivate(t)); + + // Seed 100 tokens on left (entities 1..100 with X binding) + for (let i = 1; i <= 100; i++) { + const t = new Token(null, mkFact(i, "X", i), { eid: mkId(i), x: i }); + leftMem.leftActivate(t); + } + // Seed 100 matching facts on right (entities 1..100 with Y binding) + for (let i = 1; i <= 100; i++) { + rightMem.addFact(mkId(i), "Y" as AttrKey, (i * 10) as FactValue); + } + // Left-activate all 100 tokens + for (const token of [...leftMem.tokens]) { + join.leftActivate(token); + } + // Each token joins with its matching entity — 100 output tokens + expect(outMem.tokens).toHaveLength(100); + // Each has correct y binding + expect( + outMem.tokens.every((t) => typeof t.bindings["y"] === "number"), + ).toBe(true); + }); + + it("3-condition chain (100 entities): left-activating a join produces correct count", () => { + // Simulates (?id, X, ?x) ∧ (?id, Y, ?y) ∧ (?id, Z, ?z) + const root = new BetaMemory(); + const alphaX = new AlphaMemory(); + const alphaY = new AlphaMemory(); + const alphaZ = new AlphaMemory(); + + const mem1 = new BetaMemory(); + const mem2 = new BetaMemory(); + const outMem = new BetaMemory(); + + // First join: root × X → binds eid, x (no tests; seeds the chain) + const join1 = new JoinNode(root, alphaX, [], "x", "eid"); + join1.addDownstreamActivate((t) => mem1.leftActivate(t)); + + // Second join: mem1 × Y with idEquality on eid → binds y + const join2 = new JoinNode( + mem1, + alphaY, + [{ type: "idEquality", leftVar: "eid" }], + "y", + null, + ); + join2.addDownstreamActivate((t) => mem2.leftActivate(t)); + mem1.addDownstreamActivate((t) => join2.leftActivate(t)); + + // Third join: mem2 × Z with idEquality on eid → binds z + const join3 = new JoinNode( + mem2, + alphaZ, + [{ type: "idEquality", leftVar: "eid" }], + "z", + null, + ); + join3.addDownstreamActivate((t) => outMem.leftActivate(t)); + mem2.addDownstreamActivate((t) => join3.leftActivate(t)); + + // Seed all three alpha memories with 100 entities each + for (let i = 1; i <= 100; i++) { + alphaX.addFact(mkId(i), "X" as AttrKey, i as FactValue); + alphaY.addFact(mkId(i), "Y" as AttrKey, (i * 2) as FactValue); + alphaZ.addFact(mkId(i), "Z" as AttrKey, (i * 3) as FactValue); + } + + // Seed a single root "dummy" token; left-activate join1 against alphaX + const rootToken = new Token(null, mkFact(0, "__root", null), {}); + root.leftActivate(rootToken); + join1.leftActivate(rootToken); + + // Chain should yield exactly 100 output tokens (one per entity) + expect(outMem.tokens).toHaveLength(100); + // Check bindings on a sample + const sample = outMem.tokens[0]; + expect(sample).toBeDefined(); + expect(typeof sample?.bindings["x"]).toBe("number"); + expect(typeof sample?.bindings["y"]).toBe("number"); + expect(typeof sample?.bindings["z"]).toBe("number"); + }); +}); diff --git a/packages/rete/src/join.ts b/packages/rete/src/join.ts new file mode 100644 index 0000000..d64cc3a --- /dev/null +++ b/packages/rete/src/join.ts @@ -0,0 +1,331 @@ +/** + * JoinNode — the core of the Rete beta network. + * + * Per `packages/rete/SPEC.md §Rete II Reference Target` this module implements + * the JoinNode from the Doorenbos beta network. A join combines a left input + * ({@link BetaMemory} of partial matches) with a right input ({@link + * AlphaMemory} of facts that passed constant tests) and emits a new partial + * match for every `(token, fact)` pair that satisfies the node's equality + * tests. This is the step that makes Rete incremental: when either side + * changes, only the cross product against the *other* side is considered, + * never the full working memory. + * + * ### Equality tests + * + * A {@link JoinTest} expresses an equality constraint between a variable + * already bound by the token and some field of the right fact. Two kinds are + * recognised in Phase 1: + * + * - `idEquality` — the right fact's *entity id* must equal `token.bindings[leftVar]`. + * This is how multi-condition rules enforce "same entity across conditions", + * e.g. `(?id, Health, ?h) ∧ (?id, Position, ?p)` reuses `?id` by emitting an + * `idEquality` test at the second join. + * - `valueEquality` — the right fact's *value* must equal `token.bindings[leftVar]`. + * Used when a variable was bound as the value of an earlier condition and + * needs to line up with the value of this condition (e.g. chaining through + * a pointer-typed attribute). + * + * Tests are conjunctive — a `(token, fact)` pair must satisfy *every* test to + * propagate. An empty test list produces the full cartesian product, which is + * the correct behaviour for the first join in a rule (no prior variables to + * constrain) and for genuine cross-product joins. + * + * Inequality/arithmetic filters are *not* handled here — those belong to the + * FilterNode from P1.9. Keeping JoinNode focused on equality lets us index + * right-side access in later phases (hash-join) without pulling in arbitrary + * predicates. + * + * ### Token production and retraction bookkeeping + * + * On a successful join this node allocates exactly one new {@link Token} + * whose `parent` is the left token and whose `fact` / `bindings` extend the + * match with the right fact. Downstream subscribers registered via + * {@link JoinNode.addDownstreamActivate} fire in registration order. + * + * Retraction is the subtle part: + * + * - **Right deactivation** (a fact leaves AlphaMemory) must retract every + * downstream token that was produced from that fact. We index produced + * tokens by the right-fact key `${id}:${attr}` during + * {@link JoinNode.rightActivate} so the lookup on retract is O(1) + + * O(matches). + * - **Left deactivation** (a token leaves the left BetaMemory) must retract + * every downstream token whose parent chain contains that token. We walk + * the produced-tokens map and filter by ancestor check. This is O(P · D) + * where P is the produced-token count and D is the depth of the match + * chain; Phase 1 traffic is small enough that the simpler implementation + * wins over a reverse index. + * + * Both retraction paths invoke registered deactivate listeners so downstream + * memories can mirror the change. + */ +import type { EntityId } from "./schema.js"; +import type { AttrKey, FactValue } from "./wm.js"; +import { type Bindings, Token, BetaMemory, type TokenFact } from "./beta.js"; +import type { AlphaMemory } from "./alpha.js"; + +/** + * Equality constraint between a token's variable binding and a right fact. + * + * The discriminant `type` selects *which* field of the right fact is compared: + * `"idEquality"` compares against the fact's entity id (used for same-entity + * joins), `"valueEquality"` compares against the fact's value (used for value + * threading). In both cases the left operand is `token.bindings[leftVar]`. + * + * There is no mechanism for comparing two variables from the *same* side + * here — the builder is expected to hoist such cases into earlier tests so + * only cross-side comparisons reach JoinNode. + */ +export type JoinTest = + | { + /** Right fact's entity id must equal `token.bindings[leftVar]`. */ + type: "idEquality"; + leftVar: string; + } + | { + /** Right fact's value must equal `token.bindings[leftVar]`. */ + type: "valueEquality"; + leftVar: string; + }; + +/** Downstream subscriber fired when this join emits a new match. */ +type DownstreamActivate = (token: Token) => void; +/** Downstream subscriber fired when this join retracts a previously emitted match. */ +type DownstreamDeactivate = (token: Token) => void; + +/** + * Beta-network join node. + * + * Left input: an upstream {@link BetaMemory} of partial matches. + * Right input: an {@link AlphaMemory} of facts that passed constant tests. + * Output: zero or more {@link Token}s per `(token, fact)` pair that satisfies + * {@link JoinNode.tests}, delivered to downstream subscribers. + * + * The two "binding slots" — {@link valueBinding} and {@link idBinding} — name + * the variables under which the right fact's value and entity id are recorded + * in the produced token's bindings. Either may be `null` if the rule does not + * need that projection (e.g. an anonymous-value condition `(?id, Dead, _)` + * passes `null` for the value binding). + */ +export class JoinNode { + readonly #activateListeners: DownstreamActivate[] = []; + readonly #deactivateListeners: DownstreamDeactivate[] = []; + + /** + * Produced tokens indexed by the right fact key `${id}:${attr}`. + * + * Each entry is the list of downstream tokens we emitted the last time the + * fact was right-activated. On {@link JoinNode.rightDeactivate} we use this + * index to retract exactly those tokens in O(matches) without scanning all + * downstream state. + * + * The key intentionally omits `value` because an alpha memory stores at + * most one value per `(id, attr)` pair at any time — reactivation after an + * update produces a fresh entry under the same key. + */ + readonly #producedTokens = new Map(); + + readonly #leftMemory: BetaMemory; + readonly #rightMemory: AlphaMemory; + readonly #tests: readonly JoinTest[]; + readonly #valueBinding: string | null; + readonly #idBinding: string | null; + + /** + * @param leftMemory Upstream beta memory supplying partial matches. + * @param rightMemory Alpha memory supplying facts for this condition. + * @param tests Equality tests evaluated against every `(token, fact)` pair. + * @param valueBinding Variable name under which the right fact's *value* is + * recorded in produced tokens, or `null` to omit. + * @param idBinding Variable name under which the right fact's *entity id* + * is recorded in produced tokens, or `null` to omit. + */ + constructor( + leftMemory: BetaMemory, + rightMemory: AlphaMemory, + tests: readonly JoinTest[], + valueBinding: string | null, + idBinding: string | null, + ) { + this.#leftMemory = leftMemory; + this.#rightMemory = rightMemory; + this.#tests = tests; + this.#valueBinding = valueBinding; + this.#idBinding = idBinding; + } + + /** Subscribe to downstream activations. Listeners fire in registration order. */ + addDownstreamActivate(listener: DownstreamActivate): void { + this.#activateListeners.push(listener); + } + + /** Subscribe to downstream deactivations. Listeners fire in registration order. */ + addDownstreamDeactivate(listener: DownstreamDeactivate): void { + this.#deactivateListeners.push(listener); + } + + /** + * Process a new right-side fact. + * + * Called by the upstream AlphaNode's activate listener. Walks the left + * memory, emits one extended token per token that passes the tests, and + * records the batch under the fact key so retraction can find them. + */ + rightActivate(id: EntityId, attr: AttrKey, value: FactValue): void { + const factKey = `${id}:${attr}`; + const produced: Token[] = []; + + for (const token of this.#leftMemory.tokens) { + if (this.#testsPass(token, id, value)) { + const newToken = this.#makeToken(token, id, attr, value); + produced.push(newToken); + for (const l of this.#activateListeners) l(newToken); + } + } + + if (produced.length > 0) { + this.#producedTokens.set(factKey, produced); + } + } + + /** + * Withdraw matches derived from a retracted right-side fact. + * + * Looks up the batch recorded by {@link JoinNode.rightActivate} under the + * fact key and fires deactivate listeners for each. If there is no such + * batch (e.g. no tokens matched originally) the call is a no-op. + * + * The `_value` parameter is unused — alpha memories key by `(id, attr)` + * and updates are modelled as retract-then-insert of the same pair, so the + * key alone is sufficient. + */ + rightDeactivate(id: EntityId, attr: AttrKey, _value: FactValue): void { + const factKey = `${id}:${attr}`; + const produced = this.#producedTokens.get(factKey); + if (!produced) return; + + for (const token of produced) { + for (const l of this.#deactivateListeners) l(token); + } + this.#producedTokens.delete(factKey); + } + + /** + * Process a new left-side token. + * + * Called when an upstream beta memory admits a new partial match. Walks the + * (sorted) right memory and emits extended tokens for every right fact that + * passes the tests. Each produced token is also recorded in the per-fact + * index so a later retraction of either side retracts consistently. + */ + leftActivate(token: Token): void { + for (const { id, attr, value } of this.#rightMemory.facts) { + if (this.#testsPass(token, id, value)) { + const newToken = this.#makeToken(token, id, attr, value); + const factKey = `${id}:${attr}`; + let bucket = this.#producedTokens.get(factKey); + if (!bucket) { + bucket = []; + this.#producedTokens.set(factKey, bucket); + } + bucket.push(newToken); + for (const l of this.#activateListeners) l(newToken); + } + } + } + + /** + * Withdraw matches derived from a retracted left-side token. + * + * Walks the produced-tokens index and retracts every recorded token whose + * parent chain contains the deactivated token. Entries that become empty + * are removed from the index to bound memory growth over long sessions. + */ + leftDeactivate(token: Token): void { + for (const [key, tokens] of this.#producedTokens) { + const remaining: Token[] = []; + const removed: Token[] = []; + for (const t of tokens) { + if (this.#hasAncestor(t, token)) { + removed.push(t); + } else { + remaining.push(t); + } + } + for (const r of removed) { + for (const l of this.#deactivateListeners) l(r); + } + if (remaining.length === 0) { + this.#producedTokens.delete(key); + } else if (removed.length > 0) { + this.#producedTokens.set(key, remaining); + } + } + } + + /** + * Evaluate every join test against `(token, right fact)`. + * + * Short-circuits on the first failing test. `idEquality` compares the right + * entity id; `valueEquality` compares the right value. A test whose + * `leftVar` is missing from the token bindings always fails — this keeps + * the node safe against builder bugs that forget to bind a variable before + * referencing it. + */ + #testsPass(token: Token, rightId: EntityId, rightValue: FactValue): boolean { + for (const test of this.#tests) { + const leftVal = token.bindings[test.leftVar]; + if (leftVal === undefined) return false; + if (test.type === "idEquality") { + if (leftVal !== rightId) return false; + } else { + // valueEquality + if (leftVal !== rightValue) return false; + } + } + return true; + } + + /** + * Allocate the extended token for a successful `(token, fact)` pair. + * + * The new token's bindings are a shallow copy of the parent's bindings + * plus (optionally) the value and id slots configured on this node. Copy + * semantics are deliberate — sibling produced tokens must not share a + * bindings object, otherwise a later filter/handler could mutate one match + * and silently corrupt another. + */ + #makeToken( + parent: Token, + rightId: EntityId, + rightAttr: AttrKey, + rightValue: FactValue, + ): Token { + const newBindings: Bindings = { ...parent.bindings }; + if (this.#valueBinding !== null) { + newBindings[this.#valueBinding] = rightValue; + } + if (this.#idBinding !== null) { + newBindings[this.#idBinding] = rightId; + } + const fact: TokenFact = { id: rightId, attr: rightAttr, value: rightValue }; + return new Token(parent, fact, newBindings); + } + + /** + * Walk a token's `parent` chain looking for `ancestor`. + * + * Equality is by object reference — tokens are never duplicated structurally + * within a chain, so reference identity is the correct relation. Returns + * `true` as soon as `ancestor` is seen, including the trivial case where + * `token === ancestor`. + */ + #hasAncestor(token: Token, ancestor: Token): boolean { + let current: Token | null = token; + while (current !== null) { + if (current === ancestor) return true; + current = current.parent; + } + return false; + } +}