From 1731c43eb2692dcbaff94ddd2e2349f7a0c3320b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 14:21:39 -0600 Subject: [PATCH] feat(rete): add negation nodes (NOT) (P2.1) --- packages/rete/src/index.ts | 2 + packages/rete/src/negation.test.ts | 235 +++++++++++++++++++++++++++ packages/rete/src/negation.ts | 249 +++++++++++++++++++++++++++++ 3 files changed, 486 insertions(+) create mode 100644 packages/rete/src/negation.test.ts create mode 100644 packages/rete/src/negation.ts diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index 47bc186..65416c3 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -20,6 +20,8 @@ export { Token, BetaMemory, BetaMemoryNode } from "./beta.js"; export type { JoinTest } from "./join.js"; export { JoinNode } from "./join.js"; +export { NegationNode, UnsafeNegationError } from "./negation.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/negation.test.ts b/packages/rete/src/negation.test.ts new file mode 100644 index 0000000..bb5b48c --- /dev/null +++ b/packages/rete/src/negation.test.ts @@ -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"); + }); +}); diff --git a/packages/rete/src/negation.ts b/packages/rete/src/negation.ts new file mode 100644 index 0000000..f01ffc6 --- /dev/null +++ b/packages/rete/src/negation.ts @@ -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(); + + /** + * 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(); + + /** + * 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; + } +}