1use std::cmp::Ordering;
4use std::collections::{HashMap, HashSet, VecDeque};
5
6use schemars::JsonSchema;
7
8use crate::entity::EntityId;
9use crate::store::{EdgeSource, InEdge, Store};
10
11#[derive(
21 Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize, JsonSchema,
22)]
23#[serde(rename_all = "lowercase")]
24pub enum TraversalDirection {
25 Out,
27 In,
29 #[default]
33 Both,
34}
35
36impl TraversalDirection {
37 fn follows_out(self) -> bool {
39 !matches!(self, TraversalDirection::In)
40 }
41 fn follows_in(self) -> bool {
43 !matches!(self, TraversalDirection::Out)
44 }
45}
46
47pub fn reachable_distances(
52 store: &Store,
53 from: &EntityId,
54 max_depth: usize,
55 direction: TraversalDirection,
56) -> HashMap<EntityId, usize> {
57 let mut dist: HashMap<EntityId, usize> = HashMap::new();
58 dist.insert(from.clone(), 0);
59
60 let mut queue: VecDeque<(EntityId, usize)> = VecDeque::new();
61 queue.push_back((from.clone(), 0));
62
63 while let Some((id, depth)) = queue.pop_front() {
64 if depth >= max_depth {
65 continue;
66 }
67 if direction.follows_out() {
68 for edge in store.outgoing(&id) {
69 if !dist.contains_key(&edge.target) {
70 dist.insert(edge.target.clone(), depth + 1);
71 queue.push_back((edge.target.clone(), depth + 1));
72 }
73 }
74 }
75 if direction.follows_in() {
76 for edge in store.incoming(&id) {
77 if !dist.contains_key(&edge.from) {
78 dist.insert(edge.from.clone(), depth + 1);
79 queue.push_back((edge.from.clone(), depth + 1));
80 }
81 }
82 }
83 }
84 dist
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ReachedVia {
93 pub id: EntityId,
94 pub via_edge: String,
95 pub depth: usize,
96 pub direction: TraversalDirection,
100}
101
102pub fn reachable_via(
111 store: &Store,
112 from: &EntityId,
113 edge_types: &[String],
114 max_depth: usize,
115 direction: TraversalDirection,
116) -> Vec<ReachedVia> {
117 if max_depth == 0 || edge_types.is_empty() {
118 return Vec::new();
119 }
120
121 let mut visited: HashSet<EntityId> = HashSet::new();
122 visited.insert(from.clone());
123
124 let mut results: Vec<ReachedVia> = Vec::new();
125 let mut queue: VecDeque<(EntityId, usize)> = VecDeque::new();
126 queue.push_back((from.clone(), 0));
127
128 while let Some((id, depth)) = queue.pop_front() {
129 if depth >= max_depth {
130 continue;
131 }
132 if direction.follows_out() {
133 for edge in store.outgoing(&id) {
134 if !edge_types.iter().any(|t| t == &edge.rel_type) {
135 continue;
136 }
137 if visited.insert(edge.target.clone()) {
138 results.push(ReachedVia {
139 id: edge.target.clone(),
140 via_edge: edge.rel_type.clone(),
141 depth: depth + 1,
142 direction: TraversalDirection::Out,
143 });
144 queue.push_back((edge.target.clone(), depth + 1));
145 }
146 }
147 }
148 if direction.follows_in() {
149 for edge in store.incoming(&id) {
150 if !edge_types.iter().any(|t| t == &edge.rel_type) {
151 continue;
152 }
153 if visited.insert(edge.from.clone()) {
154 results.push(ReachedVia {
155 id: edge.from.clone(),
156 via_edge: edge.rel_type.clone(),
157 depth: depth + 1,
158 direction: TraversalDirection::In,
159 });
160 queue.push_back((edge.from.clone(), depth + 1));
161 }
162 }
163 }
164 }
165
166 results
167}
168
169pub fn would_cycle(
178 store: &Store,
179 from: &EntityId,
180 to: &EntityId,
181 rel_type: &str,
182) -> Option<Vec<EntityId>> {
183 if from == to {
184 return Some(vec![from.clone()]);
185 }
186
187 let mut parent: std::collections::HashMap<EntityId, EntityId> =
188 std::collections::HashMap::new();
189 let mut visited: HashSet<EntityId> = HashSet::new();
190 visited.insert(to.clone());
191
192 let mut queue: VecDeque<EntityId> = VecDeque::new();
193 queue.push_back(to.clone());
194
195 while let Some(current) = queue.pop_front() {
196 for edge in store.outgoing(¤t) {
197 if edge.rel_type != rel_type {
198 continue;
199 }
200 let next = &edge.target;
201 if *next == *from {
202 let mut path = vec![from.clone(), current.clone()];
203 let mut cursor = current;
204 while let Some(p) = parent.get(&cursor) {
205 path.push(p.clone());
206 cursor = p.clone();
207 }
208 path.reverse();
209 return Some(path);
210 }
211 if visited.insert(next.clone()) {
212 parent.insert(next.clone(), current.clone());
213 queue.push_back(next.clone());
214 }
215 }
216 }
217 None
218}
219
220pub fn would_cycle_in_set(
226 store: &Store,
227 from: &EntityId,
228 to: &EntityId,
229 set: &[String],
230) -> Option<(Vec<EntityId>, Vec<String>)> {
231 if from == to {
232 return Some((vec![from.clone()], Vec::new()));
233 }
234
235 let mut parent: std::collections::HashMap<EntityId, (EntityId, String)> =
237 std::collections::HashMap::new();
238 let mut visited: HashSet<EntityId> = HashSet::new();
239 visited.insert(to.clone());
240
241 let mut queue: VecDeque<EntityId> = VecDeque::new();
242 queue.push_back(to.clone());
243
244 while let Some(current) = queue.pop_front() {
245 for edge in store.outgoing(¤t) {
246 if !set.iter().any(|n| n == &edge.rel_type) {
247 continue;
248 }
249 let next = &edge.target;
250 if *next == *from {
251 let mut ids = vec![current.clone()];
252 let mut rels = vec![edge.rel_type.clone()];
253 let mut cursor = current.clone();
254 while let Some((p, r)) = parent.get(&cursor) {
255 ids.push(p.clone());
256 rels.push(r.clone());
257 cursor = p.clone();
258 }
259 ids.reverse();
260 rels.reverse();
261 ids.push(from.clone());
262 return Some((ids, rels));
263 }
264 if visited.insert(next.clone()) {
265 parent.insert(next.clone(), (current.clone(), edge.rel_type.clone()));
266 queue.push_back(next.clone());
267 }
268 }
269 }
270 None
271}
272
273pub fn find_orphans(store: &Store) -> Vec<EntityId> {
275 find_orphans_with_schemas(store, &std::collections::HashMap::new())
276}
277
278pub fn find_orphans_with_schemas(
286 store: &Store,
287 schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
288) -> Vec<EntityId> {
289 let mut results = Vec::new();
290 for entity in store.all_entities() {
291 if entity.stub {
292 continue;
293 }
294 if entity_is_declared_leaf(entity, schemas) {
295 continue;
296 }
297 let out = store.outgoing(&entity.id);
298 let inc = store.incoming(&entity.id);
299 if out.is_empty() && inc.is_empty() {
300 results.push(entity.id.clone());
301 }
302 }
303 results
304}
305
306fn entity_is_declared_leaf(
308 entity: &crate::entity::Entity,
309 schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
310) -> bool {
311 schemas
312 .get(entity.mem.as_str())
313 .and_then(|s| s.types.get(&entity.entity_type))
314 .is_some_and(|t| t.leaf)
315}
316
317pub fn leaf_population(
323 store: &Store,
324 schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
325) -> std::collections::BTreeMap<String, usize> {
326 let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
327 for entity in store.all_entities() {
328 if entity.stub {
329 continue;
330 }
331 if let Some(schema) = schemas.get(entity.mem.as_str())
332 && schema
333 .types
334 .get(&entity.entity_type)
335 .is_some_and(|t| t.leaf)
336 {
337 let (name, version) = schema.id();
338 *out.entry(format!("{name}@{version}:{}", entity.entity_type))
339 .or_default() += 1;
340 }
341 }
342 out
343}
344
345pub fn find_stubs(store: &Store) -> Vec<(EntityId, Vec<EntityId>)> {
351 let mut results = Vec::new();
352 for entity in store.all_entities() {
353 if !entity.stub {
354 continue;
355 }
356 let mut referenced_by: Vec<EntityId> = store
357 .incoming(&entity.id)
358 .iter()
359 .map(|e| e.from.clone())
360 .collect();
361 referenced_by.sort_by(|a, b| a.0.cmp(&b.0));
362 results.push((entity.id.clone(), referenced_by));
363 }
364 results.sort_by(|a, b| a.0.0.cmp(&b.0.0));
365 results
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct Connectivity {
381 pub id: EntityId,
382 pub total: usize,
383 pub incoming: usize,
384 pub outgoing: usize,
385 pub typed_total: usize,
386 pub typed_incoming: usize,
387 pub typed_outgoing: usize,
388}
389
390pub fn connectivity_for(
395 store: &Store,
396 id: &EntityId,
397 incoming_counts: impl Fn(&InEdge) -> bool,
398) -> Connectivity {
399 let out = store.outgoing(id);
400 let outgoing = out.len();
401 let typed_outgoing = out
402 .iter()
403 .filter(|e| e.source != EdgeSource::BodyLink)
404 .count();
405
406 let mut incoming = 0;
407 let mut typed_incoming = 0;
408 for e in store.incoming(id) {
409 if !incoming_counts(e) {
410 continue;
411 }
412 incoming += 1;
413 if e.source != EdgeSource::BodyLink {
414 typed_incoming += 1;
415 }
416 }
417
418 Connectivity {
419 id: id.clone(),
420 total: outgoing + incoming,
421 incoming,
422 outgoing,
423 typed_total: typed_outgoing + typed_incoming,
424 typed_incoming,
425 typed_outgoing,
426 }
427}
428
429pub fn cmp_by_dependency(a: &Connectivity, b: &Connectivity) -> Ordering {
434 b.typed_total
435 .cmp(&a.typed_total)
436 .then_with(|| b.total.cmp(&a.total))
437 .then_with(|| a.id.0.cmp(&b.id.0))
438}
439
440pub fn most_connected(store: &Store, limit: usize) -> Vec<Connectivity> {
443 let mut entries: Vec<Connectivity> = store
444 .all_entities()
445 .filter(|e| !e.stub)
446 .map(|e| connectivity_for(store, &e.id, |_| true))
447 .collect();
448
449 entries.sort_by(cmp_by_dependency);
450 entries.truncate(limit);
451 entries
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use crate::entity::Entity;
458 use crate::store::{Edge, EdgeSource};
459 use indexmap::IndexMap;
460
461 fn entity(id: &str, mem: &str, stub: bool) -> Entity {
462 Entity {
463 id: EntityId(id.to_string()),
464 title: id.to_string(),
465 entity_type: "spec".to_string(),
466 mem: mem.to_string(),
467 file_path: String::new(),
468 metadata: IndexMap::new(),
469 sections: IndexMap::new(),
470 relationships: Vec::new(),
471 content_hash: String::new(),
472 stub,
473 stub_kind: if stub {
474 Some(crate::entity::StubKind::LoadTime)
475 } else {
476 None
477 },
478 heading_spans: std::collections::HashMap::new(),
479 raw_section_headings: Vec::new(),
480 }
481 }
482
483 fn add_edge(store: &mut Store, from: &str, to: &str, rel: &str) {
484 store.add_edge(
485 EntityId(from.to_string()),
486 Edge {
487 rel_type: rel.to_string(),
488 target: EntityId(to.to_string()),
489 source: EdgeSource::Explicit,
490 },
491 );
492 }
493
494 fn add_body_edge(store: &mut Store, from: &str, to: &str) {
498 store.add_edge(
499 EntityId(from.to_string()),
500 Edge {
501 rel_type: "REFERENCES".to_string(),
502 target: EntityId(to.to_string()),
503 source: EdgeSource::BodyLink,
504 },
505 );
506 }
507
508 fn build_linear_store() -> Store {
509 let mut store = Store::new();
511 store.upsert(EntityId("a".into()), entity("a", "s", false));
512 store.upsert(EntityId("b".into()), entity("b", "s", false));
513 store.upsert(EntityId("c".into()), entity("c", "s", false));
514 add_edge(&mut store, "a", "b", "USES");
515 add_edge(&mut store, "b", "c", "USES");
516 store
517 }
518
519 #[test]
520 fn reachable_distances_within_depth() {
521 let store = build_linear_store();
522 let a = EntityId("a".into());
523 let both = TraversalDirection::Both;
524
525 assert_eq!(reachable_distances(&store, &a, 0, both).len(), 1); assert_eq!(reachable_distances(&store, &a, 1, both).len(), 2); assert_eq!(reachable_distances(&store, &a, 2, both).len(), 3); }
529
530 #[test]
531 fn reachable_distances_both_is_undirected() {
532 let store = build_linear_store();
533 let c = EntityId("c".into());
534 let r = reachable_distances(&store, &c, 10, TraversalDirection::Both);
536 assert_eq!(r.len(), 3);
537 }
538
539 #[test]
546 fn reachable_distances_directional_transitive_closure() {
547 let mut store = Store::new();
548 for id in ["x", "seed", "y", "z", "w"] {
549 store.upsert(EntityId(id.into()), entity(id, "s", false));
550 }
551 add_edge(&mut store, "x", "seed", "USES");
552 add_edge(&mut store, "seed", "y", "USES");
553 add_edge(&mut store, "y", "z", "USES");
554 add_edge(&mut store, "x", "w", "USES"); let seed = EntityId("seed".into());
557 let ids = |m: &HashMap<EntityId, usize>| {
558 let mut v: Vec<String> = m.keys().map(|i| i.0.clone()).collect();
559 v.sort();
560 v
561 };
562
563 let out = reachable_distances(&store, &seed, 10, TraversalDirection::Out);
564 assert_eq!(
565 ids(&out),
566 ["seed", "y", "z"],
567 "out = transitive descendants only"
568 );
569
570 let inward = reachable_distances(&store, &seed, 10, TraversalDirection::In);
571 assert_eq!(
572 ids(&inward),
573 ["seed", "x"],
574 "in = transitive ancestors only"
575 );
576
577 let both = reachable_distances(&store, &seed, 10, TraversalDirection::Both);
578 assert_eq!(
579 ids(&both),
580 ["seed", "w", "x", "y", "z"],
581 "both = the historical undirected set, mixed walks included"
582 );
583 }
584
585 #[test]
586 fn find_orphans_isolated_node() {
587 let mut store = Store::new();
588 store.upsert(EntityId("a".into()), entity("a", "s", false));
589 store.upsert(EntityId("b".into()), entity("b", "s", false));
590 add_edge(&mut store, "a", "b", "USES");
591 store.upsert(EntityId("c".into()), entity("c", "s", false));
592 let orphans = find_orphans(&store);
595 assert_eq!(orphans.len(), 1);
596 assert_eq!(orphans[0], EntityId("c".into()));
597 }
598
599 #[test]
600 fn find_orphans_skips_stubs() {
601 let mut store = Store::new();
602 store.upsert(EntityId("a".into()), entity("a", "s", true)); store.upsert(EntityId("b".into()), entity("b", "s", false)); let orphans = find_orphans(&store);
606 assert_eq!(orphans.len(), 1);
607 assert_eq!(orphans[0], EntityId("b".into()));
608 }
609
610 #[test]
611 fn find_stubs_returns_stub_entities() {
612 let mut store = Store::new();
613 store.upsert(EntityId("real".into()), entity("real", "s", false));
614 store.upsert(EntityId("stub1".into()), entity("stub1", "s", true));
615 add_edge(&mut store, "real", "stub1", "REFERENCES");
616
617 let stubs = find_stubs(&store);
618 assert_eq!(stubs.len(), 1);
619 assert_eq!(stubs[0].0, EntityId("stub1".into()));
620 assert_eq!(stubs[0].1, vec![EntityId("real".into())]);
621 }
622
623 #[test]
624 fn most_connected_sorted_descending() {
625 let mut store = Store::new();
626 store.upsert(EntityId("a".into()), entity("a", "s", false));
627 store.upsert(EntityId("b".into()), entity("b", "s", false));
628 store.upsert(EntityId("c".into()), entity("c", "s", false));
629 add_edge(&mut store, "a", "b", "USES");
633 add_edge(&mut store, "c", "a", "PART_OF");
634
635 let top = most_connected(&store, 10);
636 assert_eq!(top[0].id, EntityId("a".into()));
637 assert_eq!(top[0].total, 2);
638 assert_eq!(top[0].incoming, 1);
639 assert_eq!(top[0].outgoing, 1);
640 }
641
642 #[test]
643 fn most_connected_respects_limit() {
644 let mut store = Store::new();
645 for i in 0..5 {
646 store.upsert(
647 EntityId(format!("e{i}")),
648 entity(&format!("e{i}"), "s", false),
649 );
650 }
651 let top = most_connected(&store, 2);
652 assert_eq!(top.len(), 2);
653 }
654
655 #[test]
658 fn reachable_via_filters_by_edge_type() {
659 let mut store = Store::new();
661 store.upsert(EntityId("a".into()), entity("a", "s", false));
662 store.upsert(EntityId("b".into()), entity("b", "s", false));
663 store.upsert(EntityId("c".into()), entity("c", "s", false));
664 add_edge(&mut store, "a", "b", "USES");
665 add_edge(&mut store, "a", "c", "REFERENCES");
666
667 let r = reachable_via(
668 &store,
669 &EntityId("a".into()),
670 &["USES".to_string()],
671 1,
672 TraversalDirection::Both,
673 );
674 assert_eq!(r.len(), 1);
675 assert_eq!(r[0].id, EntityId("b".into()));
676 assert_eq!(r[0].via_edge, "USES");
677 assert_eq!(r[0].depth, 1);
678 assert_eq!(r[0].direction, TraversalDirection::Out);
679 }
680
681 #[test]
682 fn reachable_via_bidirectional() {
683 let mut store = Store::new();
685 store.upsert(EntityId("a".into()), entity("a", "s", false));
686 store.upsert(EntityId("b".into()), entity("b", "s", false));
687 add_edge(&mut store, "a", "b", "USES");
688 let r = reachable_via(
689 &store,
690 &EntityId("b".into()),
691 &["USES".to_string()],
692 1,
693 TraversalDirection::Both,
694 );
695 assert_eq!(r.len(), 1);
696 assert_eq!(r[0].id, EntityId("a".into()));
697 assert_eq!(r[0].depth, 1);
698 assert_eq!(
699 r[0].direction,
700 TraversalDirection::In,
701 "reached against the edge — reported as `in`"
702 );
703
704 let out = reachable_via(
707 &store,
708 &EntityId("b".into()),
709 &["USES".to_string()],
710 1,
711 TraversalDirection::Out,
712 );
713 assert!(out.is_empty(), "no out-edges from b: {out:?}");
714 let inward = reachable_via(
715 &store,
716 &EntityId("b".into()),
717 &["USES".to_string()],
718 1,
719 TraversalDirection::In,
720 );
721 assert_eq!(inward.len(), 1);
722 assert_eq!(inward[0].id, EntityId("a".into()));
723 }
724
725 #[test]
726 fn reachable_via_zero_depth_empty() {
727 let store = build_linear_store();
728 let r = reachable_via(
729 &store,
730 &EntityId("a".into()),
731 &["USES".to_string()],
732 0,
733 TraversalDirection::Both,
734 );
735 assert!(r.is_empty());
736 }
737
738 #[test]
739 fn reachable_via_empty_edge_types_empty() {
740 let store = build_linear_store();
741 let r = reachable_via(
742 &store,
743 &EntityId("a".into()),
744 &[],
745 10,
746 TraversalDirection::Both,
747 );
748 assert!(r.is_empty());
749 }
750
751 #[test]
752 fn reachable_via_respects_depth_limit() {
753 let store = build_linear_store(); let r1 = reachable_via(
755 &store,
756 &EntityId("a".into()),
757 &["USES".to_string()],
758 1,
759 TraversalDirection::Both,
760 );
761 assert_eq!(r1.len(), 1, "depth 1 reaches b only");
762 assert_eq!(r1[0].id, EntityId("b".into()));
763 assert_eq!(r1[0].depth, 1);
764
765 let r2 = reachable_via(
766 &store,
767 &EntityId("a".into()),
768 &["USES".to_string()],
769 2,
770 TraversalDirection::Both,
771 );
772 assert_eq!(r2.len(), 2);
773 let depths: std::collections::HashMap<EntityId, usize> =
774 r2.iter().map(|r| (r.id.clone(), r.depth)).collect();
775 assert_eq!(depths[&EntityId("b".into())], 1);
776 assert_eq!(depths[&EntityId("c".into())], 2);
777 }
778
779 #[test]
780 fn reachable_via_bfs_records_shortest_depth() {
781 let mut store = Store::new();
784 for id in ["a", "b", "c", "d"] {
785 store.upsert(EntityId(id.into()), entity(id, "s", false));
786 }
787 add_edge(&mut store, "a", "b", "R");
788 add_edge(&mut store, "a", "c", "R");
789 add_edge(&mut store, "b", "d", "R");
790 add_edge(&mut store, "c", "d", "R");
791
792 let r = reachable_via(
793 &store,
794 &EntityId("a".into()),
795 &["R".to_string()],
796 3,
797 TraversalDirection::Both,
798 );
799 let entries: std::collections::HashMap<EntityId, usize> =
800 r.iter().map(|e| (e.id.clone(), e.depth)).collect();
801 assert_eq!(entries.len(), 3, "b, c, d each appear once");
802 assert_eq!(entries[&EntityId("d".into())], 2);
803 }
804
805 #[test]
806 fn most_connected_skips_stubs() {
807 let mut store = Store::new();
808 store.upsert(EntityId("real".into()), entity("real", "s", false));
809 store.upsert(EntityId("stub".into()), entity("stub", "s", true));
810 add_edge(&mut store, "real", "stub", "REFERENCES");
811
812 let top = most_connected(&store, 10);
813 assert_eq!(top.len(), 1);
814 assert_eq!(top[0].id, EntityId("real".into()));
815 }
816
817 #[test]
820 fn would_cycle_self_loop_always_reported() {
821 let mut store = Store::new();
822 store.upsert(EntityId("a".into()), entity("a", "s", false));
823 let path = would_cycle(
824 &store,
825 &EntityId("a".into()),
826 &EntityId("a".into()),
827 "PART_OF",
828 );
829 assert_eq!(path, Some(vec![EntityId("a".into())]));
830 }
831
832 #[test]
833 fn would_cycle_single_back_edge() {
834 let mut store = Store::new();
836 store.upsert(EntityId("a".into()), entity("a", "s", false));
837 store.upsert(EntityId("b".into()), entity("b", "s", false));
838 add_edge(&mut store, "a", "b", "PART_OF");
839 let path = would_cycle(
840 &store,
841 &EntityId("b".into()),
842 &EntityId("a".into()),
843 "PART_OF",
844 )
845 .expect("cycle");
846 assert_eq!(path, vec![EntityId("a".into()), EntityId("b".into())]);
847 }
848
849 #[test]
850 fn would_cycle_deep_chain() {
851 let mut store = Store::new();
853 for id in ["foo", "bar", "baz"] {
854 store.upsert(EntityId(id.into()), entity(id, "s", false));
855 }
856 add_edge(&mut store, "bar", "baz", "PART_OF");
857 add_edge(&mut store, "baz", "foo", "PART_OF");
858 let path = would_cycle(
859 &store,
860 &EntityId("foo".into()),
861 &EntityId("bar".into()),
862 "PART_OF",
863 )
864 .expect("cycle");
865 assert_eq!(
866 path,
867 vec![
868 EntityId("bar".into()),
869 EntityId("baz".into()),
870 EntityId("foo".into())
871 ]
872 );
873 }
874
875 #[test]
876 fn would_cycle_ignores_other_rel_types() {
877 let mut store = Store::new();
881 store.upsert(EntityId("a".into()), entity("a", "s", false));
882 store.upsert(EntityId("b".into()), entity("b", "s", false));
883 add_edge(&mut store, "a", "b", "DEPENDS_ON");
884 assert!(
885 would_cycle(
886 &store,
887 &EntityId("b".into()),
888 &EntityId("a".into()),
889 "PART_OF"
890 )
891 .is_none()
892 );
893 }
894
895 #[test]
896 fn would_cycle_none_for_disjoint_graph() {
897 let mut store = Store::new();
898 for id in ["a", "b", "c", "d"] {
899 store.upsert(EntityId(id.into()), entity(id, "s", false));
900 }
901 add_edge(&mut store, "c", "d", "PART_OF");
902 assert!(
903 would_cycle(
904 &store,
905 &EntityId("a".into()),
906 &EntityId("b".into()),
907 "PART_OF"
908 )
909 .is_none()
910 );
911 }
912
913 #[test]
914 fn would_cycle_parallel_paths_do_not_trip() {
915 let mut store = Store::new();
920 for id in ["a", "b", "c"] {
921 store.upsert(EntityId(id.into()), entity(id, "s", false));
922 }
923 add_edge(&mut store, "a", "b", "PART_OF");
924 add_edge(&mut store, "a", "c", "PART_OF");
925 assert!(
927 would_cycle(
928 &store,
929 &EntityId("a".into()),
930 &EntityId("b".into()),
931 "PART_OF"
932 )
933 .is_none(),
934 "sibling paths must not trip"
935 );
936 assert!(
938 would_cycle(
939 &store,
940 &EntityId("b".into()),
941 &EntityId("a".into()),
942 "PART_OF"
943 )
944 .is_some()
945 );
946 }
947
948 #[test]
949 fn most_connected_distinguishes_hub_vs_fanout() {
950 let mut store = Store::new();
951 for id in [
952 "hub", "fanout", "r1", "r2", "r3", "r4", "t1", "t2", "t3", "t4",
953 ] {
954 store.upsert(EntityId(id.into()), entity(id, "s", false));
955 }
956 add_edge(&mut store, "r1", "hub", "REFERENCES");
958 add_edge(&mut store, "r2", "hub", "REFERENCES");
959 add_edge(&mut store, "r3", "hub", "REFERENCES");
960 add_edge(&mut store, "r4", "hub", "REFERENCES");
961 add_edge(&mut store, "fanout", "t1", "USES");
963 add_edge(&mut store, "fanout", "t2", "USES");
964 add_edge(&mut store, "fanout", "t3", "USES");
965 add_edge(&mut store, "fanout", "t4", "USES");
966
967 let top = most_connected(&store, 10);
968 let hub = top.iter().find(|c| c.id == EntityId("hub".into())).unwrap();
969 assert_eq!(hub.total, 4);
970 assert_eq!(hub.incoming, 4);
971 assert_eq!(hub.outgoing, 0);
972 let fanout = top
973 .iter()
974 .find(|c| c.id == EntityId("fanout".into()))
975 .unwrap();
976 assert_eq!(fanout.total, 4);
977 assert_eq!(fanout.incoming, 0);
978 assert_eq!(fanout.outgoing, 4);
979
980 let fanout_pos = top.iter().position(|c| c.id.0 == "fanout").unwrap();
982 let hub_pos = top.iter().position(|c| c.id.0 == "hub").unwrap();
983 assert!(
984 fanout_pos < hub_pos,
985 "ties must resolve by id lex ascending"
986 );
987 }
988
989 #[test]
994 fn most_connected_ranks_by_dependency_not_mention() {
995 let mut store = Store::new();
996 for id in [
997 "mentionhub",
998 "dephub",
999 "m1",
1000 "m2",
1001 "m3",
1002 "m4",
1003 "m5",
1004 "d1",
1005 "d2",
1006 ] {
1007 store.upsert(EntityId(id.into()), entity(id, "s", false));
1008 }
1009 for m in ["m1", "m2", "m3", "m4", "m5"] {
1011 add_body_edge(&mut store, m, "mentionhub");
1012 }
1013 add_edge(&mut store, "d1", "dephub", "USES");
1015 add_edge(&mut store, "d2", "dephub", "USES");
1016
1017 let top = most_connected(&store, 10);
1018 let mh = top.iter().find(|c| c.id.0 == "mentionhub").unwrap();
1019 let dh = top.iter().find(|c| c.id.0 == "dephub").unwrap();
1020
1021 assert_eq!(mh.total, 5);
1023 assert_eq!(mh.typed_total, 0, "all of mentionhub's edges are mentions");
1024 assert_eq!(dh.total, 2);
1025 assert_eq!(dh.typed_total, 2, "dephub's edges are typed dependencies");
1026
1027 let mh_pos = top.iter().position(|c| c.id.0 == "mentionhub").unwrap();
1030 let dh_pos = top.iter().position(|c| c.id.0 == "dephub").unwrap();
1031 assert!(
1032 dh_pos < mh_pos,
1033 "dependency hub must outrank co-mention hub"
1034 );
1035 }
1036
1037 #[test]
1043 fn leaf_declared_types_exempt_from_orphans_but_visible_as_population() {
1044 use std::collections::HashMap;
1045 use std::sync::Arc;
1046
1047 let manifest = r#"
1048name: leafy
1049version: 0.1.0
1050description: leaf test schema
1051when_to_use: tests
1052types:
1053 - obs
1054 - spec
1055relationships:
1056 mode: strict
1057 definitions:
1058 - name: USES
1059 description: u
1060 default_weight: 1.0
1061 - name: PART_OF
1062 description: hier
1063 default_weight: 1.0
1064 acyclic: true
1065 - name: _default
1066 description: fallback
1067 default_weight: 1.0
1068community:
1069 resolution: 1.0
1070 seed: 42
1071"#;
1072 let body = "sections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n";
1073 let obs_yaml = format!("name: obs\ndescription: t\nwhen_to_use: h\nleaf: true\n{body}");
1074 let spec_yaml = format!("name: spec\ndescription: t\nwhen_to_use: h\n{body}");
1075 let schema = Arc::new(
1076 memstead_schema::load_schema_from_memory(
1077 manifest,
1078 &[
1079 ("obs".to_string(), obs_yaml),
1080 ("spec".to_string(), spec_yaml),
1081 ],
1082 )
1083 .expect("leaf fixture schema parses"),
1084 );
1085 let mut schemas: HashMap<String, Arc<memstead_schema::Schema>> = HashMap::new();
1086 schemas.insert("s".to_string(), schema);
1087
1088 let mut store = Store::new();
1089 let mut e = |id: &str, ty: &str| {
1090 let mut ent = entity(id, "s", false);
1091 ent.entity_type = ty.to_string();
1092 store.upsert(EntityId(id.into()), ent);
1093 };
1094 e("lonely-spec", "spec"); e("lonely-obs", "obs"); e("linked-obs", "obs"); e("hub", "spec");
1098 add_edge(&mut store, "linked-obs", "hub", "USES");
1099
1100 let orphans = find_orphans_with_schemas(&store, &schemas);
1102 assert_eq!(
1103 orphans,
1104 vec![EntityId("lonely-spec".into())],
1105 "leaf-typed edge-less entities are exempt; non-leaf count as before"
1106 );
1107 let pop = leaf_population(&store, &schemas);
1109 assert_eq!(pop.get("leafy@0.1.0:obs"), Some(&2));
1110 assert_eq!(pop.len(), 1);
1111
1112 let blind = find_orphans(&store);
1114 let mut blind_sorted: Vec<String> = blind.iter().map(|i| i.0.clone()).collect();
1115 blind_sorted.sort();
1116 assert_eq!(blind_sorted, vec!["lonely-obs", "lonely-spec"]);
1117 assert!(leaf_population(&store, &HashMap::new()).is_empty());
1118 }
1119}