feat(rete): add cycle detection with recursionLimit (P1.12)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 13:58:12 -06:00
commit 04804545da
No known key found for this signature in database
4 changed files with 184 additions and 4 deletions

View file

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

View file

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

View file

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

View file

@ -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<SessionOptions>;
#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). */