feat(rete): add BetaMemory + Token propagation (P1.7)
This commit is contained in:
parent
c4b1eb0a61
commit
eb01a88fe9
3 changed files with 280 additions and 0 deletions
135
packages/rete/src/beta.test.ts
Normal file
135
packages/rete/src/beta.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
142
packages/rete/src/beta.ts
Normal file
142
packages/rete/src/beta.ts
Normal file
|
|
@ -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<string, EntityId | FactValue>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
|
@ -14,6 +14,9 @@ export { WorkingMemory } from "./wm.js";
|
||||||
export type { AlphaCondition } from "./alpha.js";
|
export type { AlphaCondition } from "./alpha.js";
|
||||||
export { AlphaNetwork, AlphaNode, AlphaMemory } 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 type { VariableDescriptor, RuleCondition, RuleDefinition, DefineRuleOpts } from "./builder.js";
|
||||||
export { v, defineRule } from "./builder.js";
|
export { v, defineRule } from "./builder.js";
|
||||||
export type { HandlerFn, PredicateFn } from "./registry.js";
|
export type { HandlerFn, PredicateFn } from "./registry.js";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue