Skip to main content

memstead_base/graph/
query.rs

1//! BFS reachability search and orphan/stub detection.
2
3use std::cmp::Ordering;
4use std::collections::{HashMap, HashSet, VecDeque};
5
6use crate::entity::EntityId;
7use crate::store::{EdgeSource, InEdge, Store};
8
9/// Find all entities reachable from a starting entity within max_depth hops.
10/// Undirected traversal (follows both outgoing and incoming edges).
11/// Returns the set of reachable entity IDs including the start node.
12pub 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
41/// Like [`reachable`] but returns each reached entity's hop-distance from
42/// `from` — the depth at which BFS first reaches it (`from` itself is 0).
43/// Undirected (outgoing + incoming), same membership as [`reachable`]. The
44/// distances drive proximity ranking of a `related_to` neighbourhood:
45/// nearer first.
46pub 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
77/// Find all entities reachable from `from` by walking only edges whose
78/// `rel_type` is in `edge_types`, up to `max_depth` hops. Undirected
79/// traversal (outgoing + incoming). Returns one tuple per reached entity
80/// — never `from` itself — carrying the edge label used to first reach it
81/// and the depth of that first reach (1 = direct neighbour).
82///
83/// `max_depth == 0` or an empty `edge_types` returns an empty vec.
84///
85/// This is the graph-expansion primitive behind `SearchScope.expand_via`.
86/// Shares the BFS shape with `reachable` but filters by rel_type and
87/// reports how each entity was reached.
88pub 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
132/// Would adding an edge `from --rel_type--> to` close a cycle in the
133/// subgraph restricted to edges of `rel_type`? Returns the back-path as
134/// `[to, …, from]` when a cycle exists, `None` otherwise.
135///
136/// A self-loop (`from == to`) is a length-1 cycle and is reported without
137/// a BFS. Otherwise this walks forward from `to` along outgoing edges
138/// whose `rel_type` matches, looking for `from`. Cost is O(edges of that
139/// rel_type) in the worst case.
140pub 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(&current) {
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
183/// Find orphan entities — non-stub entities with no edges at all (completely isolated).
184pub 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
199/// Find stub entities — entities created from unresolved references.
200/// Returns each stub with the list of entities that reference it.
201pub 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/// One entity's degree counts. `total == incoming + outgoing`; kept explicit
218/// so the JSON wire shape is self-describing and callers don't re-derive it.
219///
220/// `typed_*` excludes auto-emitted mention edges (`EdgeSource::BodyLink` —
221/// the `[[wiki-link]]` → REFERENCES alias-synthesis pass) so centrality can
222/// rank by declared dependency rather than co-mention. Auto-emitted mentions
223/// are the bulk of all edges, so `total` (which keeps them) is dominated by
224/// co-mention; `typed_total` is the dependency degree. The raw `total` is
225/// retained — the mention edges are not dropped from the graph, only set
226/// aside for ranking. Mention degree is `total - typed_total`.
227/// Stubs are never included in `most_connected` results.
228#[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
239/// Compute one entity's raw and typed degree. `incoming_counts` decides
240/// which incoming edges contribute (e.g. source-in-mem scoping for a
241/// mem-filtered health view); every outgoing edge always counts. Typed
242/// degree excludes `EdgeSource::BodyLink` (auto-emitted mention) edges.
243pub 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
278/// Centrality ordering: dependency degree (`typed_total`) descending first,
279/// then raw `total` descending, then `id` lexicographic ascending as a
280/// stable deterministic tie-break. Ranking by `typed_total` keeps a
281/// co-mention-inflated hub from outranking a real dependency hub.
282pub 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
289/// Find the most connected non-stub entities, ranked by dependency degree
290/// (typed edges) — see [`cmp_by_dependency`]. Returns up to `limit` entries.
291pub 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    /// An auto-emitted mention edge (the `[[wiki-link]]` → REFERENCES
343    /// alias-synthesis pass), marked `EdgeSource::BodyLink` — excluded
344    /// from the typed (dependency) degree.
345    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        // A -> B -> C
358        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); // just self
374
375        let r1 = reachable(&store, &a, 1);
376        assert_eq!(r1.len(), 2); // a + b
377
378        let r2 = reachable(&store, &a, 2);
379        assert_eq!(r2.len(), 3); // a + b + c
380    }
381
382    #[test]
383    fn reachable_undirected() {
384        let store = build_linear_store();
385        let c = EntityId("c".into());
386        // From C, going backwards via incoming edges
387        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        // c has no edges
399
400        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)); // stub, isolated
409        store.upsert(EntityId("b".into()), entity("b", "s", false)); // non-stub, isolated
410
411        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        // a has 2 edges (1 out + 1 in from c->a)
436        // b has 1 edge (1 in from a->b)
437        // c has 1 edge (1 out to a)
438        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    // ---- reachable_via ----
462
463    #[test]
464    fn reachable_via_filters_by_edge_type() {
465        // a --USES--> b ; a --REFERENCES--> c
466        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        // From b, walk back to a via incoming edge.
483        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(); // a -> b -> c with USES
510        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        // Diamond: a -> b -> d ; a -> c -> d. d is reachable via 2 hops from a
526        // through two paths. BFS should record depth=2 exactly once.
527        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    // ---- would_cycle ----
556
557    #[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        // a -PART_OF-> b already. Adding b -PART_OF-> a closes a cycle.
573        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        // foo's future edge: foo -PART_OF-> bar. Existing: bar->baz->foo.
590        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        // a -DEPENDS_ON-> b exists. Proposed b -PART_OF-> a should not
616        // trip the PART_OF subgraph even though a non-PART_OF back-edge
617        // exists.
618        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        // a -PART_OF-> b and a -PART_OF-> c (no path from b to a).
654        // Proposed c -PART_OF-> a should be flagged (c has no back-path
655        // today, but adding it alongside existing a->c would form a
656        // cycle a->c->a — confirm the BFS catches that).
657        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        // Proposed a -PART_OF-> b is fine — a already -PART_OF-> b.
664        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        // Proposed b -PART_OF-> a would close a cycle a->b->a.
675        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        // hub: 4 incoming, 0 outgoing
695        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        // fanout: 0 incoming, 4 outgoing
700        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        // Tie-break: "fanout" < "hub" lex, so fanout appears first.
719        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    /// #46: a node inflated purely by auto-emitted mentions (BodyLink)
728    /// must not outrank a node with real typed dependencies. `typed_total`
729    /// drives the ranking; `total` (which keeps the mentions) is retained
730    /// but only a secondary tie-break.
731    #[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        // mentionhub: 5 incoming mention edges — high total, zero typed.
748        for m in ["m1", "m2", "m3", "m4", "m5"] {
749            add_body_edge(&mut store, m, "mentionhub");
750        }
751        // dephub: 2 incoming typed (USES) edges — lower total, real deps.
752        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        // Raw total still counts the mentions (not dropped from the graph).
760        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        // Ranking: dephub (2 typed) outranks mentionhub (0 typed) despite
766        // mentionhub's higher raw total — the co-mention inflation is gone.
767        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}