From 3104d33985b8624d5bd6428b7f63ae84b62b6f7f Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 13:59:24 -0600 Subject: [PATCH] feat(rete): add derived facts with thenFinally + truth maintenance (P1.11) --- packages/rete/src/derived.test.ts | 155 ++++++++++++++++++++++++++++ packages/rete/src/derived.ts | 161 ++++++++++++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 packages/rete/src/derived.test.ts create mode 100644 packages/rete/src/derived.ts diff --git a/packages/rete/src/derived.test.ts b/packages/rete/src/derived.test.ts new file mode 100644 index 0000000..62c9b01 --- /dev/null +++ b/packages/rete/src/derived.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from "vitest"; +import { DerivedFactProduction, type DerivedHandler } from "./derived.js"; +import { WorkingMemory } from "./wm.js"; +import { Token } 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("DerivedFactProduction", () => { + it("inserts a derived fact into working memory when a match activates", () => { + const wm = new WorkingMemory(); + const handler: DerivedHandler = (match) => [ + { attr: "AllHP", value: (match["hp"] as number) * 2 }, + ]; + + const prod = new DerivedFactProduction("sum-hp", wm, handler); + + const token = new Token(null, mkFact(1, "Health", 100), { hp: 100 }); + prod.leftActivate(token); + + // Derived fact should exist in WM with negative id + const allFacts = wm.allFacts(); + const derived = allFacts.find((f) => f.attr === "AllHP"); + expect(derived).toBeDefined(); + expect(derived?.value).toBe(200); + expect(derived?.id as number).toBeLessThan(0); // negative id + }); + + it("retracts derived fact when supporting match deactivates", () => { + const wm = new WorkingMemory(); + const handler: DerivedHandler = (match) => [ + { attr: "Derived", value: match["x"] as FactValue }, + ]; + + const prod = new DerivedFactProduction("derive-x", wm, handler); + const token = new Token(null, mkFact(1, "X", 42), { x: 42 }); + + prod.leftActivate(token); + expect(wm.allFacts().find((f) => f.attr === "Derived")).toBeDefined(); + + prod.leftDeactivate(token); + expect(wm.allFacts().find((f) => f.attr === "Derived")).toBeUndefined(); + }); + + it("inserts multiple derived facts from one match", () => { + const wm = new WorkingMemory(); + const handler: DerivedHandler = (match) => [ + { attr: "SumA", value: match["a"] as FactValue }, + { attr: "SumB", value: match["b"] as FactValue }, + ]; + + const prod = new DerivedFactProduction("multi-derive", wm, handler); + const token = new Token(null, mkFact(1, "X", 1), { a: 10, b: 20 }); + + prod.leftActivate(token); + + const facts = wm.allFacts(); + expect(facts.find((f) => f.attr === "SumA")?.value).toBe(10); + expect(facts.find((f) => f.attr === "SumB")?.value).toBe(20); + }); + + it("retracts all derived facts for a match when it deactivates", () => { + const wm = new WorkingMemory(); + const handler: DerivedHandler = (match) => [ + { attr: "SumA", value: match["a"] as FactValue }, + { attr: "SumB", value: match["b"] as FactValue }, + ]; + + const prod = new DerivedFactProduction("multi-derive", wm, handler); + const token = new Token(null, mkFact(1, "X", 1), { a: 10, b: 20 }); + + prod.leftActivate(token); + prod.leftDeactivate(token); + + const facts = wm.allFacts(); + expect(facts.find((f) => f.attr === "SumA")).toBeUndefined(); + expect(facts.find((f) => f.attr === "SumB")).toBeUndefined(); + }); + + it("different matches produce different derived entity ids", () => { + const wm = new WorkingMemory(); + const handler: DerivedHandler = (match) => [ + { attr: "Score", value: match["score"] as FactValue }, + ]; + + const prod = new DerivedFactProduction("score-derive", wm, handler); + const t1 = new Token(null, mkFact(1, "X", 1), { score: 100 }); + const t2 = new Token(null, mkFact(2, "X", 2), { score: 200 }); + + prod.leftActivate(t1); + prod.leftActivate(t2); + + const facts = wm.allFacts().filter((f) => f.attr === "Score"); + expect(facts).toHaveLength(2); + // Both have negative ids, and they're different + const ids = facts.map((f) => f.id as number); + expect(ids[0]).not.toBe(ids[1]); + expect(ids.every((id) => id < 0)).toBe(true); + }); + + it("deactivating one match does not remove derived facts from other matches", () => { + const wm = new WorkingMemory(); + const handler: DerivedHandler = (match) => [ + { attr: "Score", value: match["score"] as FactValue }, + ]; + + const prod = new DerivedFactProduction("score-derive", wm, handler); + const t1 = new Token(null, mkFact(1, "X", 1), { score: 100 }); + const t2 = new Token(null, mkFact(2, "X", 2), { score: 200 }); + + prod.leftActivate(t1); + prod.leftActivate(t2); + prod.leftDeactivate(t1); // remove only t1's derived fact + + const facts = wm.allFacts().filter((f) => f.attr === "Score"); + expect(facts).toHaveLength(1); + expect(facts[0]?.value).toBe(200); + }); + + it("leftDeactivate on unknown token is a no-op", () => { + const wm = new WorkingMemory(); + const handler: DerivedHandler = () => [{ attr: "X", value: 1 }]; + const prod = new DerivedFactProduction("noop", wm, handler); + + const token = new Token(null, mkFact(1, "X", 1), {}); + // Never activated — deactivate should not throw and should not mutate WM. + expect(() => prod.leftDeactivate(token)).not.toThrow(); + expect(wm.allFacts()).toHaveLength(0); + }); + + it("handler producing no facts is valid (no-op activation/deactivation)", () => { + const wm = new WorkingMemory(); + const handler: DerivedHandler = () => []; + const prod = new DerivedFactProduction("empty", wm, handler); + + const token = new Token(null, mkFact(1, "X", 1), {}); + prod.leftActivate(token); + expect(wm.allFacts()).toHaveLength(0); + + // Deactivation of an activated-but-empty token must still clean up. + prod.leftDeactivate(token); + expect(wm.allFacts()).toHaveLength(0); + // Second deactivation is a no-op (entry already removed). + expect(() => prod.leftDeactivate(token)).not.toThrow(); + }); + + it("exposes ruleName for diagnostics", () => { + const wm = new WorkingMemory(); + const prod = new DerivedFactProduction("my-rule", wm, () => []); + expect(prod.ruleName).toBe("my-rule"); + }); +}); diff --git a/packages/rete/src/derived.ts b/packages/rete/src/derived.ts new file mode 100644 index 0000000..3eaa218 --- /dev/null +++ b/packages/rete/src/derived.ts @@ -0,0 +1,161 @@ +/** + * DerivedFactProduction — `thenFinally` equivalent with truth maintenance. + * + * Per `packages/rete/SPEC.md §Truth Maintenance` and §Rete II Reference + * Target, a `DerivedFactProduction` is a terminal node that turns full + * matches into materialised EAV facts in working memory. It differs from + * {@link ProductionNode} in two ways: + * + * 1. It does not merely record matches — it *asserts new facts* computed + * from the match's variable bindings. Those facts are indistinguishable + * from user-asserted facts to the rest of the Rete network, which means + * they can participate in further matches (chained inference). + * + * 2. The asserted facts are tied to the life-cycle of the supporting match. + * When the match is withdrawn (`leftDeactivate`), the facts it produced + * are retracted automatically. This is the "truth maintenance" contract: + * downstream rules see a derived fact if and only if its supporting + * match is currently active. + * + * Derived facts use **negative {@link EntityId}s** minted from an internal + * per-node counter (`-1`, `-2`, ...). Per SPEC §ID Authority user-minted ids + * via `Session.nextId()` are strictly positive, so the sign of the id is a + * compile-free way to tell derived facts apart from user facts without + * needing a sidecar registry. + * + * The handler receives the match's {@link Bindings} and returns the list of + * `(attr, value)` pairs to assert under the derived id. Returning an empty + * array is legal — it records the activation (so later deactivation is a + * clean no-op) without mutating working memory. + * + * The handler is invoked **only on activate**, never on deactivate. On + * deactivate we retract the exact attrs previously asserted for that token, + * which is stored on activation. This avoids re-running user code during + * retraction — important because retraction happens during fact-removal + * cascades where re-entering the handler could produce a different output + * than the one that was originally asserted (e.g. if the handler reads + * other working-memory state). + */ +import type { EntityId } from "./schema.js"; +import type { AttrKey, FactValue, WorkingMemory } from "./wm.js"; +import type { Bindings, Token } from "./beta.js"; + +/** + * A single `(attr, value)` pair produced by a {@link DerivedHandler}. + * + * The entity id is supplied by the {@link DerivedFactProduction} itself + * (derived facts are minted with negative ids), so the handler only returns + * the attribute-and-value half of the triple. + */ +export interface DerivedFact { + readonly attr: AttrKey; + readonly value: FactValue; +} + +/** + * Computes the derived facts to assert for a single full match. + * + * Invoked by {@link DerivedFactProduction.leftActivate} with the match's + * accumulated {@link Bindings}. Returning an empty array is valid and means + * "this match contributes no derived facts"; the activation is still + * recorded so a later {@link DerivedFactProduction.leftDeactivate} is a + * clean no-op. + */ +export type DerivedHandler = (bindings: Bindings) => readonly DerivedFact[]; + +/** + * Per-token record of what was asserted so it can be retracted later. + * + * `attrs` is the list of attribute names asserted under `id`; it is stored + * — rather than re-derived from the handler on deactivation — so that + * retraction remains deterministic even if the handler is non-idempotent + * (e.g. reads working-memory state that has since changed). + */ +interface DerivedEntry { + readonly id: EntityId; + readonly attrs: readonly AttrKey[]; +} + +/** + * Terminal Rete node that materialises derived facts from full matches. + * + * Wiring: subscribe this node to the final {@link BetaMemory} (or filter + * node) of a rule via `addDownstreamActivate(prod.leftActivate.bind(prod))` + * and `addDownstreamDeactivate(prod.leftDeactivate.bind(prod))`. + * + * Concurrency: activations and deactivations are expected to be serialised + * by the surrounding {@link Session.fireRules} loop; no internal locking. + */ +export class DerivedFactProduction { + /** + * Monotonically decreasing counter used to mint negative entity ids for + * derived facts. Starts at `0` and is pre-decremented on each activation, + * yielding `-1, -2, -3, ...`. Scoped per node so different productions do + * not collide on ids in any externally-observable way (the ids are opaque + * to consumers; only the sign is meaningful). + */ + #idCounter = 0; + + /** + * Map from the activating {@link Token} reference to the record of facts + * asserted for it. Keyed by reference — matching {@link BetaMemory}'s + * identity-based token semantics — so `leftDeactivate` can find the + * exact entry to retract without structural comparison. + */ + readonly #tokenToEntry = new Map(); + + /** Working memory into which derived facts are asserted and retracted. */ + readonly #wm: WorkingMemory; + /** Computes derived facts from a match's bindings. */ + readonly #handler: DerivedHandler; + + constructor( + /** Human-readable rule name, surfaced in diagnostics. */ + readonly ruleName: string, + wm: WorkingMemory, + handler: DerivedHandler, + ) { + this.#wm = wm; + this.#handler = handler; + } + + /** + * Accept a new full match and assert its derived facts. + * + * Mints a fresh negative entity id, invokes the handler with the token's + * bindings, and inserts each returned `(attr, value)` pair into working + * memory under that id. Records the list of attrs on the token so a + * future {@link leftDeactivate} can retract exactly those facts. + */ + leftActivate(token: Token): void { + const derivedId = (--this.#idCounter) as EntityId; + const derivedFacts = this.#handler(token.bindings); + + const attrs: AttrKey[] = []; + for (const { attr, value } of derivedFacts) { + this.#wm.insert(derivedId, attr, value); + attrs.push(attr); + } + + this.#tokenToEntry.set(token, { id: derivedId, attrs }); + } + + /** + * Withdraw a previously-accepted match and retract its derived facts. + * + * Retracts each attr that was asserted on activation — not re-derived + * from the handler, see module header for rationale. Unknown tokens are + * a no-op: upstream memories broadcast deactivations to all subscribers, + * and a production that never saw a given token must simply ignore it + * (matching {@link BetaMemory.leftDeactivate} / {@link ProductionNode.leftDeactivate}). + */ + leftDeactivate(token: Token): void { + const entry = this.#tokenToEntry.get(token); + if (!entry) return; + + for (const attr of entry.attrs) { + this.#wm.retract(entry.id, attr); + } + this.#tokenToEntry.delete(token); + } +}