feat(rete): add Session lifecycle (P1.4)
This commit is contained in:
parent
2501edd886
commit
76b21ddd5f
3 changed files with 227 additions and 0 deletions
|
|
@ -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";
|
||||
|
|
|
|||
118
packages/rete/src/session.test.ts
Normal file
118
packages/rete/src/session.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/rete/src/session.ts
Normal file
106
packages/rete/src/session.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue