1use 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
14pub struct PetTopologyGraph {
16 graph: Graph<NodeData, EdgeData, petgraph::Undirected>,
17 node_index: HashMap<NodeId, NodeIndex>,
19 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 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 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 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 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 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 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 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); }
210
211 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 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 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 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 #[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) }
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 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[¤t];
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 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 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 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 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 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 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 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 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 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 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 let pruned = graph.decay_edges(0.5, 0.08);
572 assert_eq!(pruned.len(), 1);
573 assert_eq!(graph.edge_count(), 1);
574 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 graph.set_edge(id1, id2, EdgeData {
626 weight: 0.5,
627 co_activations: 5,
628 created_tick: 0,
629 last_activated_tick: 95, });
631 graph.set_edge(id2, id3, EdgeData {
633 weight: 0.5,
634 co_activations: 1,
635 created_tick: 0,
636 last_activated_tick: 10, });
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 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 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 graph.set_edge(hub_id, nid, EdgeData {
668 weight: 1.0 - (i as f64 * 0.02), 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 assert_eq!(pruned.len(), 20, "hub should drop its 20 weakest edges");
687 assert_eq!(graph.edge_count(), 10);
688
689 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 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 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); clique.prune_to_max_degree(max_k);
724
725 assert!(
728 clique.edge_count() < initial_edges,
729 "edge count {} should be less than initial {initial_edges}",
730 clique.edge_count()
731 );
732 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 graph.set_edge(id1, id2, EdgeData {
759 weight: 0.3,
760 co_activations: 20, created_tick: 0,
762 last_activated_tick: 10,
763 });
764 graph.set_edge(id2, id3, EdgeData {
766 weight: 0.3,
767 co_activations: 1, created_tick: 0,
769 last_activated_tick: 10,
770 });
771
772 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 assert!(
782 strong_edge.is_some(),
783 "high co_activation edge should survive activity decay"
784 );
785 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 }
794}