houserules/packages/rete/src/session.ts

197 lines
6.7 KiB
TypeScript

/**
* Session — main entry point for the @paratype/rete engine.
* Owns WorkingMemory, AlphaNetwork, and the production agenda.
* Per SPEC.md §ID Authority, §Conflict Resolution, §RHS Purity Contract.
*/
import { WorkingMemory, type AttrKey, type FactValue } from "./wm.js";
import { AlphaNetwork } from "./alpha.js";
import type { EntityId } from "./schema.js";
import type { RuleDefinition } from "./builder.js";
import type { OrderableRule } from "./conflict.js";
import { RecursionLimitExceededError } from "./cycle.js";
import type { EventLog } from "./eventlog.js";
/**
* Internal record pairing a registered {@link RuleDefinition} with its
* zero-based insertion index. The index is the third tiebreaker used by
* {@link orderActivations} (see conflict.ts).
*/
interface RegisteredRule {
readonly rule: RuleDefinition;
readonly addedAt: number;
}
export interface SessionOptions {
/** If true, fireRules() is called automatically after each insert/retract. Default: true. */
autoFire?: boolean;
/** Maximum recursion depth for fireRules(). Default: 64. */
recursionLimit?: number;
/**
* Optional append-only {@link EventLog}. When provided, user-initiated
* {@link Session.insert} and {@link Session.retract} calls append a
* corresponding event. Derived facts (negative {@link EntityId}s) are
* excluded — per SPEC.md §Truth Maintenance they are re-derived on
* replay rather than recorded directly.
*/
eventLog?: EventLog;
}
export class Session {
readonly #wm: WorkingMemory;
readonly #alpha: AlphaNetwork;
readonly #rules: RegisteredRule[] = [];
readonly #opts: Required<Pick<SessionOptions, "autoFire" | "recursionLimit">>;
readonly #eventLog: EventLog | undefined;
#idCounter = 0;
/**
* Internal recursion depth counter for fireRules().
* Underscore prefix signals package-internal use; not exported from index.
* Per SPEC.md §Cycle Detection.
*/
_fireDepth = 0;
constructor(opts: SessionOptions = {}) {
this.#opts = {
autoFire: opts.autoFire ?? true,
recursionLimit: opts.recursionLimit ?? 64,
};
this.#eventLog = opts.eventLog;
this.#wm = new WorkingMemory();
this.#alpha = new AlphaNetwork();
// Wire WorkingMemory events to AlphaNetwork
this.#wm.onInsert((id, attr, value) => {
this.#alpha.notifyInsert(id, attr, value);
});
this.#wm.onRetract((id, attr, value) => {
this.#alpha.notifyRetract(id, attr, value);
});
}
/** Mint a new unique EntityId (server-authoritative per SPEC.md §ID Authority). */
nextId(): EntityId {
return ++this.#idCounter as EntityId;
}
/**
* Insert (or update) a fact. If autoFire, triggers fireRules().
*
* When an {@link EventLog} is attached, records the mutation —
* updates are recorded as a retract-of-old-value followed by an
* insert-of-new-value, mirroring the WM's internal update semantics
* (see wm.ts). Derived facts (negative ids) are never logged.
*/
insert(id: EntityId, attr: AttrKey, value: FactValue): void {
const log = this.#eventLog;
const shouldLog = log !== undefined && (id as number) >= 0;
if (shouldLog) {
const oldValue = this.#wm.get(id, attr);
if (oldValue !== undefined || this.#wm.contains(id, attr)) {
log.appendRetract(id, attr, oldValue);
}
}
this.#wm.insert(id, attr, value);
if (shouldLog) log.appendInsert(id, attr, value);
if (this.#opts.autoFire) this.fireRules();
}
/**
* Retract a fact. If autoFire, triggers fireRules().
*
* When an {@link EventLog} is attached, the previous value is read
* from WM before the retraction and recorded on the event so replay
* can reconstruct the missing fact. Retracting a non-existent fact
* is a no-op and is not logged.
*/
retract(id: EntityId, attr: AttrKey): void {
const log = this.#eventLog;
const shouldLog = log !== undefined && (id as number) >= 0;
let oldValue: FactValue | undefined;
let existed = false;
if (shouldLog) {
existed = this.#wm.contains(id, attr);
if (existed) oldValue = this.#wm.get(id, attr);
}
this.#wm.retract(id, attr);
if (shouldLog && existed) log.appendRetract(id, attr, oldValue);
if (this.#opts.autoFire) this.fireRules();
}
/** Get fact value. */
get(id: EntityId, attr: AttrKey): FactValue | undefined {
return this.#wm.get(id, attr);
}
/** Check fact existence. */
contains(id: EntityId, attr: AttrKey): boolean {
return this.#wm.contains(id, attr);
}
/** Return all facts sorted [id asc, attr asc]. */
allFacts(): ReturnType<WorkingMemory["allFacts"]> {
return this.#wm.allFacts();
}
/**
* Register a rule with the session.
* Accepts a RuleDefinition produced by defineRule() (see builder.ts).
*
* Each registration is assigned a zero-based `addedAt` index, exposed
* via {@link _getOrderableRules} for the conflict-resolution sort
* (see conflict.ts, SPEC.md §Conflict Resolution).
*/
add(rule: RuleDefinition): void {
this.#rules.push({ rule, addedAt: this.#rules.length });
}
/**
* Package-internal accessor returning registered rules in a shape
* compatible with {@link OrderableRule} (i.e. with `addedAt` attached).
*
* Used by the agenda/beta network to build {@link Activation} values
* that can be passed to {@link orderActivations}. Not exported from
* the package index — consumers should go through `fireRules()`.
*/
_getOrderableRules(): readonly OrderableRule[] {
return this.#rules.map(({ rule, addedAt }) => ({
name: rule.name,
salience: rule.salience,
conditions: rule.conditions,
handler: rule.handler,
addedAt,
}));
}
/**
* Fire all pending rule activations in deterministic order.
* Returns the count of rule firings.
* Per SPEC.md §Conflict Resolution: salience desc → specificity desc → insertion order asc.
* Per SPEC.md §Cycle Detection: throws RecursionLimitExceededError when depth exceeds limit.
* A limit of 0 disables the check.
*/
fireRules(opts?: { recursionLimit?: number }): number {
const limit = opts?.recursionLimit ?? this.#opts.recursionLimit;
if (limit > 0 && this._fireDepth >= limit) {
throw new RecursionLimitExceededError(this._fireDepth, []);
}
this._fireDepth++;
try {
// Stub: no production nodes wired yet (Phase 1.7+)
// Returns 0 until beta network + production nodes are integrated.
return 0;
} finally {
this._fireDepth--;
}
}
/** Expose AlphaNetwork for wiring by beta/join nodes (package-internal). */
_getAlpha(): AlphaNetwork {
return this.#alpha;
}
/** Expose WorkingMemory for wiring by production nodes (package-internal). */
_getWM(): WorkingMemory {
return this.#wm;
}
}