1use 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
15pub struct PetTopologyGraph {
17 graph: Graph<NodeData, EdgeData, petgraph::Undirected>,
18 node_index: HashMap<NodeId, NodeIndex>,
20 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 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 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 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 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 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 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 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); }
211
212 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 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 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 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 #[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) }
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 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[¤t];
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let pruned = graph.decay_edges(0.5, 0.08);
610 assert_eq!(pruned.len(), 1);
611 assert_eq!(graph.edge_count(), 1);
612 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 graph.set_edge(id1, id2, EdgeData {
664 weight: 0.5,
665 co_activations: 5,
666 created_tick: 0,
667 last_activated_tick: 95, });
669 graph.set_edge(id2, id3, EdgeData {
671 weight: 0.5,
672 co_activations: 1,
673 created_tick: 0,
674 last_activated_tick: 10, });
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 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 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 graph.set_edge(hub_id, nid, EdgeData {
706 weight: 1.0 - (i as f64 * 0.02), 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 assert_eq!(pruned.len(), 20, "hub should drop its 20 weakest edges");
725 assert_eq!(graph.edge_count(), 10);
726
727 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 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 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); clique.prune_to_max_degree(max_k);
762
763 assert!(
766 clique.edge_count() < initial_edges,
767 "edge count {} should be less than initial {initial_edges}",
768 clique.edge_count()
769 );
770 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 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 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 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 assert_eq!(result.communities.len(), 2, "Expected 2 communities, found {}", result.communities.len());
822
823 assert!(result.modularity > 0.0, "Modularity should be positive, got {}", result.modularity);
825
826 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 graph.set_edge(id1, id2, EdgeData {
847 weight: 0.3,
848 co_activations: 20, created_tick: 0,
850 last_activated_tick: 10,
851 });
852 graph.set_edge(id2, id3, EdgeData {
854 weight: 0.3,
855 co_activations: 1, created_tick: 0,
857 last_activated_tick: 10,
858 });
859
860 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 assert!(
870 strong_edge.is_some(),
871 "high co_activation edge should survive activity decay"
872 );
873 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 }
882}