import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { Arbiter } from '../../src/core/Arbiter.js'; describe('Partial graph overlay', () => { test('direct relation resolves from partial graph', () => { const arbiter = new Arbiter(); arbiter.addNode('user:1', 'user'); arbiter.addNode('doc:1', 'doc'); arbiter.setRelationConfig('can_read', { type: 'direct' }); const missing = arbiter.check('user:1', 'can_read', 'doc:1'); assert.strictEqual(missing.possibility, 0); const partialGraph = { relations: [ { src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 1.0 } ] }; const result = arbiter.check('user:1', 'can_read', 'doc:1', { partialGraph }); assert.strictEqual(result.possibility, 1.0); }); test('persistent relation wins when partial conflicts on same triple', () => { const arbiter = new Arbiter(); arbiter.addNode('user:1', 'user'); arbiter.addNode('doc:1', 'doc'); arbiter.setRelationConfig('can_read', { type: 'direct' }); arbiter.addRelation('user:1', 'can_read', 'doc:1', 0.2); const baseline = arbiter.check('user:1', 'can_read', 'doc:1'); assert.strictEqual(baseline.possibility, 0.2); const partialGraph = { relations: [ { src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 0.9 } ] }; const result = arbiter.check('user:1', 'can_read', 'doc:1', { partialGraph }); assert.strictEqual(result.possibility, 0.2); }); test('mixed overlay precedence reports persistent source and audit conflict', () => { const arbiter = new Arbiter(); arbiter.addNode('user:1', 'user'); arbiter.addNode('doc:1', 'doc'); arbiter.setRelationConfig('can_read', { type: 'direct' }); arbiter.addRelation('user:1', 'can_read', 'doc:1', 0.2); const partialGraph = { relations: [ { src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 0.9 } ] }; const result = arbiter.explain('user:1', 'can_read', 'doc:1', { partialGraph }); assert.strictEqual(result.decision.possibility, 0.2); assert.strictEqual(result.trace.path[0].source, 'persistent'); assert.strictEqual(result.audit.provenance.partial_fact_used, false); assert.strictEqual(result.audit.provenance.provenance_conflicts.length, 1); }); test('explain marks partial provenance', () => { const arbiter = new Arbiter(); arbiter.addNode('user:1', 'user'); arbiter.addNode('doc:1', 'doc'); arbiter.setRelationConfig('can_read', { type: 'direct' }); const partialGraph = { relations: [ { src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 1.0 } ] }; const result = arbiter.explain('user:1', 'can_read', 'doc:1', { partialGraph }); assert.strictEqual(result.trace.path[0].source, 'partial'); }); test('partial nodes participate in chain rules', () => { const arbiter = new Arbiter(); arbiter.addNode('user:1', 'user'); arbiter.addNode('account:1', 'account'); arbiter.setRelationConfig('device_link', { type: 'direct' }); arbiter.setRelationConfig('logged_in_as', { type: 'direct' }); arbiter.setRelationConfig('can_login', { type: 'chain', steps: [ { relation: 'device_link', direction: 'out' }, { relation: 'logged_in_as', direction: 'out' } ] }); const partialGraph = { nodes: [ { key: 'device:abc', type: 'device' } ], relations: [ { src: 'user:1', relation: 'device_link', dst: 'device:abc', possibility: 1.0 }, { src: 'device:abc', relation: 'logged_in_as', dst: 'account:1', possibility: 1.0 } ] }; const result = arbiter.check('user:1', 'can_login', 'account:1', { partialGraph }); assert.ok(result.possibility > 0); }); test('multi-hop explain includes partial path sources', () => { const arbiter = new Arbiter(); arbiter.addNode('user:1', 'user'); arbiter.addNode('doc:1', 'doc'); arbiter.setRelationConfig('path', { type: 'multi_hop', relation: 'link', maxDepth: 3 }); const partialGraph = { nodes: [ { key: 'mid:1', type: 'group' } ], relations: [ { src: 'user:1', relation: 'link', dst: 'mid:1', possibility: 1.0 }, { src: 'mid:1', relation: 'link', dst: 'doc:1', possibility: 1.0 } ] }; const result = arbiter.explain('user:1', 'path', 'doc:1', { partialGraph }); const pathSteps = result.trace.rulePaths[0]?.pathSteps || []; assert.ok(pathSteps.length > 0); for (const step of pathSteps) { assert.strictEqual(step.source, 'partial'); } }); test('chain collected values include source', () => { const arbiter = new Arbiter(); arbiter.addNode('user:1', 'user'); arbiter.addNode('account:1', 'account'); arbiter.setRelationConfig('device_link', { type: 'direct' }); arbiter.setRelationConfig('logged_in_as', { type: 'direct' }); arbiter.setRelationConfig('can_login', { type: 'chain', steps: [ { relation: 'device_link', direction: 'out' }, { relation: 'logged_in_as', direction: 'out' } ] }); const partialGraph = { nodes: [ { key: 'device:abc', type: 'device' } ], relations: [ { src: 'user:1', relation: 'device_link', dst: 'device:abc', possibility: 1.0, value: 1 }, { src: 'device:abc', relation: 'logged_in_as', dst: 'account:1', possibility: 1.0, value: 1 } ] }; const result = arbiter.explain('user:1', 'can_login', 'account:1', { partialGraph }); const pathSteps = result.trace.rulePaths[0]?.pathSteps || []; assert.ok(pathSteps.length > 0); let sawPartial = false; for (const step of pathSteps) { assert.ok(step.source === 'partial' || step.source === 'persistent'); if (step.source === 'partial') sawPartial = true; } assert.ok(sawPartial); const collectedValues = result.trace.values || []; assert.ok(collectedValues.length > 0); for (const cv of collectedValues) { assert.strictEqual(cv.metadata.source, 'partial'); } }); test('relational comparator uses partial value provenance', () => { const arbiter = new Arbiter(); arbiter.addNode('user:1', 'user'); arbiter.addNode('account:1', 'account'); arbiter.setRelationConfig('device_risk', { type: 'direct' }); arbiter.setRelationConfig('risk_limit', { type: 'direct' }); arbiter.setRelationConfig('risk_ok', { type: 'relational_comparator', left: { rule: { type: 'direct', relation: 'device_risk' }, extractValue: true, valueRelation: 'device_risk', evaluateFrom: 'auto' }, right: { rule: { type: 'direct', relation: 'risk_limit' }, extractValue: true, valueRelation: 'risk_limit', evaluateFrom: 'auto' }, comparator: '<=' }); const partialGraph = { relations: [ { src: 'user:1', relation: 'device_risk', dst: 'account:1', possibility: 1.0, value: 0.2 }, { src: 'user:1', relation: 'risk_limit', dst: 'account:1', possibility: 1.0, value: 0.8 } ] }; const result = arbiter.explain('user:1', 'risk_ok', 'account:1', { partialGraph }); assert.ok(result.decision.possibility > 0); const comparatorNode = result.trace.path.find(node => node.type === 'relational_comparator'); const details = comparatorNode?.details || {}; assert.strictEqual(details.leftSource, 'partial'); assert.strictEqual(details.rightSource, 'partial'); }); });