feat(rete): add negation nodes (NOT) (P2.1)
This commit is contained in:
parent
0401295bbc
commit
1731c43eb2
3 changed files with 486 additions and 0 deletions
|
|
@ -20,6 +20,8 @@ export { Token, BetaMemory, BetaMemoryNode } from "./beta.js";
|
||||||
export type { JoinTest } from "./join.js";
|
export type { JoinTest } from "./join.js";
|
||||||
export { JoinNode } from "./join.js";
|
export { JoinNode } from "./join.js";
|
||||||
|
|
||||||
|
export { NegationNode, UnsafeNegationError } from "./negation.js";
|
||||||
|
|
||||||
export type { VariableDescriptor, RuleCondition, RuleDefinition, DefineRuleOpts } from "./builder.js";
|
export type { VariableDescriptor, RuleCondition, RuleDefinition, DefineRuleOpts } from "./builder.js";
|
||||||
export { v, defineRule } from "./builder.js";
|
export { v, defineRule } from "./builder.js";
|
||||||
export type { HandlerFn, PredicateFn } from "./registry.js";
|
export type { HandlerFn, PredicateFn } from "./registry.js";
|
||||||
|
|
|
||||||
235
packages/rete/src/negation.test.ts
Normal file
235
packages/rete/src/negation.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { NegationNode, UnsafeNegationError } from "./negation.js";
|
||||||
|
import { AlphaMemory } from "./alpha.js";
|
||||||
|
import { Token, BetaMemory } from "./beta.js";
|
||||||
|
import type { JoinTest } from "./join.js";
|
||||||
|
import type { EntityId } from "./schema.js";
|
||||||
|
import type { AttrKey, FactValue } from "./wm.js";
|
||||||
|
|
||||||
|
const mkId = (n: number) => n as EntityId;
|
||||||
|
const mkFact = (id: number, attr: string, value: unknown) => ({
|
||||||
|
id: mkId(id),
|
||||||
|
attr: attr as AttrKey,
|
||||||
|
value: value as FactValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NegationNode", () => {
|
||||||
|
it("passes token when negated pattern has NO matching facts", () => {
|
||||||
|
const rightMem = new AlphaMemory();
|
||||||
|
const outMem = new BetaMemory();
|
||||||
|
const negation = new NegationNode(rightMem, []);
|
||||||
|
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||||
|
|
||||||
|
const token = new Token(null, mkFact(1, "X", 1), { x: 1 });
|
||||||
|
negation.leftActivate(token);
|
||||||
|
|
||||||
|
expect(outMem.tokens).toHaveLength(1);
|
||||||
|
expect(outMem.tokens[0]).toBe(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks token when negated pattern HAS a matching fact", () => {
|
||||||
|
const rightMem = new AlphaMemory();
|
||||||
|
const outMem = new BetaMemory();
|
||||||
|
const negation = new NegationNode(rightMem, []);
|
||||||
|
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||||
|
|
||||||
|
rightMem.addFact(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||||
|
|
||||||
|
const token = new Token(null, mkFact(1, "X", 1), { x: 1 });
|
||||||
|
negation.leftActivate(token);
|
||||||
|
|
||||||
|
expect(outMem.tokens).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("right-activate: blocks a previously-passing token", () => {
|
||||||
|
const rightMem = new AlphaMemory();
|
||||||
|
const outMem = new BetaMemory();
|
||||||
|
const negation = new NegationNode(rightMem, []);
|
||||||
|
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||||
|
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||||
|
|
||||||
|
const token = new Token(null, mkFact(1, "X", 1), { x: 1 });
|
||||||
|
negation.leftActivate(token);
|
||||||
|
expect(outMem.tokens).toHaveLength(1);
|
||||||
|
|
||||||
|
negation.rightActivate(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||||
|
expect(outMem.tokens).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("right-deactivate: unblocks a previously-blocked token", () => {
|
||||||
|
const rightMem = new AlphaMemory();
|
||||||
|
const outMem = new BetaMemory();
|
||||||
|
const negation = new NegationNode(rightMem, []);
|
||||||
|
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||||
|
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||||
|
|
||||||
|
rightMem.addFact(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||||
|
const token = new Token(null, mkFact(1, "X", 1), { x: 1 });
|
||||||
|
negation.leftActivate(token);
|
||||||
|
expect(outMem.tokens).toHaveLength(0);
|
||||||
|
|
||||||
|
negation.rightDeactivate(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||||
|
expect(outMem.tokens).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple blocking facts: token stays blocked until ALL are retracted", () => {
|
||||||
|
const rightMem = new AlphaMemory();
|
||||||
|
const outMem = new BetaMemory();
|
||||||
|
const negation = new NegationNode(rightMem, []);
|
||||||
|
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||||
|
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||||
|
|
||||||
|
rightMem.addFact(mkId(1), "Dead" as AttrKey, true as FactValue);
|
||||||
|
rightMem.addFact(mkId(2), "Dead" as AttrKey, true as FactValue);
|
||||||
|
|
||||||
|
const token = new Token(null, mkFact(3, "X", 1), {});
|
||||||
|
negation.leftActivate(token);
|
||||||
|
expect(outMem.tokens).toHaveLength(0);
|
||||||
|
|
||||||
|
negation.rightDeactivate(mkId(1), "Dead" as AttrKey, true as FactValue);
|
||||||
|
expect(outMem.tokens).toHaveLength(0);
|
||||||
|
|
||||||
|
negation.rightDeactivate(mkId(2), "Dead" as AttrKey, true as FactValue);
|
||||||
|
expect(outMem.tokens).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("left-deactivate: removes a passing token from downstream", () => {
|
||||||
|
const rightMem = new AlphaMemory();
|
||||||
|
const outMem = new BetaMemory();
|
||||||
|
const negation = new NegationNode(rightMem, []);
|
||||||
|
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||||
|
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||||
|
|
||||||
|
const token = new Token(null, mkFact(1, "X", 1), {});
|
||||||
|
negation.leftActivate(token);
|
||||||
|
expect(outMem.tokens).toHaveLength(1);
|
||||||
|
|
||||||
|
negation.leftDeactivate(token);
|
||||||
|
expect(outMem.tokens).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("left-deactivate: does NOT emit downstream-deactivate when token was blocked", () => {
|
||||||
|
const rightMem = new AlphaMemory();
|
||||||
|
const outMem = new BetaMemory();
|
||||||
|
const negation = new NegationNode(rightMem, []);
|
||||||
|
let deactivateCalls = 0;
|
||||||
|
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||||
|
negation.addDownstreamDeactivate(() => {
|
||||||
|
deactivateCalls++;
|
||||||
|
});
|
||||||
|
|
||||||
|
rightMem.addFact(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||||
|
const token = new Token(null, mkFact(1, "X", 1), {});
|
||||||
|
negation.leftActivate(token);
|
||||||
|
expect(outMem.tokens).toHaveLength(0);
|
||||||
|
|
||||||
|
negation.leftDeactivate(token);
|
||||||
|
expect(deactivateCalls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idEquality join test: only blocking facts matching token binding count", () => {
|
||||||
|
const rightMem = new AlphaMemory();
|
||||||
|
const outMem = new BetaMemory();
|
||||||
|
const tests: JoinTest[] = [{ type: "idEquality", leftVar: "target" }];
|
||||||
|
const negation = new NegationNode(rightMem, tests);
|
||||||
|
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||||
|
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||||
|
|
||||||
|
// Fact on entity 42 is unrelated to our token's target=7
|
||||||
|
rightMem.addFact(mkId(42), "Dead" as AttrKey, true as FactValue);
|
||||||
|
|
||||||
|
const token = new Token(null, mkFact(7, "X", 1), { target: mkId(7) });
|
||||||
|
negation.leftActivate(token);
|
||||||
|
// Unrelated fact does not block
|
||||||
|
expect(outMem.tokens).toHaveLength(1);
|
||||||
|
|
||||||
|
// Now a matching fact arrives
|
||||||
|
negation.rightActivate(mkId(7), "Dead" as AttrKey, true as FactValue);
|
||||||
|
expect(outMem.tokens).toHaveLength(0);
|
||||||
|
|
||||||
|
// Unrelated fact retracted — still blocked (didn't count)
|
||||||
|
negation.rightDeactivate(mkId(42), "Dead" as AttrKey, true as FactValue);
|
||||||
|
expect(outMem.tokens).toHaveLength(0);
|
||||||
|
|
||||||
|
// Matching fact retracted — unblocked
|
||||||
|
negation.rightDeactivate(mkId(7), "Dead" as AttrKey, true as FactValue);
|
||||||
|
expect(outMem.tokens).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("valueEquality join test: right value must equal bound variable", () => {
|
||||||
|
const rightMem = new AlphaMemory();
|
||||||
|
const outMem = new BetaMemory();
|
||||||
|
const tests: JoinTest[] = [{ type: "valueEquality", leftVar: "needle" }];
|
||||||
|
const negation = new NegationNode(rightMem, tests);
|
||||||
|
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||||
|
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||||
|
|
||||||
|
rightMem.addFact(mkId(1), "Tag" as AttrKey, "red" as FactValue);
|
||||||
|
|
||||||
|
// Token looking for "blue" — not blocked
|
||||||
|
const tokenBlue = new Token(null, mkFact(1, "X", 1), { needle: "blue" });
|
||||||
|
negation.leftActivate(tokenBlue);
|
||||||
|
expect(outMem.tokens).toHaveLength(1);
|
||||||
|
|
||||||
|
// Token looking for "red" — blocked by existing fact
|
||||||
|
const tokenRed = new Token(null, mkFact(2, "X", 1), { needle: "red" });
|
||||||
|
negation.leftActivate(tokenRed);
|
||||||
|
expect(outMem.tokens).toHaveLength(1); // only blue passed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idempotent right-deactivate on unknown fact does not underflow", () => {
|
||||||
|
const rightMem = new AlphaMemory();
|
||||||
|
const outMem = new BetaMemory();
|
||||||
|
const negation = new NegationNode(rightMem, []);
|
||||||
|
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||||
|
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||||
|
|
||||||
|
const token = new Token(null, mkFact(1, "X", 1), {});
|
||||||
|
negation.leftActivate(token);
|
||||||
|
expect(outMem.tokens).toHaveLength(1);
|
||||||
|
|
||||||
|
// Retract a fact that was never added — count should clamp at 0
|
||||||
|
negation.rightDeactivate(
|
||||||
|
mkId(99),
|
||||||
|
"Dead" as AttrKey,
|
||||||
|
true as FactValue,
|
||||||
|
);
|
||||||
|
expect(outMem.tokens).toHaveLength(1);
|
||||||
|
|
||||||
|
// Real blocking activation still works
|
||||||
|
negation.rightActivate(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||||
|
expect(outMem.tokens).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple left tokens: each tracked independently", () => {
|
||||||
|
const rightMem = new AlphaMemory();
|
||||||
|
const outMem = new BetaMemory();
|
||||||
|
const tests: JoinTest[] = [{ type: "idEquality", leftVar: "tid" }];
|
||||||
|
const negation = new NegationNode(rightMem, tests);
|
||||||
|
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||||
|
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||||
|
|
||||||
|
const t1 = new Token(null, mkFact(1, "X", 1), { tid: mkId(1) });
|
||||||
|
const t2 = new Token(null, mkFact(2, "X", 1), { tid: mkId(2) });
|
||||||
|
negation.leftActivate(t1);
|
||||||
|
negation.leftActivate(t2);
|
||||||
|
expect(outMem.tokens).toHaveLength(2);
|
||||||
|
|
||||||
|
// Fact blocks only t1
|
||||||
|
negation.rightActivate(mkId(1), "Dead" as AttrKey, true as FactValue);
|
||||||
|
expect(outMem.tokens).toHaveLength(1);
|
||||||
|
expect(outMem.tokens[0]).toBe(t2);
|
||||||
|
|
||||||
|
// Retract it — t1 unblocks
|
||||||
|
negation.rightDeactivate(mkId(1), "Dead" as AttrKey, true as FactValue);
|
||||||
|
expect(outMem.tokens).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("UnsafeNegationError class exists and carries the unbound var name", () => {
|
||||||
|
const err = new UnsafeNegationError("eid");
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err).toBeInstanceOf(UnsafeNegationError);
|
||||||
|
expect(err.name).toBe("UnsafeNegationError");
|
||||||
|
expect(err.message).toContain("eid");
|
||||||
|
});
|
||||||
|
});
|
||||||
249
packages/rete/src/negation.ts
Normal file
249
packages/rete/src/negation.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
/**
|
||||||
|
* NegationNode — NOT pattern, per Doorenbos 1995 §2.6.1.
|
||||||
|
*
|
||||||
|
* A negation node passes a left token downstream only when **zero** facts in
|
||||||
|
* its right {@link AlphaMemory} satisfy the negated condition's join tests
|
||||||
|
* against that token. Any matching fact inhibits the token.
|
||||||
|
*
|
||||||
|
* ### Count-based bookkeeping
|
||||||
|
*
|
||||||
|
* The node maintains a per-token block count: how many right facts currently
|
||||||
|
* satisfy the join tests for that token. A token is downstream-active iff its
|
||||||
|
* count is zero. This avoids scanning the whole right memory on every right-
|
||||||
|
* side event — a change only needs to inspect left tokens once, adjust their
|
||||||
|
* counts, and fire activate/deactivate transitions on the 0↔1 boundary.
|
||||||
|
*
|
||||||
|
* ### Activation flow
|
||||||
|
*
|
||||||
|
* - **leftActivate(token)**: count matching right facts once; if zero, emit
|
||||||
|
* downstream activate immediately, else record the count and stay quiet.
|
||||||
|
* - **leftDeactivate(token)**: forget the token; if it was currently passing,
|
||||||
|
* fire downstream deactivate. No-op for blocked tokens — downstream never
|
||||||
|
* saw them.
|
||||||
|
* - **rightActivate(fact)**: for each left token whose join tests pass with
|
||||||
|
* this fact, increment count. If the count transitions 0→1, fire downstream
|
||||||
|
* deactivate (the token was passing and is now blocked).
|
||||||
|
* - **rightDeactivate(fact)**: for each left token whose join tests pass with
|
||||||
|
* this fact, decrement count (clamped at 0). If the count transitions 1→0,
|
||||||
|
* fire downstream activate.
|
||||||
|
*
|
||||||
|
* ### Unsafe NOT
|
||||||
|
*
|
||||||
|
* A negated condition referencing a variable that is not bound in the left
|
||||||
|
* token is "unsafe" (Doorenbos §2.6.1) — the match set is unbounded. We
|
||||||
|
* expose {@link UnsafeNegationError} for the rule builder to throw when it
|
||||||
|
* detects this statically. The node itself accepts any {@link JoinTest} list
|
||||||
|
* at construction; detection lives at rule-build time where the bound-
|
||||||
|
* variable set is known.
|
||||||
|
*/
|
||||||
|
import type { EntityId } from "./schema.js";
|
||||||
|
import type { AttrKey, FactValue } from "./wm.js";
|
||||||
|
import type { Token } from "./beta.js";
|
||||||
|
import type { AlphaMemory } from "./alpha.js";
|
||||||
|
import type { JoinTest } from "./join.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by the rule builder when a negated condition references a variable
|
||||||
|
* that is not bound by any earlier (positive) condition in the rule.
|
||||||
|
*
|
||||||
|
* The node itself does not throw this — it has no visibility into the full
|
||||||
|
* rule's binding set. The error type lives here so the builder can report
|
||||||
|
* it with a consistent name and callers can catch it specifically.
|
||||||
|
*/
|
||||||
|
export class UnsafeNegationError extends Error {
|
||||||
|
constructor(unboundVar: string) {
|
||||||
|
super(
|
||||||
|
`UnsafeNegationError: variable "${unboundVar}" in NOT pattern is not bound by any preceding condition`,
|
||||||
|
);
|
||||||
|
this.name = "UnsafeNegationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downstream subscriber fired when a token begins passing (count hit 0). */
|
||||||
|
type ActivateListener = (token: Token) => void;
|
||||||
|
/** Downstream subscriber fired when a token stops passing (count left 0). */
|
||||||
|
type DeactivateListener = (token: Token) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beta-network NOT node.
|
||||||
|
*
|
||||||
|
* Left input: tokens from an upstream {@link BetaMemory} / {@link JoinNode}.
|
||||||
|
* Right input: an {@link AlphaMemory} of facts that match the negated pattern's
|
||||||
|
* constant tests. The node does not subscribe to the alpha memory itself —
|
||||||
|
* the network wiring (typically the builder) calls {@link rightActivate} and
|
||||||
|
* {@link rightDeactivate} when alpha-memory membership changes.
|
||||||
|
*
|
||||||
|
* Output: a subset of left tokens (never extended — negation contributes no
|
||||||
|
* new bindings), emitted to registered downstream activate/deactivate
|
||||||
|
* listeners in registration order.
|
||||||
|
*/
|
||||||
|
export class NegationNode {
|
||||||
|
readonly #activateListeners: ActivateListener[] = [];
|
||||||
|
readonly #deactivateListeners: DeactivateListener[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-token block count: number of right facts currently satisfying the
|
||||||
|
* join tests for that token. A token passes downstream iff its count is
|
||||||
|
* zero. Absent = token unknown (never left-activated or already
|
||||||
|
* left-deactivated).
|
||||||
|
*/
|
||||||
|
readonly #blockCount = new Map<Token, number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens that are currently emitted downstream (count === 0 and not yet
|
||||||
|
* left-deactivated). Maintained in lock-step with {@link #blockCount} so
|
||||||
|
* that we know whether a deactivate transition actually needs to fire.
|
||||||
|
*/
|
||||||
|
readonly #passingTokens = new Set<Token>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every left token we've seen and not yet deactivated, in insertion order.
|
||||||
|
* Right-side events iterate this to determine which counts to adjust.
|
||||||
|
* Kept as an array (not a Set) so iteration order is deterministic per
|
||||||
|
* SPEC.md §Iteration Order.
|
||||||
|
*/
|
||||||
|
readonly #leftTokens: Token[] = [];
|
||||||
|
|
||||||
|
readonly #rightMemory: AlphaMemory;
|
||||||
|
readonly #tests: readonly JoinTest[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param rightMemory Alpha memory supplying candidate blocking facts.
|
||||||
|
* @param tests Equality tests that a fact must satisfy (against the
|
||||||
|
* left token's bindings) to count as a blocker. An empty
|
||||||
|
* list means any fact in `rightMemory` blocks.
|
||||||
|
*/
|
||||||
|
constructor(rightMemory: AlphaMemory, tests: readonly JoinTest[]) {
|
||||||
|
this.#rightMemory = rightMemory;
|
||||||
|
this.#tests = tests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to downstream activations. Listeners fire in registration order. */
|
||||||
|
addDownstreamActivate(listener: ActivateListener): void {
|
||||||
|
this.#activateListeners.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to downstream deactivations. Listeners fire in registration order. */
|
||||||
|
addDownstreamDeactivate(listener: DeactivateListener): void {
|
||||||
|
this.#deactivateListeners.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a new left token.
|
||||||
|
*
|
||||||
|
* Computes the initial block count against the current right memory. If
|
||||||
|
* zero, the token is emitted downstream immediately; otherwise it is held
|
||||||
|
* in internal state until enough blockers are retracted to drop the count
|
||||||
|
* back to zero.
|
||||||
|
*/
|
||||||
|
leftActivate(token: Token): void {
|
||||||
|
this.#leftTokens.push(token);
|
||||||
|
const count = this.#countMatchingFacts(token);
|
||||||
|
this.#blockCount.set(token, count);
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
this.#passingTokens.add(token);
|
||||||
|
for (const l of this.#activateListeners) l(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Withdraw a left token.
|
||||||
|
*
|
||||||
|
* Only fires downstream deactivate listeners if the token was currently
|
||||||
|
* passing — downstream never saw a blocked token, so retracting it would
|
||||||
|
* be a spurious event.
|
||||||
|
*/
|
||||||
|
leftDeactivate(token: Token): void {
|
||||||
|
const idx = this.#leftTokens.indexOf(token);
|
||||||
|
if (idx !== -1) this.#leftTokens.splice(idx, 1);
|
||||||
|
|
||||||
|
const wasPassing = this.#passingTokens.delete(token);
|
||||||
|
this.#blockCount.delete(token);
|
||||||
|
|
||||||
|
if (wasPassing) {
|
||||||
|
for (const l of this.#deactivateListeners) l(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a new right-side fact.
|
||||||
|
*
|
||||||
|
* For each known left token whose join tests pass with this fact, increment
|
||||||
|
* the block count. A 0→1 transition means the token just became blocked —
|
||||||
|
* fire downstream deactivate.
|
||||||
|
*/
|
||||||
|
rightActivate(id: EntityId, _attr: AttrKey, value: FactValue): void {
|
||||||
|
for (const token of this.#leftTokens) {
|
||||||
|
if (!this.#factMatchesToken(token, id, value)) continue;
|
||||||
|
const prev = this.#blockCount.get(token) ?? 0;
|
||||||
|
this.#blockCount.set(token, prev + 1);
|
||||||
|
|
||||||
|
if (prev === 0 && this.#passingTokens.has(token)) {
|
||||||
|
this.#passingTokens.delete(token);
|
||||||
|
for (const l of this.#deactivateListeners) l(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a retracted right-side fact.
|
||||||
|
*
|
||||||
|
* For each known left token whose join tests pass with this fact, decrement
|
||||||
|
* the block count (clamped at zero against spurious retracts). A 1→0
|
||||||
|
* transition means the token is newly unblocked — fire downstream activate.
|
||||||
|
*/
|
||||||
|
rightDeactivate(id: EntityId, _attr: AttrKey, value: FactValue): void {
|
||||||
|
for (const token of this.#leftTokens) {
|
||||||
|
if (!this.#factMatchesToken(token, id, value)) continue;
|
||||||
|
const prev = this.#blockCount.get(token) ?? 0;
|
||||||
|
const next = prev > 0 ? prev - 1 : 0;
|
||||||
|
this.#blockCount.set(token, next);
|
||||||
|
|
||||||
|
if (prev === 1 && next === 0 && !this.#passingTokens.has(token)) {
|
||||||
|
this.#passingTokens.add(token);
|
||||||
|
for (const l of this.#activateListeners) l(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count right facts that currently satisfy the join tests for `token`.
|
||||||
|
*
|
||||||
|
* Called only on {@link leftActivate} — incremental right-side events
|
||||||
|
* maintain the count thereafter. O(|rightMemory| · |tests|) per token.
|
||||||
|
*/
|
||||||
|
#countMatchingFacts(token: Token): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const fact of this.#rightMemory.facts) {
|
||||||
|
if (this.#factMatchesToken(token, fact.id, fact.value)) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a right fact `(rightId, rightValue)` satisfies every join
|
||||||
|
* test against `token`.
|
||||||
|
*
|
||||||
|
* A missing `leftVar` binding fails the test — this mirrors {@link JoinNode}
|
||||||
|
* and protects against builder bugs that reference an unbound variable
|
||||||
|
* despite the {@link UnsafeNegationError} guard.
|
||||||
|
*/
|
||||||
|
#factMatchesToken(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue