/** * tests/ChainStepComposition.test.js — evidence composition inside CHAIN * steps. A chain step that references a derived evidence is expanded at * compile time: * - a DIRECT evidence step → renamed to its underlying relation * (member_of(user,*g){ group_read(g,doc) } where group_read = can_view * becomes step 'can_view'); * - a CHAIN evidence step → its steps are spliced into the parent chain * (a sub-path flattens into the linear source→…→object traversal); * - a DEFEASIBLE / LOGICAL / COMPARATOR evidence step is not an edge * traversal and is rejected at compile time; * - cycles and self-references through chain steps are compile errors. */ 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 member_of(user: Employee, group: Group) fact group_has(group: Group, sub: Group) fact can_view(group: Group, doc: Doc) fact can_access(group: Group, doc: Doc) fact banned(group: Group) `; function compile(dsl, name = 'chain-compose') { const arb = new Arbiter(); const compiler = new DSLCompiler(arb); const result = compiler.compile(dsl, name); return { arb, result }; } describe('Chain step composition', () => { it('renames a direct-evidence chain step to its underlying relation', () => { const { arb, result } = compile(` ${DEFS} evidence group_read(group: Group, doc: Doc) { can_view(group, doc) } evidence can_via(user: Employee, doc: Doc) { member_of(user, *g) { group_read(g, doc) } } `); assert.ok(result.success, JSON.stringify(result.errors)); // step 'group_read' → 'can_view' assert.deepEqual(arb.relationConfigs.get('can_via').steps, ['member_of', 'can_view']); 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_view', 'doc:9', { possibility: 0.7 }); assert.equal(arb.check('u:1', 'can_via', 'doc:9').possibility, 0.7); }); it('splices a chain-evidence step into the parent chain', () => { const { arb, result } = compile(` ${DEFS} evidence group_enter(group: Group, doc: Doc) { group_has(group, *s) { can_access(s, doc) } } evidence can_deep(user: Employee, doc: Doc) { member_of(user, *g) { group_enter(g, doc) } } `); assert.ok(result.success, JSON.stringify(result.errors)); // step 'group_enter' → its steps [group_has, can_access] assert.deepEqual(arb.relationConfigs.get('can_deep').steps, ['member_of', 'group_has', 'can_access']); arb.addNode('u:1', 'Employee'); arb.addNode('g:1', 'Group'); arb.addNode('g2:2', 'Group'); arb.addNode('doc:9', 'Doc'); arb.addRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); arb.addRelation('g:1', 'group_has', 'g2:2', { possibility: 0.9 }); arb.addRelation('g2:2', 'can_access', 'doc:9', { possibility: 0.8 }); assert.equal(arb.check('u:1', 'can_deep', 'doc:9').possibility, 0.8); }); it('expands a chain step whose direct evidence is itself composed', () => { const { arb, result } = compile(` ${DEFS} evidence group_view(group: Group, doc: Doc) { can_view(group, doc) } evidence group_read(group: Group, doc: Doc) { group_view(group, doc) } evidence can_via(user: Employee, doc: Doc) { member_of(user, *g) { group_read(g, doc) } } `); assert.ok(result.success, JSON.stringify(result.errors)); assert.deepEqual(arb.relationConfigs.get('can_via').steps, ['member_of', 'can_view']); 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_view', 'doc:9', { possibility: 0.6 }); assert.equal(arb.check('u:1', 'can_via', 'doc:9').possibility, 0.6); }); it('rejects a defeasible/logical evidence as a chain step', () => { const { result } = compile(` ${DEFS} evidence gated(group: Group, doc: Doc) { WHEN can_view(group, doc) UNLESS banned(group) } evidence can_via(user: Employee, doc: Doc) { member_of(user, *g) { gated(g, doc) } } `); assert.equal(result.success, false); assert.ok(result.errors.some(e => /Chain step 'gated'/.test(e)), JSON.stringify(result.errors)); }); it('rejects a mutual cycle through chain steps', () => { const { result } = compile(` ${DEFS} evidence cyc_a(group: Group, doc: Doc) { group_has(group, *g) { cyc_b(g, doc) } } evidence cyc_b(group: Group, doc: Doc) { cyc_a(group, doc) } `); assert.equal(result.success, false); assert.ok(result.errors.some(e => /[Cc]yclic/.test(e)), JSON.stringify(result.errors)); }); it('rejects a self-reference through its own chain step', () => { const { result } = compile(` ${DEFS} evidence cyc_c(group: Group, doc: Doc) { group_has(group, *g) { cyc_c(g, doc) } } `); assert.equal(result.success, false); assert.ok(result.errors.some(e => /[Cc]yclic/.test(e)), JSON.stringify(result.errors)); }); it('re-derives transitive dependencies through expanded chain steps', () => { const { arb, result } = compile(` ${DEFS} evidence group_read(group: Group, doc: Doc) { can_view(group, doc) } evidence can_via(user: Employee, doc: Doc) { member_of(user, *g) { group_read(g, doc) } } `); assert.ok(result.success, JSON.stringify(result.errors)); // dependsOn reflects the expanded step, not the evidence reference assert.deepEqual(arb.relationConfigs.get('can_via').dependsOn, ['member_of', 'can_view']); }); });