feat(rete): add Session lifecycle (P1.4)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 13:39:54 -06:00
commit 76b21ddd5f
No known key found for this signature in database
3 changed files with 227 additions and 0 deletions

View file

@ -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";

View file

@ -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();
});
});
});

View file

@ -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<SessionOptions>;
#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<WorkingMemory["allFacts"]> {
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;
}
}