diff --git a/packages/rete/src/cycle.test.ts b/packages/rete/src/cycle.test.ts new file mode 100644 index 0000000..ba8477f --- /dev/null +++ b/packages/rete/src/cycle.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { RecursionLimitExceededError } from "./cycle.js"; +import { Session } from "./session.js"; + +describe("RecursionLimitExceededError", () => { + it("is an Error subclass", () => { + const err = new RecursionLimitExceededError(10, ["ruleA", "ruleB"]); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("RecursionLimitExceededError"); + }); + + it("stores depth and activationTrace", () => { + const err = new RecursionLimitExceededError(5, ["rule1", "rule2", "rule1"]); + expect(err.depth).toBe(5); + expect(err.activationTrace).toEqual(["rule1", "rule2", "rule1"]); + }); + + it("message includes rule name(s) from trace", () => { + const err = new RecursionLimitExceededError(10, ["cycleRule"]); + expect(err.message).toContain("cycleRule"); + }); + + it("message includes the depth value", () => { + const err = new RecursionLimitExceededError(42, []); + expect(err.message).toContain("42"); + }); + + it("handles empty activationTrace", () => { + const err = new RecursionLimitExceededError(7, []); + expect(err.activationTrace).toEqual([]); + expect(err.message).toContain("7"); + }); +}); + +describe("Session.fireRules() recursion limit", () => { + it("throws RecursionLimitExceededError when recursion depth exceeds limit", () => { + const session = new Session({ autoFire: false, recursionLimit: 3 }); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any)._fireDepth = 4; + session.fireRules({ recursionLimit: 3 }); + }).toThrow(RecursionLimitExceededError); + }); + + it("throws when depth equals limit (>=)", () => { + const session = new Session({ autoFire: false, recursionLimit: 3 }); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any)._fireDepth = 3; + session.fireRules({ recursionLimit: 3 }); + }).toThrow(RecursionLimitExceededError); + }); + + it("does not throw when depth is below limit", () => { + const session = new Session({ autoFire: false, recursionLimit: 64 }); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any)._fireDepth = 0; + session.fireRules({ recursionLimit: 64 }); + }).not.toThrow(); + }); + + it("uses session default recursionLimit when opts omitted", () => { + const session = new Session({ autoFire: false, recursionLimit: 3 }); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any)._fireDepth = 5; + session.fireRules(); + }).toThrow(RecursionLimitExceededError); + }); + + it("resets recursion depth to 0 between independent fireRules() calls", () => { + const session = new Session({ autoFire: false, recursionLimit: 3 }); + expect(() => session.fireRules()).not.toThrow(); + expect(() => session.fireRules()).not.toThrow(); + expect(() => session.fireRules()).not.toThrow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((session as any)._fireDepth).toBe(0); + }); + + it("decrements depth even if fireRules body were to throw (finally)", () => { + const session = new Session({ autoFire: false, recursionLimit: 3 }); + // Force recursion guard to throw; internal counter must not be left elevated + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any)._fireDepth = 5; + expect(() => session.fireRules({ recursionLimit: 3 })).toThrow( + RecursionLimitExceededError, + ); + // After throw, depth should still be 5 (guard throws before increment) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((session as any)._fireDepth).toBe(5); + }); + + it("recursionLimit: 0 means no limit — does not throw", () => { + const session = new Session({ autoFire: false, recursionLimit: 0 }); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any)._fireDepth = 1000; + session.fireRules({ recursionLimit: 0 }); + }).not.toThrow(); + }); + + it("recursionLimit: 0 at session level also means no limit", () => { + const session = new Session({ autoFire: false, recursionLimit: 0 }); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any)._fireDepth = 1000; + session.fireRules(); + }).not.toThrow(); + }); + + it("thrown error carries the actual depth value", () => { + const session = new Session({ autoFire: false, recursionLimit: 3 }); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any)._fireDepth = 7; + session.fireRules({ recursionLimit: 3 }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(RecursionLimitExceededError); + expect((err as RecursionLimitExceededError).depth).toBe(7); + } + }); + + it("opts.recursionLimit overrides the session default", () => { + const session = new Session({ autoFire: false, recursionLimit: 64 }); + // Session default is 64 — would not throw. But opts override to 2. + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any)._fireDepth = 2; + session.fireRules({ recursionLimit: 2 }); + }).toThrow(RecursionLimitExceededError); + }); +}); diff --git a/packages/rete/src/cycle.ts b/packages/rete/src/cycle.ts new file mode 100644 index 0000000..b91264d --- /dev/null +++ b/packages/rete/src/cycle.ts @@ -0,0 +1,19 @@ +/** + * RecursionLimitExceededError — thrown when fireRules() recursion depth exceeds the limit. + * Per SPEC.md §Cycle Detection. + */ +export class RecursionLimitExceededError extends Error { + constructor( + readonly depth: number, + readonly activationTrace: readonly string[], + ) { + const traceStr = + activationTrace.length > 0 + ? ` Last activations: [${activationTrace.join(" → ")}]` + : ""; + super( + `RecursionLimitExceededError: recursion depth ${depth} exceeded.${traceStr}`, + ); + this.name = "RecursionLimitExceededError"; + } +} diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index 2c32497..d4b8718 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -25,11 +25,19 @@ export { v, defineRule } from "./builder.js"; export type { HandlerFn, PredicateFn } from "./registry.js"; export { HandlerRegistry, PredicateRegistry, UnknownHandlerError, UnknownPredicateError } from "./registry.js"; +export type { FilterSpec } from "./condition.js"; +export { FilterNode } from "./condition.js"; + export type { SessionOptions } from "./session.js"; export { Session } from "./session.js"; +export { RecursionLimitExceededError } from "./cycle.js"; + export type { SerializedRule } from "./serialize.js"; export { serialize, deserialize, RULE_SCHEMA_V1 } from "./serialize.js"; export type { Match } from "./query.js"; export { ProductionNode, query, queryAll, NoMatchError } from "./query.js"; + +export type { DerivedFact, DerivedHandler } from "./derived.js"; +export { DerivedFactProduction } from "./derived.js"; diff --git a/packages/rete/src/session.ts b/packages/rete/src/session.ts index df90283..9d06455 100644 --- a/packages/rete/src/session.ts +++ b/packages/rete/src/session.ts @@ -7,6 +7,7 @@ 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 { RecursionLimitExceededError } from "./cycle.js"; export interface SessionOptions { /** If true, fireRules() is called automatically after each insert/retract. Default: true. */ @@ -22,6 +23,13 @@ export class Session { readonly #opts: Required; #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, @@ -83,11 +91,22 @@ export class Session { * 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 { - // Stub: no production nodes wired yet (Phase 1.7+) - // Returns 0 until beta network + production nodes are integrated. - return 0; + 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). */