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.sort_by(|a, b| a.0.cmp(&b.0));
306 results
307}
308
309fn entity_is_declared_leaf(
311 entity: &crate::entity::Entity,
312 schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
313) -> bool {
314 schemas
315 .get(entity.mem.as_str())
316 .and_then(|s| s.types.get(&entity.entity_type))
317 .is_some_and(|t| t.leaf)
318}
319
320pub fn leaf_population(
326 store: &Store,
327 schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
328) -> std::collections::BTreeMap<String, usize> {
329 let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
330 for entity in store.all_entities() {
331 if entity.stub {
332 continue;
333 }
334 if let Some(schema) = schemas.get(entity.mem.as_str())
335 && schema
336 .types
337 .get(&entity.entity_type)
338 .is_some_and(|t| t.leaf)
339 {
340 let (name, version) = schema.id();
341 *out.entry(format!("{name}@{version}:{}", entity.entity_type))
342 .or_default() += 1;
343 }
344 }
345 out
346}
347
348pub fn find_stubs(store: &Store) -> Vec<(EntityId, Vec<EntityId>)> {
354 let mut results = Vec::new();
355 for entity in store.all_entities() {
356 if !entity.stub {
357 continue;
358 }
359 let mut referenced_by: Vec<EntityId> = store
360 .incoming(&entity.id)
361 .iter()
362 .map(|e| e.from.clone())
363 .collect();
364 referenced_by.sort_by(|a, b| a.0.cmp(&b.0));
365 results.push((entity.id.clone(), referenced_by));
366 }
367 results.sort_by(|a, b| a.0.0.cmp(&b.0.0));
368 results
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct Connectivity {
384 pub id: EntityId,
385 pub total: usize,
386 pub incoming: usize,
387 pub outgoing: usize,
388 pub typed_total: usize,
389 pub typed_incoming: usize,
390 pub typed_outgoing: usize,
391}
392
393pub fn connectivity_for(
398 store: &Store,
399 id: &EntityId,
400 incoming_counts: impl Fn(&InEdge) -> bool,
401) -> Connectivity {
402 let out = store.outgoing(id);
403 let outgoing = out.len();
404 let typed_outgoing = out
405 .iter()
406 .filter(|e| e.source != EdgeSource::BodyLink)
407 .count();
408
409 let mut incoming = 0;
410 let mut typed_incoming = 0;
411 for e in store.incoming(id) {
412 if !incoming_counts(e) {
413 continue;
414 }
415 incoming += 1;
416 if e.source != EdgeSource::BodyLink {
417 typed_incoming += 1;
418 }
419 }
420
421 Connectivity {
422 id: id.clone(),
423 total: outgoing + incoming,
424 incoming,
425 outgoing,
426 typed_total: typed_outgoing + typed_incoming,
427 typed_incoming,
428 typed_outgoing,
429 }
430}
431
432pub fn cmp_by_dependency(a: &Connectivity, b: &Connectivity) -> Ordering {
437 b.typed_total
438 .cmp(&a.typed_total)
439 .then_with(|| b.total.cmp(&a.total))
440 .then_with(|| a.id.0.cmp(&b.id.0))
441}
442
443pub fn most_connected(store: &Store, limit: usize) -> Vec<Connectivity> {
446 let mut entries: Vec<Connectivity> = store
447 .all_entities()
448 .filter(|e| !e.stub)
449 .map(|e| connectivity_for(store, &e.id, |_| true))
450 .collect();
451
452 entries.sort_by(cmp_by_dependency);
453 entries.truncate(limit);
454 entries
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::entity::Entity;
461 use crate::store::{Edge, EdgeSource};
462 use indexmap::IndexMap;
463
464 fn entity(id: &str, mem: &str, stub: bool) -> Entity {
465 Entity {
466 id: EntityId(id.to_string()),
467 title: id.to_string(),
468 entity_type: "spec".to_string(),
469 mem: mem.to_string(),
470 file_path: String::new(),
471 metadata: IndexMap::new(),
472 sections: IndexMap::new(),
473 relationships: Vec::new(),
474 content_hash: String::new(),
475 stub,
476 stub_kind: if stub {
477 Some(crate::entity::StubKind::LoadTime)
478 } else {
479 None
480 },
481 heading_spans: std::collections::HashMap::new(),
482 raw_section_headings: Vec::new(),
483 }
484 }
485
486 fn add_edge(store: &mut Store, from: &str, to: &str, rel: &str) {
487 store.add_edge(
488 EntityId(from.to_string()),
489 Edge {
490 rel_type: rel.to_string(),
491 target: EntityId(to.to_string()),
492 source: EdgeSource::Explicit,
493 },
494 );
495 }
496
497 fn add_body_edge(store: &mut Store, from: &str, to: &str) {
501 store.add_edge(
502 EntityId(from.to_string()),
503 Edge {
504 rel_type: "REFERENCES".to_string(),
505 target: EntityId(to.to_string()),
506 source: EdgeSource::BodyLink,
507 },
508 );
509 }
510
511 fn build_linear_store() -> Store {
512 let mut store = Store::new();
514 store.upsert(EntityId("a".into()), entity("a", "s", false));
515 store.upsert(EntityId("b".into()), entity("b", "s", false));
516 store.upsert(EntityId("c".into()), entity("c", "s", false));
517 add_edge(&mut store, "a", "b", "USES");
518 add_edge(&mut store, "b", "c", "USES");
519 store
520 }
521
522 #[test]
523 fn reachable_distances_within_depth() {
524 let store = build_linear_store();
525 let a = EntityId("a".into());
526 let both = TraversalDirection::Both;
527
528 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); }
532
533 #[test]
534 fn reachable_distances_both_is_undirected() {
535 let store = build_linear_store();
536 let c = EntityId("c".into());
537 let r = reachable_distances(&store, &c, 10, TraversalDirection::Both);
539 assert_eq!(r.len(), 3);
540 }
541
542 #[test]
549 fn reachable_distances_directional_transitive_closure() {
550 let mut store = Store::new();
551 for id in ["x", "seed", "y", "z", "w"] {
552 store.upsert(EntityId(id.into()), entity(id, "s", false));
553 }
554 add_edge(&mut store, "x", "seed", "USES");
555 add_edge(&mut store, "seed", "y", "USES");
556 add_edge(&mut store, "y", "z", "USES");
557 add_edge(&mut store, "x", "w", "USES"); let seed = EntityId("seed".into());
560 let ids = |m: &HashMap<EntityId, usize>| {
561 let mut v: Vec<String> = m.keys().map(|i| i.0.clone()).collect();
562 v.sort();
563 v
564 };
565
566 let out = reachable_distances(&store, &seed, 10, TraversalDirection::Out);
567 assert_eq!(
568 ids(&out),
569 ["seed", "y", "z"],
570 "out = transitive descendants only"
571 );
572
573 let inward = reachable_distances(&store, &seed, 10, TraversalDirection::In);
574 assert_eq!(
575 ids(&inward),
576 ["seed", "x"],
577 "in = transitive ancestors only"
578 );
579
580 let both = reachable_distances(&store, &seed, 10, TraversalDirection::Both);
581 assert_eq!(
582 ids(&both),
583 ["seed", "w", "x", "y", "z"],
584 "both = the historical undirected set, mixed walks included"
585 );
586 }
587
588 #[test]
589 fn find_orphans_isolated_node() {
590 let mut store = Store::new();
591 store.upsert(EntityId("a".into()), entity("a", "s", false));
592 store.upsert(EntityId("b".into()), entity("b", "s", false));
593 add_edge(&mut store, "a", "b", "USES");
594 store.upsert(EntityId("c".into()), entity("c", "s", false));
595 let orphans = find_orphans(&store);
598 assert_eq!(orphans.len(), 1);
599 assert_eq!(orphans[0], EntityId("c".into()));
600 }
601
602 #[test]
603 fn find_orphans_skips_stubs() {
604 let mut store = Store::new();
605 store.upsert(EntityId("a".into()), entity("a", "s", true)); store.upsert(EntityId("b".into()), entity("b", "s", false)); let orphans = find_orphans(&store);
609 assert_eq!(orphans.len(), 1);
610 assert_eq!(orphans[0], EntityId("b".into()));
611 }
612
613 #[test]
614 fn find_stubs_returns_stub_entities() {
615 let mut store = Store::new();
616 store.upsert(EntityId("real".into()), entity("real", "s", false));
617 store.upsert(EntityId("stub1".into()), entity("stub1", "s", true));
618 add_edge(&mut store, "real", "stub1", "REFERENCES");
619
620 let stubs = find_stubs(&store);
621 assert_eq!(stubs.len(), 1);
622 assert_eq!(stubs[0].0, EntityId("stub1".into()));
623 assert_eq!(stubs[0].1, vec![EntityId("real".into())]);
624 }
625
626 #[test]
627 fn most_connected_sorted_descending() {
628 let mut store = Store::new();
629 store.upsert(EntityId("a".into()), entity("a", "s", false));
630 store.upsert(EntityId("b".into()), entity("b", "s", false));
631 store.upsert(EntityId("c".into()), entity("c", "s", false));
632 add_edge(&mut store, "a", "b", "USES");
636 add_edge(&mut store, "c", "a", "PART_OF");
637
638 let top = most_connected(&store, 10);
639 assert_eq!(top[0].id, EntityId("a".into()));
640 assert_eq!(top[0].total, 2);
641 assert_eq!(top[0].incoming, 1);
642 assert_eq!(top[0].outgoing, 1);
643 }
644
645 #[test]
646 fn most_connected_respects_limit() {
647 let mut store = Store::new();
648 for i in 0..5 {
649 store.upsert(
650 EntityId(format!("e{i}")),
651 entity(&format!("e{i}"), "s", false),
652 );
653 }
654 let top = most_connected(&store, 2);
655 assert_eq!(top.len(), 2);
656 }
657
658 #[test]
661 fn reachable_via_filters_by_edge_type() {
662 let mut store = Store::new();
664 store.upsert(EntityId("a".into()), entity("a", "s", false));
665 store.upsert(EntityId("b".into()), entity("b", "s", false));
666 store.upsert(EntityId("c".into()), entity("c", "s", false));
667 add_edge(&mut store, "a", "b", "USES");
668 add_edge(&mut store, "a", "c", "REFERENCES");
669
670 let r = reachable_via(
671 &store,
672 &EntityId("a".into()),
673 &["USES".to_string()],
674 1,
675 TraversalDirection::Both,
676 );
677 assert_eq!(r.len(), 1);
678 assert_eq!(r[0].id, EntityId("b".into()));
679 assert_eq!(r[0].via_edge, "USES");
680 assert_eq!(r[0].depth, 1);
681 assert_eq!(r[0].direction, TraversalDirection::Out);
682 }
683
684 #[test]
685 fn reachable_via_bidirectional() {
686 let mut store = Store::new();
688 store.upsert(EntityId("a".into()), entity("a", "s", false));
689 store.upsert(EntityId("b".into()), entity("b", "s", false));
690 add_edge(&mut store, "a", "b", "USES");
691 let r = reachable_via(
692 &store,
693 &EntityId("b".into()),
694 &["USES".to_string()],
695 1,
696 TraversalDirection::Both,
697 );
698 assert_eq!(r.len(), 1);
699 assert_eq!(r[0].id, EntityId("a".into()));
700 assert_eq!(r[0].depth, 1);
701 assert_eq!(
702 r[0].direction,
703 TraversalDirection::In,
704 "reached against the edge — reported as `in`"
705 );
706
707 let out = reachable_via(
710 &store,
711 &EntityId("b".into()),
712 &["USES".to_string()],
713 1,
714 TraversalDirection::Out,
715 );
716 assert!(out.is_empty(), "no out-edges from b: {out:?}");
717 let inward = reachable_via(
718 &store,
719 &EntityId("b".into()),
720 &["USES".to_string()],
721 1,
722 TraversalDirection::In,
723 );
724 assert_eq!(inward.len(), 1);
725 assert_eq!(inward[0].id, EntityId("a".into()));
726 }
727
728 #[test]
729 fn reachable_via_zero_depth_empty() {
730 let store = build_linear_store();
731 let r = reachable_via(
732 &store,
733 &EntityId("a".into()),
734 &["USES".to_string()],
735 0,
736 TraversalDirection::Both,
737 );
738 assert!(r.is_empty());
739 }
740
741 #[test]
742 fn reachable_via_empty_edge_types_empty() {
743 let store = build_linear_store();
744 let r = reachable_via(
745 &store,
746 &EntityId("a".into()),
747 &[],
748 10,
749 TraversalDirection::Both,
750 );
751 assert!(r.is_empty());
752 }
753
754 #[test]
755 fn reachable_via_respects_depth_limit() {
756 let store = build_linear_store(); let r1 = reachable_via(
758 &store,
759 &EntityId("a".into()),
760 &["USES".to_string()],
761 1,
762 TraversalDirection::Both,
763 );
764 assert_eq!(r1.len(), 1, "depth 1 reaches b only");
765 assert_eq!(r1[0].id, EntityId("b".into()));
766 assert_eq!(r1[0].depth, 1);
767
768 let r2 = reachable_via(
769 &store,
770 &EntityId("a".into()),
771 &["USES".to_string()],
772 2,
773 TraversalDirection::Both,
774 );
775 assert_eq!(r2.len(), 2);
776 let depths: std::collections::HashMap<EntityId, usize> =
777 r2.iter().map(|r| (r.id.clone(), r.depth)).collect();
778 assert_eq!(depths[&EntityId("b".into())], 1);
779 assert_eq!(depths[&EntityId("c".into())], 2);
780 }
781
782 #[test]
783 fn reachable_via_bfs_records_shortest_depth() {
784 let mut store = Store::new();
787 for id in ["a", "b", "c", "d"] {
788 store.upsert(EntityId(id.into()), entity(id, "s", false));
789 }
790 add_edge(&mut store, "a", "b", "R");
791 add_edge(&mut store, "a", "c", "R");
792 add_edge(&mut store, "b", "d", "R");
793 add_edge(&mut store, "c", "d", "R");
794
795 let r = reachable_via(
796 &store,
797 &EntityId("a".into()),
798 &["R".to_string()],
799 3,
800 TraversalDirection::Both,
801 );
802 let entries: std::collections::HashMap<EntityId, usize> =
803 r.iter().map(|e| (e.id.clone(), e.depth)).collect();
804 assert_eq!(entries.len(), 3, "b, c, d each appear once");
805 assert_eq!(entries[&EntityId("d".into())], 2);
806 }
807
808 #[test]
809 fn most_connected_skips_stubs() {
810 let mut store = Store::new();
811 store.upsert(EntityId("real".into()), entity("real", "s", false));
812 store.upsert(EntityId("stub".into()), entity("stub", "s", true));
813 add_edge(&mut store, "real", "stub", "REFERENCES");
814
815 let top = most_connected(&store, 10);
816 assert_eq!(top.len(), 1);
817 assert_eq!(top[0].id, EntityId("real".into()));
818 }
819
820 #[test]
823 fn would_cycle_self_loop_always_reported() {
824 let mut store = Store::new();
825 store.upsert(EntityId("a".into()), entity("a", "s", false));
826 let path = would_cycle(
827 &store,
828 &EntityId("a".into()),
829 &EntityId("a".into()),
830 "PART_OF",
831 );
832 assert_eq!(path, Some(vec![EntityId("a".into())]));
833 }
834
835 #[test]
836 fn would_cycle_single_back_edge() {
837 let mut store = Store::new();
839 store.upsert(EntityId("a".into()), entity("a", "s", false));
840 store.upsert(EntityId("b".into()), entity("b", "s", false));
841 add_edge(&mut store, "a", "b", "PART_OF");
842 let path = would_cycle(
843 &store,
844 &EntityId("b".into()),
845 &EntityId("a".into()),
846 "PART_OF",
847 )
848 .expect("cycle");
849 assert_eq!(path, vec![EntityId("a".into()), EntityId("b".into())]);
850 }
851
852 #[test]
853 fn would_cycle_deep_chain() {
854 let mut store = Store::new();
856 for id in ["foo", "bar", "baz"] {
857 store.upsert(EntityId(id.into()), entity(id, "s", false));
858 }
859 add_edge(&mut store, "bar", "baz", "PART_OF");
860 add_edge(&mut store, "baz", "foo", "PART_OF");
861 let path = would_cycle(
862 &store,
863 &EntityId("foo".into()),
864 &EntityId("bar".into()),
865 "PART_OF",
866 )
867 .expect("cycle");
868 assert_eq!(
869 path,
870 vec![
871 EntityId("bar".into()),
872 EntityId("baz".into()),
873 EntityId("foo".into())
874 ]
875 );
876 }
877
878 #[test]
879 fn would_cycle_ignores_other_rel_types() {
880 let mut store = Store::new();
884 store.upsert(EntityId("a".into()), entity("a", "s", false));
885 store.upsert(EntityId("b".into()), entity("b", "s", false));
886 add_edge(&mut store, "a", "b", "DEPENDS_ON");
887 assert!(
888 would_cycle(
889 &store,
890 &EntityId("b".into()),
891 &EntityId("a".into()),
892 "PART_OF"
893 )
894 .is_none()
895 );
896 }
897
898 #[test]
899 fn would_cycle_none_for_disjoint_graph() {
900 let mut store = Store::new();
901 for id in ["a", "b", "c", "d"] {
902 store.upsert(EntityId(id.into()), entity(id, "s", false));
903 }
904 add_edge(&mut store, "c", "d", "PART_OF");
905 assert!(
906 would_cycle(
907 &store,
908 &EntityId("a".into()),
909 &EntityId("b".into()),
910 "PART_OF"
911 )
912 .is_none()
913 );
914 }
915
916 #[test]
917 fn would_cycle_parallel_paths_do_not_trip() {
918 let mut store = Store::new();
923 for id in ["a", "b", "c"] {
924 store.upsert(EntityId(id.into()), entity(id, "s", false));
925 }
926 add_edge(&mut store, "a", "b", "PART_OF");
927 add_edge(&mut store, "a", "c", "PART_OF");
928 assert!(
930 would_cycle(
931 &store,
932 &EntityId("a".into()),
933 &EntityId("b".into()),
934 "PART_OF"
935 )
936 .is_none(),
937 "sibling paths must not trip"
938 );
939 assert!(
941 would_cycle(
942 &store,
943 &EntityId("b".into()),
944 &EntityId("a".into()),
945 "PART_OF"
946 )
947 .is_some()
948 );
949 }
950
951 #[test]
952 fn most_connected_distinguishes_hub_vs_fanout() {
953 let mut store = Store::new();
954 for id in [
955 "hub", "fanout", "r1", "r2", "r3", "r4", "t1", "t2", "t3", "t4",
956 ] {
957 store.upsert(EntityId(id.into()), entity(id, "s", false));
958 }
959 add_edge(&mut store, "r1", "hub", "REFERENCES");
961 add_edge(&mut store, "r2", "hub", "REFERENCES");
962 add_edge(&mut store, "r3", "hub", "REFERENCES");
963 add_edge(&mut store, "r4", "hub", "REFERENCES");
964 add_edge(&mut store, "fanout", "t1", "USES");
966 add_edge(&mut store, "fanout", "t2", "USES");
967 add_edge(&mut store, "fanout", "t3", "USES");
968 add_edge(&mut store, "fanout", "t4", "USES");
969
970 let top = most_connected(&store, 10);
971 let hub = top.iter().find(|c| c.id == EntityId("hub".into())).unwrap();
972 assert_eq!(hub.total, 4);
973 assert_eq!(hub.incoming, 4);
974 assert_eq!(hub.outgoing, 0);
975 let fanout = top
976 .iter()
977 .find(|c| c.id == EntityId("fanout".into()))
978 .unwrap();
979 assert_eq!(fanout.total, 4);
980 assert_eq!(fanout.incoming, 0);
981 assert_eq!(fanout.outgoing, 4);
982
983 let fanout_pos = top.iter().position(|c| c.id.0 == "fanout").unwrap();
985 let hub_pos = top.iter().position(|c| c.id.0 == "hub").unwrap();
986 assert!(
987 fanout_pos < hub_pos,
988 "ties must resolve by id lex ascending"
989 );
990 }
991
992 #[test]
997 fn most_connected_ranks_by_dependency_not_mention() {
998 let mut store = Store::new();
999 for id in [
1000 "mentionhub",
1001 "dephub",
1002 "m1",
1003 "m2",
1004 "m3",
1005 "m4",
1006 "m5",
1007 "d1",
1008 "d2",
1009 ] {
1010 store.upsert(EntityId(id.into()), entity(id, "s", false));
1011 }
1012 for m in ["m1", "m2", "m3", "m4", "m5"] {
1014 add_body_edge(&mut store, m, "mentionhub");
1015 }
1016 add_edge(&mut store, "d1", "dephub", "USES");
1018 add_edge(&mut store, "d2", "dephub", "USES");
1019
1020 let top = most_connected(&store, 10);
1021 let mh = top.iter().find(|c| c.id.0 == "mentionhub").unwrap();
1022 let dh = top.iter().find(|c| c.id.0 == "dephub").unwrap();
1023
1024 assert_eq!(mh.total, 5);
1026 assert_eq!(mh.typed_total, 0, "all of mentionhub's edges are mentions");
1027 assert_eq!(dh.total, 2);
1028 assert_eq!(dh.typed_total, 2, "dephub's edges are typed dependencies");
1029
1030 let mh_pos = top.iter().position(|c| c.id.0 == "mentionhub").unwrap();
1033 let dh_pos = top.iter().position(|c| c.id.0 == "dephub").unwrap();
1034 assert!(
1035 dh_pos < mh_pos,
1036 "dependency hub must outrank co-mention hub"
1037 );
1038 }
1039
1040 #[test]
1046 fn leaf_declared_types_exempt_from_orphans_but_visible_as_population() {
1047 use std::collections::HashMap;
1048 use std::sync::Arc;
1049
1050 let manifest = r#"
1051name: leafy
1052version: 0.1.0
1053description: leaf test schema
1054when_to_use: tests
1055types:
1056 - obs
1057 - spec
1058relationships:
1059 mode: strict
1060 definitions:
1061 - name: USES
1062 description: u
1063 default_weight: 1.0
1064 - name: PART_OF
1065 description: hier
1066 default_weight: 1.0
1067 acyclic: true
1068 - name: _default
1069 description: fallback
1070 default_weight: 1.0
1071community:
1072 resolution: 1.0
1073 seed: 42
1074"#;
1075 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";
1076 let obs_yaml = format!("name: obs\ndescription: t\nwhen_to_use: h\nleaf: true\n{body}");
1077 let spec_yaml = format!("name: spec\ndescription: t\nwhen_to_use: h\n{body}");
1078 let schema = Arc::new(
1079 memstead_schema::load_schema_from_memory(
1080 manifest,
1081 &[
1082 ("obs".to_string(), obs_yaml),
1083 ("spec".to_string(), spec_yaml),
1084 ],
1085 )
1086 .expect("leaf fixture schema parses"),
1087 );
1088 let mut schemas: HashMap<String, Arc<memstead_schema::Schema>> = HashMap::new();
1089 schemas.insert("s".to_string(), schema);
1090
1091 let mut store = Store::new();
1092 let mut e = |id: &str, ty: &str| {
1093 let mut ent = entity(id, "s", false);
1094 ent.entity_type = ty.to_string();
1095 store.upsert(EntityId(id.into()), ent);
1096 };
1097 e("lonely-spec", "spec"); e("lonely-obs", "obs"); e("linked-obs", "obs"); e("hub", "spec");
1101 add_edge(&mut store, "linked-obs", "hub", "USES");
1102
1103 let orphans = find_orphans_with_schemas(&store, &schemas);
1105 assert_eq!(
1106 orphans,
1107 vec![EntityId("lonely-spec".into())],
1108 "leaf-typed edge-less entities are exempt; non-leaf count as before"
1109 );
1110 let pop = leaf_population(&store, &schemas);
1112 assert_eq!(pop.get("leafy@0.1.0:obs"), Some(&2));
1113 assert_eq!(pop.len(), 1);
1114
1115 let blind = find_orphans(&store);
1117 let mut blind_sorted: Vec<String> = blind.iter().map(|i| i.0.clone()).collect();
1118 blind_sorted.sort();
1119 assert_eq!(blind_sorted, vec!["lonely-obs", "lonely-spec"]);
1120 assert!(leaf_population(&store, &HashMap::new()).is_empty());
1121 }
1122}