feat(rete): add derived facts with thenFinally + truth maintenance (P1.11)
This commit is contained in:
parent
04804545da
commit
3104d33985
2 changed files with 316 additions and 0 deletions
155
packages/rete/src/derived.test.ts
Normal file
155
packages/rete/src/derived.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
161
packages/rete/src/derived.ts
Normal file
161
packages/rete/src/derived.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue