feat: bounded self-recursion (transitive closure) for evidence
CI / publish (push) Successful in 9s
CI / test (push) Successful in 18s

An evidence whose config contains a chain step referencing ITSELF is now
unrolled at compile time into a bounded transitive closure: a union of paths
— base, hop+base, hop²+base, …, hop^N+base — where `hop` is the recursive
chain's steps before the self-reference and the depth N comes from the
pattern's `limit N` (or the compiler's maxRecursionDepth default, 3). The
base (the evidence's non-recursive statements) is verified as a condition
step at each path's terminal node, so the engine needs no new machinery.

- Chain configs carry the pattern's `limit` as maxDepth.
- resolveEvidenceReferences detects a self-reference (_findSelfReference),
  extracts the base (_extractBase), and unrolls (_unrollRecursiveEvidence).
- Pure recursion with no base case is a compile-time error; mutual cycles
  between distinct evidence remain a compile-time error.

Example: can_access_via = can_access OR (reports_to + can_access_via) up to
the declared limit grants access inherited up a reporting chain.

Tests: Recursion (unroll shape, base + multi-hop grants, depth-limit
enforcement, default depth, pure-recursion error, mutual-cycle guard).
This commit is contained in:
John Dvorak
2026-08-03 16:35:19 -07:00
parent 9111c4b20d
commit 4d498b07e8
4 changed files with 249 additions and 7 deletions
+110 -4
View File
@@ -5,12 +5,14 @@ import { ProgramNode, DefinitionNode, FactNode, EvidenceNode, MeasureNode, Direc
* Generates rule configurations that interface with the existing rule system
*/
export class RuleGenerator {
constructor(arbiter) {
constructor(arbiter, options = {}) {
this.arbiter = arbiter;
this.generatedRules = new Map();
this.errors = [];
this.dependencyIndex = new Map();
this.evidenceNames = new Set();
// Default depth for bounded self-recursion when the DSL `limit N` is absent.
this.maxRecursionDepth = options.maxRecursionDepth ?? 3;
}
/**
@@ -629,7 +631,8 @@ export class RuleGenerator {
type: 'chain',
steps,
aggregator: 'max',
collectValues: true
collectValues: true,
maxDepth: patternMatch.limit || null
};
}
@@ -670,7 +673,10 @@ export class RuleGenerator {
type: 'chain',
steps,
aggregator: 'max',
collectValues: true
collectValues: true,
// Carry the pattern's `limit N` as a max depth so a self-referential
// chain step can be unrolled into bounded transitive closure.
maxDepth: patternMatch.limit || null
};
}
@@ -990,13 +996,113 @@ export class RuleGenerator {
resolveEvidenceReferences() {
for (const name of this.evidenceNames) {
if (!this.generatedRules.has(name)) continue;
let config = this.generatedRules.get(name);
// Bounded self-recursion (transitive closure): an evidence whose config
// contains a chain step referencing ITSELF is unrolled into a union of
// bounded paths — base, hop+base, hop²+base, …, hop^N+base — where `hop`
// is the recursive chain's steps before the self-reference and the depth
// N comes from the pattern's `limit N` (or the compiler default).
const selfRef = this._findSelfReference(config, name);
if (selfRef) {
const depth = selfRef.limit ?? this.maxRecursionDepth;
const unrolled = this._unrollRecursiveEvidence(name, config, selfRef.hop, depth);
if (unrolled) {
config = unrolled;
this.generatedRules.set(name, config);
}
}
const stack = new Set([name]);
const resolved = this._resolveRule(this.generatedRules.get(name), stack);
const resolved = this._resolveRule(config, stack);
this.generatedRules.set(name, resolved);
this._annotateDependencies(name, resolved);
}
}
/**
* Find the first chain step within `config` that references `name` (a
* self-reference). Returns { hop, limit } where hop is the chain's steps
* before the self-reference and limit is the chain's declared max depth.
* Returns null when there is no self-reference.
*/
_findSelfReference(config, name) {
let found = null;
const walk = (rule) => {
if (!rule || typeof rule !== 'object' || found) return;
if (rule.type === 'chain' && Array.isArray(rule.steps)) {
const idx = rule.steps.findIndex(s => (typeof s === 'string' ? s : s && s.relation) === name);
if (idx >= 0) {
const lim = rule.maxDepth;
const limit = lim && typeof lim === 'object' ? lim.value : lim;
found = { hop: rule.steps.slice(0, idx), limit: Number.isFinite(limit) ? limit : null };
return;
}
}
for (const key of ['union', 'intersection', 'exclusion', 'never', 'always', 'requires', 'when', 'unless']) {
const node = rule[key];
if (!node) continue;
if (Array.isArray(node.rules)) for (const c of node.rules) walk(c);
if (Array.isArray(node.union?.rules)) for (const c of node.union.rules) walk(c);
if (Array.isArray(node.intersection?.rules)) for (const c of node.intersection.rules) walk(c);
if (node.direct) walk(node.direct);
if (node.rule) walk(node.rule);
}
};
walk(config);
return found;
}
/**
* Unroll a self-recursive evidence into a bounded transitive closure.
* The recursive chain is removed from the config; the remainder is the base.
* Result: union([base, hop+base, hop²+base, …, hop^depth+base]) where the
* base is verified as a condition step at each path's terminal node.
*/
_unrollRecursiveEvidence(name, config, hop, depth) {
if (hop.length === 0) {
this.errors.push(`Recursive evidence '${name}' has an empty recursion hop (no steps before the self-reference).`);
return null;
}
const base = this._extractBase(config, name);
if (!base) {
this.errors.push(`Recursive evidence '${name}' has no base case — pure recursion cannot grant. Add a non-recursive statement.`);
return null;
}
const rules = [this._deepCloneRule(base)];
for (let d = 1; d <= depth; d++) {
const steps = [];
for (let h = 0; h < d; h++) steps.push(...hop.map(s => this._deepCloneRule(s)));
steps.push({ rule: this._deepCloneRule(base), conditionStep: true });
rules.push({ type: 'chain', steps, aggregator: 'max', collectValues: true });
}
return { type: 'logical', union: { rules, aggregator: 'max' } };
}
/**
* Remove the recursive chain (the chain containing a self-reference) from an
* evidence config and return the remainder as the base case. Returns null if
* there is no base (pure recursion).
*/
_extractBase(config, name) {
if (config.type === 'chain') {
const hasSelf = (config.steps || []).some(s => (typeof s === 'string' ? s : s && s.relation) === name);
return hasSelf ? null : this._deepCloneRule(config);
}
if (config.type === 'logical' && config.intersection) {
const remaining = (config.intersection.rules || []).filter(r => {
// keep rules that are not (or do not contain) the recursive chain
return !this._containsSelfReference(r, name);
});
if (remaining.length === 0) return null;
if (remaining.length === 1) return this._deepCloneRule(remaining[0]);
return { type: 'logical', intersection: { rules: remaining.map(r => this._deepCloneRule(r)), aggregator: config.intersection.aggregator || 'min' } };
}
return this._containsSelfReference(config, name) ? null : this._deepCloneRule(config);
}
_containsSelfReference(rule, name) {
return this._findSelfReference(rule, name) !== null;
}
/**
* Recursively rewrite a rule tree, inlining references to derived evidence
* configs. `stack` holds the evidence names currently being expanded so a