//! RFC-0120 Extension: Bellman-Ford Betrayal Detection //! //! Detects negative cycles in the trust graph, which indicate: //! - Collusion rings (Sybil attacks) //! - Decade-level betrayals (cascading trust decay) //! - Cartel behavior (coordinated false vouches) //! //! Complexity: O(|V| × |E|) with early exit optimization. const std = @import("std"); const time = @import("time"); const types = @import("types.zig"); const NodeId = types.NodeId; const RiskGraph = types.RiskGraph; const RiskEdge = types.RiskEdge; const AnomalyScore = types.AnomalyScore; /// Result of Bellman-Ford betrayal detection. pub const BellmanFordResult = struct { allocator: std.mem.Allocator, /// Shortest distances from source (accounting for negative edges) distances: std.AutoHashMapUnmanaged(NodeId, f64), /// Predecessor map for path reconstruction predecessors: std.AutoHashMapUnmanaged(NodeId, ?NodeId), /// Detected betrayal cycles (negative cycles) betrayal_cycles: std.ArrayListUnmanaged([]NodeId), pub fn deinit(self: *BellmanFordResult) void { self.distances.deinit(self.allocator); self.predecessors.deinit(self.allocator); for (self.betrayal_cycles.items) |cycle| { self.allocator.free(cycle); } self.betrayal_cycles.deinit(self.allocator); } /// Compute anomaly score based on detected cycles. /// Score is normalized to [0, 1]. pub fn computeAnomalyScore(self: *const BellmanFordResult) f64 { if (self.betrayal_cycles.items.len > 0) return 1.0; // Any negative cycle is critical return 0.0; } /// Get nodes involved in any betrayal cycle. pub fn getCompromisedNodes(self: *const BellmanFordResult, allocator: std.mem.Allocator) ![]NodeId { var seen = std.AutoHashMapUnmanaged(NodeId, void){}; defer seen.deinit(allocator); for (self.betrayal_cycles.items) |cycle| { for (cycle) |node| { try seen.put(allocator, node, {}); } } var result = try allocator.alloc(NodeId, seen.count()); var i: usize = 0; var it = seen.keyIterator(); while (it.next()) |key| { result[i] = key.*; i += 1; } return result; } /// Generate cryptographic evidence of betrayal (serialized cycle with weights) /// Format: version(1) + cycle_len(4) + [NodeID(4) + Risk(8)]... pub fn generateEvidence( self: *const BellmanFordResult, graph: *const RiskGraph, allocator: std.mem.Allocator, ) ![]u8 { if (self.betrayal_cycles.items.len == 0) return error.NoEvidence; const cycle = self.betrayal_cycles.items[0]; var evidence = std.ArrayListUnmanaged(u8){}; errdefer evidence.deinit(allocator); try evidence.writer(allocator).writeByte(0x01); // Version try evidence.writer(allocator).writeInt(u32, @intCast(cycle.len), .little); for (cycle, 0..) |node, i| { try evidence.writer(allocator).writeInt(u32, node, .little); // Find edge to next const next = cycle[(i + 1) % cycle.len]; var risk: f64 = 0.0; if (graph.getEdge(node, next)) |edge| { risk = edge.risk; } try evidence.writer(allocator).writeAll(std.mem.asBytes(&risk)); } return evidence.toOwnedSlice(allocator); } }; /// Run Bellman-Ford from source, detecting negative cycles (betrayal rings). /// /// Algorithm: /// 1. Relax all edges |V|-1 times. /// 2. On |V|th pass: If any edge still improves → negative cycle exists. /// 3. Trace cycle via predecessor map. pub fn detectBetrayal( graph: *const RiskGraph, source: NodeId, allocator: std.mem.Allocator, ) !BellmanFordResult { const n = graph.nodeCount(); if (n == 0) { return BellmanFordResult{ .allocator = allocator, .distances = .{}, .predecessors = .{}, .betrayal_cycles = .{}, }; } var dist = std.AutoHashMapUnmanaged(NodeId, f64){}; var prev = std.AutoHashMapUnmanaged(NodeId, ?NodeId){}; // Initialize distances for (graph.nodes.items) |node| { try dist.put(allocator, node, std.math.inf(f64)); try prev.put(allocator, node, null); } try dist.put(allocator, source, 0.0); // Relax edges |V|-1 times for (0..n - 1) |_| { var improved = false; for (graph.edges.items) |edge| { const d_from = dist.get(edge.from) orelse continue; if (d_from == std.math.inf(f64)) continue; const d_to = dist.get(edge.to) orelse std.math.inf(f64); const new_dist = d_from + edge.risk; if (new_dist < d_to) { try dist.put(allocator, edge.to, new_dist); try prev.put(allocator, edge.to, edge.from); improved = true; } } if (!improved) break; // Early exit: no more improvements } // Detect negative cycles (betrayal rings) var cycles = std.ArrayListUnmanaged([]NodeId){}; var in_cycle = std.AutoHashMapUnmanaged(NodeId, bool){}; defer in_cycle.deinit(allocator); for (graph.edges.items) |edge| { const d_from = dist.get(edge.from) orelse continue; if (d_from == std.math.inf(f64)) continue; const d_to = dist.get(edge.to) orelse continue; if (d_from + edge.risk < d_to) { // Negative cycle detected; trace it if (in_cycle.get(edge.to)) |_| continue; // Already traced const cycle = try traceCycle(edge.to, &prev, allocator); if (cycle.len > 0) { for (cycle) |node| { try in_cycle.put(allocator, node, true); } try cycles.append(allocator, cycle); } } } return BellmanFordResult{ .allocator = allocator, .distances = dist, .predecessors = prev, .betrayal_cycles = cycles, }; } /// Trace a cycle starting from a node in a negative cycle. fn traceCycle( start: NodeId, prev: *std.AutoHashMapUnmanaged(NodeId, ?NodeId), allocator: std.mem.Allocator, ) ![]NodeId { var visited = std.AutoHashMapUnmanaged(NodeId, usize){}; defer visited.deinit(allocator); var path = std.ArrayListUnmanaged(NodeId){}; defer path.deinit(allocator); var current: ?NodeId = start; var idx: usize = 0; // Walk backward until we hit a repeat (cycle entry) while (current) |curr| { if (visited.get(curr)) |cycle_start_idx| { // Found cycle; extract it const cycle_len = idx - cycle_start_idx; if (cycle_len == 0) return &[_]NodeId{}; const cycle = try allocator.alloc(NodeId, cycle_len); @memcpy(cycle, path.items[cycle_start_idx..idx]); return cycle; } try visited.put(allocator, curr, idx); try path.append(allocator, curr); current = if (prev.get(curr)) |p| p else null; idx += 1; if (idx > 10000) return error.CycleTooLong; // Safety limit } return &[_]NodeId{}; // No cycle found } // ============================================================================ // TESTS // ============================================================================ test "Bellman-Ford: No betrayal in clean graph" { const allocator = std.testing.allocator; var graph = RiskGraph.init(allocator); defer graph.deinit(); // A -> B -> C (all positive) try graph.addNode(0); try graph.addNode(1); try graph.addNode(2); try graph.addEdge(.{ .from = 0, .to = 1, .risk = 0.5, .timestamp = time.SovereignTimestamp.fromSeconds(0, .system_boot), .nonce = 0, .level = 3, .expires_at = time.SovereignTimestamp.fromSeconds(0, .system_boot) }); try graph.addEdge(.{ .from = 1, .to = 2, .risk = 0.3, .timestamp = time.SovereignTimestamp.fromSeconds(0, .system_boot), .nonce = 0, .level = 3, .expires_at = time.SovereignTimestamp.fromSeconds(0, .system_boot) }); var result = try detectBetrayal(&graph, 0, allocator); defer result.deinit(); try std.testing.expectEqual(result.betrayal_cycles.items.len, 0); try std.testing.expectEqual(result.computeAnomalyScore(), 0.0); } test "Bellman-Ford: Detect negative cycle (betrayal ring)" { const allocator = std.testing.allocator; var graph = RiskGraph.init(allocator); defer graph.deinit(); // Triangle: A -> B -> C -> A with negative total weight // A --0.2-> B --0.2-> C ---(-0.8)--> A = total -0.4 (negative) try graph.addNode(0); try graph.addNode(1); try graph.addNode(2); try graph.addEdge(.{ .from = 0, .to = 1, .risk = 0.2, .timestamp = time.SovereignTimestamp.fromSeconds(0, .system_boot), .nonce = 0, .level = 3, .expires_at = time.SovereignTimestamp.fromSeconds(0, .system_boot) }); try graph.addEdge(.{ .from = 1, .to = 2, .risk = 0.2, .timestamp = time.SovereignTimestamp.fromSeconds(0, .system_boot), .nonce = 0, .level = 3, .expires_at = time.SovereignTimestamp.fromSeconds(0, .system_boot) }); try graph.addEdge(.{ .from = 2, .to = 0, .risk = -0.8, .timestamp = time.SovereignTimestamp.fromSeconds(0, .system_boot), .nonce = 0, .level = 1, .expires_at = time.SovereignTimestamp.fromSeconds(0, .system_boot) }); // Betrayal! var result = try detectBetrayal(&graph, 0, allocator); defer result.deinit(); try std.testing.expectEqual(result.betrayal_cycles.items.len, 1); try std.testing.expect(result.computeAnomalyScore() > 0.0); // Check evidence generation const evidence = try result.generateEvidence(&graph, allocator); defer allocator.free(evidence); try std.testing.expect(evidence.len > 0); try std.testing.expectEqual(evidence[0], 0x01); // Version }