2026-08-03 11:17:30 -07:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2026-08-04 17:51:13 -07:00
|
|
|
import { DSLRuntime } from '../src/runtime/DSLRuntime.js';
|
|
|
|
|
import { DSLValueGraph } from '../src/value-graph/DSLValueGraph.js';
|
2026-08-03 11:17:30 -07:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-08-04 17:51:13 -07:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-03 11:17:30 -07:00
|
|
|
});
|