houserules/packages/rete/src/beta.ts

142 lines
5.4 KiB
TypeScript

/**
* 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 };