/** * tests/EvidenceComposition.test.js — referencing a derived evidence as a * sub-rule of another evidence (WHEN can_read(user, doc) where can_read is * itself an evidence). * * Composition is resolved at COMPILE time: the generator inlines each * evidence reference with the referenced evidence's own config (a linker * pass that handles forward references and rejects cycles), so the engine * evaluates a fully-resolved, acyclic config tree. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { Arbiter } from '@arbiter/core'; import { DSLCompiler } from '../src/DSLCompiler.js'; import { DSLRuntime } from '../src/runtime/DSLRuntime.js'; import { DSLValueGraph } from '../src/value-graph/DSLValueGraph.js'; const DEFS = ` definition Employee { id: string } definition Group { id: string } definition Doc { id: string } fact owns(user: Employee, doc: Doc) fact *trusted(user: Employee) fact member_of(user: Employee, group: Group) fact can_access(group: Group, doc: Doc) `; function compile(dsl, name = 'compose') { const arb = new Arbiter(); const compiler = new DSLCompiler(arb); const result = compiler.compile(dsl, name); return { arb, result }; } describe('Evidence composition', () => { it('composes a direct evidence into another evidence', () => { const { arb, result } = compile(` ${DEFS} evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } evidence can_browse(user: Employee, doc: Doc) { can_read(user, doc) } `); assert.ok(result.success, JSON.stringify(result.errors)); arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); arb.addRelation('u:1', 'owns', 'doc:9', { possibility: 0.8 }); const res = arb.check('u:1', 'can_browse', 'doc:9'); assert.equal(res.possibility, 0.8); // The reference is inlined to the underlying fact config. assert.equal(arb.relationConfigs.get('can_browse').type, 'direct'); assert.equal(arb.relationConfigs.get('can_browse').relation, 'owns'); }); it('composes an evidence inside a defeasible WHEN/UNLESS', () => { const { arb, result } = compile(` ${DEFS} evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } evidence can_open(user: Employee, doc: Doc) { WHEN can_read(user, doc) UNLESS trusted(user) } `); assert.ok(result.success, JSON.stringify(result.errors)); arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); arb.addRelation('u:1', 'owns', 'doc:9', { possibility: 0.9 }); assert.equal(arb.check('u:1', 'can_open', 'doc:9').possibility, 0.9); arb.addRelation('u:1', 'trusted', 'u:1', { possibility: 1.0 }); const denied = arb.check('u:1', 'can_open', 'doc:9'); assert.equal(denied.possibility, 0); assert.equal(denied.reason, 'defeated_by_unless'); }); it('composes a chain evidence into another evidence', () => { const { arb, result } = compile(` ${DEFS} evidence can_enter(user: Employee, doc: Doc) { member_of(user, *g) { can_access(g, doc) } } evidence can_work(user: Employee, doc: Doc) { can_enter(user, doc) } `); assert.ok(result.success, JSON.stringify(result.errors)); arb.addNode('u:1', 'Employee'); arb.addNode('g:1', 'Group'); arb.addNode('doc:9', 'Doc'); arb.addRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); arb.addRelation('g:1', 'can_access', 'doc:9', { possibility: 0.7 }); const res = arb.check('u:1', 'can_work', 'doc:9'); assert.equal(res.possibility, 0.7); assert.equal(arb.relationConfigs.get('can_work').type, 'chain'); }); it('composes transitively (A → B → fact) and re-derives dependencies', () => { const { arb, result } = compile(` ${DEFS} evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } evidence can_browse(user: Employee, doc: Doc) { can_read(user, doc) } evidence can_open(user: Employee, doc: Doc) { can_browse(user, doc) } `); assert.ok(result.success, JSON.stringify(result.errors)); arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); arb.addRelation('u:1', 'owns', 'doc:9', { possibility: 0.6 }); assert.equal(arb.check('u:1', 'can_open', 'doc:9').possibility, 0.6); assert.deepEqual(arb.relationConfigs.get('can_open').dependsOn, ['owns']); }); it('composes a value-carrying evidence and preserves subject-as-object scope', () => { const { arb, result } = compile(` ${DEFS} fact *user_risk(user: Employee, value: number) evidence risk_ok(user: Employee, doc: Doc) { user_risk(user, 1) } evidence can_proceed(user: Employee, doc: Doc) { risk_ok(user, doc) } `); assert.ok(result.success, JSON.stringify(result.errors)); arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); arb.addRelation('u:1', 'user_risk', 'u:1', { possibility: 1.0, value: 1 }); const res = arb.check('u:1', 'can_proceed', 'doc:9'); assert.equal(res.possibility, 1); }); it('composes evidence inside a comparator operand', () => { const { arb, result } = compile(` ${DEFS} fact *user_risk(user: Employee, value: number) fact *risk_limit(doc: Doc, value: number) evidence user_risk_ok(user: Employee, doc: Doc) { user_risk(user, 1) } evidence can_proceed(user: Employee, doc: Doc) { user_risk_ok(user, doc) } `); assert.ok(result.success, JSON.stringify(result.errors)); arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); arb.addRelation('u:1', 'user_risk', 'u:1', { possibility: 1.0, value: 1 }); assert.equal(arb.check('u:1', 'can_proceed', 'doc:9').possibility, 1); }); it('rejects cyclic evidence references at compile time', () => { const { result } = compile(` ${DEFS} evidence a(user: Employee, doc: Doc) { b(user, doc) } evidence b(user: Employee, doc: Doc) { a(user, doc) } `); assert.equal(result.success, false); assert.ok(result.errors.some(e => /[Cc]yclic/.test(e)), JSON.stringify(result.errors)); }); it('rejects self-referencing evidence at compile time', () => { const { result } = compile(` ${DEFS} evidence a(user: Employee, doc: Doc) { a(user, doc) } `); assert.equal(result.success, false); assert.ok(result.errors.some(e => /[Cc]yclic/.test(e)), JSON.stringify(result.errors)); }); it('keeps the referenced evidence checkable in its own right', () => { const { arb, result } = compile(` ${DEFS} evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } evidence can_browse(user: Employee, doc: Doc) { can_read(user, doc) } `); assert.ok(result.success); arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); arb.addRelation('u:1', 'owns', 'doc:9', { possibility: 0.5 }); assert.equal(arb.check('u:1', 'can_read', 'doc:9').possibility, 0.5); assert.equal(arb.check('u:1', 'can_browse', 'doc:9').possibility, 0.5); }); describe('measure + evidence composition', () => { const M_DEFS = ` definition Employee { id: string } measure budget_used(user: Employee) { } PROVIDES number measure budget_limit(user: Employee) { } PROVIDES number evidence can_use(user: Employee, doc: string) { budget_used(user) <= budget_limit(user) } `; it('compiles an evidence comparator over measure valueRelations', () => { const { arb, result } = compile(M_DEFS, 'm+e-compile'); assert.ok(result.success, JSON.stringify(result.errors)); const cfg = arb.relationConfigs.get('can_use'); assert.equal(cfg.type, 'relational_comparator'); assert.equal(cfg.left.valueRelation, 'budget_used'); assert.equal(cfg.right.valueRelation, 'budget_limit'); assert.deepEqual(cfg.dependsOn, ['budget_used', 'budget_limit']); assert.equal(cfg._needsValues, true); }); it('evaluates with measure values from registered providers (measure → evidence)', async () => { const rt = new DSLRuntime(new Arbiter()).compile(M_DEFS, 'm+e-provider'); const dvg = new DSLValueGraph(rt); dvg.attach(); // wires runtime.measure through the value-graph rt.registerMeasure('budget_used', async () => ({ value: 900 })); rt.registerMeasure('budget_limit', async () => ({ value: 1000 })); rt.arbiter.addNode('u:1', 'Employee'); // Both systems live: the measure resolves standalone... assert.equal((await rt.measure('budget_used', { user: 'u:1' })).value, 900); // ...and composes into the evidence comparator. const allow = await rt.check('u:1', 'can_use', 'doc:9'); assert.equal(allow.possibility, 1); assert.equal(allow.reason, 'allow_rule_matched'); assert.ok(allow.providedFacts.includes('budget_used'), 'measure injected as a partial-graph edge'); }); it('evaluates with measure values stored in the value-graph (setValue)', async () => { const rt = new DSLRuntime(new Arbiter()).compile(M_DEFS, 'm+e-vg'); const dvg = new DSLValueGraph(rt); dvg.attach(); dvg.setValue('budget_used', { __subject: 'u:1', user: 'u:1' }, 900); dvg.setValue('budget_limit', { __subject: 'u:1', user: 'u:1' }, 1000); rt.arbiter.addNode('u:1', 'Employee'); const allow = await rt.check('u:1', 'can_use', 'doc:9'); assert.equal(allow.possibility, 1, 'in-budget evidence allowed from graph-sourced measures'); // Over budget → the comparator denies. const rt2 = new DSLRuntime(new Arbiter()).compile(M_DEFS, 'm+e-vg2'); const dvg2 = new DSLValueGraph(rt2); dvg2.attach(); dvg2.setValue('budget_used', { __subject: 'u:1', user: 'u:1' }, 1200); dvg2.setValue('budget_limit', { __subject: 'u:1', user: 'u:1' }, 1000); rt2.arbiter.addNode('u:1', 'Employee'); const deny = await rt2.check('u:1', 'can_use', 'doc:9'); assert.equal(deny.possibility, 0); assert.equal(deny.reason, 'values_compared_comparison_false'); }); }); });