feat(rete): add JoinNode with variable-binding equality tests (P1.8)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 13:50:54 -06:00
commit ea27fd1539
No known key found for this signature in database
3 changed files with 595 additions and 0 deletions

View file

@ -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";

View file

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

331
packages/rete/src/join.ts Normal file
View file

@ -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<string, Token[]>();
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;
}
}