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