From 4d498b07e8f39d6cf1bec9c710e4c9d166e0ffb3 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Mon, 3 Aug 2026 16:35:19 -0700 Subject: [PATCH] feat: bounded self-recursion (transitive closure) for evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- package.json | 2 +- src/DSLCompiler.js | 4 +- src/generator/RuleGenerator.js | 114 ++++++++++++++++++++++++++- tests/Recursion.test.js | 136 +++++++++++++++++++++++++++++++++ 4 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 tests/Recursion.test.js diff --git a/package.json b/package.json index f1add7d..6d30e2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@arbiter/evidence-dsl", - "version": "1.10.0", + "version": "1.11.0", "description": "Evidence DSL v2 compiler: translates the natural Evidence DSL (ADR-000) into @arbiter/core relation configurations.", "license": "ISC", "type": "module", diff --git a/src/DSLCompiler.js b/src/DSLCompiler.js index 8a1f796..98a5b6f 100644 --- a/src/DSLCompiler.js +++ b/src/DSLCompiler.js @@ -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(); } diff --git a/src/generator/RuleGenerator.js b/src/generator/RuleGenerator.js index a61e463..21f311c 100644 --- a/src/generator/RuleGenerator.js +++ b/src/generator/RuleGenerator.js @@ -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 diff --git a/tests/Recursion.test.js b/tests/Recursion.test.js new file mode 100644 index 0000000..769f3ca --- /dev/null +++ b/tests/Recursion.test.js @@ -0,0 +1,136 @@ +/** + * tests/Recursion.test.js — bounded self-recursion (transitive closure). + * + * An evidence whose config contains a chain step referencing ITSELF is + * unrolled at compile time into a bounded transitive closure: a union of + * paths — base, hop+base, hop²+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). The base (the + * evidence's non-recursive statements) is verified as a condition step at each + * path's terminal node. + * + * A pure recursion (no base case) cannot grant and is a compile-time error. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Arbiter } from '@arbiter/core'; +import { DSLCompiler } from '../src/DSLCompiler.js'; + +const BASE = ` + definition Employee { id: string } + definition Doc { id: string } + fact can_access(user: Employee, doc: Doc) + fact reports_to(user: Employee, manager: Employee) +`; + +const RECURSIVE = ` + evidence can_access_via(user: Employee, doc: Doc) { + can_access(user, doc) + reports_to(user, *m) { can_access_via(m, doc) } limit 3 + } +`; + +function compile(dsl, name = 'rec') { + const arb = new Arbiter(); + const result = new DSLCompiler(arb).compile(dsl, name); + return { arb, result }; +} + +describe('Bounded self-recursion', () => { + it('unrolls into a union of base + bounded hop chains', () => { + const { arb, result } = compile(BASE + RECURSIVE); + assert.ok(result.success, JSON.stringify(result.errors)); + const cfg = arb.relationConfigs.get('can_access_via'); + assert.equal(cfg.type, 'logical'); + assert.ok(cfg.union, 'recursion should compile to a union of paths'); + // base + 3 hops (limit 3) + assert.equal(cfg.union.rules.length, 4); + }); + + it('grants through the base case and through multi-hop chains', () => { + const { arb, result } = compile(BASE + RECURSIVE); + assert.ok(result.success); + arb.addNode('u:1', 'Employee'); arb.addNode('m:1', 'Employee'); arb.addNode('m2:1', 'Employee'); arb.addNode('doc:9', 'Doc'); + // base + arb.addRelation('u:1', 'can_access', 'doc:9', { possibility: 1.0 }); + assert.equal(arb.check('u:1', 'can_access_via', 'doc:9').possibility, 1.0); + // 1-hop: u -> m -> doc + arb.removeRelation('u:1', 'can_access', 'doc:9'); + arb.addRelation('u:1', 'reports_to', 'm:1', { possibility: 1.0 }); + arb.addRelation('m:1', 'can_access', 'doc:9', { possibility: 0.7 }); + assert.equal(arb.check('u:1', 'can_access_via', 'doc:9').possibility, 0.7); + // 2-hop: u -> m -> m2 -> doc + arb.addRelation('m:1', 'reports_to', 'm2:1', { possibility: 1.0 }); + arb.addRelation('m2:1', 'can_access', 'doc:9', { possibility: 0.5 }); + // union takes the best path: max(0.7, 0.5) = 0.7 + assert.equal(arb.check('u:1', 'can_access_via', 'doc:9').possibility, 0.7); + // 2-hop alone (remove the 1-hop can_access) + arb.removeRelation('m:1', 'can_access', 'doc:9'); + assert.equal(arb.check('u:1', 'can_access_via', 'doc:9').possibility, 0.5); + }); + + it('enforces the recursion depth limit', () => { + const { arb, result } = compile(` + ${BASE} + evidence can_access_via(user: Employee, doc: Doc) { + can_access(user, doc) + reports_to(user, *m) { can_access_via(m, doc) } limit 2 + } + `); + assert.ok(result.success); + arb.addNode('u:1', 'Employee'); arb.addNode('m:1', 'Employee'); arb.addNode('m2:1', 'Employee'); arb.addNode('m3:1', 'Employee'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'reports_to', 'm:1', { possibility: 1.0 }); + arb.addRelation('m:1', 'reports_to', 'm2:1', { possibility: 1.0 }); + arb.addRelation('m2:1', 'reports_to', 'm3:1', { possibility: 1.0 }); + arb.addRelation('m2:1', 'can_access', 'doc:9', { possibility: 0.5 }); // 2 hops + arb.addRelation('m3:1', 'can_access', 'doc:9', { possibility: 0.9 }); // 3 hops + assert.equal(arb.check('u:1', 'can_access_via', 'doc:9').possibility, 0.5); + arb.removeRelation('m2:1', 'can_access', 'doc:9'); + // only the 3-hop path remains — beyond the limit -> denied + assert.equal(arb.check('u:1', 'can_access_via', 'doc:9').possibility, 0); + }); + + it('uses the compiler maxRecursionDepth default when no limit is given', () => { + const dsl = ` + ${BASE} + evidence can_access_via(user: Employee, doc: Doc) { + can_access(user, doc) + reports_to(user, *m) { can_access_via(m, doc) } + } + `; + const { arb, result } = compile(dsl); + assert.ok(result.success, JSON.stringify(result.errors)); + // default depth 3 -> base + 3 hops + assert.equal(arb.relationConfigs.get('can_access_via').union.rules.length, 4); + // a deeper path (4 hops) is not granted + arb.addNode('u:1', 'Employee'); arb.addNode('m:1', 'Employee'); arb.addNode('m2:1', 'Employee'); arb.addNode('m3:1', 'Employee'); arb.addNode('m4:1', 'Employee'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'reports_to', 'm:1', { possibility: 1.0 }); + arb.addRelation('m:1', 'reports_to', 'm2:1', { possibility: 1.0 }); + arb.addRelation('m2:1', 'reports_to', 'm3:1', { possibility: 1.0 }); + arb.addRelation('m3:1', 'reports_to', 'm4:1', { possibility: 1.0 }); + arb.addRelation('m4:1', 'can_access', 'doc:9', { possibility: 1.0 }); + assert.equal(arb.check('u:1', 'can_access_via', 'doc:9').possibility, 0, '4-hop path exceeds default depth'); + }); + + it('rejects a pure recursion with no base case', () => { + const { result } = compile(` + ${BASE} + evidence can_access_via(user: Employee, doc: Doc) { + reports_to(user, *m) { can_access_via(m, doc) } limit 3 + } + `); + assert.equal(result.success, false); + assert.ok(result.errors.some(e => /no base case/.test(e)), JSON.stringify(result.errors)); + }); + + it('keeps mutual (non-self) cycles a compile error', () => { + const { result } = compile(` + ${BASE} + fact peer(user: Employee, other: Employee) + evidence a(user: Employee, doc: Doc) { peer(user, *p) { b(p, doc) } } + evidence b(user: Employee, doc: Doc) { peer(user, *p) { a(p, doc) } } + `); + assert.equal(result.success, false); + assert.ok(result.errors.some(e => /[Cc]yclic/.test(e)), JSON.stringify(result.errors)); + }); +});