Files
evidence-dsl/tests/Recursion.test.js
John Dvorak 4d498b07e8
CI / publish (push) Successful in 9s
CI / test (push) Successful in 18s
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).
2026-08-03 16:35:19 -07:00

137 lines
6.1 KiB
JavaScript

/**
* 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));
});
});