From 08515012b197640e3af5964acc801ef3d40c4e48 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 14:22:21 -0600 Subject: [PATCH] feat(rete): add NCC nodes (P2.3) --- packages/rete/src/index.ts | 2 + packages/rete/src/ncc.test.ts | 192 ++++++++++++++++++++++++++++++++ packages/rete/src/ncc.ts | 202 ++++++++++++++++++++++++++++++++++ 3 files changed, 396 insertions(+) create mode 100644 packages/rete/src/ncc.test.ts create mode 100644 packages/rete/src/ncc.ts diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index 65416c3..7be75d2 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -22,6 +22,8 @@ export { JoinNode } from "./join.js"; export { NegationNode, UnsafeNegationError } from "./negation.js"; +export { NccNode, NccPartner } from "./ncc.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/ncc.test.ts b/packages/rete/src/ncc.test.ts new file mode 100644 index 0000000..04ea9c5 --- /dev/null +++ b/packages/rete/src/ncc.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from "vitest"; +import { NccNode, NccPartner } from "./ncc.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; +const mkFact = (id: number, attr: string, value: unknown) => ({ + id: mkId(id), + attr: attr as AttrKey, + value: value as FactValue, +}); + +describe("NccNode + NccPartner", () => { + it("passes token when NCC partner has no sub-matches", () => { + const partner = new NccPartner(); + const ncc = new NccNode(partner); + const outMem = new BetaMemory(); + ncc.addDownstreamActivate((t) => outMem.leftActivate(t)); + + const token = new Token(null, mkFact(1, "X", 1), {}); + ncc.leftActivate(token); + + expect(outMem.tokens).toHaveLength(1); // no sub-matches → passes + expect(outMem.tokens[0]).toBe(token); + }); + + it("blocks token when NCC partner has sub-matches before outer activates", () => { + const partner = new NccPartner(); + const ncc = new NccNode(partner); + const outMem = new BetaMemory(); + ncc.addDownstreamActivate((t) => outMem.leftActivate(t)); + + const token = new Token(null, mkFact(1, "X", 1), {}); + + // Sub-match arrives before outer token + const subToken = new Token(token, mkFact(2, "Y", 2), { y: 2 }); + partner.leftActivate(subToken, token); + + // Now outer token arrives + ncc.leftActivate(token); + expect(outMem.tokens).toHaveLength(0); // blocked + }); + + it("partner cleanup on retract: token unblocks when sub-match removed", () => { + const partner = new NccPartner(); + const ncc = new NccNode(partner); + const outMem = new BetaMemory(); + ncc.addDownstreamActivate((t) => outMem.leftActivate(t)); + ncc.addDownstreamDeactivate((t) => outMem.leftDeactivate(t)); + + const token = new Token(null, mkFact(1, "X", 1), {}); + ncc.leftActivate(token); + expect(outMem.tokens).toHaveLength(1); // initially passes + + // Sub-match arrives → blocks + const subToken = new Token(token, mkFact(2, "Y", 2), { y: 2 }); + partner.leftActivate(subToken, token); + expect(outMem.tokens).toHaveLength(0); // blocked + + // Sub-match removed → unblocks + partner.leftDeactivate(subToken, token); + expect(outMem.tokens).toHaveLength(1); // passes again + }); + + it("multiple sub-matches: stays blocked until all removed", () => { + const partner = new NccPartner(); + const ncc = new NccNode(partner); + const outMem = new BetaMemory(); + ncc.addDownstreamActivate((t) => outMem.leftActivate(t)); + ncc.addDownstreamDeactivate((t) => outMem.leftDeactivate(t)); + + const token = new Token(null, mkFact(1, "X", 1), {}); + ncc.leftActivate(token); + + const sub1 = new Token(token, mkFact(2, "Y", 1), {}); + const sub2 = new Token(token, mkFact(3, "Y", 2), {}); + partner.leftActivate(sub1, token); + partner.leftActivate(sub2, token); + + expect(outMem.tokens).toHaveLength(0); + partner.leftDeactivate(sub1, token); + expect(outMem.tokens).toHaveLength(0); // still 1 sub-match + partner.leftDeactivate(sub2, token); + expect(outMem.tokens).toHaveLength(1); // now passes + }); + + it("left-deactivate outer token removes it from downstream", () => { + const partner = new NccPartner(); + const ncc = new NccNode(partner); + const outMem = new BetaMemory(); + ncc.addDownstreamActivate((t) => outMem.leftActivate(t)); + ncc.addDownstreamDeactivate((t) => outMem.leftDeactivate(t)); + + const token = new Token(null, mkFact(1, "X", 1), {}); + ncc.leftActivate(token); + expect(outMem.tokens).toHaveLength(1); + + ncc.leftDeactivate(token); + expect(outMem.tokens).toHaveLength(0); + }); + + it("left-deactivate does NOT emit downstream-deactivate when token was blocked", () => { + const partner = new NccPartner(); + const ncc = new NccNode(partner); + let deactivateCalls = 0; + ncc.addDownstreamActivate(() => {}); + ncc.addDownstreamDeactivate(() => { + deactivateCalls++; + }); + + const token = new Token(null, mkFact(1, "X", 1), {}); + // Pre-seed sub-match so token is blocked on activation + const subToken = new Token(token, mkFact(2, "Y", 1), {}); + partner.leftActivate(subToken, token); + + ncc.leftActivate(token); + // token was never passing, so leftDeactivate should not emit + ncc.leftDeactivate(token); + expect(deactivateCalls).toBe(0); + }); + + it("idempotent sub-match retract does not underflow", () => { + const partner = new NccPartner(); + const ncc = new NccNode(partner); + const outMem = new BetaMemory(); + ncc.addDownstreamActivate((t) => outMem.leftActivate(t)); + ncc.addDownstreamDeactivate((t) => outMem.leftDeactivate(t)); + + const token = new Token(null, mkFact(1, "X", 1), {}); + ncc.leftActivate(token); + expect(outMem.tokens).toHaveLength(1); + + // Retract a sub-match that was never activated — count clamps at 0 + const ghostSub = new Token(token, mkFact(99, "Y", 0), {}); + partner.leftDeactivate(ghostSub, token); + expect(outMem.tokens).toHaveLength(1); + + // Real sub-match still blocks correctly + const realSub = new Token(token, mkFact(2, "Y", 1), {}); + partner.leftActivate(realSub, token); + expect(outMem.tokens).toHaveLength(0); + }); + + it("multiple outer tokens: each tracked independently by partner", () => { + const partner = new NccPartner(); + const ncc = new NccNode(partner); + const outMem = new BetaMemory(); + ncc.addDownstreamActivate((t) => outMem.leftActivate(t)); + ncc.addDownstreamDeactivate((t) => outMem.leftDeactivate(t)); + + const t1 = new Token(null, mkFact(1, "X", 1), { id: mkId(1) }); + const t2 = new Token(null, mkFact(2, "X", 1), { id: mkId(2) }); + ncc.leftActivate(t1); + ncc.leftActivate(t2); + expect(outMem.tokens).toHaveLength(2); + + // Sub-match blocks only t1 + const sub = new Token(t1, mkFact(3, "Y", 1), {}); + partner.leftActivate(sub, t1); + expect(outMem.tokens).toHaveLength(1); + expect(outMem.tokens[0]).toBe(t2); + + // Retract it — t1 unblocks + partner.leftDeactivate(sub, t1); + expect(outMem.tokens).toHaveLength(2); + }); + + it("partner without bound ncc is a no-op (defensive)", () => { + const partner = new NccPartner(); + const token = new Token(null, mkFact(1, "X", 1), {}); + const subToken = new Token(token, mkFact(2, "Y", 1), {}); + // No NccNode attached — activate/deactivate must not throw + expect(() => partner.leftActivate(subToken, token)).not.toThrow(); + expect(() => partner.leftDeactivate(subToken, token)).not.toThrow(); + }); + + it("sub-match activate for unknown outer token does not spuriously emit", () => { + const partner = new NccPartner(); + const ncc = new NccNode(partner); + const outMem = new BetaMemory(); + ncc.addDownstreamActivate((t) => outMem.leftActivate(t)); + ncc.addDownstreamDeactivate((t) => outMem.leftDeactivate(t)); + + // Outer token was never passed to ncc.leftActivate + const outerGhost = new Token(null, mkFact(1, "X", 1), {}); + const sub = new Token(outerGhost, mkFact(2, "Y", 1), {}); + partner.leftActivate(sub, outerGhost); + partner.leftDeactivate(sub, outerGhost); + expect(outMem.tokens).toHaveLength(0); + }); +}); diff --git a/packages/rete/src/ncc.ts b/packages/rete/src/ncc.ts new file mode 100644 index 0000000..feb2618 --- /dev/null +++ b/packages/rete/src/ncc.ts @@ -0,0 +1,202 @@ +/** + * NccNode + NccPartner — subconjunction (not-count-condition) negation. + * + * Per Doorenbos 1995 §2.6.3. Unlike simple negation (NOT of a single pattern), + * NCC negates an entire conjunction of N conditions: the outer token passes + * only if there is NO combination of facts satisfying all N sub-conditions. + * + * Architecture: + * ┌───────────────┐ ┌──────────────────┐ ┌────────────┐ + * │ outer left in │──────▶ │ NccNode │──────▶ │ downstream │ + * └───────────────┘ └──────────────────┘ └────────────┘ + * ▲ + * │ sub-match notifications + * ┌───────┴────────┐ + * │ NccPartner │ + * └────────────────┘ + * ▲ + * │ feed from + * NCC sub-network terminal + * + * The NCC sub-network (a chain of join/beta nodes matching the negated + * conjunction) has its terminal feed `partner.leftActivate(subToken, outerToken)` + * whenever a complete sub-match is formed, and `leftDeactivate(...)` whenever + * that sub-match dissolves. The `outerToken` argument is the parent partial + * match from which the sub-network extended — this is the token whose + * pass/block state depends on the sub-match count. + * + * The NccNode maintains a reference count of active sub-matches per outer + * token: when the count transitions 0 → 1 an outer token that was passing + * downstream is withdrawn; when it transitions 1 → 0 the token is + * re-activated downstream (provided the outer token is still known). + */ +import type { Token } from "./beta.js"; + +/** Listener invoked when an outer token passes downstream. */ +type ActivateListener = (token: Token) => void; +/** Listener invoked when an outer token is withdrawn downstream. */ +type DeactivateListener = (token: Token) => void; + +/** + * Terminal node for the NCC sub-network. + * + * Carries a back-reference to its {@link NccNode}; the NccNode is set + * exactly once by the NccNode's constructor. If the partner is not yet + * wired to an NccNode, activations are silently ignored — this keeps + * construction order of `new NccPartner()` / `new NccNode(partner)` + * flexible for tests and builder code. + */ +export class NccPartner { + #ncc: NccNode | null = null; + + /** + * Notify the NccNode that a new complete sub-match has formed for the + * given `outerToken`. Called by the sub-network's terminal production. + */ + leftActivate(subToken: Token, outerToken: Token): void { + this.#ncc?.onSubMatchActivate(subToken, outerToken); + } + + /** + * Notify the NccNode that a previously-formed sub-match has dissolved. + */ + leftDeactivate(subToken: Token, outerToken: Token): void { + this.#ncc?.onSubMatchDeactivate(subToken, outerToken); + } + + /** + * Bind this partner to its owning NccNode. Called by the NccNode + * constructor. Rebinding is intentionally a no-op after the first call — + * a partner is paired with exactly one NccNode for life. + */ + setNcc(ncc: NccNode): void { + if (this.#ncc === null) { + this.#ncc = ncc; + } + } +} + +/** + * Beta-network node implementing subconjunction negation. + * + * Downstream subscribers see an outer token iff the NCC sub-network yields + * zero complete sub-matches for it. Arrivals and departures of sub-matches + * are reported via the paired {@link NccPartner}. + */ +export class NccNode { + readonly #activateListeners: ActivateListener[] = []; + readonly #deactivateListeners: DeactivateListener[] = []; + + /** Active sub-match count per outer token (absent entry ≡ 0). */ + readonly #subMatchCount = new Map(); + /** Outer tokens currently propagated downstream. */ + readonly #passingTokens = new Set(); + /** All outer tokens left-activated and not yet left-deactivated. */ + readonly #outerTokens = new Set(); + + constructor(partner: NccPartner) { + partner.setNcc(this); + } + + /** Subscribe to downstream activations (tokens passing the NCC filter). */ + addDownstreamActivate(listener: ActivateListener): void { + this.#activateListeners.push(listener); + } + + /** Subscribe to downstream deactivations (tokens withdrawn). */ + addDownstreamDeactivate(listener: DeactivateListener): void { + this.#deactivateListeners.push(listener); + } + + /** + * Accept an outer partial match. + * + * The token is passed downstream immediately when the sub-match count + * for it is zero. If a sub-match already exists (e.g. the sub-network + * activated before the outer left input did), the token is tracked but + * withheld from downstream until the count returns to zero. + */ + leftActivate(token: Token): void { + this.#outerTokens.add(token); + const count = this.#subMatchCount.get(token) ?? 0; + + if (count === 0) { + this.#passingTokens.add(token); + for (const listener of this.#activateListeners) { + listener(token); + } + } + } + + /** + * Withdraw an outer partial match. + * + * If the token is currently passing, fire downstream-deactivate; otherwise + * silently drop it. The sub-match count is discarded so stale sub-match + * retractions after this point become no-ops for this token. + */ + leftDeactivate(token: Token): void { + this.#outerTokens.delete(token); + this.#subMatchCount.delete(token); + + if (this.#passingTokens.has(token)) { + this.#passingTokens.delete(token); + for (const listener of this.#deactivateListeners) { + listener(token); + } + } + } + + /** + * Internal: sub-network reports a new complete sub-match. + * + * Incrementing the count from 0 to 1 blocks the outer token if it is + * currently passing. The `_subToken` argument is unused by the + * count-based scheme but retained in the signature per Doorenbos so that + * more elaborate variants (e.g. preserving matched sub-tokens for + * explanation) can slot in without changing the partner protocol. + */ + onSubMatchActivate(_subToken: Token, outerToken: Token): void { + const prev = this.#subMatchCount.get(outerToken) ?? 0; + this.#subMatchCount.set(outerToken, prev + 1); + + if (prev === 0 && this.#passingTokens.has(outerToken)) { + this.#passingTokens.delete(outerToken); + for (const listener of this.#deactivateListeners) { + listener(outerToken); + } + } + } + + /** + * Internal: sub-network reports a sub-match has dissolved. + * + * Decrementing the count from 1 to 0 re-activates the outer token + * downstream, provided the token has been left-activated and is not + * already passing. The count is clamped at zero to tolerate idempotent + * retractions. + */ + onSubMatchDeactivate(_subToken: Token, outerToken: Token): void { + const prev = this.#subMatchCount.get(outerToken) ?? 0; + if (prev === 0) { + return; // clamp — ignore spurious retracts + } + const next = prev - 1; + if (next === 0) { + this.#subMatchCount.delete(outerToken); + } else { + this.#subMatchCount.set(outerToken, next); + } + + if ( + next === 0 && + this.#outerTokens.has(outerToken) && + !this.#passingTokens.has(outerToken) + ) { + this.#passingTokens.add(outerToken); + for (const listener of this.#activateListeners) { + listener(outerToken); + } + } + } +}