feat(rete): add NCC nodes (P2.3)

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

View file

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

View file

@ -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);
});
});

202
packages/rete/src/ncc.ts Normal file
View file

@ -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<Token, number>();
/** Outer tokens currently propagated downstream. */
readonly #passingTokens = new Set<Token>();
/** All outer tokens left-activated and not yet left-deactivated. */
readonly #outerTokens = new Set<Token>();
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);
}
}
}
}