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 find_orphans(store: &Store) -> Vec<EntityId> {
222 find_orphans_with_schemas(store, &std::collections::HashMap::new())
223}
224
225pub fn find_orphans_with_schemas(
233 store: &Store,
234 schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
235) -> Vec<EntityId> {
236 let mut results = Vec::new();
237 for entity in store.all_entities() {
238 if entity.stub {
239 continue;
240 }
241 if entity_is_declared_leaf(entity, schemas) {
242 continue;
243 }
244 let out = store.outgoing(&entity.id);
245 let inc = store.incoming(&entity.id);
246 if out.is_empty() && inc.is_empty() {
247 results.push(entity.id.clone());
248 }
249 }
250 results
251}
252
253fn entity_is_declared_leaf(
255 entity: &crate::entity::Entity,
256 schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
257) -> bool {
258 schemas
259 .get(entity.mem.as_str())
260 .and_then(|s| s.types.get(&entity.entity_type))
261 .is_some_and(|t| t.leaf)
262}
263
264pub fn leaf_population(
270 store: &Store,
271 schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
272) -> std::collections::BTreeMap<String, usize> {
273 let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
274 for entity in store.all_entities() {
275 if entity.stub {
276 continue;
277 }
278 if let Some(schema) = schemas.get(entity.mem.as_str())
279 && schema
280 .types
281 .get(&entity.entity_type)
282 .is_some_and(|t| t.leaf)
283 {
284 let (name, version) = schema.id();
285 *out.entry(format!("{name}@{version}:{}", entity.entity_type))
286 .or_default() += 1;
287 }
288 }
289 out
290}
291
292pub fn find_stubs(store: &Store) -> Vec<(EntityId, Vec<EntityId>)> {
295 let mut results = Vec::new();
296 for entity in store.all_entities() {
297 if !entity.stub {
298 continue;
299 }
300 let referenced_by: Vec<EntityId> = store
301 .incoming(&entity.id)
302 .iter()
303 .map(|e| e.from.clone())
304 .collect();
305 results.push((entity.id.clone(), referenced_by));
306 }
307 results
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct Connectivity {
323 pub id: EntityId,
324 pub total: usize,
325 pub incoming: usize,
326 pub outgoing: usize,
327 pub typed_total: usize,
328 pub typed_incoming: usize,
329 pub typed_outgoing: usize,
330}
331
332pub fn connectivity_for(
337 store: &Store,
338 id: &EntityId,
339 incoming_counts: impl Fn(&InEdge) -> bool,
340) -> Connectivity {
341 let out = store.outgoing(id);
342 let outgoing = out.len();
343 let typed_outgoing = out
344 .iter()
345 .filter(|e| e.source != EdgeSource::BodyLink)
346 .count();
347
348 let mut incoming = 0;
349 let mut typed_incoming = 0;
350 for e in store.incoming(id) {
351 if !incoming_counts(e) {
352 continue;
353 }
354 incoming += 1;
355 if e.source != EdgeSource::BodyLink {
356 typed_incoming += 1;
357 }
358 }
359
360 Connectivity {
361 id: id.clone(),
362 total: outgoing + incoming,
363 incoming,
364 outgoing,
365 typed_total: typed_outgoing + typed_incoming,
366 typed_incoming,
367 typed_outgoing,
368 }
369}
370
371pub fn cmp_by_dependency(a: &Connectivity, b: &Connectivity) -> Ordering {
376 b.typed_total
377 .cmp(&a.typed_total)
378 .then_with(|| b.total.cmp(&a.total))
379 .then_with(|| a.id.0.cmp(&b.id.0))
380}
381
382pub fn most_connected(store: &Store, limit: usize) -> Vec<Connectivity> {
385 let mut entries: Vec<Connectivity> = store
386 .all_entities()
387 .filter(|e| !e.stub)
388 .map(|e| connectivity_for(store, &e.id, |_| true))
389 .collect();
390
391 entries.sort_by(cmp_by_dependency);
392 entries.truncate(limit);
393 entries
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::entity::Entity;
400 use crate::store::{Edge, EdgeSource};
401 use indexmap::IndexMap;
402
403 fn entity(id: &str, mem: &str, stub: bool) -> Entity {
404 Entity {
405 id: EntityId(id.to_string()),
406 title: id.to_string(),
407 entity_type: "spec".to_string(),
408 mem: mem.to_string(),
409 file_path: String::new(),
410 metadata: IndexMap::new(),
411 sections: IndexMap::new(),
412 relationships: Vec::new(),
413 content_hash: String::new(),
414 stub,
415 stub_kind: if stub {
416 Some(crate::entity::StubKind::LoadTime)
417 } else {
418 None
419 },
420 heading_spans: std::collections::HashMap::new(),
421 raw_section_headings: Vec::new(),
422 }
423 }
424
425 fn add_edge(store: &mut Store, from: &str, to: &str, rel: &str) {
426 store.add_edge(
427 EntityId(from.to_string()),
428 Edge {
429 rel_type: rel.to_string(),
430 target: EntityId(to.to_string()),
431 source: EdgeSource::Explicit,
432 },
433 );
434 }
435
436 fn add_body_edge(store: &mut Store, from: &str, to: &str) {
440 store.add_edge(
441 EntityId(from.to_string()),
442 Edge {
443 rel_type: "REFERENCES".to_string(),
444 target: EntityId(to.to_string()),
445 source: EdgeSource::BodyLink,
446 },
447 );
448 }
449
450 fn build_linear_store() -> Store {
451 let mut store = Store::new();
453 store.upsert(EntityId("a".into()), entity("a", "s", false));
454 store.upsert(EntityId("b".into()), entity("b", "s", false));
455 store.upsert(EntityId("c".into()), entity("c", "s", false));
456 add_edge(&mut store, "a", "b", "USES");
457 add_edge(&mut store, "b", "c", "USES");
458 store
459 }
460
461 #[test]
462 fn reachable_distances_within_depth() {
463 let store = build_linear_store();
464 let a = EntityId("a".into());
465 let both = TraversalDirection::Both;
466
467 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); }
471
472 #[test]
473 fn reachable_distances_both_is_undirected() {
474 let store = build_linear_store();
475 let c = EntityId("c".into());
476 let r = reachable_distances(&store, &c, 10, TraversalDirection::Both);
478 assert_eq!(r.len(), 3);
479 }
480
481 #[test]
488 fn reachable_distances_directional_transitive_closure() {
489 let mut store = Store::new();
490 for id in ["x", "seed", "y", "z", "w"] {
491 store.upsert(EntityId(id.into()), entity(id, "s", false));
492 }
493 add_edge(&mut store, "x", "seed", "USES");
494 add_edge(&mut store, "seed", "y", "USES");
495 add_edge(&mut store, "y", "z", "USES");
496 add_edge(&mut store, "x", "w", "USES"); let seed = EntityId("seed".into());
499 let ids = |m: &HashMap<EntityId, usize>| {
500 let mut v: Vec<String> = m.keys().map(|i| i.0.clone()).collect();
501 v.sort();
502 v
503 };
504
505 let out = reachable_distances(&store, &seed, 10, TraversalDirection::Out);
506 assert_eq!(
507 ids(&out),
508 ["seed", "y", "z"],
509 "out = transitive descendants only"
510 );
511
512 let inward = reachable_distances(&store, &seed, 10, TraversalDirection::In);
513 assert_eq!(
514 ids(&inward),
515 ["seed", "x"],
516 "in = transitive ancestors only"
517 );
518
519 let both = reachable_distances(&store, &seed, 10, TraversalDirection::Both);
520 assert_eq!(
521 ids(&both),
522 ["seed", "w", "x", "y", "z"],
523 "both = the historical undirected set, mixed walks included"
524 );
525 }
526
527 #[test]
528 fn find_orphans_isolated_node() {
529 let mut store = Store::new();
530 store.upsert(EntityId("a".into()), entity("a", "s", false));
531 store.upsert(EntityId("b".into()), entity("b", "s", false));
532 add_edge(&mut store, "a", "b", "USES");
533 store.upsert(EntityId("c".into()), entity("c", "s", false));
534 let orphans = find_orphans(&store);
537 assert_eq!(orphans.len(), 1);
538 assert_eq!(orphans[0], EntityId("c".into()));
539 }
540
541 #[test]
542 fn find_orphans_skips_stubs() {
543 let mut store = Store::new();
544 store.upsert(EntityId("a".into()), entity("a", "s", true)); store.upsert(EntityId("b".into()), entity("b", "s", false)); let orphans = find_orphans(&store);
548 assert_eq!(orphans.len(), 1);
549 assert_eq!(orphans[0], EntityId("b".into()));
550 }
551
552 #[test]
553 fn find_stubs_returns_stub_entities() {
554 let mut store = Store::new();
555 store.upsert(EntityId("real".into()), entity("real", "s", false));
556 store.upsert(EntityId("stub1".into()), entity("stub1", "s", true));
557 add_edge(&mut store, "real", "stub1", "REFERENCES");
558
559 let stubs = find_stubs(&store);
560 assert_eq!(stubs.len(), 1);
561 assert_eq!(stubs[0].0, EntityId("stub1".into()));
562 assert_eq!(stubs[0].1, vec![EntityId("real".into())]);
563 }
564
565 #[test]
566 fn most_connected_sorted_descending() {
567 let mut store = Store::new();
568 store.upsert(EntityId("a".into()), entity("a", "s", false));
569 store.upsert(EntityId("b".into()), entity("b", "s", false));
570 store.upsert(EntityId("c".into()), entity("c", "s", false));
571 add_edge(&mut store, "a", "b", "USES");
575 add_edge(&mut store, "c", "a", "PART_OF");
576
577 let top = most_connected(&store, 10);
578 assert_eq!(top[0].id, EntityId("a".into()));
579 assert_eq!(top[0].total, 2);
580 assert_eq!(top[0].incoming, 1);
581 assert_eq!(top[0].outgoing, 1);
582 }
583
584 #[test]
585 fn most_connected_respects_limit() {
586 let mut store = Store::new();
587 for i in 0..5 {
588 store.upsert(
589 EntityId(format!("e{i}")),
590 entity(&format!("e{i}"), "s", false),
591 );
592 }
593 let top = most_connected(&store, 2);
594 assert_eq!(top.len(), 2);
595 }
596
597 #[test]
600 fn reachable_via_filters_by_edge_type() {
601 let mut store = Store::new();
603 store.upsert(EntityId("a".into()), entity("a", "s", false));
604 store.upsert(EntityId("b".into()), entity("b", "s", false));
605 store.upsert(EntityId("c".into()), entity("c", "s", false));
606 add_edge(&mut store, "a", "b", "USES");
607 add_edge(&mut store, "a", "c", "REFERENCES");
608
609 let r = reachable_via(
610 &store,
611 &EntityId("a".into()),
612 &["USES".to_string()],
613 1,
614 TraversalDirection::Both,
615 );
616 assert_eq!(r.len(), 1);
617 assert_eq!(r[0].id, EntityId("b".into()));
618 assert_eq!(r[0].via_edge, "USES");
619 assert_eq!(r[0].depth, 1);
620 assert_eq!(r[0].direction, TraversalDirection::Out);
621 }
622
623 #[test]
624 fn reachable_via_bidirectional() {
625 let mut store = Store::new();
627 store.upsert(EntityId("a".into()), entity("a", "s", false));
628 store.upsert(EntityId("b".into()), entity("b", "s", false));
629 add_edge(&mut store, "a", "b", "USES");
630 let r = reachable_via(
631 &store,
632 &EntityId("b".into()),
633 &["USES".to_string()],
634 1,
635 TraversalDirection::Both,
636 );
637 assert_eq!(r.len(), 1);
638 assert_eq!(r[0].id, EntityId("a".into()));
639 assert_eq!(r[0].depth, 1);
640 assert_eq!(
641 r[0].direction,
642 TraversalDirection::In,
643 "reached against the edge — reported as `in`"
644 );
645
646 let out = reachable_via(
649 &store,
650 &EntityId("b".into()),
651 &["USES".to_string()],
652 1,
653 TraversalDirection::Out,
654 );
655 assert!(out.is_empty(), "no out-edges from b: {out:?}");
656 let inward = reachable_via(
657 &store,
658 &EntityId("b".into()),
659 &["USES".to_string()],
660 1,
661 TraversalDirection::In,
662 );
663 assert_eq!(inward.len(), 1);
664 assert_eq!(inward[0].id, EntityId("a".into()));
665 }
666
667 #[test]
668 fn reachable_via_zero_depth_empty() {
669 let store = build_linear_store();
670 let r = reachable_via(
671 &store,
672 &EntityId("a".into()),
673 &["USES".to_string()],
674 0,
675 TraversalDirection::Both,
676 );
677 assert!(r.is_empty());
678 }
679
680 #[test]
681 fn reachable_via_empty_edge_types_empty() {
682 let store = build_linear_store();
683 let r = reachable_via(
684 &store,
685 &EntityId("a".into()),
686 &[],
687 10,
688 TraversalDirection::Both,
689 );
690 assert!(r.is_empty());
691 }
692
693 #[test]
694 fn reachable_via_respects_depth_limit() {
695 let store = build_linear_store(); let r1 = reachable_via(
697 &store,
698 &EntityId("a".into()),
699 &["USES".to_string()],
700 1,
701 TraversalDirection::Both,
702 );
703 assert_eq!(r1.len(), 1, "depth 1 reaches b only");
704 assert_eq!(r1[0].id, EntityId("b".into()));
705 assert_eq!(r1[0].depth, 1);
706
707 let r2 = reachable_via(
708 &store,
709 &EntityId("a".into()),
710 &["USES".to_string()],
711 2,
712 TraversalDirection::Both,
713 );
714 assert_eq!(r2.len(), 2);
715 let depths: std::collections::HashMap<EntityId, usize> =
716 r2.iter().map(|r| (r.id.clone(), r.depth)).collect();
717 assert_eq!(depths[&EntityId("b".into())], 1);
718 assert_eq!(depths[&EntityId("c".into())], 2);
719 }
720
721 #[test]
722 fn reachable_via_bfs_records_shortest_depth() {
723 let mut store = Store::new();
726 for id in ["a", "b", "c", "d"] {
727 store.upsert(EntityId(id.into()), entity(id, "s", false));
728 }
729 add_edge(&mut store, "a", "b", "R");
730 add_edge(&mut store, "a", "c", "R");
731 add_edge(&mut store, "b", "d", "R");
732 add_edge(&mut store, "c", "d", "R");
733
734 let r = reachable_via(
735 &store,
736 &EntityId("a".into()),
737 &["R".to_string()],
738 3,
739 TraversalDirection::Both,
740 );
741 let entries: std::collections::HashMap<EntityId, usize> =
742 r.iter().map(|e| (e.id.clone(), e.depth)).collect();
743 assert_eq!(entries.len(), 3, "b, c, d each appear once");
744 assert_eq!(entries[&EntityId("d".into())], 2);
745 }
746
747 #[test]
748 fn most_connected_skips_stubs() {
749 let mut store = Store::new();
750 store.upsert(EntityId("real".into()), entity("real", "s", false));
751 store.upsert(EntityId("stub".into()), entity("stub", "s", true));
752 add_edge(&mut store, "real", "stub", "REFERENCES");
753
754 let top = most_connected(&store, 10);
755 assert_eq!(top.len(), 1);
756 assert_eq!(top[0].id, EntityId("real".into()));
757 }
758
759 #[test]
762 fn would_cycle_self_loop_always_reported() {
763 let mut store = Store::new();
764 store.upsert(EntityId("a".into()), entity("a", "s", false));
765 let path = would_cycle(
766 &store,
767 &EntityId("a".into()),
768 &EntityId("a".into()),
769 "PART_OF",
770 );
771 assert_eq!(path, Some(vec![EntityId("a".into())]));
772 }
773
774 #[test]
775 fn would_cycle_single_back_edge() {
776 let mut store = Store::new();
778 store.upsert(EntityId("a".into()), entity("a", "s", false));
779 store.upsert(EntityId("b".into()), entity("b", "s", false));
780 add_edge(&mut store, "a", "b", "PART_OF");
781 let path = would_cycle(
782 &store,
783 &EntityId("b".into()),
784 &EntityId("a".into()),
785 "PART_OF",
786 )
787 .expect("cycle");
788 assert_eq!(path, vec![EntityId("a".into()), EntityId("b".into())]);
789 }
790
791 #[test]
792 fn would_cycle_deep_chain() {
793 let mut store = Store::new();
795 for id in ["foo", "bar", "baz"] {
796 store.upsert(EntityId(id.into()), entity(id, "s", false));
797 }
798 add_edge(&mut store, "bar", "baz", "PART_OF");
799 add_edge(&mut store, "baz", "foo", "PART_OF");
800 let path = would_cycle(
801 &store,
802 &EntityId("foo".into()),
803 &EntityId("bar".into()),
804 "PART_OF",
805 )
806 .expect("cycle");
807 assert_eq!(
808 path,
809 vec![
810 EntityId("bar".into()),
811 EntityId("baz".into()),
812 EntityId("foo".into())
813 ]
814 );
815 }
816
817 #[test]
818 fn would_cycle_ignores_other_rel_types() {
819 let mut store = Store::new();
823 store.upsert(EntityId("a".into()), entity("a", "s", false));
824 store.upsert(EntityId("b".into()), entity("b", "s", false));
825 add_edge(&mut store, "a", "b", "DEPENDS_ON");
826 assert!(
827 would_cycle(
828 &store,
829 &EntityId("b".into()),
830 &EntityId("a".into()),
831 "PART_OF"
832 )
833 .is_none()
834 );
835 }
836
837 #[test]
838 fn would_cycle_none_for_disjoint_graph() {
839 let mut store = Store::new();
840 for id in ["a", "b", "c", "d"] {
841 store.upsert(EntityId(id.into()), entity(id, "s", false));
842 }
843 add_edge(&mut store, "c", "d", "PART_OF");
844 assert!(
845 would_cycle(
846 &store,
847 &EntityId("a".into()),
848 &EntityId("b".into()),
849 "PART_OF"
850 )
851 .is_none()
852 );
853 }
854
855 #[test]
856 fn would_cycle_parallel_paths_do_not_trip() {
857 let mut store = Store::new();
862 for id in ["a", "b", "c"] {
863 store.upsert(EntityId(id.into()), entity(id, "s", false));
864 }
865 add_edge(&mut store, "a", "b", "PART_OF");
866 add_edge(&mut store, "a", "c", "PART_OF");
867 assert!(
869 would_cycle(
870 &store,
871 &EntityId("a".into()),
872 &EntityId("b".into()),
873 "PART_OF"
874 )
875 .is_none(),
876 "sibling paths must not trip"
877 );
878 assert!(
880 would_cycle(
881 &store,
882 &EntityId("b".into()),
883 &EntityId("a".into()),
884 "PART_OF"
885 )
886 .is_some()
887 );
888 }
889
890 #[test]
891 fn most_connected_distinguishes_hub_vs_fanout() {
892 let mut store = Store::new();
893 for id in [
894 "hub", "fanout", "r1", "r2", "r3", "r4", "t1", "t2", "t3", "t4",
895 ] {
896 store.upsert(EntityId(id.into()), entity(id, "s", false));
897 }
898 add_edge(&mut store, "r1", "hub", "REFERENCES");
900 add_edge(&mut store, "r2", "hub", "REFERENCES");
901 add_edge(&mut store, "r3", "hub", "REFERENCES");
902 add_edge(&mut store, "r4", "hub", "REFERENCES");
903 add_edge(&mut store, "fanout", "t1", "USES");
905 add_edge(&mut store, "fanout", "t2", "USES");
906 add_edge(&mut store, "fanout", "t3", "USES");
907 add_edge(&mut store, "fanout", "t4", "USES");
908
909 let top = most_connected(&store, 10);
910 let hub = top.iter().find(|c| c.id == EntityId("hub".into())).unwrap();
911 assert_eq!(hub.total, 4);
912 assert_eq!(hub.incoming, 4);
913 assert_eq!(hub.outgoing, 0);
914 let fanout = top
915 .iter()
916 .find(|c| c.id == EntityId("fanout".into()))
917 .unwrap();
918 assert_eq!(fanout.total, 4);
919 assert_eq!(fanout.incoming, 0);
920 assert_eq!(fanout.outgoing, 4);
921
922 let fanout_pos = top.iter().position(|c| c.id.0 == "fanout").unwrap();
924 let hub_pos = top.iter().position(|c| c.id.0 == "hub").unwrap();
925 assert!(
926 fanout_pos < hub_pos,
927 "ties must resolve by id lex ascending"
928 );
929 }
930
931 #[test]
936 fn most_connected_ranks_by_dependency_not_mention() {
937 let mut store = Store::new();
938 for id in [
939 "mentionhub",
940 "dephub",
941 "m1",
942 "m2",
943 "m3",
944 "m4",
945 "m5",
946 "d1",
947 "d2",
948 ] {
949 store.upsert(EntityId(id.into()), entity(id, "s", false));
950 }
951 for m in ["m1", "m2", "m3", "m4", "m5"] {
953 add_body_edge(&mut store, m, "mentionhub");
954 }
955 add_edge(&mut store, "d1", "dephub", "USES");
957 add_edge(&mut store, "d2", "dephub", "USES");
958
959 let top = most_connected(&store, 10);
960 let mh = top.iter().find(|c| c.id.0 == "mentionhub").unwrap();
961 let dh = top.iter().find(|c| c.id.0 == "dephub").unwrap();
962
963 assert_eq!(mh.total, 5);
965 assert_eq!(mh.typed_total, 0, "all of mentionhub's edges are mentions");
966 assert_eq!(dh.total, 2);
967 assert_eq!(dh.typed_total, 2, "dephub's edges are typed dependencies");
968
969 let mh_pos = top.iter().position(|c| c.id.0 == "mentionhub").unwrap();
972 let dh_pos = top.iter().position(|c| c.id.0 == "dephub").unwrap();
973 assert!(
974 dh_pos < mh_pos,
975 "dependency hub must outrank co-mention hub"
976 );
977 }
978
979 #[test]
985 fn leaf_declared_types_exempt_from_orphans_but_visible_as_population() {
986 use std::collections::HashMap;
987 use std::sync::Arc;
988
989 let manifest = r#"
990name: leafy
991version: 0.1.0
992description: leaf test schema
993when_to_use: tests
994types:
995 - obs
996 - spec
997relationships:
998 mode: strict
999 definitions:
1000 - name: USES
1001 description: u
1002 default_weight: 1.0
1003 - name: PART_OF
1004 description: hier
1005 default_weight: 1.0
1006 acyclic: true
1007 - name: _default
1008 description: fallback
1009 default_weight: 1.0
1010community:
1011 resolution: 1.0
1012 seed: 42
1013"#;
1014 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";
1015 let obs_yaml = format!("name: obs\ndescription: t\nwhen_to_use: h\nleaf: true\n{body}");
1016 let spec_yaml = format!("name: spec\ndescription: t\nwhen_to_use: h\n{body}");
1017 let schema = Arc::new(
1018 memstead_schema::load_schema_from_memory(
1019 manifest,
1020 &[
1021 ("obs".to_string(), obs_yaml),
1022 ("spec".to_string(), spec_yaml),
1023 ],
1024 )
1025 .expect("leaf fixture schema parses"),
1026 );
1027 let mut schemas: HashMap<String, Arc<memstead_schema::Schema>> = HashMap::new();
1028 schemas.insert("s".to_string(), schema);
1029
1030 let mut store = Store::new();
1031 let mut e = |id: &str, ty: &str| {
1032 let mut ent = entity(id, "s", false);
1033 ent.entity_type = ty.to_string();
1034 store.upsert(EntityId(id.into()), ent);
1035 };
1036 e("lonely-spec", "spec"); e("lonely-obs", "obs"); e("linked-obs", "obs"); e("hub", "spec");
1040 add_edge(&mut store, "linked-obs", "hub", "USES");
1041
1042 let orphans = find_orphans_with_schemas(&store, &schemas);
1044 assert_eq!(
1045 orphans,
1046 vec![EntityId("lonely-spec".into())],
1047 "leaf-typed edge-less entities are exempt; non-leaf count as before"
1048 );
1049 let pop = leaf_population(&store, &schemas);
1051 assert_eq!(pop.get("leafy@0.1.0:obs"), Some(&2));
1052 assert_eq!(pop.len(), 1);
1053
1054 let blind = find_orphans(&store);
1056 let mut blind_sorted: Vec<String> = blind.iter().map(|i| i.0.clone()).collect();
1057 blind_sorted.sort();
1058 assert_eq!(blind_sorted, vec!["lonely-obs", "lonely-spec"]);
1059 assert!(leaf_population(&store, &HashMap::new()).is_empty());
1060 }
1061}