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/// Set variant of [`would_cycle`]: walks the UNION subgraph of the
221/// declared acyclicity set, so the returned back-path may mix
222/// rel-types. Returns the path (same shape as `would_cycle`) plus one
223/// rel-type per hop of the path (`len == path.len() - 1`; empty for
224/// the self-loop case).
225pub fn would_cycle_in_set(
226    store: &Store,
227    from: &EntityId,
228    to: &EntityId,
229    set: &[String],
230) -> Option<(Vec<EntityId>, Vec<String>)> {
231    if from == to {
232        return Some((vec![from.clone()], Vec::new()));
233    }
234
235    // child -> (parent, rel-type of the parent -> child edge)
236    let mut parent: std::collections::HashMap<EntityId, (EntityId, String)> =
237        std::collections::HashMap::new();
238    let mut visited: HashSet<EntityId> = HashSet::new();
239    visited.insert(to.clone());
240
241    let mut queue: VecDeque<EntityId> = VecDeque::new();
242    queue.push_back(to.clone());
243
244    while let Some(current) = queue.pop_front() {
245        for edge in store.outgoing(&current) {
246            if !set.iter().any(|n| n == &edge.rel_type) {
247                continue;
248            }
249            let next = &edge.target;
250            if *next == *from {
251                let mut ids = vec![current.clone()];
252                let mut rels = vec![edge.rel_type.clone()];
253                let mut cursor = current.clone();
254                while let Some((p, r)) = parent.get(&cursor) {
255                    ids.push(p.clone());
256                    rels.push(r.clone());
257                    cursor = p.clone();
258                }
259                ids.reverse();
260                rels.reverse();
261                ids.push(from.clone());
262                return Some((ids, rels));
263            }
264            if visited.insert(next.clone()) {
265                parent.insert(next.clone(), (current.clone(), edge.rel_type.clone()));
266                queue.push_back(next.clone());
267            }
268        }
269    }
270    None
271}
272
273/// Find orphan entities — non-stub entities with no edges at all (completely isolated).
274pub fn find_orphans(store: &Store) -> Vec<EntityId> {
275    find_orphans_with_schemas(store, &std::collections::HashMap::new())
276}
277
278/// Schema-aware orphan scan: like [`find_orphans`], but entities whose
279/// type declares `leaf: true` in their mem's schema are exempt — a
280/// leaf is edge-less BY CONSTRUCTION, so counting it as an orphan is
281/// noise that masks real orphans (agent-trust plan 06). The exempted
282/// population stays visible through [`leaf_population`]. An empty
283/// schema map (tests, ad-hoc callers) reproduces the schema-blind
284/// behaviour exactly.
285pub fn find_orphans_with_schemas(
286    store: &Store,
287    schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
288) -> Vec<EntityId> {
289    let mut results = Vec::new();
290    for entity in store.all_entities() {
291        if entity.stub {
292            continue;
293        }
294        if entity_is_declared_leaf(entity, schemas) {
295            continue;
296        }
297        let out = store.outgoing(&entity.id);
298        let inc = store.incoming(&entity.id);
299        if out.is_empty() && inc.is_empty() {
300            results.push(entity.id.clone());
301        }
302    }
303    results
304}
305
306/// Whether `entity`'s type declares `leaf: true` in its mem's schema.
307fn entity_is_declared_leaf(
308    entity: &crate::entity::Entity,
309    schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
310) -> bool {
311    schemas
312        .get(entity.mem.as_str())
313        .and_then(|s| s.types.get(&entity.entity_type))
314        .is_some_and(|t| t.leaf)
315}
316
317/// The leaf population health reports beside the orphan axis: for
318/// every leaf-declared type with at least one real entity, the count
319/// of its entities, keyed `<schema_ref>:<type>`. Visible, never
320/// vanished — the reader still sees the population the orphan
321/// exemption covers.
322pub fn leaf_population(
323    store: &Store,
324    schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
325) -> std::collections::BTreeMap<String, usize> {
326    let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
327    for entity in store.all_entities() {
328        if entity.stub {
329            continue;
330        }
331        if let Some(schema) = schemas.get(entity.mem.as_str())
332            && schema
333                .types
334                .get(&entity.entity_type)
335                .is_some_and(|t| t.leaf)
336        {
337            let (name, version) = schema.id();
338            *out.entry(format!("{name}@{version}:{}", entity.entity_type))
339                .or_default() += 1;
340        }
341    }
342    out
343}
344
345/// Find stub entities — entities created from unresolved references.
346/// Returns each stub with the list of entities that reference it.
347/// Deterministic: stubs sorted by id, referrers sorted within each —
348/// the store iterates a HashMap, so without the sorts two identical
349/// runs can serve the same list in different orders.
350pub fn find_stubs(store: &Store) -> Vec<(EntityId, Vec<EntityId>)> {
351    let mut results = Vec::new();
352    for entity in store.all_entities() {
353        if !entity.stub {
354            continue;
355        }
356        let mut referenced_by: Vec<EntityId> = store
357            .incoming(&entity.id)
358            .iter()
359            .map(|e| e.from.clone())
360            .collect();
361        referenced_by.sort_by(|a, b| a.0.cmp(&b.0));
362        results.push((entity.id.clone(), referenced_by));
363    }
364    results.sort_by(|a, b| a.0.0.cmp(&b.0.0));
365    results
366}
367
368/// One entity's degree counts. `total == incoming + outgoing`; kept explicit
369/// so the JSON wire shape is self-describing and callers don't re-derive it.
370///
371/// `typed_*` excludes auto-emitted mention edges (`EdgeSource::BodyLink` —
372/// the `[[wiki-link]]` → REFERENCES alias-synthesis pass) so centrality can
373/// rank by declared dependency rather than co-mention. Auto-emitted mentions
374/// are the bulk of all edges, so `total` (which keeps them) is dominated by
375/// co-mention; `typed_total` is the dependency degree. The raw `total` is
376/// retained — the mention edges are not dropped from the graph, only set
377/// aside for ranking. Mention degree is `total - typed_total`.
378/// Stubs are never included in `most_connected` results.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct Connectivity {
381    pub id: EntityId,
382    pub total: usize,
383    pub incoming: usize,
384    pub outgoing: usize,
385    pub typed_total: usize,
386    pub typed_incoming: usize,
387    pub typed_outgoing: usize,
388}
389
390/// Compute one entity's raw and typed degree. `incoming_counts` decides
391/// which incoming edges contribute (e.g. source-in-mem scoping for a
392/// mem-filtered health view); every outgoing edge always counts. Typed
393/// degree excludes `EdgeSource::BodyLink` (auto-emitted mention) edges.
394pub fn connectivity_for(
395    store: &Store,
396    id: &EntityId,
397    incoming_counts: impl Fn(&InEdge) -> bool,
398) -> Connectivity {
399    let out = store.outgoing(id);
400    let outgoing = out.len();
401    let typed_outgoing = out
402        .iter()
403        .filter(|e| e.source != EdgeSource::BodyLink)
404        .count();
405
406    let mut incoming = 0;
407    let mut typed_incoming = 0;
408    for e in store.incoming(id) {
409        if !incoming_counts(e) {
410            continue;
411        }
412        incoming += 1;
413        if e.source != EdgeSource::BodyLink {
414            typed_incoming += 1;
415        }
416    }
417
418    Connectivity {
419        id: id.clone(),
420        total: outgoing + incoming,
421        incoming,
422        outgoing,
423        typed_total: typed_outgoing + typed_incoming,
424        typed_incoming,
425        typed_outgoing,
426    }
427}
428
429/// Centrality ordering: dependency degree (`typed_total`) descending first,
430/// then raw `total` descending, then `id` lexicographic ascending as a
431/// stable deterministic tie-break. Ranking by `typed_total` keeps a
432/// co-mention-inflated hub from outranking a real dependency hub.
433pub fn cmp_by_dependency(a: &Connectivity, b: &Connectivity) -> Ordering {
434    b.typed_total
435        .cmp(&a.typed_total)
436        .then_with(|| b.total.cmp(&a.total))
437        .then_with(|| a.id.0.cmp(&b.id.0))
438}
439
440/// Find the most connected non-stub entities, ranked by dependency degree
441/// (typed edges) — see [`cmp_by_dependency`]. Returns up to `limit` entries.
442pub fn most_connected(store: &Store, limit: usize) -> Vec<Connectivity> {
443    let mut entries: Vec<Connectivity> = store
444        .all_entities()
445        .filter(|e| !e.stub)
446        .map(|e| connectivity_for(store, &e.id, |_| true))
447        .collect();
448
449    entries.sort_by(cmp_by_dependency);
450    entries.truncate(limit);
451    entries
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::entity::Entity;
458    use crate::store::{Edge, EdgeSource};
459    use indexmap::IndexMap;
460
461    fn entity(id: &str, mem: &str, stub: bool) -> Entity {
462        Entity {
463            id: EntityId(id.to_string()),
464            title: id.to_string(),
465            entity_type: "spec".to_string(),
466            mem: mem.to_string(),
467            file_path: String::new(),
468            metadata: IndexMap::new(),
469            sections: IndexMap::new(),
470            relationships: Vec::new(),
471            content_hash: String::new(),
472            stub,
473            stub_kind: if stub {
474                Some(crate::entity::StubKind::LoadTime)
475            } else {
476                None
477            },
478            heading_spans: std::collections::HashMap::new(),
479            raw_section_headings: Vec::new(),
480        }
481    }
482
483    fn add_edge(store: &mut Store, from: &str, to: &str, rel: &str) {
484        store.add_edge(
485            EntityId(from.to_string()),
486            Edge {
487                rel_type: rel.to_string(),
488                target: EntityId(to.to_string()),
489                source: EdgeSource::Explicit,
490            },
491        );
492    }
493
494    /// An auto-emitted mention edge (the `[[wiki-link]]` → REFERENCES
495    /// alias-synthesis pass), marked `EdgeSource::BodyLink` — excluded
496    /// from the typed (dependency) degree.
497    fn add_body_edge(store: &mut Store, from: &str, to: &str) {
498        store.add_edge(
499            EntityId(from.to_string()),
500            Edge {
501                rel_type: "REFERENCES".to_string(),
502                target: EntityId(to.to_string()),
503                source: EdgeSource::BodyLink,
504            },
505        );
506    }
507
508    fn build_linear_store() -> Store {
509        // A -> B -> C
510        let mut store = Store::new();
511        store.upsert(EntityId("a".into()), entity("a", "s", false));
512        store.upsert(EntityId("b".into()), entity("b", "s", false));
513        store.upsert(EntityId("c".into()), entity("c", "s", false));
514        add_edge(&mut store, "a", "b", "USES");
515        add_edge(&mut store, "b", "c", "USES");
516        store
517    }
518
519    #[test]
520    fn reachable_distances_within_depth() {
521        let store = build_linear_store();
522        let a = EntityId("a".into());
523        let both = TraversalDirection::Both;
524
525        assert_eq!(reachable_distances(&store, &a, 0, both).len(), 1); // just self
526        assert_eq!(reachable_distances(&store, &a, 1, both).len(), 2); // a + b
527        assert_eq!(reachable_distances(&store, &a, 2, both).len(), 3); // a + b + c
528    }
529
530    #[test]
531    fn reachable_distances_both_is_undirected() {
532        let store = build_linear_store();
533        let c = EntityId("c".into());
534        // From C, `both` still walks backwards via incoming edges.
535        let r = reachable_distances(&store, &c, 10, TraversalDirection::Both);
536        assert_eq!(r.len(), 3);
537    }
538
539    /// The per-hop property the feature exists for: on a chain
540    /// x --> seed --> y --> z, `out` from the seed is exactly {seed, y,
541    /// z} at depth 2+, `in` exactly {seed, x} — and an entity reachable
542    /// only by ALTERNATING directions (w, via seed <- x -> w) is in
543    /// neither directed result at any depth, because depth > 1 is a
544    /// pure transitive closure, never a mixed walk.
545    #[test]
546    fn reachable_distances_directional_transitive_closure() {
547        let mut store = Store::new();
548        for id in ["x", "seed", "y", "z", "w"] {
549            store.upsert(EntityId(id.into()), entity(id, "s", false));
550        }
551        add_edge(&mut store, "x", "seed", "USES");
552        add_edge(&mut store, "seed", "y", "USES");
553        add_edge(&mut store, "y", "z", "USES");
554        add_edge(&mut store, "x", "w", "USES"); // reachable only via in-then-out
555
556        let seed = EntityId("seed".into());
557        let ids = |m: &HashMap<EntityId, usize>| {
558            let mut v: Vec<String> = m.keys().map(|i| i.0.clone()).collect();
559            v.sort();
560            v
561        };
562
563        let out = reachable_distances(&store, &seed, 10, TraversalDirection::Out);
564        assert_eq!(
565            ids(&out),
566            ["seed", "y", "z"],
567            "out = transitive descendants only"
568        );
569
570        let inward = reachable_distances(&store, &seed, 10, TraversalDirection::In);
571        assert_eq!(
572            ids(&inward),
573            ["seed", "x"],
574            "in = transitive ancestors only"
575        );
576
577        let both = reachable_distances(&store, &seed, 10, TraversalDirection::Both);
578        assert_eq!(
579            ids(&both),
580            ["seed", "w", "x", "y", "z"],
581            "both = the historical undirected set, mixed walks included"
582        );
583    }
584
585    #[test]
586    fn find_orphans_isolated_node() {
587        let mut store = Store::new();
588        store.upsert(EntityId("a".into()), entity("a", "s", false));
589        store.upsert(EntityId("b".into()), entity("b", "s", false));
590        add_edge(&mut store, "a", "b", "USES");
591        store.upsert(EntityId("c".into()), entity("c", "s", false));
592        // c has no edges
593
594        let orphans = find_orphans(&store);
595        assert_eq!(orphans.len(), 1);
596        assert_eq!(orphans[0], EntityId("c".into()));
597    }
598
599    #[test]
600    fn find_orphans_skips_stubs() {
601        let mut store = Store::new();
602        store.upsert(EntityId("a".into()), entity("a", "s", true)); // stub, isolated
603        store.upsert(EntityId("b".into()), entity("b", "s", false)); // non-stub, isolated
604
605        let orphans = find_orphans(&store);
606        assert_eq!(orphans.len(), 1);
607        assert_eq!(orphans[0], EntityId("b".into()));
608    }
609
610    #[test]
611    fn find_stubs_returns_stub_entities() {
612        let mut store = Store::new();
613        store.upsert(EntityId("real".into()), entity("real", "s", false));
614        store.upsert(EntityId("stub1".into()), entity("stub1", "s", true));
615        add_edge(&mut store, "real", "stub1", "REFERENCES");
616
617        let stubs = find_stubs(&store);
618        assert_eq!(stubs.len(), 1);
619        assert_eq!(stubs[0].0, EntityId("stub1".into()));
620        assert_eq!(stubs[0].1, vec![EntityId("real".into())]);
621    }
622
623    #[test]
624    fn most_connected_sorted_descending() {
625        let mut store = Store::new();
626        store.upsert(EntityId("a".into()), entity("a", "s", false));
627        store.upsert(EntityId("b".into()), entity("b", "s", false));
628        store.upsert(EntityId("c".into()), entity("c", "s", false));
629        // a has 2 edges (1 out + 1 in from c->a)
630        // b has 1 edge (1 in from a->b)
631        // c has 1 edge (1 out to a)
632        add_edge(&mut store, "a", "b", "USES");
633        add_edge(&mut store, "c", "a", "PART_OF");
634
635        let top = most_connected(&store, 10);
636        assert_eq!(top[0].id, EntityId("a".into()));
637        assert_eq!(top[0].total, 2);
638        assert_eq!(top[0].incoming, 1);
639        assert_eq!(top[0].outgoing, 1);
640    }
641
642    #[test]
643    fn most_connected_respects_limit() {
644        let mut store = Store::new();
645        for i in 0..5 {
646            store.upsert(
647                EntityId(format!("e{i}")),
648                entity(&format!("e{i}"), "s", false),
649            );
650        }
651        let top = most_connected(&store, 2);
652        assert_eq!(top.len(), 2);
653    }
654
655    // ---- reachable_via ----
656
657    #[test]
658    fn reachable_via_filters_by_edge_type() {
659        // a --USES--> b ; a --REFERENCES--> c
660        let mut store = Store::new();
661        store.upsert(EntityId("a".into()), entity("a", "s", false));
662        store.upsert(EntityId("b".into()), entity("b", "s", false));
663        store.upsert(EntityId("c".into()), entity("c", "s", false));
664        add_edge(&mut store, "a", "b", "USES");
665        add_edge(&mut store, "a", "c", "REFERENCES");
666
667        let r = reachable_via(
668            &store,
669            &EntityId("a".into()),
670            &["USES".to_string()],
671            1,
672            TraversalDirection::Both,
673        );
674        assert_eq!(r.len(), 1);
675        assert_eq!(r[0].id, EntityId("b".into()));
676        assert_eq!(r[0].via_edge, "USES");
677        assert_eq!(r[0].depth, 1);
678        assert_eq!(r[0].direction, TraversalDirection::Out);
679    }
680
681    #[test]
682    fn reachable_via_bidirectional() {
683        // From b, walk back to a via incoming edge.
684        let mut store = Store::new();
685        store.upsert(EntityId("a".into()), entity("a", "s", false));
686        store.upsert(EntityId("b".into()), entity("b", "s", false));
687        add_edge(&mut store, "a", "b", "USES");
688        let r = reachable_via(
689            &store,
690            &EntityId("b".into()),
691            &["USES".to_string()],
692            1,
693            TraversalDirection::Both,
694        );
695        assert_eq!(r.len(), 1);
696        assert_eq!(r[0].id, EntityId("a".into()));
697        assert_eq!(r[0].depth, 1);
698        assert_eq!(
699            r[0].direction,
700            TraversalDirection::In,
701            "reached against the edge — reported as `in`"
702        );
703
704        // Directional complements on the same store: from b, `out`
705        // reaches nothing (no outgoing USES), `in` reaches a.
706        let out = reachable_via(
707            &store,
708            &EntityId("b".into()),
709            &["USES".to_string()],
710            1,
711            TraversalDirection::Out,
712        );
713        assert!(out.is_empty(), "no out-edges from b: {out:?}");
714        let inward = reachable_via(
715            &store,
716            &EntityId("b".into()),
717            &["USES".to_string()],
718            1,
719            TraversalDirection::In,
720        );
721        assert_eq!(inward.len(), 1);
722        assert_eq!(inward[0].id, EntityId("a".into()));
723    }
724
725    #[test]
726    fn reachable_via_zero_depth_empty() {
727        let store = build_linear_store();
728        let r = reachable_via(
729            &store,
730            &EntityId("a".into()),
731            &["USES".to_string()],
732            0,
733            TraversalDirection::Both,
734        );
735        assert!(r.is_empty());
736    }
737
738    #[test]
739    fn reachable_via_empty_edge_types_empty() {
740        let store = build_linear_store();
741        let r = reachable_via(
742            &store,
743            &EntityId("a".into()),
744            &[],
745            10,
746            TraversalDirection::Both,
747        );
748        assert!(r.is_empty());
749    }
750
751    #[test]
752    fn reachable_via_respects_depth_limit() {
753        let store = build_linear_store(); // a -> b -> c with USES
754        let r1 = reachable_via(
755            &store,
756            &EntityId("a".into()),
757            &["USES".to_string()],
758            1,
759            TraversalDirection::Both,
760        );
761        assert_eq!(r1.len(), 1, "depth 1 reaches b only");
762        assert_eq!(r1[0].id, EntityId("b".into()));
763        assert_eq!(r1[0].depth, 1);
764
765        let r2 = reachable_via(
766            &store,
767            &EntityId("a".into()),
768            &["USES".to_string()],
769            2,
770            TraversalDirection::Both,
771        );
772        assert_eq!(r2.len(), 2);
773        let depths: std::collections::HashMap<EntityId, usize> =
774            r2.iter().map(|r| (r.id.clone(), r.depth)).collect();
775        assert_eq!(depths[&EntityId("b".into())], 1);
776        assert_eq!(depths[&EntityId("c".into())], 2);
777    }
778
779    #[test]
780    fn reachable_via_bfs_records_shortest_depth() {
781        // Diamond: a -> b -> d ; a -> c -> d. d is reachable via 2 hops from a
782        // through two paths. BFS should record depth=2 exactly once.
783        let mut store = Store::new();
784        for id in ["a", "b", "c", "d"] {
785            store.upsert(EntityId(id.into()), entity(id, "s", false));
786        }
787        add_edge(&mut store, "a", "b", "R");
788        add_edge(&mut store, "a", "c", "R");
789        add_edge(&mut store, "b", "d", "R");
790        add_edge(&mut store, "c", "d", "R");
791
792        let r = reachable_via(
793            &store,
794            &EntityId("a".into()),
795            &["R".to_string()],
796            3,
797            TraversalDirection::Both,
798        );
799        let entries: std::collections::HashMap<EntityId, usize> =
800            r.iter().map(|e| (e.id.clone(), e.depth)).collect();
801        assert_eq!(entries.len(), 3, "b, c, d each appear once");
802        assert_eq!(entries[&EntityId("d".into())], 2);
803    }
804
805    #[test]
806    fn most_connected_skips_stubs() {
807        let mut store = Store::new();
808        store.upsert(EntityId("real".into()), entity("real", "s", false));
809        store.upsert(EntityId("stub".into()), entity("stub", "s", true));
810        add_edge(&mut store, "real", "stub", "REFERENCES");
811
812        let top = most_connected(&store, 10);
813        assert_eq!(top.len(), 1);
814        assert_eq!(top[0].id, EntityId("real".into()));
815    }
816
817    // ---- would_cycle ----
818
819    #[test]
820    fn would_cycle_self_loop_always_reported() {
821        let mut store = Store::new();
822        store.upsert(EntityId("a".into()), entity("a", "s", false));
823        let path = would_cycle(
824            &store,
825            &EntityId("a".into()),
826            &EntityId("a".into()),
827            "PART_OF",
828        );
829        assert_eq!(path, Some(vec![EntityId("a".into())]));
830    }
831
832    #[test]
833    fn would_cycle_single_back_edge() {
834        // a -PART_OF-> b already. Adding b -PART_OF-> a closes a cycle.
835        let mut store = Store::new();
836        store.upsert(EntityId("a".into()), entity("a", "s", false));
837        store.upsert(EntityId("b".into()), entity("b", "s", false));
838        add_edge(&mut store, "a", "b", "PART_OF");
839        let path = would_cycle(
840            &store,
841            &EntityId("b".into()),
842            &EntityId("a".into()),
843            "PART_OF",
844        )
845        .expect("cycle");
846        assert_eq!(path, vec![EntityId("a".into()), EntityId("b".into())]);
847    }
848
849    #[test]
850    fn would_cycle_deep_chain() {
851        // foo's future edge: foo -PART_OF-> bar. Existing: bar->baz->foo.
852        let mut store = Store::new();
853        for id in ["foo", "bar", "baz"] {
854            store.upsert(EntityId(id.into()), entity(id, "s", false));
855        }
856        add_edge(&mut store, "bar", "baz", "PART_OF");
857        add_edge(&mut store, "baz", "foo", "PART_OF");
858        let path = would_cycle(
859            &store,
860            &EntityId("foo".into()),
861            &EntityId("bar".into()),
862            "PART_OF",
863        )
864        .expect("cycle");
865        assert_eq!(
866            path,
867            vec![
868                EntityId("bar".into()),
869                EntityId("baz".into()),
870                EntityId("foo".into())
871            ]
872        );
873    }
874
875    #[test]
876    fn would_cycle_ignores_other_rel_types() {
877        // a -DEPENDS_ON-> b exists. Proposed b -PART_OF-> a should not
878        // trip the PART_OF subgraph even though a non-PART_OF back-edge
879        // exists.
880        let mut store = Store::new();
881        store.upsert(EntityId("a".into()), entity("a", "s", false));
882        store.upsert(EntityId("b".into()), entity("b", "s", false));
883        add_edge(&mut store, "a", "b", "DEPENDS_ON");
884        assert!(
885            would_cycle(
886                &store,
887                &EntityId("b".into()),
888                &EntityId("a".into()),
889                "PART_OF"
890            )
891            .is_none()
892        );
893    }
894
895    #[test]
896    fn would_cycle_none_for_disjoint_graph() {
897        let mut store = Store::new();
898        for id in ["a", "b", "c", "d"] {
899            store.upsert(EntityId(id.into()), entity(id, "s", false));
900        }
901        add_edge(&mut store, "c", "d", "PART_OF");
902        assert!(
903            would_cycle(
904                &store,
905                &EntityId("a".into()),
906                &EntityId("b".into()),
907                "PART_OF"
908            )
909            .is_none()
910        );
911    }
912
913    #[test]
914    fn would_cycle_parallel_paths_do_not_trip() {
915        // a -PART_OF-> b and a -PART_OF-> c (no path from b to a).
916        // Proposed c -PART_OF-> a should be flagged (c has no back-path
917        // today, but adding it alongside existing a->c would form a
918        // cycle a->c->a — confirm the BFS catches that).
919        let mut store = Store::new();
920        for id in ["a", "b", "c"] {
921            store.upsert(EntityId(id.into()), entity(id, "s", false));
922        }
923        add_edge(&mut store, "a", "b", "PART_OF");
924        add_edge(&mut store, "a", "c", "PART_OF");
925        // Proposed a -PART_OF-> b is fine — a already -PART_OF-> b.
926        assert!(
927            would_cycle(
928                &store,
929                &EntityId("a".into()),
930                &EntityId("b".into()),
931                "PART_OF"
932            )
933            .is_none(),
934            "sibling paths must not trip"
935        );
936        // Proposed b -PART_OF-> a would close a cycle a->b->a.
937        assert!(
938            would_cycle(
939                &store,
940                &EntityId("b".into()),
941                &EntityId("a".into()),
942                "PART_OF"
943            )
944            .is_some()
945        );
946    }
947
948    #[test]
949    fn most_connected_distinguishes_hub_vs_fanout() {
950        let mut store = Store::new();
951        for id in [
952            "hub", "fanout", "r1", "r2", "r3", "r4", "t1", "t2", "t3", "t4",
953        ] {
954            store.upsert(EntityId(id.into()), entity(id, "s", false));
955        }
956        // hub: 4 incoming, 0 outgoing
957        add_edge(&mut store, "r1", "hub", "REFERENCES");
958        add_edge(&mut store, "r2", "hub", "REFERENCES");
959        add_edge(&mut store, "r3", "hub", "REFERENCES");
960        add_edge(&mut store, "r4", "hub", "REFERENCES");
961        // fanout: 0 incoming, 4 outgoing
962        add_edge(&mut store, "fanout", "t1", "USES");
963        add_edge(&mut store, "fanout", "t2", "USES");
964        add_edge(&mut store, "fanout", "t3", "USES");
965        add_edge(&mut store, "fanout", "t4", "USES");
966
967        let top = most_connected(&store, 10);
968        let hub = top.iter().find(|c| c.id == EntityId("hub".into())).unwrap();
969        assert_eq!(hub.total, 4);
970        assert_eq!(hub.incoming, 4);
971        assert_eq!(hub.outgoing, 0);
972        let fanout = top
973            .iter()
974            .find(|c| c.id == EntityId("fanout".into()))
975            .unwrap();
976        assert_eq!(fanout.total, 4);
977        assert_eq!(fanout.incoming, 0);
978        assert_eq!(fanout.outgoing, 4);
979
980        // Tie-break: "fanout" < "hub" lex, so fanout appears first.
981        let fanout_pos = top.iter().position(|c| c.id.0 == "fanout").unwrap();
982        let hub_pos = top.iter().position(|c| c.id.0 == "hub").unwrap();
983        assert!(
984            fanout_pos < hub_pos,
985            "ties must resolve by id lex ascending"
986        );
987    }
988
989    /// #46: a node inflated purely by auto-emitted mentions (BodyLink)
990    /// must not outrank a node with real typed dependencies. `typed_total`
991    /// drives the ranking; `total` (which keeps the mentions) is retained
992    /// but only a secondary tie-break.
993    #[test]
994    fn most_connected_ranks_by_dependency_not_mention() {
995        let mut store = Store::new();
996        for id in [
997            "mentionhub",
998            "dephub",
999            "m1",
1000            "m2",
1001            "m3",
1002            "m4",
1003            "m5",
1004            "d1",
1005            "d2",
1006        ] {
1007            store.upsert(EntityId(id.into()), entity(id, "s", false));
1008        }
1009        // mentionhub: 5 incoming mention edges — high total, zero typed.
1010        for m in ["m1", "m2", "m3", "m4", "m5"] {
1011            add_body_edge(&mut store, m, "mentionhub");
1012        }
1013        // dephub: 2 incoming typed (USES) edges — lower total, real deps.
1014        add_edge(&mut store, "d1", "dephub", "USES");
1015        add_edge(&mut store, "d2", "dephub", "USES");
1016
1017        let top = most_connected(&store, 10);
1018        let mh = top.iter().find(|c| c.id.0 == "mentionhub").unwrap();
1019        let dh = top.iter().find(|c| c.id.0 == "dephub").unwrap();
1020
1021        // Raw total still counts the mentions (not dropped from the graph).
1022        assert_eq!(mh.total, 5);
1023        assert_eq!(mh.typed_total, 0, "all of mentionhub's edges are mentions");
1024        assert_eq!(dh.total, 2);
1025        assert_eq!(dh.typed_total, 2, "dephub's edges are typed dependencies");
1026
1027        // Ranking: dephub (2 typed) outranks mentionhub (0 typed) despite
1028        // mentionhub's higher raw total — the co-mention inflation is gone.
1029        let mh_pos = top.iter().position(|c| c.id.0 == "mentionhub").unwrap();
1030        let dh_pos = top.iter().position(|c| c.id.0 == "dephub").unwrap();
1031        assert!(
1032            dh_pos < mh_pos,
1033            "dependency hub must outrank co-mention hub"
1034        );
1035    }
1036
1037    /// Agent-trust plan 06 (criterion 1): leaf-declared types are
1038    /// exempt from the orphan scan — visible instead through
1039    /// `leaf_population` — while non-leaf types count exactly as
1040    /// before, a leaf WITH edges stays legal, and an empty schema map
1041    /// reproduces the schema-blind behaviour byte-for-byte.
1042    #[test]
1043    fn leaf_declared_types_exempt_from_orphans_but_visible_as_population() {
1044        use std::collections::HashMap;
1045        use std::sync::Arc;
1046
1047        let manifest = r#"
1048name: leafy
1049version: 0.1.0
1050description: leaf test schema
1051when_to_use: tests
1052types:
1053  - obs
1054  - spec
1055relationships:
1056  mode: strict
1057  definitions:
1058    - name: USES
1059      description: u
1060      default_weight: 1.0
1061    - name: PART_OF
1062      description: hier
1063      default_weight: 1.0
1064      acyclic: true
1065    - name: _default
1066      description: fallback
1067      default_weight: 1.0
1068community:
1069  resolution: 1.0
1070  seed: 42
1071"#;
1072        let body = "sections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n";
1073        let obs_yaml = format!("name: obs\ndescription: t\nwhen_to_use: h\nleaf: true\n{body}");
1074        let spec_yaml = format!("name: spec\ndescription: t\nwhen_to_use: h\n{body}");
1075        let schema = Arc::new(
1076            memstead_schema::load_schema_from_memory(
1077                manifest,
1078                &[
1079                    ("obs".to_string(), obs_yaml),
1080                    ("spec".to_string(), spec_yaml),
1081                ],
1082            )
1083            .expect("leaf fixture schema parses"),
1084        );
1085        let mut schemas: HashMap<String, Arc<memstead_schema::Schema>> = HashMap::new();
1086        schemas.insert("s".to_string(), schema);
1087
1088        let mut store = Store::new();
1089        let mut e = |id: &str, ty: &str| {
1090            let mut ent = entity(id, "s", false);
1091            ent.entity_type = ty.to_string();
1092            store.upsert(EntityId(id.into()), ent);
1093        };
1094        e("lonely-spec", "spec"); // real orphan
1095        e("lonely-obs", "obs"); // leaf: exempt
1096        e("linked-obs", "obs"); // leaf with an edge: legal, not orphan anyway
1097        e("hub", "spec");
1098        add_edge(&mut store, "linked-obs", "hub", "USES");
1099
1100        // Schema-aware: only the non-leaf edge-less entity is an orphan.
1101        let orphans = find_orphans_with_schemas(&store, &schemas);
1102        assert_eq!(
1103            orphans,
1104            vec![EntityId("lonely-spec".into())],
1105            "leaf-typed edge-less entities are exempt; non-leaf count as before"
1106        );
1107        // The exempted population is visible, keyed schema_ref:type.
1108        let pop = leaf_population(&store, &schemas);
1109        assert_eq!(pop.get("leafy@0.1.0:obs"), Some(&2));
1110        assert_eq!(pop.len(), 1);
1111
1112        // Empty schema map == historical schema-blind behaviour.
1113        let blind = find_orphans(&store);
1114        let mut blind_sorted: Vec<String> = blind.iter().map(|i| i.0.clone()).collect();
1115        blind_sorted.sort();
1116        assert_eq!(blind_sorted, vec!["lonely-obs", "lonely-spec"]);
1117        assert!(leaf_population(&store, &HashMap::new()).is_empty());
1118    }
1119}