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:
@@ -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