feat(rete): add negation nodes (NOT) (P2.1)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 14:21:39 -06:00
commit 1731c43eb2
No known key found for this signature in database
3 changed files with 486 additions and 0 deletions

View file

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

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

View 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;
}
}