feat: evidence composition — compile-time reference resolution for evidence sub-rules
CI / publish (push) Successful in 11s
CI / test (push) Successful in 15s

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:
John Dvorak
2026-08-03 11:17:30 -07:00
parent 0a744329e6
commit 88f10f9db4
7 changed files with 371 additions and 42 deletions
+154
View File
@@ -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);
});
});