feat(rete): add cycle detection with recursionLimit (P1.12)
This commit is contained in:
parent
572ffd27e0
commit
04804545da
4 changed files with 184 additions and 4 deletions
134
packages/rete/src/cycle.test.ts
Normal file
134
packages/rete/src/cycle.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
packages/rete/src/cycle.ts
Normal file
19
packages/rete/src/cycle.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,11 +25,19 @@ export { v, defineRule } from "./builder.js";
|
||||||
export type { HandlerFn, PredicateFn } from "./registry.js";
|
export type { HandlerFn, PredicateFn } from "./registry.js";
|
||||||
export { HandlerRegistry, PredicateRegistry, UnknownHandlerError, UnknownPredicateError } 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 type { SessionOptions } from "./session.js";
|
||||||
export { Session } from "./session.js";
|
export { Session } from "./session.js";
|
||||||
|
|
||||||
|
export { RecursionLimitExceededError } from "./cycle.js";
|
||||||
|
|
||||||
export type { SerializedRule } from "./serialize.js";
|
export type { SerializedRule } from "./serialize.js";
|
||||||
export { serialize, deserialize, RULE_SCHEMA_V1 } from "./serialize.js";
|
export { serialize, deserialize, RULE_SCHEMA_V1 } from "./serialize.js";
|
||||||
|
|
||||||
export type { Match } from "./query.js";
|
export type { Match } from "./query.js";
|
||||||
export { ProductionNode, query, queryAll, NoMatchError } from "./query.js";
|
export { ProductionNode, query, queryAll, NoMatchError } from "./query.js";
|
||||||
|
|
||||||
|
export type { DerivedFact, DerivedHandler } from "./derived.js";
|
||||||
|
export { DerivedFactProduction } from "./derived.js";
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { WorkingMemory, type AttrKey, type FactValue } from "./wm.js";
|
||||||
import { AlphaNetwork } from "./alpha.js";
|
import { AlphaNetwork } from "./alpha.js";
|
||||||
import type { EntityId } from "./schema.js";
|
import type { EntityId } from "./schema.js";
|
||||||
import type { RuleDefinition } from "./builder.js";
|
import type { RuleDefinition } from "./builder.js";
|
||||||
|
import { RecursionLimitExceededError } from "./cycle.js";
|
||||||
|
|
||||||
export interface SessionOptions {
|
export interface SessionOptions {
|
||||||
/** If true, fireRules() is called automatically after each insert/retract. Default: true. */
|
/** If true, fireRules() is called automatically after each insert/retract. Default: true. */
|
||||||
|
|
@ -22,6 +23,13 @@ export class Session {
|
||||||
readonly #opts: Required<SessionOptions>;
|
readonly #opts: Required<SessionOptions>;
|
||||||
#idCounter = 0;
|
#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 = {}) {
|
constructor(opts: SessionOptions = {}) {
|
||||||
this.#opts = {
|
this.#opts = {
|
||||||
autoFire: opts.autoFire ?? true,
|
autoFire: opts.autoFire ?? true,
|
||||||
|
|
@ -83,11 +91,22 @@ export class Session {
|
||||||
* Fire all pending rule activations in deterministic order.
|
* Fire all pending rule activations in deterministic order.
|
||||||
* Returns the count of rule firings.
|
* Returns the count of rule firings.
|
||||||
* Per SPEC.md §Conflict Resolution: salience desc → specificity desc → insertion order asc.
|
* 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 {
|
fireRules(opts?: { recursionLimit?: number }): number {
|
||||||
// Stub: no production nodes wired yet (Phase 1.7+)
|
const limit = opts?.recursionLimit ?? this.#opts.recursionLimit;
|
||||||
// Returns 0 until beta network + production nodes are integrated.
|
if (limit > 0 && this._fireDepth >= limit) {
|
||||||
return 0;
|
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). */
|
/** Expose AlphaNetwork for wiring by beta/join nodes (package-internal). */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue