feat(rete): add JoinNode with variable-binding equality tests (P1.8)
This commit is contained in:
parent
eb01a88fe9
commit
ea27fd1539
3 changed files with 595 additions and 0 deletions
|
|
@ -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";
|
||||
|
|
|
|||
261
packages/rete/src/join.test.ts
Normal file
261
packages/rete/src/join.test.ts
Normal 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
331
packages/rete/src/join.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue