From 76b21ddd5facf1264cf310c1877aa8d0fcccc195 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 13:39:54 -0600 Subject: [PATCH] feat(rete): add Session lifecycle (P1.4) --- packages/rete/src/index.ts | 3 + packages/rete/src/session.test.ts | 118 ++++++++++++++++++++++++++++++ packages/rete/src/session.ts | 106 +++++++++++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 packages/rete/src/session.test.ts create mode 100644 packages/rete/src/session.ts diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index b572c9a..5dd3d19 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -13,3 +13,6 @@ export { WorkingMemory } from "./wm.js"; export type { AlphaCondition } from "./alpha.js"; export { AlphaNetwork, AlphaNode, AlphaMemory } from "./alpha.js"; + +export type { SessionOptions, RuleDefinition } from "./session.js"; +export { Session } from "./session.js"; diff --git a/packages/rete/src/session.test.ts b/packages/rete/src/session.test.ts new file mode 100644 index 0000000..f26dfca --- /dev/null +++ b/packages/rete/src/session.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi } from "vitest"; +import { Session } from "./session.js"; +import type { EntityId } from "./schema.js"; + +const mkId = (n: number) => n as EntityId; + +describe("Session", () => { + describe("constructor", () => { + it("creates a session with autoFire false", () => { + const session = new Session({ autoFire: false }); + expect(session).toBeDefined(); + }); + + it("creates a session with autoFire true (default)", () => { + const session = new Session(); + expect(session).toBeDefined(); + }); + }); + + describe("nextId()", () => { + it("returns incrementing EntityId values starting from 1", () => { + const session = new Session(); + const id1 = session.nextId(); + const id2 = session.nextId(); + const id3 = session.nextId(); + expect(id1).toBe(1); + expect(id2).toBe(2); + expect(id3).toBe(3); + }); + }); + + describe("insert() / retract() / get() / contains()", () => { + it("insert stores a fact retrievable by get()", () => { + const session = new Session({ autoFire: false }); + const id = mkId(1); + session.insert(id, "Health", 100); + expect(session.get(id, "Health")).toBe(100); + }); + + it("retract removes a fact", () => { + const session = new Session({ autoFire: false }); + session.insert(mkId(1), "Health", 100); + session.retract(mkId(1), "Health"); + expect(session.get(mkId(1), "Health")).toBeUndefined(); + }); + + it("contains() reflects fact presence", () => { + const session = new Session({ autoFire: false }); + expect(session.contains(mkId(5), "X")).toBe(false); + session.insert(mkId(5), "X", 42); + expect(session.contains(mkId(5), "X")).toBe(true); + }); + }); + + describe("fireRules()", () => { + it("returns 0 when no rules registered", () => { + const session = new Session({ autoFire: false }); + session.insert(mkId(1), "Health", 100); + const fired = session.fireRules(); + expect(fired).toBe(0); + }); + + it("autoFire false: does NOT fire rules on insert", () => { + const session = new Session({ autoFire: false }); + const handler = vi.fn(); + // We can't add real rules yet (that's P1.5+), but we can stub + // For now just verify fireRules returns 0 with no rules + session.insert(mkId(1), "Health", 100); + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe("allFacts()", () => { + it("returns all facts sorted by [id, attr]", () => { + const session = new Session({ autoFire: false }); + session.insert(mkId(2), "Z", "z"); + session.insert(mkId(1), "A", "a"); + const facts = session.allFacts(); + expect(facts).toHaveLength(2); + expect(facts[0]).toMatchObject({ id: 1, attr: "A" }); + expect(facts[1]).toMatchObject({ id: 2, attr: "Z" }); + }); + }); + + describe("add() — stub for future rules", () => { + it("add() exists and doesn't throw on no-op placeholder", () => { + const session = new Session({ autoFire: false }); + // add() will accept RuleDefinition objects in P1.5; stub for now + expect(() => session.add({ name: "placeholder" } as never)).not.toThrow(); + }); + }); + + describe("package-internal wiring", () => { + it("_getAlpha() / _getWM() expose the session's internal networks", () => { + const session = new Session({ autoFire: false }); + expect(session._getAlpha()).toBeDefined(); + expect(session._getWM()).toBeDefined(); + }); + + it("WM insert events propagate to the AlphaNetwork", () => { + const session = new Session({ autoFire: false }); + const alpha = session._getAlpha(); + const node = alpha.buildNode({ id: null, attr: "Health" }); + session.insert(mkId(1), "Health", 100); + expect(node.memory.facts).toHaveLength(1); + expect(node.memory.facts[0]).toMatchObject({ id: 1, attr: "Health", value: 100 }); + session.retract(mkId(1), "Health"); + expect(node.memory.facts).toHaveLength(0); + }); + + it("autoFire true: fireRules() runs on insert/retract (returns 0 stub)", () => { + const session = new Session({ autoFire: true }); + // Just verify it does not throw and path is exercised + expect(() => session.insert(mkId(1), "Health", 100)).not.toThrow(); + expect(() => session.retract(mkId(1), "Health")).not.toThrow(); + }); + }); +}); diff --git a/packages/rete/src/session.ts b/packages/rete/src/session.ts new file mode 100644 index 0000000..a81089f --- /dev/null +++ b/packages/rete/src/session.ts @@ -0,0 +1,106 @@ +/** + * 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"; + +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; +} + +// Opaque rule definition — P1.5 will refine this type +export interface RuleDefinition { + readonly name: string; +} + +export class Session { + readonly #wm: WorkingMemory; + readonly #alpha: AlphaNetwork; + readonly #rules: RuleDefinition[] = []; + readonly #opts: Required; + #idCounter = 0; + + constructor(opts: SessionOptions = {}) { + this.#opts = { + autoFire: opts.autoFire ?? true, + recursionLimit: opts.recursionLimit ?? 64, + }; + 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(). */ + insert(id: EntityId, attr: AttrKey, value: FactValue): void { + this.#wm.insert(id, attr, value); + if (this.#opts.autoFire) this.fireRules(); + } + + /** Retract a fact. If autoFire, triggers fireRules(). */ + retract(id: EntityId, attr: AttrKey): void { + this.#wm.retract(id, attr); + 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 { + return this.#wm.allFacts(); + } + + /** + * Register a rule with the session. + * P1.5 will make this typed; currently accepts an opaque RuleDefinition. + */ + add(rule: RuleDefinition): void { + this.#rules.push(rule); + } + + /** + * 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. + */ + fireRules(_opts?: { recursionLimit?: number }): number { + // Stub: no production nodes wired yet (Phase 1.7+) + // Returns 0 until beta network + production nodes are integrated. + return 0; + } + + /** 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; + } +}