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