278 lines
9.5 KiB
Zig
278 lines
9.5 KiB
Zig
//! RFC-0120 Extension: Loopy Belief Propagation
|
|
//!
|
|
//! Bayesian inference over the trust DAG for:
|
|
//! - Edge weight estimation under uncertainty
|
|
//! - Probabilistic betrayal detection (integrates with Bellman-Ford)
|
|
//! - Robust anomaly scoring under partial visibility (eclipse attacks)
|
|
//!
|
|
//! Design: Treat DAG as factor graph; nodes send belief messages
|
|
//! until convergence (delta < epsilon). Output: per-node anomaly scores.
|
|
|
|
const std = @import("std");
|
|
const types = @import("types.zig");
|
|
|
|
const NodeId = types.NodeId;
|
|
const RiskGraph = types.RiskGraph;
|
|
const RiskEdge = types.RiskEdge;
|
|
const AnomalyScore = types.AnomalyScore;
|
|
|
|
/// Belief Propagation configuration.
|
|
pub const BPConfig = struct {
|
|
/// Maximum iterations before forced stop
|
|
max_iterations: usize = 100,
|
|
/// Convergence threshold (max belief delta)
|
|
epsilon: f64 = 1e-6,
|
|
/// Damping factor to prevent oscillation (0 = no damping, 1 = full damping)
|
|
damping: f64 = 0.5,
|
|
/// Prior belief (uniform assumption)
|
|
prior: f64 = 0.5,
|
|
};
|
|
|
|
/// Result of Belief Propagation inference.
|
|
pub const BPResult = struct {
|
|
allocator: std.mem.Allocator,
|
|
/// Final beliefs per node: P(node is trustworthy)
|
|
beliefs: std.AutoHashMapUnmanaged(NodeId, f64),
|
|
/// Anomaly scores derived from beliefs
|
|
anomaly_scores: std.ArrayListUnmanaged(AnomalyScore),
|
|
/// Iterations until convergence
|
|
iterations: usize,
|
|
/// Whether convergence was achieved
|
|
converged: bool,
|
|
|
|
pub fn deinit(self: *BPResult) void {
|
|
self.beliefs.deinit(self.allocator);
|
|
self.anomaly_scores.deinit(self.allocator);
|
|
}
|
|
|
|
/// Get anomaly score for a specific node.
|
|
pub fn getAnomalyScore(self: *const BPResult, node: NodeId) ?f64 {
|
|
// Low belief = high anomaly
|
|
if (self.beliefs.get(node)) |belief| {
|
|
return 1.0 - belief;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Get all nodes with anomaly score above threshold.
|
|
pub fn getAnomalousNodes(self: *const BPResult, threshold: f64, allocator: std.mem.Allocator) ![]AnomalyScore {
|
|
var result = std.ArrayListUnmanaged(AnomalyScore){};
|
|
|
|
for (self.anomaly_scores.items) |score| {
|
|
if (score.score >= threshold) {
|
|
try result.append(allocator, score);
|
|
}
|
|
}
|
|
|
|
return result.toOwnedSlice(allocator);
|
|
}
|
|
};
|
|
|
|
/// Run Loopy Belief Propagation on the trust graph.
|
|
///
|
|
/// Algorithm:
|
|
/// 1. Initialize all beliefs to prior (0.5 = uncertain).
|
|
/// 2. For each iteration:
|
|
/// a. Compute messages from edges (influence of neighbors).
|
|
/// b. Update beliefs based on incoming messages.
|
|
/// c. Check for convergence.
|
|
/// 3. Convert low beliefs to anomaly scores.
|
|
pub fn runInference(
|
|
graph: *const RiskGraph,
|
|
config: BPConfig,
|
|
allocator: std.mem.Allocator,
|
|
) !BPResult {
|
|
const n = graph.nodeCount();
|
|
if (n == 0) {
|
|
return BPResult{
|
|
.allocator = allocator,
|
|
.beliefs = .{},
|
|
.anomaly_scores = .{},
|
|
.iterations = 0,
|
|
.converged = true,
|
|
};
|
|
}
|
|
|
|
// Initialize beliefs to prior
|
|
var beliefs = std.AutoHashMapUnmanaged(NodeId, f64){};
|
|
var new_beliefs = std.AutoHashMapUnmanaged(NodeId, f64){};
|
|
defer new_beliefs.deinit(allocator);
|
|
|
|
for (graph.nodes.items) |node| {
|
|
try beliefs.put(allocator, node, config.prior);
|
|
try new_beliefs.put(allocator, node, config.prior);
|
|
}
|
|
|
|
// Message storage: edge -> belief contribution
|
|
var messages = std.AutoHashMapUnmanaged(usize, f64){}; // edge_idx -> message
|
|
defer messages.deinit(allocator);
|
|
|
|
for (0..graph.edgeCount()) |edge_idx| {
|
|
try messages.put(allocator, edge_idx, config.prior);
|
|
}
|
|
|
|
var iteration: usize = 0;
|
|
var converged = false;
|
|
|
|
while (iteration < config.max_iterations) : (iteration += 1) {
|
|
var max_delta: f64 = 0.0;
|
|
|
|
// Step 1: Compute messages from each edge
|
|
for (graph.edges.items, 0..) |edge, edge_idx| {
|
|
const sender_belief = beliefs.get(edge.from) orelse config.prior;
|
|
|
|
// Message: sender's belief modulated by edge risk
|
|
// High risk (negative) = low trust propagation
|
|
// Low risk (positive) = high trust propagation
|
|
const risk_factor = (1.0 - @abs(edge.risk)) * @as(f64, if (edge.risk >= 0) 1.0 else 0.5);
|
|
const new_msg = sender_belief * risk_factor;
|
|
|
|
const old_msg = messages.get(edge_idx) orelse config.prior;
|
|
// Apply damping
|
|
const damped_msg = config.damping * old_msg + (1.0 - config.damping) * new_msg;
|
|
try messages.put(allocator, edge_idx, damped_msg);
|
|
}
|
|
|
|
// Step 2: Update beliefs based on incoming messages
|
|
for (graph.nodes.items) |node| {
|
|
var incoming_sum: f64 = 0.0;
|
|
var incoming_count: usize = 0;
|
|
|
|
// Find all edges TO this node
|
|
for (graph.edges.items, 0..) |edge, edge_idx| {
|
|
if (edge.to == node) {
|
|
incoming_sum += messages.get(edge_idx) orelse config.prior;
|
|
incoming_count += 1;
|
|
}
|
|
}
|
|
|
|
const old_belief = beliefs.get(node) orelse config.prior;
|
|
const new_belief = if (incoming_count > 0)
|
|
incoming_sum / @as(f64, @floatFromInt(incoming_count))
|
|
else
|
|
config.prior;
|
|
|
|
// Apply damping
|
|
const damped_belief = config.damping * old_belief + (1.0 - config.damping) * new_belief;
|
|
// Clamp to [0, 1]
|
|
const clamped_belief = @max(0.0, @min(1.0, damped_belief));
|
|
try new_beliefs.put(allocator, node, clamped_belief);
|
|
|
|
const delta = @abs(clamped_belief - old_belief);
|
|
max_delta = @max(max_delta, delta);
|
|
}
|
|
|
|
// Copy new beliefs to beliefs
|
|
var it = new_beliefs.iterator();
|
|
while (it.next()) |entry| {
|
|
try beliefs.put(allocator, entry.key_ptr.*, entry.value_ptr.*);
|
|
}
|
|
|
|
// Check convergence
|
|
if (max_delta < config.epsilon) {
|
|
converged = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Step 3: Convert beliefs to anomaly scores
|
|
var anomaly_scores = std.ArrayListUnmanaged(AnomalyScore){};
|
|
for (graph.nodes.items) |node| {
|
|
const belief = beliefs.get(node) orelse config.prior;
|
|
const score = 1.0 - belief; // Low belief = high anomaly
|
|
if (score > 0.3) { // Only track notable anomalies
|
|
try anomaly_scores.append(allocator, .{
|
|
.node = node,
|
|
.score = score,
|
|
.reason = .bp_divergence,
|
|
});
|
|
}
|
|
}
|
|
|
|
return BPResult{
|
|
.allocator = allocator,
|
|
.beliefs = beliefs,
|
|
.anomaly_scores = anomaly_scores,
|
|
.iterations = iteration,
|
|
.converged = converged,
|
|
};
|
|
}
|
|
|
|
/// Update edge risks in graph based on BP beliefs.
|
|
/// This feeds BP output into Bellman-Ford for "probabilistic betrayal detection".
|
|
pub fn updateGraphFromBP(
|
|
graph: *RiskGraph,
|
|
result: *const BPResult,
|
|
) void {
|
|
for (graph.edges.items) |*edge| {
|
|
const from_belief = result.beliefs.get(edge.from) orelse 0.5;
|
|
const to_belief = result.beliefs.get(edge.to) orelse 0.5;
|
|
|
|
// Modulate risk by average belief
|
|
const avg_belief = (from_belief + to_belief) / 2.0;
|
|
edge.risk = edge.risk * avg_belief;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// TESTS
|
|
// ============================================================================
|
|
|
|
test "BP: Converges on clean graph" {
|
|
const allocator = std.testing.allocator;
|
|
var graph = types.RiskGraph.init(allocator);
|
|
defer graph.deinit();
|
|
|
|
// Simple chain: 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.8, .entropy_stamp = 0, .level = 3, .expires_at = 0 });
|
|
try graph.addEdge(.{ .from = 1, .to = 2, .risk = 0.7, .entropy_stamp = 0, .level = 3, .expires_at = 0 });
|
|
|
|
var result = try runInference(&graph, .{}, allocator);
|
|
defer result.deinit();
|
|
|
|
try std.testing.expect(result.converged);
|
|
try std.testing.expect(result.iterations < 100);
|
|
}
|
|
|
|
test "BP: Detects suspicious node" {
|
|
const allocator = std.testing.allocator;
|
|
var graph = types.RiskGraph.init(allocator);
|
|
defer graph.deinit();
|
|
|
|
// Node 2 has negative edges (suspicious)
|
|
try graph.addNode(0);
|
|
try graph.addNode(1);
|
|
try graph.addNode(2);
|
|
|
|
try graph.addEdge(.{ .from = 0, .to = 1, .risk = 0.9, .entropy_stamp = 0, .level = 3, .expires_at = 0 });
|
|
try graph.addEdge(.{ .from = 0, .to = 2, .risk = -0.5, .entropy_stamp = 0, .level = 1, .expires_at = 0 }); // Betrayal
|
|
try graph.addEdge(.{ .from = 1, .to = 2, .risk = -0.3, .entropy_stamp = 0, .level = 1, .expires_at = 0 }); // Betrayal
|
|
|
|
var result = try runInference(&graph, .{ .max_iterations = 50 }, allocator);
|
|
defer result.deinit();
|
|
|
|
// Node 2 should have lower belief (higher anomaly)
|
|
const score_2 = result.getAnomalyScore(2);
|
|
const score_0 = result.getAnomalyScore(0);
|
|
try std.testing.expect(score_2 != null);
|
|
try std.testing.expect(score_0 != null);
|
|
// Score 2 should be higher (more anomalous) than score 0
|
|
try std.testing.expect(score_2.? >= score_0.?);
|
|
}
|
|
|
|
test "BP: Empty graph" {
|
|
const allocator = std.testing.allocator;
|
|
var graph = types.RiskGraph.init(allocator);
|
|
defer graph.deinit();
|
|
|
|
var result = try runInference(&graph, .{}, allocator);
|
|
defer result.deinit();
|
|
|
|
try std.testing.expect(result.converged);
|
|
try std.testing.expectEqual(result.iterations, 0);
|
|
}
|