feat: evidence composition — compile-time reference resolution for evidence sub-rules
An evidence may now reference another derived evidence as a sub-rule (WHEN can_read(user, doc) where can_read is itself an evidence). Resolution is a compile-time linker pass: after every evidence config is generated, each direct reference to an evidence is inlined with that evidence's own (resolved) config, so the engine evaluates a fully-resolved, acyclic config tree. - resolveEvidenceReferences(): post-generation pass over evidence configs, recursing into logical/defeasible containers (when/unless/never/always/ requires/union/intersection), always.direct nests, and comparator operands. - Forward references resolve (all configs exist before the pass runs). - Cycles and self-references are compile-time errors. - _subjectAsObject scoping is preserved through inlining. - dependsOn is recomputed after resolution, so partial-graph requirements reach transitively through composed evidence. - buildDirectRule/buildPredicateRule now apply subject-scoping to top-level PredicateCall evidence bodies (latent gap, previously missed). - validation: reject relation names shared across facts/sources/evidence/ measures (a collision silently overwrote configs and read as a false cycle). Tests: EvidenceComposition (9), DSLRuntime transitive requiredFacts, oracle campaign composition construct, illegal-mutations cycle + cross-kind cases.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user