Skip to main content

phago_runtime/
topology_impl.rs

1//! Concrete implementation of the TopologyGraph trait using petgraph.
2//!
3//! The knowledge graph is the substrate's structural backbone.
4//! This implementation uses petgraph's `Graph` as the backing store
5//! with HashMap indices for O(1) node/edge lookup by ID.
6
7use petgraph::graph::{Graph, NodeIndex};
8use petgraph::visit::EdgeRef;
9use phago_core::louvain::{self, LouvainResult};
10use phago_core::topology::TopologyGraph;
11use phago_core::types::*;
12use std::cmp::Ordering;
13use std::collections::HashMap;
14
15/// Petgraph-backed implementation of the topology graph.
16pub struct PetTopologyGraph {
17    graph: Graph<NodeData, EdgeData, petgraph::Undirected>,
18    /// Map from our NodeId to petgraph's internal index.
19    node_index: HashMap<NodeId, NodeIndex>,
20    /// Index from lowercase label to node IDs for O(1) exact lookup.
21    label_index: HashMap<String, Vec<NodeId>>,
22}
23
24impl PetTopologyGraph {
25    pub fn new() -> Self {
26        Self {
27            graph: Graph::new_undirected(),
28            node_index: HashMap::new(),
29            label_index: HashMap::new(),
30        }
31    }
32
33    /// O(1) exact label lookup (case-insensitive).
34    pub fn find_nodes_by_exact_label(&self, label: &str) -> &[NodeId] {
35        self.label_index
36            .get(&label.to_lowercase())
37            .map(|v| v.as_slice())
38            .unwrap_or(&[])
39    }
40}
41
42impl Default for PetTopologyGraph {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl TopologyGraph for PetTopologyGraph {
49    fn add_node(&mut self, data: NodeData) -> NodeId {
50        let id = data.id;
51        let label_key = data.label.to_lowercase();
52        let idx = self.graph.add_node(data);
53        self.node_index.insert(id, idx);
54        self.label_index.entry(label_key).or_default().push(id);
55        id
56    }
57
58    fn get_node(&self, id: &NodeId) -> Option<&NodeData> {
59        self.node_index.get(id).map(|idx| &self.graph[*idx])
60    }
61
62    fn get_node_mut(&mut self, id: &NodeId) -> Option<&mut NodeData> {
63        self.node_index
64            .get(id)
65            .copied()
66            .map(|idx| &mut self.graph[idx])
67    }
68
69    fn set_edge(&mut self, from: NodeId, to: NodeId, data: EdgeData) {
70        let Some(&from_idx) = self.node_index.get(&from) else {
71            return;
72        };
73        let Some(&to_idx) = self.node_index.get(&to) else {
74            return;
75        };
76
77        // Check if edge already exists
78        if let Some(edge_idx) = self.graph.find_edge(from_idx, to_idx) {
79            self.graph[edge_idx] = data;
80        } else {
81            self.graph.add_edge(from_idx, to_idx, data);
82        }
83    }
84
85    fn get_edge(&self, from: &NodeId, to: &NodeId) -> Option<&EdgeData> {
86        let from_idx = self.node_index.get(from)?;
87        let to_idx = self.node_index.get(to)?;
88        let edge_idx = self.graph.find_edge(*from_idx, *to_idx)?;
89        Some(&self.graph[edge_idx])
90    }
91
92    fn get_edge_mut(&mut self, from: &NodeId, to: &NodeId) -> Option<&mut EdgeData> {
93        let from_idx = *self.node_index.get(from)?;
94        let to_idx = *self.node_index.get(to)?;
95        let edge_idx = self.graph.find_edge(from_idx, to_idx)?;
96        Some(&mut self.graph[edge_idx])
97    }
98
99    fn neighbors(&self, node: &NodeId) -> Vec<(NodeId, &EdgeData)> {
100        let Some(&node_idx) = self.node_index.get(node) else {
101            return Vec::new();
102        };
103
104        self.graph
105            .edges(node_idx)
106            .map(|edge| {
107                let other_idx = if edge.source() == node_idx {
108                    edge.target()
109                } else {
110                    edge.source()
111                };
112                let other_data = &self.graph[other_idx];
113                (other_data.id, edge.weight())
114            })
115            .collect()
116    }
117
118    fn remove_edge(&mut self, from: &NodeId, to: &NodeId) -> Option<EdgeData> {
119        let from_idx = *self.node_index.get(from)?;
120        let to_idx = *self.node_index.get(to)?;
121        let edge_idx = self.graph.find_edge(from_idx, to_idx)?;
122        self.graph.remove_edge(edge_idx)
123    }
124
125    fn all_nodes(&self) -> Vec<NodeId> {
126        self.graph
127            .node_indices()
128            .map(|idx| self.graph[idx].id)
129            .collect()
130    }
131
132    fn all_edges(&self) -> Vec<(NodeId, NodeId, &EdgeData)> {
133        self.graph
134            .edge_indices()
135            .map(|idx| {
136                let (a, b) = self.graph.edge_endpoints(idx).unwrap();
137                (self.graph[a].id, self.graph[b].id, &self.graph[idx])
138            })
139            .collect()
140    }
141
142    fn node_count(&self) -> usize {
143        self.graph.node_count()
144    }
145
146    fn edge_count(&self) -> usize {
147        self.graph.edge_count()
148    }
149
150    fn decay_edges(&mut self, rate: f64, prune_threshold: f64) -> Vec<PrunedConnection> {
151        // First pass: decay all weights and collect edges to prune
152        let mut to_remove = Vec::new();
153
154        for edge_idx in self.graph.edge_indices() {
155            self.graph[edge_idx].weight *= 1.0 - rate;
156        }
157
158        // Second pass: identify edges below threshold
159        for edge_idx in self.graph.edge_indices() {
160            if self.graph[edge_idx].weight < prune_threshold {
161                let (a, b) = self.graph.edge_endpoints(edge_idx).unwrap();
162                to_remove.push((edge_idx, self.graph[a].id, self.graph[b].id, self.graph[edge_idx].weight));
163            }
164        }
165
166        let pruned: Vec<PrunedConnection> = to_remove
167            .iter()
168            .map(|(_, from, to, weight)| PrunedConnection {
169                from: *from,
170                to: *to,
171                final_weight: *weight,
172            })
173            .collect();
174
175        // Remove in reverse order to avoid index invalidation
176        let mut indices: Vec<_> = to_remove.into_iter().map(|(idx, _, _, _)| idx).collect();
177        indices.sort();
178        for idx in indices.into_iter().rev() {
179            self.graph.remove_edge(idx);
180        }
181
182        pruned
183    }
184
185    fn decay_edges_activity(
186        &mut self,
187        base_rate: f64,
188        prune_threshold: f64,
189        current_tick: u64,
190        staleness_factor: f64,
191        maturation_ticks: u64,
192    ) -> Vec<PrunedConnection> {
193        let mut to_remove = Vec::new();
194
195        // Decay pass: compute per-edge effective rate
196        for edge_idx in self.graph.edge_indices() {
197            let edge = &self.graph[edge_idx];
198            let age = current_tick.saturating_sub(edge.created_tick);
199
200            let effective_rate = if age < maturation_ticks {
201                // Young edges: base rate only (maturation window)
202                base_rate
203            } else {
204                let staleness = current_tick.saturating_sub(edge.last_activated_tick) as f64;
205                let activity_factor = 1.0 / (1.0 + edge.co_activations as f64 * 0.5);
206                base_rate * (1.0 + staleness_factor * (staleness / 100.0) * activity_factor)
207            };
208
209            self.graph[edge_idx].weight *= 1.0 - effective_rate.min(0.5); // cap at 50% per tick
210        }
211
212        // Prune pass: only prune mature edges (young edges get a grace period)
213        for edge_idx in self.graph.edge_indices() {
214            let edge = &self.graph[edge_idx];
215            let age = current_tick.saturating_sub(edge.created_tick);
216            if age >= maturation_ticks && edge.weight < prune_threshold {
217                let (a, b) = self.graph.edge_endpoints(edge_idx).unwrap();
218                to_remove.push((edge_idx, self.graph[a].id, self.graph[b].id, self.graph[edge_idx].weight));
219            }
220        }
221
222        let pruned: Vec<PrunedConnection> = to_remove
223            .iter()
224            .map(|(_, from, to, weight)| PrunedConnection {
225                from: *from,
226                to: *to,
227                final_weight: *weight,
228            })
229            .collect();
230
231        let mut indices: Vec<_> = to_remove.into_iter().map(|(idx, _, _, _)| idx).collect();
232        indices.sort();
233        for idx in indices.into_iter().rev() {
234            self.graph.remove_edge(idx);
235        }
236
237        pruned
238    }
239
240    fn prune_to_max_degree(&mut self, max_degree: usize) -> Vec<PrunedConnection> {
241        use std::collections::HashSet;
242
243        // For each over-degree node, identify which edges to drop (weakest beyond top-K).
244        // An edge is removed if ANY of its endpoints wants to drop it.
245        let mut dropped_edges: HashSet<petgraph::graph::EdgeIndex> = HashSet::new();
246
247        for node_idx in self.graph.node_indices() {
248            let mut edges: Vec<(petgraph::graph::EdgeIndex, f64)> = self
249                .graph
250                .edges(node_idx)
251                .map(|e| (e.id(), e.weight().weight))
252                .collect();
253
254            if edges.len() > max_degree {
255                // Sort descending by weight, drop everything beyond top-K
256                edges.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
257                for (eidx, _) in edges.iter().skip(max_degree) {
258                    dropped_edges.insert(*eidx);
259                }
260            }
261        }
262
263        // Remove all dropped edges
264        let mut to_remove = Vec::new();
265        for &edge_idx in &dropped_edges {
266            if let Some((a, b)) = self.graph.edge_endpoints(edge_idx) {
267                to_remove.push((edge_idx, self.graph[a].id, self.graph[b].id, self.graph[edge_idx].weight));
268            }
269        }
270
271        let pruned: Vec<PrunedConnection> = to_remove
272            .iter()
273            .map(|(_, from, to, weight)| PrunedConnection {
274                from: *from,
275                to: *to,
276                final_weight: *weight,
277            })
278            .collect();
279
280        let mut indices: Vec<_> = to_remove.into_iter().map(|(idx, _, _, _)| idx).collect();
281        indices.sort();
282        for idx in indices.into_iter().rev() {
283            self.graph.remove_edge(idx);
284        }
285
286        pruned
287    }
288
289    fn find_nodes_by_label(&self, query: &str) -> Vec<NodeId> {
290        let query_lower = query.to_lowercase();
291        self.graph
292            .node_indices()
293            .filter(|&idx| self.graph[idx].label.to_lowercase().contains(&query_lower))
294            .map(|idx| self.graph[idx].id)
295            .collect()
296    }
297
298    fn shortest_path(&self, from: &NodeId, to: &NodeId) -> Option<(Vec<NodeId>, f64)> {
299        use std::collections::BinaryHeap;
300        use std::cmp::Ordering;
301
302        let from_idx = *self.node_index.get(from)?;
303        let to_idx = *self.node_index.get(to)?;
304
305        // Dijkstra with inverse weight as cost (prefer stronger edges)
306        #[derive(PartialEq)]
307        struct State {
308            cost: f64,
309            node: NodeIndex,
310        }
311        impl Eq for State {}
312        impl PartialOrd for State {
313            fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
314                other.cost.partial_cmp(&self.cost) // min-heap
315            }
316        }
317        impl Ord for State {
318            fn cmp(&self, other: &Self) -> Ordering {
319                self.partial_cmp(other).unwrap_or(Ordering::Equal)
320            }
321        }
322
323        let mut dist: HashMap<NodeIndex, f64> = HashMap::new();
324        let mut prev: HashMap<NodeIndex, NodeIndex> = HashMap::new();
325        let mut heap = BinaryHeap::new();
326
327        dist.insert(from_idx, 0.0);
328        heap.push(State { cost: 0.0, node: from_idx });
329
330        while let Some(State { cost, node }) = heap.pop() {
331            if node == to_idx {
332                // Reconstruct path
333                let mut path = Vec::new();
334                let mut current = to_idx;
335                while current != from_idx {
336                    path.push(self.graph[current].id);
337                    current = prev[&current];
338                }
339                path.push(self.graph[from_idx].id);
340                path.reverse();
341                return Some((path, cost));
342            }
343
344            if cost > *dist.get(&node).unwrap_or(&f64::INFINITY) {
345                continue;
346            }
347
348            for edge in self.graph.edges(node) {
349                let next = if edge.source() == node { edge.target() } else { edge.source() };
350                // Cost = 1/weight so strong edges are "shorter"
351                let edge_cost = 1.0 / edge.weight().weight.max(0.001);
352                let next_cost = cost + edge_cost;
353
354                if next_cost < *dist.get(&next).unwrap_or(&f64::INFINITY) {
355                    dist.insert(next, next_cost);
356                    prev.insert(next, node);
357                    heap.push(State { cost: next_cost, node: next });
358                }
359            }
360        }
361        None
362    }
363
364    fn betweenness_centrality(&self, sample_size: usize) -> Vec<(NodeId, f64)> {
365        let nodes: Vec<NodeIndex> = self.graph.node_indices().collect();
366        let n = nodes.len();
367        if n < 2 {
368            return Vec::new();
369        }
370
371        let mut centrality: HashMap<NodeIndex, f64> = HashMap::new();
372        for &idx in &nodes {
373            centrality.insert(idx, 0.0);
374        }
375
376        // Sample pairs for approximate centrality
377        let pairs_to_sample = sample_size.min(n * (n - 1) / 2);
378        let mut seed: u64 = 42;
379        let mut sampled = 0;
380
381        while sampled < pairs_to_sample {
382            // Simple PRNG for pair selection
383            seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
384            let i = (seed >> 33) as usize % n;
385            seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
386            let j = (seed >> 33) as usize % n;
387            if i == j { continue; }
388            sampled += 1;
389
390            let from_id = self.graph[nodes[i]].id;
391            let to_id = self.graph[nodes[j]].id;
392
393            if let Some((path, _)) = self.shortest_path(&from_id, &to_id) {
394                // Credit intermediate nodes (not endpoints)
395                for node_id in &path[1..path.len().saturating_sub(1)] {
396                    if let Some(&idx) = self.node_index.get(node_id) {
397                        *centrality.entry(idx).or_insert(0.0) += 1.0;
398                    }
399                }
400            }
401        }
402
403        // Normalize by number of pairs sampled
404        let norm = if sampled > 0 { sampled as f64 } else { 1.0 };
405        let mut result: Vec<(NodeId, f64)> = centrality
406            .into_iter()
407            .map(|(idx, c)| (self.graph[idx].id, c / norm))
408            .collect();
409        result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
410        result
411    }
412
413    fn bridge_nodes(&self, top_k: usize) -> Vec<(NodeId, f64)> {
414        let base_components = self.connected_components();
415        let nodes: Vec<NodeIndex> = self.graph.node_indices().collect();
416
417        let mut fragility: Vec<(NodeId, f64)> = Vec::new();
418
419        for &node_idx in &nodes {
420            let degree = self.graph.edges(node_idx).count();
421            if degree == 0 { continue; }
422
423            // Simulate removal: count how many components would result
424            // by doing BFS on the remaining graph
425            let mut visited = std::collections::HashSet::new();
426            visited.insert(node_idx);
427            let mut components = 0;
428
429            for &start in &nodes {
430                if visited.contains(&start) { continue; }
431                components += 1;
432                // BFS from start, skipping node_idx
433                let mut queue = std::collections::VecDeque::new();
434                queue.push_back(start);
435                visited.insert(start);
436                while let Some(current) = queue.pop_front() {
437                    for edge in self.graph.edges(current) {
438                        let next = if edge.source() == current { edge.target() } else { edge.source() };
439                        if !visited.contains(&next) {
440                            visited.insert(next);
441                            queue.push_back(next);
442                        }
443                    }
444                }
445            }
446
447            let delta = components as f64 - base_components as f64;
448            let score = delta / degree as f64;
449            if score > 0.0 {
450                fragility.push((self.graph[node_idx].id, score));
451            }
452        }
453
454        fragility.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
455        fragility.truncate(top_k);
456        fragility
457    }
458
459    fn connected_components(&self) -> usize {
460        let mut visited = std::collections::HashSet::new();
461        let mut components = 0;
462
463        for node_idx in self.graph.node_indices() {
464            if visited.contains(&node_idx) { continue; }
465            components += 1;
466            let mut queue = std::collections::VecDeque::new();
467            queue.push_back(node_idx);
468            visited.insert(node_idx);
469            while let Some(current) = queue.pop_front() {
470                for edge in self.graph.edges(current) {
471                    let next = if edge.source() == current { edge.target() } else { edge.source() };
472                    if !visited.contains(&next) {
473                        visited.insert(next);
474                        queue.push_back(next);
475                    }
476                }
477            }
478        }
479
480        components
481    }
482
483    fn find_nodes_by_exact_label(&self, label: &str) -> Vec<NodeId> {
484        // Use O(1) index lookup
485        self.label_index
486            .get(&label.to_lowercase())
487            .map(|v| v.clone())
488            .unwrap_or_default()
489    }
490
491    fn louvain_communities(&self) -> LouvainResult {
492        // Collect node IDs and create index mapping
493        let node_ids: Vec<NodeId> = self.all_nodes();
494        if node_ids.is_empty() {
495            return LouvainResult {
496                communities: Vec::new(),
497                modularity: 0.0,
498                passes: 0,
499            };
500        }
501
502        // Create NodeId -> index mapping
503        let id_to_idx: std::collections::HashMap<NodeId, usize> = node_ids
504            .iter()
505            .enumerate()
506            .map(|(i, &id)| (id, i))
507            .collect();
508
509        // Collect edges as (from_idx, to_idx, weight)
510        let edges: Vec<(usize, usize, f64)> = self
511            .all_edges()
512            .iter()
513            .filter_map(|(from, to, edge)| {
514                let from_idx = id_to_idx.get(from)?;
515                let to_idx = id_to_idx.get(to)?;
516                // Only include each undirected edge once
517                if from_idx < to_idx {
518                    Some((*from_idx, *to_idx, edge.weight))
519                } else {
520                    None
521                }
522            })
523            .collect();
524
525        louvain::louvain_communities(&node_ids, &edges)
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn make_node(label: &str, tick: u64) -> NodeData {
534        NodeData {
535            id: NodeId::new(),
536            label: label.to_string(),
537            node_type: NodeType::Concept,
538            position: Position::new(0.0, 0.0),
539            access_count: 0,
540            created_tick: tick,
541            embedding: None,
542        }
543    }
544
545    fn make_edge(tick: u64) -> EdgeData {
546        EdgeData {
547            weight: 1.0,
548            co_activations: 1,
549            created_tick: tick,
550            last_activated_tick: tick,
551        }
552    }
553
554    #[test]
555    fn add_and_retrieve_nodes() {
556        let mut graph = PetTopologyGraph::new();
557        let node = make_node("cell", 0);
558        let id = node.id;
559        graph.add_node(node);
560
561        assert_eq!(graph.node_count(), 1);
562        assert_eq!(graph.get_node(&id).unwrap().label, "cell");
563    }
564
565    #[test]
566    fn add_and_retrieve_edges() {
567        let mut graph = PetTopologyGraph::new();
568        let n1 = make_node("cell", 0);
569        let n2 = make_node("membrane", 0);
570        let id1 = n1.id;
571        let id2 = n2.id;
572        graph.add_node(n1);
573        graph.add_node(n2);
574        graph.set_edge(id1, id2, make_edge(0));
575
576        assert_eq!(graph.edge_count(), 1);
577        assert_eq!(graph.get_edge(&id1, &id2).unwrap().weight, 1.0);
578    }
579
580    #[test]
581    fn decay_and_prune_edges() {
582        let mut graph = PetTopologyGraph::new();
583        let n1 = make_node("a", 0);
584        let n2 = make_node("b", 0);
585        let n3 = make_node("c", 0);
586        let id1 = n1.id;
587        let id2 = n2.id;
588        let id3 = n3.id;
589        graph.add_node(n1);
590        graph.add_node(n2);
591        graph.add_node(n3);
592
593        // Strong edge
594        graph.set_edge(id1, id2, EdgeData {
595            weight: 1.0,
596            co_activations: 10,
597            created_tick: 0,
598            last_activated_tick: 0,
599        });
600        // Weak edge
601        graph.set_edge(id2, id3, EdgeData {
602            weight: 0.1,
603            co_activations: 1,
604            created_tick: 0,
605            last_activated_tick: 0,
606        });
607
608        // Decay by 50% — strong edge survives, weak edge gets pruned
609        let pruned = graph.decay_edges(0.5, 0.08);
610        assert_eq!(pruned.len(), 1);
611        assert_eq!(graph.edge_count(), 1);
612        // Strong edge decayed to 0.5
613        assert!((graph.get_edge(&id1, &id2).unwrap().weight - 0.5).abs() < f64::EPSILON);
614    }
615
616    #[test]
617    fn find_nodes_by_label() {
618        let mut graph = PetTopologyGraph::new();
619        graph.add_node(make_node("cell membrane", 0));
620        graph.add_node(make_node("cell wall", 0));
621        graph.add_node(make_node("protein", 0));
622
623        let found = graph.find_nodes_by_label("cell");
624        assert_eq!(found.len(), 2);
625
626        let found = graph.find_nodes_by_label("protein");
627        assert_eq!(found.len(), 1);
628    }
629
630    #[test]
631    fn neighbors() {
632        let mut graph = PetTopologyGraph::new();
633        let n1 = make_node("a", 0);
634        let n2 = make_node("b", 0);
635        let n3 = make_node("c", 0);
636        let id1 = n1.id;
637        let id2 = n2.id;
638        let id3 = n3.id;
639        graph.add_node(n1);
640        graph.add_node(n2);
641        graph.add_node(n3);
642        graph.set_edge(id1, id2, make_edge(0));
643        graph.set_edge(id1, id3, make_edge(0));
644
645        let neighbors = graph.neighbors(&id1);
646        assert_eq!(neighbors.len(), 2);
647    }
648
649    #[test]
650    fn activity_decay_penalizes_stale_edges() {
651        let mut graph = PetTopologyGraph::new();
652        let n1 = make_node("a", 0);
653        let n2 = make_node("b", 0);
654        let n3 = make_node("c", 0);
655        let id1 = n1.id;
656        let id2 = n2.id;
657        let id3 = n3.id;
658        graph.add_node(n1);
659        graph.add_node(n2);
660        graph.add_node(n3);
661
662        // Recently activated edge
663        graph.set_edge(id1, id2, EdgeData {
664            weight: 0.5,
665            co_activations: 5,
666            created_tick: 0,
667            last_activated_tick: 95, // activated 5 ticks ago
668        });
669        // Stale edge (same initial weight, same co_activations but not activated recently)
670        graph.set_edge(id2, id3, EdgeData {
671            weight: 0.5,
672            co_activations: 1,
673            created_tick: 0,
674            last_activated_tick: 10, // activated 90 ticks ago
675        });
676
677        let current_tick = 100;
678        graph.decay_edges_activity(0.005, 0.01, current_tick, 4.0, 30);
679
680        let fresh_weight = graph.get_edge(&id1, &id2).unwrap().weight;
681        let stale_weight = graph.get_edge(&id2, &id3).unwrap().weight;
682
683        // Stale edge should have decayed more than fresh edge
684        assert!(
685            stale_weight < fresh_weight,
686            "stale edge ({stale_weight}) should be lighter than fresh edge ({fresh_weight})"
687        );
688    }
689
690    #[test]
691    fn competitive_pruning_enforces_max_degree() {
692        let mut graph = PetTopologyGraph::new();
693
694        // Create a hub node connected to 30 neighbors
695        let hub = make_node("hub", 0);
696        let hub_id = hub.id;
697        graph.add_node(hub);
698
699        let mut neighbor_ids = Vec::new();
700        for i in 0..30 {
701            let n = make_node(&format!("n{i}"), 0);
702            let nid = n.id;
703            graph.add_node(n);
704            // Assign decreasing weights so we know which survive
705            graph.set_edge(hub_id, nid, EdgeData {
706                weight: 1.0 - (i as f64 * 0.02), // 1.0, 0.98, 0.96, ...
707                co_activations: 1,
708                created_tick: 0,
709                last_activated_tick: 0,
710            });
711            neighbor_ids.push(nid);
712        }
713
714        assert_eq!(graph.edge_count(), 30);
715
716        let max_degree = 10;
717        let pruned = graph.prune_to_max_degree(max_degree);
718
719        // Hub had 30 edges, each neighbor had 1 edge.
720        // Hub keeps top 10. Each neighbor keeps its 1 edge (it's their only one).
721        // Hub had 30 edges, max_degree=10. Hub drops its weakest 20.
722        // With "any endpoint drops" policy, those 20 edges are removed
723        // even though each spoke only has degree 1.
724        assert_eq!(pruned.len(), 20, "hub should drop its 20 weakest edges");
725        assert_eq!(graph.edge_count(), 10);
726
727        // Verify the 10 surviving edges are the strongest ones
728        let hub_neighbors = graph.neighbors(&hub_id);
729        assert_eq!(hub_neighbors.len(), 10);
730        for (_, edge) in &hub_neighbors {
731            assert!(edge.weight >= 0.8, "surviving edges should be the strongest, got {:.2}", edge.weight);
732        }
733
734        // Now test with a clique where all nodes exceed max_degree
735        // Use uniform weights so each node's top-K is deterministic
736        let mut clique = PetTopologyGraph::new();
737        let mut ids = Vec::new();
738        let n_nodes = 15;
739        let max_k = 5;
740        for i in 0..n_nodes {
741            let n = make_node(&format!("c{i}"), 0);
742            ids.push(n.id);
743            clique.add_node(n);
744        }
745        // Connect with distinct weights: edge (i,j) gets weight = 1.0 - distance/n_nodes
746        // so each node's nearest-index neighbors have highest weight
747        for i in 0..n_nodes {
748            for j in (i + 1)..n_nodes {
749                let dist = (j - i) as f64;
750                clique.set_edge(ids[i], ids[j], EdgeData {
751                    weight: 1.0 - dist / n_nodes as f64,
752                    co_activations: 1,
753                    created_tick: 0,
754                    last_activated_tick: 0,
755                });
756            }
757        }
758        let initial_edges = clique.edge_count();
759        assert_eq!(initial_edges, n_nodes * (n_nodes - 1) / 2); // 105
760
761        clique.prune_to_max_degree(max_k);
762
763        // "Any endpoint drops" policy: edges pruned if either endpoint is over-degree.
764        // Verify total edges dropped significantly.
765        assert!(
766            clique.edge_count() < initial_edges,
767            "edge count {} should be less than initial {initial_edges}",
768            clique.edge_count()
769        );
770        // With max_k=5 and 15 nodes, each node keeps top 5.
771        // Total kept edges <= n_nodes * max_k (counting each edge from both endpoints)
772        // = 75, but duplicates mean actual unique edges <= 75.
773        // Still a significant reduction from 105.
774        assert!(
775            clique.edge_count() <= n_nodes * max_k,
776            "edge count {} should be at most {}",
777            clique.edge_count(),
778            n_nodes * max_k
779        );
780    }
781
782    #[test]
783    fn louvain_detects_communities() {
784        let mut graph = PetTopologyGraph::new();
785
786        // Create two clusters with weak inter-cluster connection
787        // Cluster 1: a, b, c (fully connected)
788        let a = make_node("a", 0);
789        let b = make_node("b", 0);
790        let c = make_node("c", 0);
791        let id_a = a.id;
792        let id_b = b.id;
793        let id_c = c.id;
794        graph.add_node(a);
795        graph.add_node(b);
796        graph.add_node(c);
797        graph.set_edge(id_a, id_b, EdgeData { weight: 1.0, co_activations: 5, created_tick: 0, last_activated_tick: 0 });
798        graph.set_edge(id_b, id_c, EdgeData { weight: 1.0, co_activations: 5, created_tick: 0, last_activated_tick: 0 });
799        graph.set_edge(id_a, id_c, EdgeData { weight: 1.0, co_activations: 5, created_tick: 0, last_activated_tick: 0 });
800
801        // Cluster 2: d, e, f (fully connected)
802        let d = make_node("d", 0);
803        let e = make_node("e", 0);
804        let f = make_node("f", 0);
805        let id_d = d.id;
806        let id_e = e.id;
807        let id_f = f.id;
808        graph.add_node(d);
809        graph.add_node(e);
810        graph.add_node(f);
811        graph.set_edge(id_d, id_e, EdgeData { weight: 1.0, co_activations: 5, created_tick: 0, last_activated_tick: 0 });
812        graph.set_edge(id_e, id_f, EdgeData { weight: 1.0, co_activations: 5, created_tick: 0, last_activated_tick: 0 });
813        graph.set_edge(id_d, id_f, EdgeData { weight: 1.0, co_activations: 5, created_tick: 0, last_activated_tick: 0 });
814
815        // Weak inter-cluster connection
816        graph.set_edge(id_c, id_d, EdgeData { weight: 0.1, co_activations: 1, created_tick: 0, last_activated_tick: 0 });
817
818        let result = graph.louvain_communities();
819
820        // Should find 2 communities
821        assert_eq!(result.communities.len(), 2, "Expected 2 communities, found {}", result.communities.len());
822
823        // Modularity should be positive
824        assert!(result.modularity > 0.0, "Modularity should be positive, got {}", result.modularity);
825
826        // Each community should have 3 nodes
827        let sizes: Vec<usize> = result.communities.iter().map(|c| c.len()).collect();
828        assert!(sizes.contains(&3), "One community should have 3 nodes");
829        assert_eq!(sizes.iter().sum::<usize>(), 6, "Total nodes should be 6");
830    }
831
832    #[test]
833    fn co_activation_gate_protects_strong_edges() {
834        let mut graph = PetTopologyGraph::new();
835        let n1 = make_node("a", 0);
836        let n2 = make_node("b", 0);
837        let n3 = make_node("c", 0);
838        let id1 = n1.id;
839        let id2 = n2.id;
840        let id3 = n3.id;
841        graph.add_node(n1);
842        graph.add_node(n2);
843        graph.add_node(n3);
844
845        // High co_activation edge, but stale
846        graph.set_edge(id1, id2, EdgeData {
847            weight: 0.3,
848            co_activations: 20, // well-established connection
849            created_tick: 0,
850            last_activated_tick: 10,
851        });
852        // Low co_activation edge, equally stale
853        graph.set_edge(id2, id3, EdgeData {
854            weight: 0.3,
855            co_activations: 1, // weak connection
856            created_tick: 0,
857            last_activated_tick: 10,
858        });
859
860        // Run many decay rounds
861        for tick in 100..120 {
862            graph.decay_edges_activity(0.005, 0.05, tick, 4.0, 30);
863        }
864
865        let strong_edge = graph.get_edge(&id1, &id2);
866        let weak_edge = graph.get_edge(&id2, &id3);
867
868        // The high co_activation edge should survive; the weak one may be pruned
869        assert!(
870            strong_edge.is_some(),
871            "high co_activation edge should survive activity decay"
872        );
873        // The weak edge should be pruned or at least much lighter
874        if let Some(weak) = weak_edge {
875            assert!(
876                weak.weight < strong_edge.unwrap().weight,
877                "weak edge should be lighter than strong edge"
878            );
879        }
880        // (If weak_edge is None, it was pruned — also a valid outcome)
881    }
882}