import { test, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { Arbiter } from '../../../src/index.js'; import { setupChainRuleTestGraph } from './helpers.js'; describe('ChainRule Comprehensive Tests', () => { describe('Debugging Aggregation Issues (RelationalComparatorRule Integration)', () => { it('debugs the $9000 vs $4500 aggregation problem', () => { const arbiter = new Arbiter({ embeddingDimensions: 256 }); // Replicate the exact scenario from RelationalComparatorRule test arbiter.setRelationConfig('can_debit', { type: 'direct' }); arbiter.setRelationConfig('has_balance', { type: 'direct' }); // Create nodes arbiter.addNode('user:test', 'user'); arbiter.addNode('account:business', 'account'); arbiter.addNode('account:personal', 'account'); arbiter.addNode('account:restricted', 'account'); arbiter.addNode('unit:usd', 'currency'); // Set up account balances arbiter.addRelation('account:business', 'has_balance', 'unit:usd', { value: 3000 }); arbiter.addRelation('account:personal', 'has_balance', 'unit:usd', { value: 1500 }); arbiter.addRelation('account:restricted', 'has_balance', 'unit:usd', { value: 10000 }); // Set up debit rights (only to business and personal) arbiter.addRelation('user:test', 'can_debit', 'account:business'); arbiter.addRelation('user:test', 'can_debit', 'account:personal'); // Configure the chain rule that's causing problems arbiter.setRelationConfig('balance_chain', { type: 'chain', steps: [ { relation: 'can_debit', direction: 'out' }, // user → accounts { relation: 'has_balance', direction: 'out' } // accounts → currency ], extractValues: true, extractFrom: 1, // Extract from step 1 (accounts) extractRelation: 'has_balance', valueAggregation: 'sum', // Sum all authorized balances evaluateFrom: 'user' }); // Test the chain rule directly const result = arbiter.check('user:test', 'balance_chain', 'unit:usd'); if (process.env.TEST_DEBUG === '1') console.log('\n=== CHAIN RULE AGGREGATION DEBUG ==='); if (process.env.TEST_DEBUG === '1') console.log('Expected path: user:test → [account:business, account:personal] → unit:usd'); if (process.env.TEST_DEBUG === '1') console.log('Expected balance sum: $3000 + $1500 = $4500'); if (process.env.TEST_DEBUG === '1') console.log('Actual result:', JSON.stringify(result, null, 2)); // Check if the chain rule reaches the target assert.equal(result.possibility, 1, 'Chain should successfully reach unit:usd'); assert.equal(result.reason, 'allow_rule_matched'); // The key question: what values are being collected? if (result.collectedValues) { if (process.env.TEST_DEBUG === '1') console.log('\n=== COLLECTED VALUES ANALYSIS ==='); if (process.env.TEST_DEBUG === '1') console.log('Number of collected values:', result.collectedValues.length); result.collectedValues.forEach((cv, i) => { if (process.env.TEST_DEBUG === '1') console.log(`Value ${i + 1}:`, { value: cv.value, source: cv.source, metadata: cv.metadata || cv.meta }); }); } // Check what the RelationManager thinks about this path if (process.env.TEST_DEBUG === '1') console.log('\n=== RELATION MANAGER DEBUG ==='); const userNodeId = arbiter.nodeManager.getNodeId('user:test'); const debitRels = arbiter.relationManager.getRelationsFromSrc(userNodeId, 'can_debit'); if (process.env.TEST_DEBUG === '1') console.log('Debit relations:', debitRels.map(r => ({ target: arbiter.keyByNodeId.get(r.dst), possibility: r.possibility }))); debitRels.forEach(debitRel => { const balanceRels = arbiter.relationManager.getRelationsFromSrc(debitRel.dst, 'has_balance'); if (process.env.TEST_DEBUG === '1') console.log(`Balance relations from ${arbiter.keyByNodeId.get(debitRel.dst)}:`, balanceRels.map(r => ({ target: arbiter.keyByNodeId.get(r.dst), value: r.value, possibility: r.possibility })) ); }); // This test documents the issue for investigation if (process.env.TEST_DEBUG === '1') console.log('\n=== ISSUE SUMMARY ==='); if (process.env.TEST_DEBUG === '1') console.log('If chain rule aggregation is working correctly, the total should be $4500'); if (process.env.TEST_DEBUG === '1') console.log('If we get $9000, there is likely double counting in the aggregation logic'); }); it('tests simpler two-account aggregation to isolate the issue', () => { const arbiter = new Arbiter({ embeddingDimensions: 256 }); // Simpler test case arbiter.setRelationConfig('owns', { type: 'direct' }); arbiter.setRelationConfig('worth', { type: 'direct' }); arbiter.addNode('user:simple', 'user'); arbiter.addNode('asset:a', 'asset'); arbiter.addNode('asset:b', 'asset'); arbiter.addNode('currency:usd', 'currency'); // User owns two assets arbiter.addRelation('user:simple', 'owns', 'asset:a'); arbiter.addRelation('user:simple', 'owns', 'asset:b'); // Assets have known values arbiter.addRelation('asset:a', 'worth', 'currency:usd', { value: 100 }); arbiter.addRelation('asset:b', 'worth', 'currency:usd', { value: 200 }); arbiter.setRelationConfig('total_worth_chain', { type: 'chain', steps: [ { relation: 'owns', direction: 'out' }, { relation: 'worth', direction: 'out' } ], extractValues: true, extractFrom: 1, // Extract from step 1 (assets) extractRelation: 'worth', valueAggregation: 'sum' }); const result = arbiter.check('user:simple', 'total_worth_chain', 'currency:usd'); if (process.env.TEST_DEBUG === '1') console.log('\n=== SIMPLE AGGREGATION TEST ==='); if (process.env.TEST_DEBUG === '1') console.log('Expected: $100 + $200 = $300'); if (process.env.TEST_DEBUG === '1') console.log('Result:', JSON.stringify(result, null, 2)); assert.equal(result.possibility, 1); // This should help us see if the issue is specific to the can_debit scenario // or a general problem with chain rule aggregation if (result.collectedValues) { const totalValue = result.collectedValues.reduce((sum, cv) => { const val = typeof cv.value === 'number' ? cv.value : (cv.value && typeof cv.value.value === 'number' ? cv.value.value : 0); return sum + val; }, 0); if (process.env.TEST_DEBUG === '1') console.log('Manually calculated total from collected values:', totalValue); if (process.env.TEST_DEBUG === '1') console.log('Expected total: 300'); if (totalValue !== 300) { if (process.env.TEST_DEBUG === '1') console.log('🚨 AGGREGATION ISSUE CONFIRMED: Chain rule is not summing correctly'); } else { if (process.env.TEST_DEBUG === '1') console.log('āœ… Simple aggregation works correctly'); } } }); it('tests chain rule value extraction vs RelationalComparatorRule value extraction', () => { const arbiter = new Arbiter({ embeddingDimensions: 256 }); // Set up the same scenario but test chain rule in isolation arbiter.setRelationConfig('can_debit', { type: 'direct' }); arbiter.setRelationConfig('has_balance', { type: 'direct' }); arbiter.addNode('user:compare', 'user'); arbiter.addNode('account:x', 'account'); arbiter.addNode('account:y', 'account'); arbiter.addNode('currency:usd', 'currency'); arbiter.addRelation('user:compare', 'can_debit', 'account:x'); arbiter.addRelation('user:compare', 'can_debit', 'account:y'); arbiter.addRelation('account:x', 'has_balance', 'currency:usd', { value: 1000 }); arbiter.addRelation('account:y', 'has_balance', 'currency:usd', { value: 2000 }); // Test 1: Direct ChainRule evaluation arbiter.setRelationConfig('direct_chain', { type: 'chain', steps: [ { relation: 'can_debit', direction: 'out' }, { relation: 'has_balance', direction: 'out' } ], extractValues: true, extractFrom: 1, extractRelation: 'has_balance', valueAggregation: 'sum' }); const chainResult = arbiter.check('user:compare', 'direct_chain', 'currency:usd'); // Test 2: RelationalComparatorRule using the same chain arbiter.setRelationConfig('comparator_chain', { type: 'relational_comparator', leftOperand: { rule: { type: 'chain', steps: [ { relation: 'can_debit', direction: 'out' }, { relation: 'has_balance', direction: 'out' } ], extractValues: true, extractFrom: 1, extractRelation: 'has_balance', valueAggregation: 'sum', evaluateFrom: 'user' }, extractValue: true, evaluateFrom: 'user' }, rightOperand: { rule: { type: 'direct', relation: 'has_balance' // dummy }, extractValue: false // Use possibility (1.0) as value }, comparator: '>=' }); const comparatorResult = arbiter.check('user:compare', 'comparator_chain', 'currency:usd'); if (process.env.TEST_DEBUG === '1') console.log('\n=== CHAIN VS RELATIONAL_COMPARATOR COMPARISON ==='); if (process.env.TEST_DEBUG === '1') console.log('Expected total: $1000 + $2000 = $3000'); if (process.env.TEST_DEBUG === '1') console.log('\nDirect chain result:', { possibility: chainResult.possibility, collectedValues: chainResult.collectedValues?.length || 'none', reason: chainResult.reason }); if (process.env.TEST_DEBUG === '1') console.log('\nRelationalComparatorRule result:', { possibility: comparatorResult.possibility, leftValue: comparatorResult.meta?.allow?.leftValue || comparatorResult.meta?.deny?.leftValue, rightValue: comparatorResult.meta?.allow?.rightValue || comparatorResult.meta?.deny?.rightValue, reason: comparatorResult.reason }); // Both should reach the target assert.equal(chainResult.possibility, 1); if (comparatorResult.meta?.allow?.leftValue) { if (process.env.TEST_DEBUG === '1') console.log('\nšŸ” Investigating value extraction:'); if (process.env.TEST_DEBUG === '1') console.log('RelationalComparatorRule extracted leftValue:', comparatorResult.meta.allow.leftValue); if (process.env.TEST_DEBUG === '1') console.log('Expected: 3000'); if (comparatorResult.meta.allow.leftValue === 6000) { if (process.env.TEST_DEBUG === '1') console.log('🚨 DOUBLE COUNTING CONFIRMED: 2x the expected value'); } else if (comparatorResult.meta.allow.leftValue === 3000) { if (process.env.TEST_DEBUG === '1') console.log('āœ… Correct aggregation'); } else { if (process.env.TEST_DEBUG === '1') console.log('šŸ¤” Unexpected value - needs investigation'); } } }); it('isolates collectedValues disappearing issue', () => { const arbiter = new Arbiter({ embeddingDimensions: 256 }); // Minimal test case arbiter.setRelationConfig('owns', { type: 'direct' }); arbiter.setRelationConfig('worth', { type: 'direct' }); arbiter.addNode('user:minimal', 'user'); arbiter.addNode('item:test', 'item'); arbiter.addNode('currency:usd', 'currency'); arbiter.addRelation('user:minimal', 'owns', 'item:test'); arbiter.addRelation('item:test', 'worth', 'currency:usd', { value: 100 }); arbiter.setRelationConfig('minimal_chain', { type: 'chain', steps: [ { relation: 'owns', direction: 'out' }, { relation: 'worth', direction: 'out' } ], extractValues: true, extractFrom: 1, extractRelation: 'worth', valueAggregation: 'sum' }); const result = arbiter.check('user:minimal', 'minimal_chain', 'currency:usd'); if (process.env.TEST_DEBUG === '1') console.log('\n=== MINIMAL TEST: COLLECTED VALUES INVESTIGATION ==='); if (process.env.TEST_DEBUG === '1') console.log('Full result structure:', JSON.stringify(result, null, 2)); if (process.env.TEST_DEBUG === '1') console.log('Has collectedValues field?', 'collectedValues' in result); if (process.env.TEST_DEBUG === '1') console.log('collectedValues type:', typeof result.collectedValues); if (process.env.TEST_DEBUG === '1') console.log('collectedValues value:', result.collectedValues); if (result.collectedValues && Array.isArray(result.collectedValues)) { if (process.env.TEST_DEBUG === '1') console.log('āœ… collectedValues preserved:', result.collectedValues.length, 'values'); result.collectedValues.forEach((cv, i) => { if (process.env.TEST_DEBUG === '1') console.log(` Value ${i + 1}:`, { value: cv.value, possibility: cv.possibility, source: cv.source?.relation }); }); } else { if (process.env.TEST_DEBUG === '1') console.log('🚨 collectedValues NOT preserved in final result'); if (process.env.TEST_DEBUG === '1') console.log('This explains why RelationalComparatorRule cannot extract values from ChainRule'); } assert.equal(result.possibility, 1, 'Chain should reach target'); }); it('isolates why RelationalComparatorRule gets $9000 instead of $4500', () => { const arbiter = new Arbiter({ embeddingDimensions: 256 }); // Set up the same scenario but test chain rule in isolation arbiter.setRelationConfig('can_debit', { type: 'direct' }); arbiter.setRelationConfig('has_balance', { type: 'direct' }); arbiter.addNode('user:compare', 'user'); arbiter.addNode('account:x', 'account'); arbiter.addNode('account:y', 'account'); arbiter.addNode('currency:usd', 'currency'); arbiter.addRelation('user:compare', 'can_debit', 'account:x'); arbiter.addRelation('user:compare', 'can_debit', 'account:y'); arbiter.addRelation('account:x', 'has_balance', 'currency:usd', { value: 1000 }); arbiter.addRelation('account:y', 'has_balance', 'currency:usd', { value: 2000 }); // Test 1: Direct ChainRule evaluation arbiter.setRelationConfig('direct_chain', { type: 'chain', steps: [ { relation: 'can_debit', direction: 'out' }, { relation: 'has_balance', direction: 'out' } ], extractValues: true, extractFrom: 1, extractRelation: 'has_balance', valueAggregation: 'sum' }); const chainResult = arbiter.check('user:compare', 'direct_chain', 'currency:usd'); // Test 2: RelationalComparatorRule using the same chain arbiter.setRelationConfig('comparator_chain', { type: 'relational_comparator', leftOperand: { rule: { type: 'chain', steps: [ { relation: 'can_debit', direction: 'out' }, { relation: 'has_balance', direction: 'out' } ], extractValues: true, extractFrom: 1, extractRelation: 'has_balance', valueAggregation: 'sum', evaluateFrom: 'user' }, extractValue: true, evaluateFrom: 'user' }, rightOperand: { rule: { type: 'direct', relation: 'has_balance' // dummy }, extractValue: false // Use possibility (1.0) as value }, comparator: '>=' }); const comparatorResult = arbiter.check('user:compare', 'comparator_chain', 'currency:usd'); if (process.env.TEST_DEBUG === '1') console.log('\n=== CHAIN VS RELATIONAL_COMPARATOR COMPARISON ==='); if (process.env.TEST_DEBUG === '1') console.log('Expected total: $1000 + $2000 = $3000'); if (process.env.TEST_DEBUG === '1') console.log('\nDirect chain result:', { possibility: chainResult.possibility, collectedValues: chainResult.collectedValues?.length || 'none', reason: chainResult.reason }); if (process.env.TEST_DEBUG === '1') console.log('\nRelationalComparatorRule result:', { possibility: comparatorResult.possibility, leftValue: comparatorResult.meta?.allow?.leftValue || comparatorResult.meta?.deny?.leftValue, rightValue: comparatorResult.meta?.allow?.rightValue || comparatorResult.meta?.deny?.rightValue, reason: comparatorResult.reason }); // Both should reach the target assert.equal(chainResult.possibility, 1); if (comparatorResult.meta?.allow?.leftValue) { if (process.env.TEST_DEBUG === '1') console.log('\nšŸ” Investigating value extraction:'); if (process.env.TEST_DEBUG === '1') console.log('RelationalComparatorRule extracted leftValue:', comparatorResult.meta.allow.leftValue); if (process.env.TEST_DEBUG === '1') console.log('Expected: 3000'); if (comparatorResult.meta.allow.leftValue === 6000) { if (process.env.TEST_DEBUG === '1') console.log('🚨 DOUBLE COUNTING CONFIRMED: 2x the expected value'); } else if (comparatorResult.meta.allow.leftValue === 3000) { if (process.env.TEST_DEBUG === '1') console.log('āœ… Correct aggregation'); } else { if (process.env.TEST_DEBUG === '1') console.log('šŸ¤” Unexpected value - needs investigation'); } } }); it('regression test: ensures collectedValues are preserved after AuthorizationChecker fix', () => { const arbiter = new Arbiter({ embeddingDimensions: 256 }); // Set up a simple value collection scenario arbiter.setRelationConfig('owns', { type: 'direct' }); arbiter.setRelationConfig('worth', { type: 'direct' }); arbiter.addNode('user:collector', 'user'); arbiter.addNode('asset:a', 'asset'); arbiter.addNode('asset:b', 'asset'); arbiter.addNode('currency:usd', 'currency'); // Set up asset ownership and values arbiter.addRelation('user:collector', 'owns', 'asset:a'); arbiter.addRelation('user:collector', 'owns', 'asset:b'); arbiter.addRelation('asset:a', 'worth', 'currency:usd', { value: 500 }); arbiter.addRelation('asset:b', 'worth', 'currency:usd', { value: 750 }); // Configure chain rule with value collection arbiter.setRelationConfig('asset_values', { type: 'chain', steps: [ { relation: 'owns', direction: 'out' }, { relation: 'worth', direction: 'out' } ], extractValues: true, extractFrom: 1, extractRelation: 'worth', valueAggregation: 'sum' }); const result = arbiter.check('user:collector', 'asset_values', 'currency:usd', { collectValues: true }); if (process.env.TEST_DEBUG === '1') console.log('\n=== REGRESSION TEST: COLLECTED VALUES PRESERVATION ==='); if (process.env.TEST_DEBUG === '1') console.log('Testing that collectedValues are preserved in final result...'); if (process.env.TEST_DEBUG === '1') console.log('Result structure:', { possibility: result.possibility, hasCollectedValues: 'collectedValues' in result, collectedValuesType: typeof result.collectedValues, collectedValuesLength: result.collectedValues?.length || 'N/A', reason: result.reason }); // CRITICAL REGRESSION TEST: collectedValues must be preserved assert.equal(result.possibility, 1, 'Chain should reach target successfully'); assert.ok('collectedValues' in result, '🚨 REGRESSION: collectedValues field missing from result'); assert.ok(Array.isArray(result.collectedValues), '🚨 REGRESSION: collectedValues is not an array'); assert.ok(result.collectedValues.length > 0, '🚨 REGRESSION: collectedValues array is empty'); // Verify the collected values have the expected structure const firstValue = result.collectedValues[0]; assert.ok(firstValue.value !== undefined, 'Collected value should have a value field'); assert.ok(firstValue.possibility !== undefined, 'Collected value should have a possibility field'); if (process.env.TEST_DEBUG === '1') console.log('āœ… REGRESSION TEST PASSED: collectedValues properly preserved'); if (process.env.TEST_DEBUG === '1') console.log(' collectedValues count:', result.collectedValues.length); if (process.env.TEST_DEBUG === '1') console.log(' First collected value:', { value: firstValue.value, possibility: firstValue.possibility, source: firstValue.source }); // Also verify it's in meta for backwards compatibility if (result.meta?.collectedValues) { if (process.env.TEST_DEBUG === '1') console.log('āœ… BONUS: collectedValues also available in meta'); } }); }); });