feat(rete): add derived facts with thenFinally + truth maintenance (P1.11)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 13:59:24 -06:00
commit 3104d33985
No known key found for this signature in database
2 changed files with 316 additions and 0 deletions

View file

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

View file

@ -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<Token, DerivedEntry>();
/** 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);
}
}