feat(rete): add NCC nodes (P2.3)
This commit is contained in:
parent
1731c43eb2
commit
08515012b1
3 changed files with 396 additions and 0 deletions
|
|
@ -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";
|
||||
|
|
|
|||
192
packages/rete/src/ncc.test.ts
Normal file
192
packages/rete/src/ncc.test.ts
Normal 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
202
packages/rete/src/ncc.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue