1use std::cmp::Ordering;
4use std::collections::{HashMap, HashSet, VecDeque};
5
6use crate::entity::EntityId;
7use crate::store::{EdgeSource, InEdge, Store};
8
9pub fn reachable(store: &Store, from: &EntityId, max_depth: usize) -> Vec<EntityId> {
13 let mut visited = HashSet::new();
14 visited.insert(from.clone());
15
16 let mut queue: VecDeque<(EntityId, usize)> = VecDeque::new();
17 queue.push_back((from.clone(), 0));
18
19 while let Some((id, depth)) = queue.pop_front() {
20 if depth >= max_depth {
21 continue;
22 }
23
24 for edge in store.outgoing(&id) {
25 if !visited.contains(&edge.target) {
26 visited.insert(edge.target.clone());
27 queue.push_back((edge.target.clone(), depth + 1));
28 }
29 }
30 for edge in store.incoming(&id) {
31 if !visited.contains(&edge.from) {
32 visited.insert(edge.from.clone());
33 queue.push_back((edge.from.clone(), depth + 1));
34 }
35 }
36 }
37
38 visited.into_iter().collect()
39}
40
41pub fn reachable_distances(
47 store: &Store,
48 from: &EntityId,
49 max_depth: usize,
50) -> HashMap<EntityId, usize> {
51 let mut dist: HashMap<EntityId, usize> = HashMap::new();
52 dist.insert(from.clone(), 0);
53
54 let mut queue: VecDeque<(EntityId, usize)> = VecDeque::new();
55 queue.push_back((from.clone(), 0));
56
57 while let Some((id, depth)) = queue.pop_front() {
58 if depth >= max_depth {
59 continue;
60 }
61 for edge in store.outgoing(&id) {
62 if !dist.contains_key(&edge.target) {
63 dist.insert(edge.target.clone(), depth + 1);
64 queue.push_back((edge.target.clone(), depth + 1));
65 }
66 }
67 for edge in store.incoming(&id) {
68 if !dist.contains_key(&edge.from) {
69 dist.insert(edge.from.clone(), depth + 1);
70 queue.push_back((edge.from.clone(), depth + 1));
71 }
72 }
73 }
74 dist
75}
76
77pub fn reachable_via(
89 store: &Store,
90 from: &EntityId,
91 edge_types: &[String],
92 max_depth: usize,
93) -> Vec<(EntityId, String, usize)> {
94 if max_depth == 0 || edge_types.is_empty() {
95 return Vec::new();
96 }
97
98 let mut visited: HashSet<EntityId> = HashSet::new();
99 visited.insert(from.clone());
100
101 let mut results: Vec<(EntityId, String, usize)> = Vec::new();
102 let mut queue: VecDeque<(EntityId, usize)> = VecDeque::new();
103 queue.push_back((from.clone(), 0));
104
105 while let Some((id, depth)) = queue.pop_front() {
106 if depth >= max_depth {
107 continue;
108 }
109 for edge in store.outgoing(&id) {
110 if !edge_types.iter().any(|t| t == &edge.rel_type) {
111 continue;
112 }
113 if visited.insert(edge.target.clone()) {
114 results.push((edge.target.clone(), edge.rel_type.clone(), depth + 1));
115 queue.push_back((edge.target.clone(), depth + 1));
116 }
117 }
118 for edge in store.incoming(&id) {
119 if !edge_types.iter().any(|t| t == &edge.rel_type) {
120 continue;
121 }
122 if visited.insert(edge.from.clone()) {
123 results.push((edge.from.clone(), edge.rel_type.clone(), depth + 1));
124 queue.push_back((edge.from.clone(), depth + 1));
125 }
126 }
127 }
128
129 results
130}
131
132pub fn would_cycle(
141 store: &Store,
142 from: &EntityId,
143 to: &EntityId,
144 rel_type: &str,
145) -> Option<Vec<EntityId>> {
146 if from == to {
147 return Some(vec![from.clone()]);
148 }
149
150 let mut parent: std::collections::HashMap<EntityId, EntityId> =
151 std::collections::HashMap::new();
152 let mut visited: HashSet<EntityId> = HashSet::new();
153 visited.insert(to.clone());
154
155 let mut queue: VecDeque<EntityId> = VecDeque::new();
156 queue.push_back(to.clone());
157
158 while let Some(current) = queue.pop_front() {
159 for edge in store.outgoing(¤t) {
160 if edge.rel_type != rel_type {
161 continue;
162 }
163 let next = &edge.target;
164 if *next == *from {
165 let mut path = vec![from.clone(), current.clone()];
166 let mut cursor = current;
167 while let Some(p) = parent.get(&cursor) {
168 path.push(p.clone());
169 cursor = p.clone();
170 }
171 path.reverse();
172 return Some(path);
173 }
174 if visited.insert(next.clone()) {
175 parent.insert(next.clone(), current.clone());
176 queue.push_back(next.clone());
177 }
178 }
179 }
180 None
181}
182
183pub fn find_orphans(store: &Store) -> Vec<EntityId> {
185 let mut results = Vec::new();
186 for entity in store.all_entities() {
187 if entity.stub {
188 continue;
189 }
190 let out = store.outgoing(&entity.id);
191 let inc = store.incoming(&entity.id);
192 if out.is_empty() && inc.is_empty() {
193 results.push(entity.id.clone());
194 }
195 }
196 results
197}
198
199pub fn find_stubs(store: &Store) -> Vec<(EntityId, Vec<EntityId>)> {
202 let mut results = Vec::new();
203 for entity in store.all_entities() {
204 if !entity.stub {
205 continue;
206 }
207 let referenced_by: Vec<EntityId> = store
208 .incoming(&entity.id)
209 .iter()
210 .map(|e| e.from.clone())
211 .collect();
212 results.push((entity.id.clone(), referenced_by));
213 }
214 results
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct Connectivity {
230 pub id: EntityId,
231 pub total: usize,
232 pub incoming: usize,
233 pub outgoing: usize,
234 pub typed_total: usize,
235 pub typed_incoming: usize,
236 pub typed_outgoing: usize,
237}
238
239pub fn connectivity_for(
244 store: &Store,
245 id: &EntityId,
246 incoming_counts: impl Fn(&InEdge) -> bool,
247) -> Connectivity {
248 let out = store.outgoing(id);
249 let outgoing = out.len();
250 let typed_outgoing = out
251 .iter()
252 .filter(|e| e.source != EdgeSource::BodyLink)
253 .count();
254
255 let mut incoming = 0;
256 let mut typed_incoming = 0;
257 for e in store.incoming(id) {
258 if !incoming_counts(e) {
259 continue;
260 }
261 incoming += 1;
262 if e.source != EdgeSource::BodyLink {
263 typed_incoming += 1;
264 }
265 }
266
267 Connectivity {
268 id: id.clone(),
269 total: outgoing + incoming,
270 incoming,
271 outgoing,
272 typed_total: typed_outgoing + typed_incoming,
273 typed_incoming,
274 typed_outgoing,
275 }
276}
277
278pub fn cmp_by_dependency(a: &Connectivity, b: &Connectivity) -> Ordering {
283 b.typed_total
284 .cmp(&a.typed_total)
285 .then_with(|| b.total.cmp(&a.total))
286 .then_with(|| a.id.0.cmp(&b.id.0))
287}
288
289pub fn most_connected(store: &Store, limit: usize) -> Vec<Connectivity> {
292 let mut entries: Vec<Connectivity> = store
293 .all_entities()
294 .filter(|e| !e.stub)
295 .map(|e| connectivity_for(store, &e.id, |_| true))
296 .collect();
297
298 entries.sort_by(cmp_by_dependency);
299 entries.truncate(limit);
300 entries
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::entity::Entity;
307 use crate::store::{Edge, EdgeSource};
308 use indexmap::IndexMap;
309
310 fn entity(id: &str, mem: &str, stub: bool) -> Entity {
311 Entity {
312 id: EntityId(id.to_string()),
313 title: id.to_string(),
314 entity_type: "spec".to_string(),
315 mem: mem.to_string(),
316 file_path: String::new(),
317 metadata: IndexMap::new(),
318 sections: IndexMap::new(),
319 relationships: Vec::new(),
320 content_hash: String::new(),
321 stub,
322 stub_kind: if stub {
323 Some(crate::entity::StubKind::LoadTime)
324 } else {
325 None
326 },
327 heading_spans: std::collections::HashMap::new(),
328 }
329 }
330
331 fn add_edge(store: &mut Store, from: &str, to: &str, rel: &str) {
332 store.add_edge(
333 EntityId(from.to_string()),
334 Edge {
335 rel_type: rel.to_string(),
336 target: EntityId(to.to_string()),
337 source: EdgeSource::Explicit,
338 },
339 );
340 }
341
342 fn add_body_edge(store: &mut Store, from: &str, to: &str) {
346 store.add_edge(
347 EntityId(from.to_string()),
348 Edge {
349 rel_type: "REFERENCES".to_string(),
350 target: EntityId(to.to_string()),
351 source: EdgeSource::BodyLink,
352 },
353 );
354 }
355
356 fn build_linear_store() -> Store {
357 let mut store = Store::new();
359 store.upsert(EntityId("a".into()), entity("a", "s", false));
360 store.upsert(EntityId("b".into()), entity("b", "s", false));
361 store.upsert(EntityId("c".into()), entity("c", "s", false));
362 add_edge(&mut store, "a", "b", "USES");
363 add_edge(&mut store, "b", "c", "USES");
364 store
365 }
366
367 #[test]
368 fn reachable_within_depth() {
369 let store = build_linear_store();
370 let a = EntityId("a".into());
371
372 let r0 = reachable(&store, &a, 0);
373 assert_eq!(r0.len(), 1); let r1 = reachable(&store, &a, 1);
376 assert_eq!(r1.len(), 2); let r2 = reachable(&store, &a, 2);
379 assert_eq!(r2.len(), 3); }
381
382 #[test]
383 fn reachable_undirected() {
384 let store = build_linear_store();
385 let c = EntityId("c".into());
386 let r = reachable(&store, &c, 10);
388 assert_eq!(r.len(), 3);
389 }
390
391 #[test]
392 fn find_orphans_isolated_node() {
393 let mut store = Store::new();
394 store.upsert(EntityId("a".into()), entity("a", "s", false));
395 store.upsert(EntityId("b".into()), entity("b", "s", false));
396 add_edge(&mut store, "a", "b", "USES");
397 store.upsert(EntityId("c".into()), entity("c", "s", false));
398 let orphans = find_orphans(&store);
401 assert_eq!(orphans.len(), 1);
402 assert_eq!(orphans[0], EntityId("c".into()));
403 }
404
405 #[test]
406 fn find_orphans_skips_stubs() {
407 let mut store = Store::new();
408 store.upsert(EntityId("a".into()), entity("a", "s", true)); store.upsert(EntityId("b".into()), entity("b", "s", false)); let orphans = find_orphans(&store);
412 assert_eq!(orphans.len(), 1);
413 assert_eq!(orphans[0], EntityId("b".into()));
414 }
415
416 #[test]
417 fn find_stubs_returns_stub_entities() {
418 let mut store = Store::new();
419 store.upsert(EntityId("real".into()), entity("real", "s", false));
420 store.upsert(EntityId("stub1".into()), entity("stub1", "s", true));
421 add_edge(&mut store, "real", "stub1", "REFERENCES");
422
423 let stubs = find_stubs(&store);
424 assert_eq!(stubs.len(), 1);
425 assert_eq!(stubs[0].0, EntityId("stub1".into()));
426 assert_eq!(stubs[0].1, vec![EntityId("real".into())]);
427 }
428
429 #[test]
430 fn most_connected_sorted_descending() {
431 let mut store = Store::new();
432 store.upsert(EntityId("a".into()), entity("a", "s", false));
433 store.upsert(EntityId("b".into()), entity("b", "s", false));
434 store.upsert(EntityId("c".into()), entity("c", "s", false));
435 add_edge(&mut store, "a", "b", "USES");
439 add_edge(&mut store, "c", "a", "PART_OF");
440
441 let top = most_connected(&store, 10);
442 assert_eq!(top[0].id, EntityId("a".into()));
443 assert_eq!(top[0].total, 2);
444 assert_eq!(top[0].incoming, 1);
445 assert_eq!(top[0].outgoing, 1);
446 }
447
448 #[test]
449 fn most_connected_respects_limit() {
450 let mut store = Store::new();
451 for i in 0..5 {
452 store.upsert(
453 EntityId(format!("e{i}")),
454 entity(&format!("e{i}"), "s", false),
455 );
456 }
457 let top = most_connected(&store, 2);
458 assert_eq!(top.len(), 2);
459 }
460
461 #[test]
464 fn reachable_via_filters_by_edge_type() {
465 let mut store = Store::new();
467 store.upsert(EntityId("a".into()), entity("a", "s", false));
468 store.upsert(EntityId("b".into()), entity("b", "s", false));
469 store.upsert(EntityId("c".into()), entity("c", "s", false));
470 add_edge(&mut store, "a", "b", "USES");
471 add_edge(&mut store, "a", "c", "REFERENCES");
472
473 let r = reachable_via(&store, &EntityId("a".into()), &["USES".to_string()], 1);
474 assert_eq!(r.len(), 1);
475 assert_eq!(r[0].0, EntityId("b".into()));
476 assert_eq!(r[0].1, "USES");
477 assert_eq!(r[0].2, 1);
478 }
479
480 #[test]
481 fn reachable_via_bidirectional() {
482 let mut store = Store::new();
484 store.upsert(EntityId("a".into()), entity("a", "s", false));
485 store.upsert(EntityId("b".into()), entity("b", "s", false));
486 add_edge(&mut store, "a", "b", "USES");
487 let r = reachable_via(&store, &EntityId("b".into()), &["USES".to_string()], 1);
488 assert_eq!(r.len(), 1);
489 assert_eq!(r[0].0, EntityId("a".into()));
490 assert_eq!(r[0].2, 1);
491 }
492
493 #[test]
494 fn reachable_via_zero_depth_empty() {
495 let store = build_linear_store();
496 let r = reachable_via(&store, &EntityId("a".into()), &["USES".to_string()], 0);
497 assert!(r.is_empty());
498 }
499
500 #[test]
501 fn reachable_via_empty_edge_types_empty() {
502 let store = build_linear_store();
503 let r = reachable_via(&store, &EntityId("a".into()), &[], 10);
504 assert!(r.is_empty());
505 }
506
507 #[test]
508 fn reachable_via_respects_depth_limit() {
509 let store = build_linear_store(); let r1 = reachable_via(&store, &EntityId("a".into()), &["USES".to_string()], 1);
511 assert_eq!(r1.len(), 1, "depth 1 reaches b only");
512 assert_eq!(r1[0].0, EntityId("b".into()));
513 assert_eq!(r1[0].2, 1);
514
515 let r2 = reachable_via(&store, &EntityId("a".into()), &["USES".to_string()], 2);
516 assert_eq!(r2.len(), 2);
517 let depths: std::collections::HashMap<EntityId, usize> =
518 r2.iter().map(|(id, _, d)| (id.clone(), *d)).collect();
519 assert_eq!(depths[&EntityId("b".into())], 1);
520 assert_eq!(depths[&EntityId("c".into())], 2);
521 }
522
523 #[test]
524 fn reachable_via_bfs_records_shortest_depth() {
525 let mut store = Store::new();
528 for id in ["a", "b", "c", "d"] {
529 store.upsert(EntityId(id.into()), entity(id, "s", false));
530 }
531 add_edge(&mut store, "a", "b", "R");
532 add_edge(&mut store, "a", "c", "R");
533 add_edge(&mut store, "b", "d", "R");
534 add_edge(&mut store, "c", "d", "R");
535
536 let r = reachable_via(&store, &EntityId("a".into()), &["R".to_string()], 3);
537 let entries: std::collections::HashMap<EntityId, usize> =
538 r.iter().map(|(id, _, d)| (id.clone(), *d)).collect();
539 assert_eq!(entries.len(), 3, "b, c, d each appear once");
540 assert_eq!(entries[&EntityId("d".into())], 2);
541 }
542
543 #[test]
544 fn most_connected_skips_stubs() {
545 let mut store = Store::new();
546 store.upsert(EntityId("real".into()), entity("real", "s", false));
547 store.upsert(EntityId("stub".into()), entity("stub", "s", true));
548 add_edge(&mut store, "real", "stub", "REFERENCES");
549
550 let top = most_connected(&store, 10);
551 assert_eq!(top.len(), 1);
552 assert_eq!(top[0].id, EntityId("real".into()));
553 }
554
555 #[test]
558 fn would_cycle_self_loop_always_reported() {
559 let mut store = Store::new();
560 store.upsert(EntityId("a".into()), entity("a", "s", false));
561 let path = would_cycle(
562 &store,
563 &EntityId("a".into()),
564 &EntityId("a".into()),
565 "PART_OF",
566 );
567 assert_eq!(path, Some(vec![EntityId("a".into())]));
568 }
569
570 #[test]
571 fn would_cycle_single_back_edge() {
572 let mut store = Store::new();
574 store.upsert(EntityId("a".into()), entity("a", "s", false));
575 store.upsert(EntityId("b".into()), entity("b", "s", false));
576 add_edge(&mut store, "a", "b", "PART_OF");
577 let path = would_cycle(
578 &store,
579 &EntityId("b".into()),
580 &EntityId("a".into()),
581 "PART_OF",
582 )
583 .expect("cycle");
584 assert_eq!(path, vec![EntityId("a".into()), EntityId("b".into())]);
585 }
586
587 #[test]
588 fn would_cycle_deep_chain() {
589 let mut store = Store::new();
591 for id in ["foo", "bar", "baz"] {
592 store.upsert(EntityId(id.into()), entity(id, "s", false));
593 }
594 add_edge(&mut store, "bar", "baz", "PART_OF");
595 add_edge(&mut store, "baz", "foo", "PART_OF");
596 let path = would_cycle(
597 &store,
598 &EntityId("foo".into()),
599 &EntityId("bar".into()),
600 "PART_OF",
601 )
602 .expect("cycle");
603 assert_eq!(
604 path,
605 vec![
606 EntityId("bar".into()),
607 EntityId("baz".into()),
608 EntityId("foo".into())
609 ]
610 );
611 }
612
613 #[test]
614 fn would_cycle_ignores_other_rel_types() {
615 let mut store = Store::new();
619 store.upsert(EntityId("a".into()), entity("a", "s", false));
620 store.upsert(EntityId("b".into()), entity("b", "s", false));
621 add_edge(&mut store, "a", "b", "DEPENDS_ON");
622 assert!(
623 would_cycle(
624 &store,
625 &EntityId("b".into()),
626 &EntityId("a".into()),
627 "PART_OF"
628 )
629 .is_none()
630 );
631 }
632
633 #[test]
634 fn would_cycle_none_for_disjoint_graph() {
635 let mut store = Store::new();
636 for id in ["a", "b", "c", "d"] {
637 store.upsert(EntityId(id.into()), entity(id, "s", false));
638 }
639 add_edge(&mut store, "c", "d", "PART_OF");
640 assert!(
641 would_cycle(
642 &store,
643 &EntityId("a".into()),
644 &EntityId("b".into()),
645 "PART_OF"
646 )
647 .is_none()
648 );
649 }
650
651 #[test]
652 fn would_cycle_parallel_paths_do_not_trip() {
653 let mut store = Store::new();
658 for id in ["a", "b", "c"] {
659 store.upsert(EntityId(id.into()), entity(id, "s", false));
660 }
661 add_edge(&mut store, "a", "b", "PART_OF");
662 add_edge(&mut store, "a", "c", "PART_OF");
663 assert!(
665 would_cycle(
666 &store,
667 &EntityId("a".into()),
668 &EntityId("b".into()),
669 "PART_OF"
670 )
671 .is_none(),
672 "sibling paths must not trip"
673 );
674 assert!(
676 would_cycle(
677 &store,
678 &EntityId("b".into()),
679 &EntityId("a".into()),
680 "PART_OF"
681 )
682 .is_some()
683 );
684 }
685
686 #[test]
687 fn most_connected_distinguishes_hub_vs_fanout() {
688 let mut store = Store::new();
689 for id in [
690 "hub", "fanout", "r1", "r2", "r3", "r4", "t1", "t2", "t3", "t4",
691 ] {
692 store.upsert(EntityId(id.into()), entity(id, "s", false));
693 }
694 add_edge(&mut store, "r1", "hub", "REFERENCES");
696 add_edge(&mut store, "r2", "hub", "REFERENCES");
697 add_edge(&mut store, "r3", "hub", "REFERENCES");
698 add_edge(&mut store, "r4", "hub", "REFERENCES");
699 add_edge(&mut store, "fanout", "t1", "USES");
701 add_edge(&mut store, "fanout", "t2", "USES");
702 add_edge(&mut store, "fanout", "t3", "USES");
703 add_edge(&mut store, "fanout", "t4", "USES");
704
705 let top = most_connected(&store, 10);
706 let hub = top.iter().find(|c| c.id == EntityId("hub".into())).unwrap();
707 assert_eq!(hub.total, 4);
708 assert_eq!(hub.incoming, 4);
709 assert_eq!(hub.outgoing, 0);
710 let fanout = top
711 .iter()
712 .find(|c| c.id == EntityId("fanout".into()))
713 .unwrap();
714 assert_eq!(fanout.total, 4);
715 assert_eq!(fanout.incoming, 0);
716 assert_eq!(fanout.outgoing, 4);
717
718 let fanout_pos = top.iter().position(|c| c.id.0 == "fanout").unwrap();
720 let hub_pos = top.iter().position(|c| c.id.0 == "hub").unwrap();
721 assert!(
722 fanout_pos < hub_pos,
723 "ties must resolve by id lex ascending"
724 );
725 }
726
727 #[test]
732 fn most_connected_ranks_by_dependency_not_mention() {
733 let mut store = Store::new();
734 for id in [
735 "mentionhub",
736 "dephub",
737 "m1",
738 "m2",
739 "m3",
740 "m4",
741 "m5",
742 "d1",
743 "d2",
744 ] {
745 store.upsert(EntityId(id.into()), entity(id, "s", false));
746 }
747 for m in ["m1", "m2", "m3", "m4", "m5"] {
749 add_body_edge(&mut store, m, "mentionhub");
750 }
751 add_edge(&mut store, "d1", "dephub", "USES");
753 add_edge(&mut store, "d2", "dephub", "USES");
754
755 let top = most_connected(&store, 10);
756 let mh = top.iter().find(|c| c.id.0 == "mentionhub").unwrap();
757 let dh = top.iter().find(|c| c.id.0 == "dephub").unwrap();
758
759 assert_eq!(mh.total, 5);
761 assert_eq!(mh.typed_total, 0, "all of mentionhub's edges are mentions");
762 assert_eq!(dh.total, 2);
763 assert_eq!(dh.typed_total, 2, "dephub's edges are typed dependencies");
764
765 let mh_pos = top.iter().position(|c| c.id.0 == "mentionhub").unwrap();
768 let dh_pos = top.iter().position(|c| c.id.0 == "dephub").unwrap();
769 assert!(
770 dh_pos < mh_pos,
771 "dependency hub must outrank co-mention hub"
772 );
773 }
774}