feat: bounded self-recursion (transitive closure) for evidence
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:
+2
-2
@@ -7,10 +7,10 @@ import { validateDslText } from './validation/DSLValidation.js';
|
||||
* Compiles DSL text into rule configurations for the zanzibar-graph system
|
||||
*/
|
||||
export class DSLCompiler {
|
||||
constructor(arbiter) {
|
||||
constructor(arbiter, options = {}) {
|
||||
this.arbiter = arbiter;
|
||||
this.parser = parse;
|
||||
this.generator = new RuleGenerator(arbiter);
|
||||
this.generator = new RuleGenerator(arbiter, options);
|
||||
this.compiledPrograms = new Map();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user