diff --git a/packages/rete/src/beta.test.ts b/packages/rete/src/beta.test.ts new file mode 100644 index 0000000..eeaa840 --- /dev/null +++ b/packages/rete/src/beta.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi } from "vitest"; +import { Token, BetaMemory, BetaMemoryNode } from "./beta.js"; +import type { EntityId } from "./schema.js"; +import type { AttrKey, FactValue } from "./wm.js"; + +const mkId = (n: number) => n as EntityId; + +// Helper to make a simple fact object +const mkFact = (id: number, attr: string, value: unknown) => + ({ id: mkId(id), attr, value }) as { id: EntityId; attr: AttrKey; value: FactValue }; + +describe("Token", () => { + it("creates a root token with a single fact binding", () => { + const f = mkFact(1, "Health", 100); + const token = new Token(null, f, { hp: 100, id: mkId(1) }); + expect(token.parent).toBeNull(); + expect(token.fact).toBe(f); + expect(token.bindings).toEqual({ hp: 100, id: mkId(1) }); + }); + + it("creates a child token with parent reference", () => { + const f1 = mkFact(1, "Health", 100); + const f2 = mkFact(1, "Name", "Alice"); + const root = new Token(null, f1, { hp: 100 }); + const child = new Token(root, f2, { hp: 100, name: "Alice" }); + expect(child.parent).toBe(root); + expect(child.bindings).toEqual({ hp: 100, name: "Alice" }); + }); + + it("token identity is by reference, not deep equality", () => { + const f = mkFact(1, "X", 1); + const t1 = new Token(null, f, { x: 1 }); + const t2 = new Token(null, f, { x: 1 }); + expect(t1).not.toBe(t2); + }); +}); + +describe("BetaMemory", () => { + it("stores tokens on left-activate", () => { + const mem = new BetaMemory(); + const f = mkFact(1, "Health", 100); + const token = new Token(null, f, {}); + mem.leftActivate(token); + expect(mem.tokens).toHaveLength(1); + expect(mem.tokens[0]).toBe(token); + }); + + it("removes tokens on left-deactivate", () => { + const mem = new BetaMemory(); + const f = mkFact(1, "Health", 100); + const token = new Token(null, f, {}); + mem.leftActivate(token); + mem.leftDeactivate(token); + expect(mem.tokens).toHaveLength(0); + }); + + it("does not remove token that was never added", () => { + const mem = new BetaMemory(); + const f = mkFact(1, "X", 1); + const token = new Token(null, f, {}); + // Should not throw + mem.leftDeactivate(token); + expect(mem.tokens).toHaveLength(0); + }); + + it("notifies downstream on activate", () => { + const mem = new BetaMemory(); + const downstream = vi.fn(); + mem.addDownstreamActivate(downstream); + + const f = mkFact(1, "H", 10); + const token = new Token(null, f, {}); + mem.leftActivate(token); + expect(downstream).toHaveBeenCalledOnce(); + expect(downstream).toHaveBeenCalledWith(token); + }); + + it("notifies downstream on deactivate", () => { + const mem = new BetaMemory(); + const downActivate = vi.fn(); + const downDeactivate = vi.fn(); + mem.addDownstreamActivate(downActivate); + mem.addDownstreamDeactivate(downDeactivate); + + const f = mkFact(1, "H", 10); + const token = new Token(null, f, {}); + mem.leftActivate(token); + mem.leftDeactivate(token); + expect(downDeactivate).toHaveBeenCalledOnce(); + expect(downDeactivate).toHaveBeenCalledWith(token); + }); + + it("tokens are stored in order of activation", () => { + const mem = new BetaMemory(); + const t1 = new Token(null, mkFact(1, "X", 1), { x: 1 }); + const t2 = new Token(null, mkFact(2, "X", 2), { x: 2 }); + const t3 = new Token(null, mkFact(3, "X", 3), { x: 3 }); + mem.leftActivate(t1); + mem.leftActivate(t2); + mem.leftActivate(t3); + expect(mem.tokens[0]).toBe(t1); + expect(mem.tokens[1]).toBe(t2); + expect(mem.tokens[2]).toBe(t3); + }); +}); + +describe("BetaMemoryNode (with child BetaMemory)", () => { + it("a root BetaMemory activated by an alpha node drives a child BetaMemory", () => { + const root = new BetaMemory(); + const child = new BetaMemoryNode(); + root.addDownstreamActivate((t) => child.leftActivate(t)); + root.addDownstreamDeactivate((t) => child.leftDeactivate(t)); + + const f = mkFact(1, "Health", 100); + const token = new Token(null, f, { hp: 100 }); + root.leftActivate(token); + + expect(child.tokens).toHaveLength(1); + expect(child.tokens[0]).toBe(token); + }); + + it("deactivation propagates through chain", () => { + const root = new BetaMemory(); + const child = new BetaMemory(); + root.addDownstreamActivate((t) => child.leftActivate(t)); + root.addDownstreamDeactivate((t) => child.leftDeactivate(t)); + + const f = mkFact(1, "Health", 100); + const token = new Token(null, f, { hp: 100 }); + root.leftActivate(token); + expect(child.tokens).toHaveLength(1); + root.leftDeactivate(token); + expect(child.tokens).toHaveLength(0); + }); +}); diff --git a/packages/rete/src/beta.ts b/packages/rete/src/beta.ts new file mode 100644 index 0000000..a63eda7 --- /dev/null +++ b/packages/rete/src/beta.ts @@ -0,0 +1,142 @@ +/** + * BetaMemory and Token — partial match storage for the Rete beta network. + * + * Per `packages/rete/SPEC.md §Rete II Reference Target` this module implements + * the BetaMemory node type from the Doorenbos beta network. + * + * A {@link Token} represents a partial match: a fact plus a reference to a + * parent token, so the full chain of facts that contributed to the match is + * recovered by walking `.parent` to null. This mirrors Doorenbos §2.3 and + * keeps token construction O(1) — only the newly-joined fact and its merged + * bindings are allocated per level, never the full list. + * + * A {@link BetaMemory} stores the set of currently-active tokens and + * propagates left-activate / left-deactivate notifications to downstream + * subscribers. Downstream nodes are expected to be {@link JoinNode}s + * (implemented in P1.8) or {@link ProductionNode}s; this module does not + * concern itself with join semantics. + */ +import type { EntityId } from "./schema.js"; +import type { AttrKey, FactValue } from "./wm.js"; + +/** + * Variable-binding map for a token. + * + * Keys are the variable names declared in a rule's conditions (e.g. `"hp"`, + * `"id"`); values are the concrete entity ids or fact values bound to those + * variables at this point in the partial match. + */ +export type Bindings = Record; + +/** + * The fact triple that most recently extended a token. + * + * This mirrors the shape used by {@link AlphaMemory} and {@link WorkingMemory} + * — the beta network never re-wraps facts, it just references them. + */ +export interface TokenFact { + readonly id: EntityId; + readonly attr: AttrKey; + readonly value: FactValue; +} + +/** + * A single node in the partial-match chain. + * + * A root token has `parent === null` and represents the match produced by the + * left-most condition of a rule. Each subsequent successful join appends a + * child token whose `parent` points at the previous token in the chain, its + * `fact` is the newly-joined fact, and its `bindings` are the merged + * variable bindings after applying the new fact. + * + * Tokens are compared by reference everywhere in the network — two tokens + * constructed from structurally-identical arguments are NOT considered equal. + * This lets the beta memory use `Array#indexOf` for O(n) removal without + * worrying about structural-hash collisions. + */ +export class Token { + constructor( + /** Parent token in the match chain, or `null` for a root token. */ + public readonly parent: Token | null, + /** The fact that extended the match at this level. */ + public readonly fact: TokenFact, + /** Accumulated variable bindings up to and including this token. */ + public readonly bindings: Bindings, + ) {} +} + +/** Downstream subscriber fired after a token enters beta memory. */ +type TokenActivateListener = (token: Token) => void; +/** Downstream subscriber fired after a token leaves beta memory. */ +type TokenDeactivateListener = (token: Token) => void; + +/** + * Beta-network memory node. + * + * Stores the set of partial matches (tokens) that have reached this point in + * the network and forwards left-activations / left-deactivations to + * downstream subscribers in registration order. + * + * The public `tokens` array is declared `readonly` so external code cannot + * reassign it, but its contents are mutated by the memory itself on + * {@link leftActivate} / {@link leftDeactivate}. Tests rely on positional + * access (`tokens[0]`), so the array is intentionally left indexable rather + * than wrapped in a `Set` or hidden behind a method. + */ +export class BetaMemory { + /** Currently-active tokens, in insertion order. */ + readonly tokens: Token[] = []; + + readonly #activateListeners: TokenActivateListener[] = []; + readonly #deactivateListeners: TokenDeactivateListener[] = []; + + /** + * Accept a new partial match. + * + * The token is appended to {@link tokens} and all downstream activate + * listeners are invoked synchronously in registration order. Listeners see + * a memory view that already contains the new token. + */ + leftActivate(token: Token): void { + this.tokens.push(token); + for (const listener of this.#activateListeners) { + listener(token); + } + } + + /** + * Withdraw a partial match. + * + * Removes the token by reference (first occurrence) if present. Always + * fires downstream deactivate listeners, even if the token was not found — + * downstream memories may still hold derived tokens that need to be + * withdrawn, and they perform their own presence checks. + */ + leftDeactivate(token: Token): void { + const idx = this.tokens.indexOf(token); + if (idx !== -1) { + this.tokens.splice(idx, 1); + } + for (const listener of this.#deactivateListeners) { + listener(token); + } + } + + /** Subscribe to left-activations. Listeners fire in registration order. */ + addDownstreamActivate(listener: TokenActivateListener): void { + this.#activateListeners.push(listener); + } + + /** Subscribe to left-deactivations. Listeners fire in registration order. */ + addDownstreamDeactivate(listener: TokenDeactivateListener): void { + this.#deactivateListeners.push(listener); + } +} + +/** + * Alias for {@link BetaMemory} used when the memory is a node in the network + * wiring graph (as distinct from the dummy top-node memory that seeds + * every rule). Kept as a separate export so downstream modules can document + * intent without introducing a structural difference. + */ +export { BetaMemory as BetaMemoryNode }; diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index 7c8511a..0175296 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -14,6 +14,9 @@ export { WorkingMemory } from "./wm.js"; export type { AlphaCondition } from "./alpha.js"; export { AlphaNetwork, AlphaNode, AlphaMemory } from "./alpha.js"; +export type { Bindings, TokenFact } from "./beta.js"; +export { Token, BetaMemory, BetaMemoryNode } from "./beta.js"; + export type { VariableDescriptor, RuleCondition, RuleDefinition, DefineRuleOpts } from "./builder.js"; export { v, defineRule } from "./builder.js"; export type { HandlerFn, PredicateFn } from "./registry.js";