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 { 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";
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue