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 schemars::JsonSchema;
7
8use crate::entity::EntityId;
9use crate::store::{EdgeSource, InEdge, Store};
10
11/// Traversal direction relative to the seed, applied at EVERY hop —
12/// depth > 1 is a pure transitive closure in the chosen direction,
13/// never a mixed walk (an entity reachable only by alternating
14/// directions is not in an `out` or `in` result at any depth; that
15/// per-hop property is what makes a fall-through analysis correct).
16///
17/// `in`/`out` describe the edge relative to the seed and match the
18/// Store's own vocabulary — domain words (ancestors/upstream) invert
19/// per schema, so the engine does not use them.
20#[derive(
21    Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize, JsonSchema,
22)]
23#[serde(rename_all = "lowercase")]
24pub enum TraversalDirection {
25    /// Follow edges pointing away from the seed (seed → target).
26    Out,
27    /// Follow edges pointing at the seed (source → seed).
28    In,
29    /// Follow both — the historical undirected walk, and the default:
30    /// a query that omits the selector returns exactly what it always
31    /// returned.
32    #[default]
33    Both,
34}
35
36impl TraversalDirection {
37    /// Does this walk follow outgoing edges?
38    fn follows_out(self) -> bool {
39        !matches!(self, TraversalDirection::In)
40    }
41    /// Does this walk follow incoming edges?
42    fn follows_in(self) -> bool {
43        !matches!(self, TraversalDirection::Out)
44    }
45}
46
47/// Returns each reached entity's hop-distance from `from` — the depth at
48/// which BFS first reaches it (`from` itself is 0), following only edges
49/// admitted by `direction` at every hop. The distances drive proximity
50/// ranking of a `related_to` neighbourhood: nearer first.
51pub 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/// One entity reached by [`reachable_via`]: the edge label it was first
88/// reached by, the depth of that first reach (1 = direct neighbour), and
89/// the direction that reaching edge was traversed in — so a `both` walk
90/// stays interpretable per hit.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ReachedVia {
93    pub id: EntityId,
94    pub via_edge: String,
95    pub depth: usize,
96    /// The direction of the FIRST-reaching edge: `Out` when it points
97    /// away from the entity we walked from, `In` when it points at it.
98    /// Never `Both` — a concrete edge has one traversal direction.
99    pub direction: TraversalDirection,
100}
101
102/// Find all entities reachable from `from` by walking only edges whose
103/// `rel_type` is in `edge_types`, up to `max_depth` hops, following only
104/// edges admitted by `direction` at every hop. Returns one
105/// [`ReachedVia`] per reached entity — never `from` itself.
106///
107/// `max_depth == 0` or an empty `edge_types` returns an empty vec.
108///
109/// This is the graph-expansion primitive behind `SearchScope.expand_via`.
110pub 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
169/// Would adding an edge `from --rel_type--> to` close a cycle in the
170/// subgraph restricted to edges of `rel_type`? Returns the back-path as
171/// `[to, …, from]` when a cycle exists, `None` otherwise.
172///
173/// A self-loop (`from == to`) is a length-1 cycle and is reported without
174/// a BFS. Otherwise this walks forward from `to` along outgoing edges
175/// whose `rel_type` matches, looking for `from`. Cost is O(edges of that
176/// rel_type) in the worst case.
177pub 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(&current) {
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
220/// Find orphan entities — non-stub entities with no edges at all (completely isolated).
221pub fn find_orphans(store: &Store) -> Vec<EntityId> {
222    find_orphans_with_schemas(store, &std::collections::HashMap::new())
223}
224
225/// Schema-aware orphan scan: like [`find_orphans`], but entities whose
226/// type declares `leaf: true` in their mem's schema are exempt — a
227/// leaf is edge-less BY CONSTRUCTION, so counting it as an orphan is
228/// noise that masks real orphans (agent-trust plan 06). The exempted
229/// population stays visible through [`leaf_population`]. An empty
230/// schema map (tests, ad-hoc callers) reproduces the schema-blind
231/// behaviour exactly.
232pub 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
253/// Whether `entity`'s type declares `leaf: true` in its mem's schema.
254fn 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
264/// The leaf population health reports beside the orphan axis: for
265/// every leaf-declared type with at least one real entity, the count
266/// of its entities, keyed `<schema_ref>:<type>`. Visible, never
267/// vanished — the reader still sees the population the orphan
268/// exemption covers.
269pub 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
292/// Find stub entities — entities created from unresolved references.
293/// Returns each stub with the list of entities that reference it.
294pub 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/// One entity's degree counts. `total == incoming + outgoing`; kept explicit
311/// so the JSON wire shape is self-describing and callers don't re-derive it.
312///
313/// `typed_*` excludes auto-emitted mention edges (`EdgeSource::BodyLink` —
314/// the `[[wiki-link]]` → REFERENCES alias-synthesis pass) so centrality can
315/// rank by declared dependency rather than co-mention. Auto-emitted mentions
316/// are the bulk of all edges, so `total` (which keeps them) is dominated by
317/// co-mention; `typed_total` is the dependency degree. The raw `total` is
318/// retained — the mention edges are not dropped from the graph, only set
319/// aside for ranking. Mention degree is `total - typed_total`.
320/// Stubs are never included in `most_connected` results.
321#[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
332/// Compute one entity's raw and typed degree. `incoming_counts` decides
333/// which incoming edges contribute (e.g. source-in-mem scoping for a
334/// mem-filtered health view); every outgoing edge always counts. Typed
335/// degree excludes `EdgeSource::BodyLink` (auto-emitted mention) edges.
336pub 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
371/// Centrality ordering: dependency degree (`typed_total`) descending first,
372/// then raw `total` descending, then `id` lexicographic ascending as a
373/// stable deterministic tie-break. Ranking by `typed_total` keeps a
374/// co-mention-inflated hub from outranking a real dependency hub.
375pub 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
382/// Find the most connected non-stub entities, ranked by dependency degree
383/// (typed edges) — see [`cmp_by_dependency`]. Returns up to `limit` entries.
384pub 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    /// An auto-emitted mention edge (the `[[wiki-link]]` → REFERENCES
437    /// alias-synthesis pass), marked `EdgeSource::BodyLink` — excluded
438    /// from the typed (dependency) degree.
439    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        // A -> B -> C
452        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); // just self
468        assert_eq!(reachable_distances(&store, &a, 1, both).len(), 2); // a + b
469        assert_eq!(reachable_distances(&store, &a, 2, both).len(), 3); // a + b + c
470    }
471
472    #[test]
473    fn reachable_distances_both_is_undirected() {
474        let store = build_linear_store();
475        let c = EntityId("c".into());
476        // From C, `both` still walks backwards via incoming edges.
477        let r = reachable_distances(&store, &c, 10, TraversalDirection::Both);
478        assert_eq!(r.len(), 3);
479    }
480
481    /// The per-hop property the feature exists for: on a chain
482    /// x --> seed --> y --> z, `out` from the seed is exactly {seed, y,
483    /// z} at depth 2+, `in` exactly {seed, x} — and an entity reachable
484    /// only by ALTERNATING directions (w, via seed <- x -> w) is in
485    /// neither directed result at any depth, because depth > 1 is a
486    /// pure transitive closure, never a mixed walk.
487    #[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"); // reachable only via in-then-out
497
498        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        // c has no edges
535
536        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)); // stub, isolated
545        store.upsert(EntityId("b".into()), entity("b", "s", false)); // non-stub, isolated
546
547        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        // a has 2 edges (1 out + 1 in from c->a)
572        // b has 1 edge (1 in from a->b)
573        // c has 1 edge (1 out to a)
574        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    // ---- reachable_via ----
598
599    #[test]
600    fn reachable_via_filters_by_edge_type() {
601        // a --USES--> b ; a --REFERENCES--> c
602        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        // From b, walk back to a via incoming edge.
626        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        // Directional complements on the same store: from b, `out`
647        // reaches nothing (no outgoing USES), `in` reaches a.
648        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(); // a -> b -> c with USES
696        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        // Diamond: a -> b -> d ; a -> c -> d. d is reachable via 2 hops from a
724        // through two paths. BFS should record depth=2 exactly once.
725        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    // ---- would_cycle ----
760
761    #[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        // a -PART_OF-> b already. Adding b -PART_OF-> a closes a cycle.
777        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        // foo's future edge: foo -PART_OF-> bar. Existing: bar->baz->foo.
794        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        // a -DEPENDS_ON-> b exists. Proposed b -PART_OF-> a should not
820        // trip the PART_OF subgraph even though a non-PART_OF back-edge
821        // exists.
822        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        // a -PART_OF-> b and a -PART_OF-> c (no path from b to a).
858        // Proposed c -PART_OF-> a should be flagged (c has no back-path
859        // today, but adding it alongside existing a->c would form a
860        // cycle a->c->a — confirm the BFS catches that).
861        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        // Proposed a -PART_OF-> b is fine — a already -PART_OF-> b.
868        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        // Proposed b -PART_OF-> a would close a cycle a->b->a.
879        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        // hub: 4 incoming, 0 outgoing
899        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        // fanout: 0 incoming, 4 outgoing
904        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        // Tie-break: "fanout" < "hub" lex, so fanout appears first.
923        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    /// #46: a node inflated purely by auto-emitted mentions (BodyLink)
932    /// must not outrank a node with real typed dependencies. `typed_total`
933    /// drives the ranking; `total` (which keeps the mentions) is retained
934    /// but only a secondary tie-break.
935    #[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        // mentionhub: 5 incoming mention edges — high total, zero typed.
952        for m in ["m1", "m2", "m3", "m4", "m5"] {
953            add_body_edge(&mut store, m, "mentionhub");
954        }
955        // dephub: 2 incoming typed (USES) edges — lower total, real deps.
956        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        // Raw total still counts the mentions (not dropped from the graph).
964        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        // Ranking: dephub (2 typed) outranks mentionhub (0 typed) despite
970        // mentionhub's higher raw total — the co-mention inflation is gone.
971        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    /// Agent-trust plan 06 (criterion 1): leaf-declared types are
980    /// exempt from the orphan scan — visible instead through
981    /// `leaf_population` — while non-leaf types count exactly as
982    /// before, a leaf WITH edges stays legal, and an empty schema map
983    /// reproduces the schema-blind behaviour byte-for-byte.
984    #[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"); // real orphan
1037        e("lonely-obs", "obs"); // leaf: exempt
1038        e("linked-obs", "obs"); // leaf with an edge: legal, not orphan anyway
1039        e("hub", "spec");
1040        add_edge(&mut store, "linked-obs", "hub", "USES");
1041
1042        // Schema-aware: only the non-leaf edge-less entity is an orphan.
1043        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        // The exempted population is visible, keyed schema_ref:type.
1050        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        // Empty schema map == historical schema-blind behaviour.
1055        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}