/** * Targeted B2C Diagnostic Test * * Purpose: Diagnose why B2C scenario has: * 1. False negatives (over-refusing) * 2. Low QPS (1002 vs 17241 in enterprise) * * Hypothesis: * - SCC bug causing incorrect PLTC queries (fixed, but need to verify) * - Chain evaluation not finding paths that exist * - Execution path not using PLTC efficiently * * Approach: * 1. Small controlled graph (50 users, 200 files) for easy inspection * 2. Trace execution path for each query * 3. Measure time in each phase * 4. Verify SCC correctness * 5. Verify PLTC queries * 6. Verify chain evaluation */ import { Arbiter } from '../src/index.js'; // Helper function to generate preferential attachment edges (from original test) function generatePreferentialAttachmentEdges(numNodes, avgDegree) { const edges = []; const degrees = new Array(numNodes).fill(0); // Start with a small clique for (let i = 0; i < Math.min(5, numNodes); i++) { for (let j = i + 1; j < Math.min(5, numNodes); j++) { edges.push([i, j]); degrees[i]++; degrees[j]++; } } // Preferential attachment const totalEdges = Math.floor((numNodes * avgDegree) / 2); for (let i = edges.length; i < totalEdges; i++) { // Select source node with probability proportional to degree let src = Math.floor(Math.random() * numNodes); const srcProb = degrees[src] / (edges.length * 2 + 1); if (Math.random() > srcProb) { src = Math.floor(Math.random() * numNodes); } // Select target node with probability proportional to degree let dst = Math.floor(Math.random() * numNodes); while (dst === src) { dst = Math.floor(Math.random() * numNodes); } const dstProb = degrees[dst] / (edges.length * 2 + 1); if (Math.random() > dstProb) { dst = Math.floor(Math.random() * numNodes); while (dst === src) { dst = Math.floor(Math.random() * numNodes); } } edges.push([src, dst]); degrees[src]++; degrees[dst]++; } return edges; } // Small controlled B2C scenario function setupSmallB2C() { const arbiter = new Arbiter({ fastConstructionMode: true, enableInference: false }); const numUsers = 50; const numFiles = 200; // Create users const users = []; for (let i = 0; i < numUsers; i++) { const userKey = `user:user${i}`; users.push(userKey); arbiter.addNode(userKey, 'user'); } // Create a controlled friend graph (not random) // Create 3 friend clusters with some cross-connections console.log(' Building controlled friend graph...'); const clusters = [ users.slice(0, 15), // Cluster 1: users 0-14 users.slice(15, 30), // Cluster 2: users 15-29 users.slice(30, 50) // Cluster 3: users 30-49 ]; // Within-cluster friendships (bidirectional) for (const cluster of clusters) { for (let i = 0; i < cluster.length; i++) { for (let j = i + 1; j < Math.min(i + 5, cluster.length); j++) { arbiter.addRelation(cluster[i], 'friend', cluster[j]); arbiter.addRelation(cluster[j], 'friend', cluster[i]); } } } // Cross-cluster connections (fewer, for friend-of-friend testing) for (let i = 0; i < 5; i++) { const u1 = clusters[0][Math.floor(Math.random() * clusters[0].length)]; const u2 = clusters[1][Math.floor(Math.random() * clusters[1].length)]; arbiter.addRelation(u1, 'friend', u2); arbiter.addRelation(u2, 'friend', u1); } // Create files with known owners console.log(' Creating files with known owners...'); const files = []; for (let i = 0; i < numFiles; i++) { const fileKey = `file:file${i}`; files.push(fileKey); arbiter.addNode(fileKey, 'file'); // Assign owner: first 100 files to cluster 1, next 50 to cluster 2, rest to cluster 3 let owner; if (i < 100) { owner = clusters[0][i % clusters[0].length]; } else if (i < 150) { owner = clusters[1][(i - 100) % clusters[1].length]; } else { owner = clusters[2][(i - 150) % clusters[2].length]; } arbiter.addRelation(owner, 'owns', fileKey); } // Configure relations arbiter.setRelationConfig('friend', { type: 'direct' }); arbiter.setRelationConfig('owns', { type: 'direct' }); // Chain rule: friend_file = user -> friend -> owns -> file arbiter.setRelationConfig('friend_file', { type: 'chain', steps: [ { relation: 'friend', direction: 'out' }, { relation: 'owns', direction: 'out' } ] }); // Friend-of-friend rule: user -> friend -> friend -> owns -> file arbiter.setRelationConfig('friend_of_friend_file', { type: 'chain', steps: [ { relation: 'friend', direction: 'out' }, { relation: 'friend', direction: 'out' }, { relation: 'owns', direction: 'out' } ] }); console.log(` Created ${arbiter.relations.length} relations`); console.log(` ${numUsers} users, ${numFiles} files`); return { arbiter, users, files, clusters }; } // Generate test queries with known expected results function generateTestQueries(setup) { const { arbiter, users, files, clusters } = setup; const queries = []; // Build helper maps to find actual valid paths const userToFriends = new Map(); const friendToFiles = new Map(); for (const rel of arbiter.relations) { const srcKey = arbiter.keyByNodeId.get(rel.src); const dstKey = arbiter.keyByNodeId.get(rel.dst); if (!srcKey || !dstKey) continue; const relName = rel.rel || rel.relation; if (relName === 'friend') { if (!userToFriends.has(srcKey)) userToFriends.set(srcKey, []); userToFriends.get(srcKey).push(dstKey); } else if (relName === 'owns' && users.includes(srcKey) && files.includes(dstKey)) { if (!friendToFiles.has(srcKey)) friendToFiles.set(srcKey, []); friendToFiles.get(srcKey).push(dstKey); } } // Test Case 1: Direct friend file access (should PASS) // Find a user with a friend who owns a file let foundFriendFile = false; for (const user of users) { const friends = userToFriends.get(user) || []; for (const friend of friends) { const friendFiles = friendToFiles.get(friend) || []; if (friendFiles.length > 0) { queries.push({ sourceKey: user, targetKey: friendFiles[0], relation: 'friend_file', expected: true, description: `Direct friend file access: ${user} -> friend -> ${friend} -> owns -> ${friendFiles[0]}` }); foundFriendFile = true; break; } } if (foundFriendFile) break; } // Test Case 2: Friend-of-friend file access (should PASS) // Find a user -> friend -> friend -> owns -> file path let foundFoFFile = false; for (const user of users) { const friends = userToFriends.get(user) || []; for (const friend of friends) { const friendFriends = userToFriends.get(friend) || []; for (const fof of friendFriends) { if (fof === user) continue; // Skip self const fofFiles = friendToFiles.get(fof) || []; if (fofFiles.length > 0) { queries.push({ sourceKey: user, targetKey: fofFiles[0], relation: 'friend_of_friend_file', expected: true, description: `Friend-of-friend file access: ${user} -> ${friend} -> ${fof} -> owns -> ${fofFiles[0]}` }); foundFoFFile = true; break; } } if (foundFoFFile) break; } if (foundFoFFile) break; } // Test Case 3: No path (should FAIL) // Find a file owned by a user with no connection to source queries.push({ sourceKey: clusters[0][0], targetKey: 'file:file150', // Owned by cluster 3, no connection relation: 'friend_file', expected: false, description: 'No path (different cluster, no connection)' }); // Test Case 4: Direct ownership (should PASS via 'owns', not chain) queries.push({ sourceKey: clusters[0][0], targetKey: 'file:file0', relation: 'owns', expected: true, description: 'Direct ownership' }); // Test Case 5: Simple friend_file within cluster (should PASS) // Find a user -> friend -> owns -> file path within same cluster for (const user of clusters[0]) { const friends = userToFriends.get(user) || []; for (const friend of friends) { if (!clusters[0].includes(friend)) continue; // Must be in same cluster const friendFiles = friendToFiles.get(friend) || []; if (friendFiles.length > 0) { queries.push({ sourceKey: user, targetKey: friendFiles[0], relation: 'friend_file', expected: true, description: `Friend file access within cluster: ${user} -> ${friend} -> owns -> ${friendFiles[0]}` }); return queries; // Found all test cases } } } return queries; } // Trace execution path for a single query function traceQuery(arbiter, query) { const { sourceKey, targetKey, relation } = query; const trace = { query, timings: {}, results: {}, path: [] }; const sourceId = arbiter.nodeIdByKey.get(sourceKey); const targetId = arbiter.nodeIdByKey.get(targetKey); trace.path.push(`Query: ${sourceKey} -> ${targetKey} (${relation})`); trace.path.push(`Node IDs: ${sourceId} -> ${targetId}`); // Phase 1: PLTC reachability check const rc = arbiter.graphManager.operations.reachabilityChecker; const pltcStart = Date.now(); if (rc && rc.pltcIndex && rc.pltcIndex.initialized) { // PLTC handles SCC internally - query directly with original node IDs const pltcQuery = rc.pltcIndex.query(sourceId, targetId); const isReachable = arbiter.isReachable(sourceKey, targetKey); trace.timings.pltc = Date.now() - pltcStart; trace.results.pltc = { pltcQuery, isReachable }; trace.path.push(`PLTC: query=${pltcQuery}, isReachable=${isReachable}`); } else { trace.results.pltc = { error: 'PLTC not initialized' }; } // Phase 2: Authorization check (full chain evaluation) const authStart = Date.now(); const authResult = arbiter.check(sourceKey, relation, targetKey, { noInfer: true }); trace.timings.auth = Date.now() - authStart; const authPass = authResult && authResult.possibility > 0; trace.results.auth = { result: authResult, pass: authPass, method: authResult?.meta?.method || 'unknown' }; trace.path.push(`Auth: ${authPass ? 'ALLOW' : 'DENY'} (method: ${trace.results.auth.method})`); // Phase 3: Manual chain traversal (if chain rule) if (relation === 'friend_file' || relation === 'friend_of_friend_file') { trace.path.push(`Manual chain traversal:`); const relationConfig = arbiter.relationConfigs.get(relation); if (relationConfig && relationConfig.type === 'chain') { const steps = relationConfig.steps; trace.path.push(` Steps: ${steps.map(s => `${s.relation}:${s.direction}`).join(' -> ')}`); // Manually trace the chain let currentNodes = [sourceId]; for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { const step = steps[stepIdx]; const nextNodes = []; for (const nodeId of currentNodes) { const nodeKey = arbiter.keyByNodeId.get(nodeId); const outgoing = arbiter.relations.filter(r => r.src === nodeId && (r.rel || r.relation) === step.relation ); trace.path.push(` Step ${stepIdx + 1} (${step.relation}): ${nodeKey} has ${outgoing.length} outgoing edges`); for (const rel of outgoing) { nextNodes.push(rel.dst); const dstKey = arbiter.keyByNodeId.get(rel.dst); trace.path.push(` → ${dstKey}`); } } currentNodes = nextNodes; // Check if target is reachable at this step if (currentNodes.includes(targetId)) { trace.path.push(` ✓ Target reached at step ${stepIdx + 1}!`); break; } } if (!currentNodes.includes(targetId)) { trace.path.push(` ✗ Target not reached through chain`); } } } return trace; } // Main diagnostic function function runDiagnostics() { console.log('🔬 B2C Diagnostic Test\n'); console.log('='.repeat(60)); // Setup const setup = setupSmallB2C(); const { arbiter } = setup; // Initialize PLTC console.log('\n⚡ Initializing PLTC...'); const initStart = Date.now(); arbiter.graphManager.initializeReachabilityChecker(); const initTime = Date.now() - initStart; const rc = arbiter.graphManager.operations.reachabilityChecker; console.log(` Initialization time: ${initTime}ms`); console.log(` PLTC initialized: ${rc.pltcIndex.initialized}`); console.log(` Graph edges: ${rc.pltcIndex.graph.edges.size}`); console.log(` SCC count: ${rc.pltcIndex.sccUnionFind?.getComponentCount() || 'N/A'}`); // Generate test queries const queries = generateTestQueries(setup); // Trace each query console.log('\n📊 Tracing queries...'); const allTraces = []; let totalPLTCTime = 0; let totalAuthTime = 0; for (const query of queries) { console.log(`\n${'-'.repeat(60)}`); console.log(`Query: ${query.description}`); console.log(` ${query.sourceKey} -> ${query.targetKey} (${query.relation})`); console.log(` Expected: ${query.expected ? 'PASS' : 'FAIL'}`); const trace = traceQuery(arbiter, query); allTraces.push(trace); totalPLTCTime += trace.timings.pltc || 0; totalAuthTime += trace.timings.auth || 0; // Print trace for (const line of trace.path) { console.log(` ${line}`); } // Check result const actual = trace.results.auth.pass; const match = actual === query.expected; console.log(` Result: ${actual ? 'PASS' : 'FAIL'} ${match ? '✓' : '❌'} (Expected: ${query.expected ? 'PASS' : 'FAIL'})`); if (!match) { console.log(` ⚠️ MISMATCH!`); if (query.expected && !actual) { console.log(` False negative: Should pass but failed`); console.log(` PLTC query: ${trace.results.pltc?.pltcQuery}`); console.log(` isReachable: ${trace.results.pltc?.isReachable}`); } else { console.log(` False positive: Should fail but passed`); } } } // Performance summary console.log(`\n${'='.repeat(60)}`); console.log('Performance Summary:'); console.log('='.repeat(60)); console.log(`Total queries: ${queries.length}`); console.log(`Total PLTC time: ${totalPLTCTime}ms (${(totalPLTCTime / queries.length).toFixed(2)}ms/query)`); console.log(`Total Auth time: ${totalAuthTime}ms (${(totalAuthTime / queries.length).toFixed(2)}ms/query)`); const pltcQPS = totalPLTCTime > 0 ? Math.round(queries.length / (totalPLTCTime / 1000)) : 'N/A'; const authQPS = totalAuthTime > 0 ? Math.round(queries.length / (totalAuthTime / 1000)) : 'N/A'; console.log(`PLTC QPS: ${pltcQPS}`); console.log(`Auth QPS: ${authQPS}`); // Accuracy summary const correct = allTraces.filter((t, i) => t.results.auth.pass === queries[i].expected ).length; console.log(`\nAccuracy: ${correct}/${queries.length} (${(correct / queries.length * 100).toFixed(1)}%)`); // Diagnose issues console.log(`\n${'='.repeat(60)}`); console.log('Diagnosis:'); console.log('='.repeat(60)); const falseNegatives = allTraces.filter((t, i) => queries[i].expected && !t.results.auth.pass ); if (falseNegatives.length > 0) { console.log(`\n⚠️ False Negatives (${falseNegatives.length}):`); for (const trace of falseNegatives) { console.log(` ${trace.query.description}`); console.log(` PLTC query: ${trace.results.pltc?.pltcQuery}`); console.log(` isReachable: ${trace.results.pltc?.isReachable}`); console.log(` Auth method: ${trace.results.auth.method}`); } } const falsePositives = allTraces.filter((t, i) => !queries[i].expected && t.results.auth.pass ); if (falsePositives.length > 0) { console.log(`\n⚠️ False Positives (${falsePositives.length}):`); for (const trace of falsePositives) { console.log(` ${trace.query.description}`); } } if (falseNegatives.length === 0 && falsePositives.length === 0) { console.log(`\n✓ All queries correct!`); } // Performance analysis: Why is QPS low in full scenario? console.log(`\n${'='.repeat(60)}`); console.log('Performance Analysis:'); console.log('='.repeat(60)); const avgPLTCTime = totalPLTCTime / queries.length; const avgAuthTime = totalAuthTime / queries.length; const pltcFastFail = allTraces.filter(t => t.results.auth.method === 'pltc_fast_fail' ).length; const pltcPass = allTraces.filter(t => t.results.pltc?.pltcQuery === true && t.results.auth.pass ).length; console.log(`Average PLTC time: ${avgPLTCTime.toFixed(2)}ms`); console.log(`Average Auth time: ${avgAuthTime.toFixed(2)}ms`); console.log(`PLTC fast-fail count: ${pltcFastFail}`); console.log(`PLTC pass (true positive): ${pltcPass}`); console.log(`\nObservations:`); console.log(` - PLTC is ${(avgPLTCTime / avgAuthTime).toFixed(1)}x faster than full auth check`); console.log(` - ${((pltcFastFail / queries.length) * 100).toFixed(1)}% queries fast-failed by PLTC`); console.log(` - ${((pltcPass / queries.length) * 100).toFixed(1)}% queries passed PLTC and auth`); // Check if chain evaluation is the bottleneck const chainQueries = queries.filter(q => q.relation === 'friend_file' || q.relation === 'friend_of_friend_file'); if (chainQueries.length > 0) { const chainTraces = allTraces.filter((t, i) => chainQueries.some(cq => cq === queries[i]) ); const avgChainTime = chainTraces.reduce((sum, t) => sum + (t.timings.auth || 0), 0) / chainTraces.length; console.log(` - Average chain evaluation time: ${avgChainTime.toFixed(2)}ms`); console.log(` - Chain queries: ${chainQueries.length}/${queries.length}`); } // Debug: Check if PLTC is missing paths due to condensation console.log(`\n${'='.repeat(60)}`); console.log('PLTC Path Verification:'); console.log('='.repeat(60)); // For each chain query, verify if PLTC should see the path for (let i = 0; i < queries.length; i++) { const query = queries[i]; const trace = allTraces[i]; if (query.relation === 'friend_file' || query.relation === 'friend_of_friend_file') { const sourceId = arbiter.nodeIdByKey.get(query.sourceKey); const targetId = arbiter.nodeIdByKey.get(query.targetKey); // PLTC handles SCC internally - query directly const pltcQuery = rc.pltcIndex.query(sourceId, targetId); console.log(`\nQuery: ${query.sourceKey} -> ${query.targetKey} (${query.relation})`); console.log(` PLTC query result: ${pltcQuery}`); console.log(` Expected PLTC result: ${query.expected ? 'true' : 'false'}`); if (pltcQuery !== query.expected && query.expected) { console.log(` ⚠️ PLTC MISMATCH: Expected true but got ${pltcQuery}`); console.log(` This suggests PLTC's transitive closure is missing the path`); } } } } runDiagnostics();