Skip to main content

eidos_kernel/
relational.rs

1//! Relational (call-graph / impact) retrieval over the code graph.
2//!
3//! Glyph's lexical retrieval is node-local: "what calls X" returns X itself. But the graph
4//! already encodes who-references-what as edges (`A --references--> B`), so the callers/dependents
5//! of a symbol are just its REVERSE edges. These traversals surface that. They are deterministic
6//! graph facts — confidence is `Exact`, not a similarity guess.
7
8use std::collections::{HashMap, VecDeque};
9
10use crate::graph_index::GraphIndex;
11use crate::retrieval::{Confidence, Hit};
12use crate::schema::{EdgeBasis, Graph, Kind, Node, relation};
13
14fn last_segment(id: &str) -> &str {
15    id.rsplit("::").next().unwrap_or(id)
16}
17
18fn hit_exact(id: &str, score: i64, why: String) -> Hit {
19    Hit {
20        id: id.to_string(),
21        score,
22        lexical_score: score,
23        confidence: Confidence::Exact,
24        why: vec![why],
25        relation_matches: Vec::new(),
26        anchor: 1.0,
27        relation_anchor: false,
28    }
29}
30
31fn is_function_indexed(index: &GraphIndex<'_>, id: &str) -> bool {
32    index.is_kind(id, Kind::Function)
33}
34
35fn symbol_rank(node: &Node) -> (u8, u8, &str) {
36    let test_penalty = u8::from(
37        node.id.contains("::tests::")
38            || node
39                .source_files
40                .iter()
41                .any(|path| path.contains("/tests/") || path.contains("\\tests\\")),
42    );
43    let kind_rank = match node.kind {
44        Kind::Type => 0,
45        Kind::Trait => 1,
46        Kind::Function => 2,
47        Kind::Module => 3,
48        Kind::Skill => 4,
49        Kind::Agent => 5,
50        Kind::Doc => 6,
51        Kind::Section => 7,
52        Kind::Unknown => 8,
53    };
54    (test_penalty, kind_rank, node.id.as_str())
55}
56
57fn best_symbol_candidate<'a>(nodes: impl Iterator<Item = &'a Node>) -> Option<String> {
58    let mut candidates = nodes.collect::<Vec<_>>();
59    candidates.sort_by_key(|node| symbol_rank(node));
60    candidates.first().map(|node| node.id.clone())
61}
62
63/// Resolve a symbol NAME (e.g. `verify_password`, `Task`) to a concrete node id.
64pub fn resolve_symbol(graph: &Graph, needle: &str) -> Option<String> {
65    let n = needle.trim().trim_matches('`').to_lowercase();
66    if n.is_empty() {
67        return None;
68    }
69    // 1. exact id
70    if let Some(node) = graph.nodes.iter().find(|x| x.id.to_lowercase() == n) {
71        return Some(node.id.clone());
72    }
73    // 2. last `::`-segment equals the needle (most direct), deterministic by id
74    let seg = graph
75        .nodes
76        .iter()
77        .filter(|x| x.id.to_lowercase().rsplit("::").next() == Some(n.as_str()))
78        .collect::<Vec<_>>();
79    if !seg.is_empty() {
80        return best_symbol_candidate(seg.into_iter());
81    }
82    // 3. id contains `::needle` as a segment, or title matches
83    let needle_seg = format!("::{n}");
84    best_symbol_candidate(
85        graph
86            .nodes
87            .iter()
88            .filter(|x| x.id.to_lowercase().contains(&needle_seg) || x.title.to_lowercase() == n),
89    )
90}
91
92/// Direct callers/users of a node: reverse `references` edges (+ `implements` when the node is a
93/// trait). Prefers function nodes — a containing type that only references the target *through* one
94/// of its methods is dropped in favor of the method.
95pub fn callers(graph: &Graph, node_id: &str) -> Vec<Hit> {
96    let index = GraphIndex::build(graph);
97    let is_trait = index.is_kind(node_id, Kind::Trait);
98    let mut froms: Vec<String> = index
99        .incoming(node_id)
100        // Trusted structural dependents only: a Lexical reference is a name-match candidate, not
101        // proven coupling (e.g. a cross-crate name collision), and reporting it as a caller is the
102        // over-report the EdgeBasis work exists to prevent. Resolved = import-proven or same-crate
103        // unique — the earned band.
104        .filter(|e| e.basis == EdgeBasis::Resolved)
105        .filter(|e| {
106            e.relation == relation::REFERENCES || (is_trait && e.relation == relation::IMPLEMENTS)
107        })
108        .map(|e| e.from.clone())
109        .collect();
110    froms.sort_unstable();
111    froms.dedup();
112
113    let fn_callers: Vec<&str> = froms
114        .iter()
115        .filter(|id| is_function_indexed(&index, id))
116        .map(std::string::String::as_str)
117        .collect();
118    let kept: Vec<&str> = froms
119        .iter()
120        .filter(|id| {
121            if is_function_indexed(&index, id) {
122                return true;
123            }
124            // keep a non-function caller only if it doesn't roll up a function caller
125            !fn_callers
126                .iter()
127                .any(|f| index.has_outgoing(id, relation::CONTAINS, f))
128        })
129        .map(std::string::String::as_str)
130        .collect();
131
132    let label = last_segment(node_id).to_string();
133    let mut hits: Vec<Hit> = kept
134        .iter()
135        .map(|id| hit_exact(id, 100, format!("calls/uses {label} (references edge)")))
136        .collect();
137    hits.sort_by(|a, b| {
138        let af = is_function_indexed(&index, &a.id);
139        let bf = is_function_indexed(&index, &b.id);
140        bf.cmp(&af).then_with(|| a.id.cmp(&b.id))
141    });
142    hits
143}
144
145/// Transitive dependent closure: BFS over REVERSE `references`/`implements` edges from `node_id`
146/// up to `depth` hops. Also seeds the node's `contains` children (changing a type affects users of
147/// its own methods). Excludes the subject itself. Answers "what breaks if I change X".
148pub fn impact(graph: &Graph, node_id: &str, depth: usize) -> Vec<Hit> {
149    let index = GraphIndex::build(graph);
150    let depth = depth.clamp(1, 8);
151    let mut dist: HashMap<String, usize> = HashMap::new();
152    dist.insert(node_id.to_string(), 0);
153    let mut q: VecDeque<(String, usize)> = VecDeque::new();
154    q.push_back((node_id.to_string(), 0));
155    for e in index.outgoing(node_id) {
156        if e.relation == relation::CONTAINS && !dist.contains_key(&e.to) {
157            dist.insert(e.to.clone(), 1);
158            q.push_back((e.to.clone(), 1));
159        }
160    }
161    while let Some((cur, d)) = q.pop_front() {
162        if d >= depth {
163            continue;
164        }
165        for e in index.incoming(&cur) {
166            // Trusted dependents only (see `callers`): Lexical edges are unproven name matches, so
167            // walking them is what makes "what breaks if I change X" cry wolf.
168            if e.basis == EdgeBasis::Resolved
169                && (e.relation == relation::REFERENCES || e.relation == relation::IMPLEMENTS)
170            {
171                let nd = d + 1;
172                if dist.get(&e.from).is_none_or(|&old| nd < old) {
173                    dist.insert(e.from.clone(), nd);
174                    q.push_back((e.from.clone(), nd));
175                }
176            }
177        }
178    }
179    let label = last_segment(node_id).to_string();
180    let mut items: Vec<(String, usize)> =
181        dist.into_iter().filter(|(id, _)| id != node_id).collect();
182    items.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
183    items
184        .iter()
185        .map(|(id, d)| {
186            hit_exact(
187                id,
188                (100 - *d as i64).max(1),
189                format!("depends on {label} ({d} hop(s) via references/implements)"),
190            )
191        })
192        .collect()
193}
194
195/// Map a generic, language-agnostic KIND word in the query to a node `Kind`. Used to bias
196/// retrieval toward the structural shape the user asked for ("the **struct** that…" → Type).
197/// Vocabulary only — no project- or language-specific names.
198pub fn kind_intent(query: &str) -> Option<Kind> {
199    for word in query.to_lowercase().split(|c: char| !c.is_alphanumeric()) {
200        let k = match word {
201            "struct" | "enum" | "class" | "type" | "record" | "dataclass" | "datatype"
202            | "object" => Kind::Type,
203            "trait" | "interface" | "protocol" => Kind::Trait,
204            "function" | "func" | "fn" | "method" | "def" | "procedure" | "routine" | "handler"
205            | "endpoint" | "route" | "hook" | "component" | "callback" => Kind::Function,
206            "module" | "package" | "namespace" => Kind::Module,
207            _ => continue,
208        };
209        return Some(k);
210    }
211    None
212}
213
214/// Stable re-rank: hits whose node is of `target` kind move ahead of the rest, preserving the
215/// lexical order within each group. Generic — promotes the asked-for shape without touching scores.
216pub fn rerank_by_kind(graph: &Graph, mut hits: Vec<Hit>, target: Kind) -> Vec<Hit> {
217    let matches = |id: &str| graph.nodes.iter().any(|n| n.id == id && n.kind == target);
218    hits.sort_by_key(|h| !matches(&h.id));
219    hits
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum RelMode {
224    Callers,
225    Impact,
226}
227
228/// Detect a call-graph/impact query and extract its subject symbol. Returns `None` for ordinary
229/// lexical queries (so the caller falls through to normal grounding).
230pub fn detect_relational_intent(query: &str) -> Option<(RelMode, String)> {
231    let q = query.to_lowercase();
232    const IMPACT: &[&str] = &[
233        "what depends on",
234        "depends on",
235        "what breaks if",
236        "what would break",
237        "impact of changing",
238        "dependents of",
239    ];
240    const CALLERS: &[&str] = &[
241        "what calls",
242        "who calls",
243        "callers of",
244        "what references",
245        "what uses",
246    ];
247    let (mode, pat) = if let Some(p) = IMPACT.iter().find(|p| q.contains(**p)) {
248        (RelMode::Impact, *p)
249    } else if let Some(p) = CALLERS.iter().find(|p| q.contains(**p)) {
250        (RelMode::Callers, *p)
251    } else {
252        return None;
253    };
254    let after = q.split(pat).nth(1).unwrap_or("").to_string();
255    let subject = extract_symbol(&after).or_else(|| extract_symbol(&q));
256    subject.map(|s| (mode, s))
257}
258
259fn extract_symbol(text: &str) -> Option<String> {
260    const STOP: &[&str] = &[
261        "the", "a", "an", "if", "i", "to", "my", "our", "this", "that", "change", "changing",
262        "trait", "enum", "struct", "class", "type", "function", "method", "fn", "module", "it",
263        "would", "break", "of", "on", "in", "is", "are",
264    ];
265    let toks: Vec<&str> = text
266        .split(|c: char| !(c.is_alphanumeric() || c == '_'))
267        .filter(|t| {
268            if t.is_empty() {
269                return false;
270            }
271            let tl = t.to_lowercase();
272            !STOP.contains(&tl.as_str())
273        })
274        .collect();
275    toks.last().map(std::string::ToString::to_string)
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::schema::{Edge, Node};
282
283    fn node(id: &str, kind: Kind) -> Node {
284        Node {
285            id: id.into(),
286            kind,
287            subkind: None,
288            title: last_segment(id).into(),
289            summary: String::new(),
290            aliases: vec![],
291            tags: vec![],
292            query_examples: vec![],
293            source_files: vec![],
294            span: None,
295            partition: None,
296        }
297    }
298    fn edge(from: &str, to: &str, rel: &str) -> Edge {
299        // Fixture edges model earned, same-crate structural references — the Resolved band that
300        // impact/callers walk. (EdgeBasis defaults to Lexical since D1.1, so this must be explicit.)
301        Edge {
302            from: from.into(),
303            to: to.into(),
304            relation: rel.into(),
305            evidence: String::new(),
306            basis: EdgeBasis::Resolved,
307            ..Default::default()
308        }
309    }
310    fn lexical_edge(from: &str, to: &str, rel: &str) -> Edge {
311        Edge {
312            basis: EdgeBasis::Lexical,
313            ..edge(from, to, rel)
314        }
315    }
316    fn fixture() -> Graph {
317        Graph {
318            nodes: vec![
319                node("fn.a::auth::verify_password", Kind::Function),
320                node("fn.a::routes::login", Kind::Function),
321                node("fn.a::service::TaskService::authenticate", Kind::Function),
322                node("type.a::service::TaskService", Kind::Type),
323                node("type.c::scheduler::Scheduler", Kind::Type),
324                node("type.c::scheduler::Task", Kind::Type),
325                node("fn.c::scheduler::Task::id", Kind::Function),
326                node("fn.c::scheduler::tests::Task", Kind::Function),
327            ],
328            edges: vec![
329                edge(
330                    "fn.a::routes::login",
331                    "fn.a::auth::verify_password",
332                    relation::REFERENCES,
333                ),
334                edge(
335                    "fn.a::service::TaskService::authenticate",
336                    "fn.a::auth::verify_password",
337                    relation::REFERENCES,
338                ),
339                // rolled-up parent type also references it; should be dropped for the method
340                edge(
341                    "type.a::service::TaskService",
342                    "fn.a::auth::verify_password",
343                    relation::REFERENCES,
344                ),
345                edge(
346                    "type.a::service::TaskService",
347                    "fn.a::service::TaskService::authenticate",
348                    relation::CONTAINS,
349                ),
350                edge(
351                    "type.c::scheduler::Scheduler",
352                    "type.c::scheduler::Task",
353                    relation::REFERENCES,
354                ),
355                edge(
356                    "type.c::scheduler::Task",
357                    "fn.c::scheduler::Task::id",
358                    relation::CONTAINS,
359                ),
360            ],
361            ..Default::default()
362        }
363    }
364
365    #[test]
366    fn callers_returns_function_callers_not_the_symbol_or_rolled_up_type() {
367        let g = fixture();
368        let ids: Vec<String> = callers(&g, "fn.a::auth::verify_password")
369            .into_iter()
370            .map(|h| h.id)
371            .collect();
372        assert!(ids.contains(&"fn.a::routes::login".to_string()));
373        assert!(ids.contains(&"fn.a::service::TaskService::authenticate".to_string()));
374        assert!(!ids.contains(&"fn.a::auth::verify_password".to_string()));
375        // the parent type rolled up via its method is dropped
376        assert!(!ids.contains(&"type.a::service::TaskService".to_string()));
377    }
378
379    #[test]
380    fn impact_and_callers_ignore_lexical_edges() {
381        // A Lexical reference is an unproven name-match (e.g. a cross-crate name collision). It must
382        // NOT be reported as a caller or a dependent — that is the over-report the trusted-only walk
383        // exists to prevent.
384        let mut g = fixture();
385        g.nodes
386            .push(node("fn.z::other::coincidental", Kind::Function));
387        g.edges.push(lexical_edge(
388            "fn.z::other::coincidental",
389            "fn.a::auth::verify_password",
390            relation::REFERENCES,
391        ));
392
393        let callers: Vec<String> = callers(&g, "fn.a::auth::verify_password")
394            .into_iter()
395            .map(|h| h.id)
396            .collect();
397        assert!(
398            !callers.contains(&"fn.z::other::coincidental".to_string()),
399            "a Lexical reference must not appear as a caller: {callers:?}"
400        );
401        // the genuine Resolved callers are still there
402        assert!(callers.contains(&"fn.a::routes::login".to_string()));
403
404        let impacted: Vec<String> = impact(&g, "fn.a::auth::verify_password", 8)
405            .into_iter()
406            .map(|h| h.id)
407            .collect();
408        assert!(
409            !impacted.contains(&"fn.z::other::coincidental".to_string()),
410            "a Lexical reference must not appear as a dependent: {impacted:?}"
411        );
412    }
413
414    #[test]
415    fn impact_includes_dependents_and_contained_children_not_subject() {
416        let g = fixture();
417        let ids: Vec<String> = impact(&g, "type.c::scheduler::Task", 8)
418            .into_iter()
419            .map(|h| h.id)
420            .collect();
421        assert!(ids.contains(&"type.c::scheduler::Scheduler".to_string()));
422        assert!(ids.contains(&"fn.c::scheduler::Task::id".to_string()));
423        assert!(!ids.contains(&"type.c::scheduler::Task".to_string()));
424    }
425
426    #[test]
427    fn resolve_symbol_finds_by_last_segment() {
428        let g = fixture();
429        assert_eq!(
430            resolve_symbol(&g, "verify_password").as_deref(),
431            Some("fn.a::auth::verify_password")
432        );
433        assert_eq!(
434            resolve_symbol(&g, "Task").as_deref(),
435            Some("type.c::scheduler::Task")
436        );
437        assert_eq!(
438            resolve_symbol(&g, "scheduler::Task").as_deref(),
439            Some("type.c::scheduler::Task")
440        );
441        assert_eq!(resolve_symbol(&g, "nonexistent_xyz"), None);
442    }
443
444    #[test]
445    fn intent_detection() {
446        assert_eq!(
447            detect_relational_intent("what calls verify_password"),
448            Some((RelMode::Callers, "verify_password".to_string()))
449        );
450        assert_eq!(
451            detect_relational_intent("what would break if I change the Task enum"),
452            Some((RelMode::Impact, "task".to_string()))
453        );
454        assert_eq!(
455            detect_relational_intent("who calls scheduler tick"),
456            Some((RelMode::Callers, "tick".to_string()))
457        );
458        assert_eq!(detect_relational_intent("the worker run loop"), None);
459    }
460
461    #[test]
462    fn kind_intent_maps_generic_vocabulary() {
463        assert_eq!(
464            kind_intent("the struct that owns the queue"),
465            Some(Kind::Type)
466        );
467        assert_eq!(
468            kind_intent("the trait that picks a worker"),
469            Some(Kind::Trait)
470        );
471        assert_eq!(kind_intent("the react hook for auth"), Some(Kind::Function));
472        assert_eq!(kind_intent("password hashing"), None);
473    }
474
475    #[test]
476    fn rerank_promotes_target_kind_preserving_order() {
477        let g = fixture();
478        let hits = vec![
479            hit_exact("fn.a::service::TaskService::authenticate", 90, "x".into()),
480            hit_exact("type.a::service::TaskService", 80, "x".into()),
481        ];
482        let r = rerank_by_kind(&g, hits, Kind::Type);
483        assert_eq!(r[0].id, "type.a::service::TaskService");
484    }
485}