Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d498b07e8 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@arbiter/evidence-dsl",
|
"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.",
|
"description": "Evidence DSL v2 compiler: translates the natural Evidence DSL (ADR-000) into @arbiter/core relation configurations.",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+2
-2
@@ -7,10 +7,10 @@ import { validateDslText } from './validation/DSLValidation.js';
|
|||||||
* Compiles DSL text into rule configurations for the zanzibar-graph system
|
* Compiles DSL text into rule configurations for the zanzibar-graph system
|
||||||
*/
|
*/
|
||||||
export class DSLCompiler {
|
export class DSLCompiler {
|
||||||
constructor(arbiter) {
|
constructor(arbiter, options = {}) {
|
||||||
this.arbiter = arbiter;
|
this.arbiter = arbiter;
|
||||||
this.parser = parse;
|
this.parser = parse;
|
||||||
this.generator = new RuleGenerator(arbiter);
|
this.generator = new RuleGenerator(arbiter, options);
|
||||||
this.compiledPrograms = new Map();
|
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
|
* Generates rule configurations that interface with the existing rule system
|
||||||
*/
|
*/
|
||||||
export class RuleGenerator {
|
export class RuleGenerator {
|
||||||
constructor(arbiter) {
|
constructor(arbiter, options = {}) {
|
||||||
this.arbiter = arbiter;
|
this.arbiter = arbiter;
|
||||||
this.generatedRules = new Map();
|
this.generatedRules = new Map();
|
||||||
this.errors = [];
|
this.errors = [];
|
||||||
this.dependencyIndex = new Map();
|
this.dependencyIndex = new Map();
|
||||||
this.evidenceNames = new Set();
|
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',
|
type: 'chain',
|
||||||
steps,
|
steps,
|
||||||
aggregator: 'max',
|
aggregator: 'max',
|
||||||
collectValues: true
|
collectValues: true,
|
||||||
|
maxDepth: patternMatch.limit || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,7 +673,10 @@ export class RuleGenerator {
|
|||||||
type: 'chain',
|
type: 'chain',
|
||||||
steps,
|
steps,
|
||||||
aggregator: 'max',
|
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() {
|
resolveEvidenceReferences() {
|
||||||
for (const name of this.evidenceNames) {
|
for (const name of this.evidenceNames) {
|
||||||
if (!this.generatedRules.has(name)) continue;
|
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 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.generatedRules.set(name, resolved);
|
||||||
this._annotateDependencies(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
|
* Recursively rewrite a rule tree, inlining references to derived evidence
|
||||||
* configs. `stack` holds the evidence names currently being expanded so a
|
* configs. `stack` holds the evidence names currently being expanded so a
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user