Skip to main content

lean_ctx/core/property_graph/
queries.rs

1//! Graph traversal queries: dependents, dependencies, impact analysis,
2//! dependency chains (BFS-based shortest path).
3//!
4//! All traversal queries support multi-edge traversal: imports, calls,
5//! exports, type_ref, tested_by, and more. Edge kinds are weighted
6//! for impact scoring.
7
8use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
9
10use rusqlite::{Connection, params};
11
12#[derive(Debug, Clone)]
13pub struct GraphQuery;
14
15#[derive(Debug, Clone)]
16pub struct ImpactResult {
17    pub root_file: String,
18    pub affected_files: Vec<String>,
19    /// Affected files reachable *only* through name-match guesses
20    /// (heuristic `calls` edges) — possibly not affected at all. Sorted.
21    pub weak_files: Vec<String>,
22    pub max_depth_reached: usize,
23    pub edges_traversed: usize,
24}
25
26#[derive(Debug, Clone)]
27pub struct DependencyChain {
28    pub path: Vec<String>,
29    pub depth: usize,
30}
31
32/// The serve-time source check of one request, as a traversal takes it:
33/// whether the file at a repo-relative path may be used now. One request has
34/// one such check, so the walk and the caller's output filter agree.
35pub type Served<'a> = &'a dyn Fn(&str) -> bool;
36
37/// Edge kinds considered structural (code connectivity).
38const STRUCTURAL_EDGE_KINDS: &str = "'imports','calls','references','implements','extends','exports','type_ref','tested_by','module','cochange','sibling'";
39
40/// Weight multiplier per edge kind for impact scoring.
41pub fn edge_weight(kind: &str) -> f64 {
42    match kind {
43        "imports" => 1.0,
44        "calls" | "references" => 0.8,
45        "exports" | "implements" | "extends" => 0.7,
46        "module" => 0.6,
47        "type_ref" => 0.5,
48        "tested_by" => 0.4,
49        "cochange" => 0.35,
50        "defines" => 0.3,
51        "sibling" => 0.25,
52        "changed_in" => 0.2,
53        _ => 0.1,
54    }
55}
56
57/// Files that depend on `file_path` via structural edges (imports, calls, type_ref, etc.).
58pub(super) fn dependents(conn: &Connection, file_path: &str) -> anyhow::Result<Vec<String>> {
59    let sql = format!(
60        "SELECT DISTINCT p_src.path
61         FROM edges e
62         JOIN nodes n_src ON e.source_id = n_src.id
63         JOIN nodes n_tgt ON e.target_id = n_tgt.id
64         JOIN paths p_src ON p_src.id = n_src.file_id
65         JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
66         WHERE p_tgt.path = ?1
67           AND p_src.path != ?1
68           AND e.kind IN ({STRUCTURAL_EDGE_KINDS})"
69    );
70    let mut stmt = conn.prepare(&sql)?;
71
72    let mut results: Vec<String> = stmt
73        .query_map(params![file_path], |row| row.get(0))?
74        .filter_map(std::result::Result::ok)
75        .collect();
76
77    results.sort();
78    results.dedup();
79    Ok(results)
80}
81
82/// Files that `file_path` depends on via structural edges.
83pub(super) fn dependencies(conn: &Connection, file_path: &str) -> anyhow::Result<Vec<String>> {
84    let sql = format!(
85        "SELECT DISTINCT p_tgt.path
86         FROM edges e
87         JOIN nodes n_src ON e.source_id = n_src.id
88         JOIN nodes n_tgt ON e.target_id = n_tgt.id
89         JOIN paths p_src ON p_src.id = n_src.file_id
90         JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
91         WHERE p_src.path = ?1
92           AND p_tgt.path != ?1
93           AND e.kind IN ({STRUCTURAL_EDGE_KINDS})"
94    );
95    let mut stmt = conn.prepare(&sql)?;
96
97    let mut results: Vec<String> = stmt
98        .query_map(params![file_path], |row| row.get(0))?
99        .filter_map(std::result::Result::ok)
100        .collect();
101
102    results.sort();
103    results.dedup();
104    Ok(results)
105}
106
107/// Weighted BFS from `file_path` following reverse structural edges up to `max_depth`.
108/// Edge weights attenuate propagation: calls edges carry less impact than imports.
109/// Nodes only propagate when cumulative weight exceeds the threshold (0.1).
110///
111/// `served` is the serve-time source check of the request (G04). It decides
112/// which files exist for the walk, not only which are reported: a file that
113/// is not served is not reached, nothing is reached through it, and its
114/// edges are not counted. Files are asked as the frontier reaches them, so a
115/// bounded gate spends its bound on the files nearest the root; a file it
116/// can no longer decide ends the walk there.
117pub(super) fn impact_analysis(
118    conn: &Connection,
119    file_path: &str,
120    max_depth: usize,
121    served: Served<'_>,
122) -> anyhow::Result<ImpactResult> {
123    // Graph node keys use canonical `/` separators (see the builder walk);
124    // accept native Windows input too.
125    let file_path = file_path.replace('\\', "/");
126    let file_path = file_path.as_str();
127    if !served(file_path) {
128        return Ok(ImpactResult {
129            root_file: file_path.to_string(),
130            affected_files: Vec::new(),
131            weak_files: Vec::new(),
132            max_depth_reached: 0,
133            edges_traversed: 0,
134        });
135    }
136    let reverse_graph = build_weighted_reverse_graph(conn)?;
137
138    let all = propagate(&reverse_graph, file_path, max_depth, false, served);
139    // Files also reachable over evidence-backed edges alone; the rest hang on
140    // name-match guesses only and are reported as such. A strong edge never
141    // weighs more than the pair's strongest edge overall, so the strong set
142    // is a subset of the full one.
143    let strong = propagate(&reverse_graph, file_path, max_depth, true, served);
144    let weak_files: Vec<String> = all.reached.difference(&strong.reached).cloned().collect();
145
146    Ok(ImpactResult {
147        root_file: file_path.to_string(),
148        affected_files: all.reached.into_iter().collect(),
149        weak_files,
150        max_depth_reached: all.max_depth_reached,
151        edges_traversed: all.edges_traversed,
152    })
153}
154
155struct Propagation {
156    reached: BTreeSet<String>,
157    max_depth_reached: usize,
158    edges_traversed: usize,
159}
160
161/// Weighted reverse propagation from `root` (excluded from the result) up
162/// to `max_depth` hops; a file is reached when some path to it keeps a
163/// cumulative weight ≥ 0.1. Exact, not first-come: a heavier path found
164/// later still propagates, unless an equally heavy or heavier one already
165/// arrived at the same or a shallower depth. Processing order is sorted, so
166/// the result is deterministic. With `strong_only`, name-match-only edges
167/// are not followed. An edge to a file `served` refuses does not exist.
168fn propagate(
169    reverse_graph: &ReverseGraph,
170    root: &str,
171    max_depth: usize,
172    strong_only: bool,
173    served: Served<'_>,
174) -> Propagation {
175    const PROPAGATION_THRESHOLD: f64 = 0.1;
176    // Heaviest weight per file over all depths processed so far.
177    let mut best: HashMap<&str, f64> = HashMap::from([(root, 1.0)]);
178    let mut first_depth: BTreeMap<&str, usize> = BTreeMap::new();
179    let mut layer: BTreeMap<&str, f64> = BTreeMap::from([(root, 1.0)]);
180    let mut edges_traversed = 0;
181
182    for depth in 1..=max_depth {
183        let mut next: BTreeMap<&str, f64> = BTreeMap::new();
184        for (file, weight) in &layer {
185            for dep in reverse_graph.get(*file).into_iter().flatten() {
186                let Some(edge_weight) = (if strong_only {
187                    dep.strong_weight
188                } else {
189                    Some(dep.weight)
190                }) else {
191                    continue;
192                };
193                if !served(dep.file.as_str()) {
194                    continue;
195                }
196                edges_traversed += 1;
197                let propagated = weight * edge_weight;
198                if propagated < PROPAGATION_THRESHOLD
199                    || best
200                        .get(dep.file.as_str())
201                        .is_some_and(|b| *b >= propagated)
202                {
203                    continue;
204                }
205                let slot = next.entry(dep.file.as_str()).or_insert(0.0);
206                *slot = slot.max(propagated);
207            }
208        }
209        if next.is_empty() {
210            break;
211        }
212        for (file, weight) in &next {
213            best.insert(file, *weight);
214            first_depth.entry(file).or_insert(depth);
215        }
216        layer = next;
217    }
218    first_depth.remove(root);
219    Propagation {
220        max_depth_reached: first_depth.values().copied().max().unwrap_or(0),
221        reached: first_depth.into_keys().map(str::to_string).collect(),
222        edges_traversed,
223    }
224}
225
226/// BFS shortest path from `from` to `to` following structural edges. Every
227/// step of the path is a file `served` admits (see `impact_analysis`): a
228/// chain that exists only through a withdrawn file is not a chain.
229pub(super) fn dependency_chain(
230    conn: &Connection,
231    from: &str,
232    to: &str,
233    served: Served<'_>,
234) -> anyhow::Result<Option<DependencyChain>> {
235    // Same canonicalization as `impact_analysis`.
236    let from = from.replace('\\', "/");
237    let to = to.replace('\\', "/");
238    let from = from.as_str();
239    let to = to.as_str();
240    if !served(from) {
241        return Ok(None);
242    }
243    let forward_graph = build_forward_graph(conn)?;
244
245    let mut visited: HashSet<String> = HashSet::new();
246    let mut parent: HashMap<String, String> = HashMap::new();
247    let mut queue: VecDeque<String> = VecDeque::new();
248
249    visited.insert(from.to_string());
250    queue.push_back(from.to_string());
251
252    while let Some(current) = queue.pop_front() {
253        if current == to {
254            let mut path = vec![to.to_string()];
255            let mut cursor = to.to_string();
256            while let Some(prev) = parent.get(&cursor) {
257                path.push(prev.clone());
258                cursor = prev.clone();
259            }
260            path.reverse();
261            let depth = path.len() - 1;
262            return Ok(Some(DependencyChain { path, depth }));
263        }
264
265        if let Some(deps) = forward_graph.get(&current) {
266            for dep in deps {
267                if !visited.contains(dep) && served(dep) && visited.insert(dep.clone()) {
268                    parent.insert(dep.clone(), current.clone());
269                    queue.push_back(dep.clone());
270                }
271            }
272        }
273    }
274
275    Ok(None)
276}
277
278/// Related files for a given path: direct neighbors via any structural edge,
279/// sorted by edge weight (strongest relationship first). Returns (path, weight) pairs.
280///
281/// Only neighbours `served` admits are ranked, and the limit is applied after
282/// that check in rank order: a withdrawn neighbour takes no slot, and a
283/// bounded gate is asked about the strongest neighbours first.
284pub fn related_files(
285    conn: &Connection,
286    file_path: &str,
287    limit: usize,
288    served: Served<'_>,
289) -> anyhow::Result<Vec<(String, f64)>> {
290    if !served(file_path) {
291        return Ok(Vec::new());
292    }
293    let sql = format!(
294        "SELECT p_other.path, e.kind, e.metadata
295         FROM edges e
296         JOIN nodes n_self ON (e.source_id = n_self.id OR e.target_id = n_self.id)
297         JOIN nodes n_other ON (
298             (e.source_id = n_other.id AND e.target_id = n_self.id)
299             OR (e.target_id = n_other.id AND e.source_id = n_self.id)
300         )
301         JOIN paths p_self ON p_self.id = n_self.file_id
302         JOIN paths p_other ON p_other.id = n_other.file_id
303         WHERE p_self.path = ?1
304           AND p_other.path != ?1
305           AND e.kind IN ({STRUCTURAL_EDGE_KINDS})"
306    );
307    let mut stmt = conn.prepare(&sql)?;
308
309    // One relationship can be stored several times — file→file plus
310    // symbol→symbol `calls` edges for the same call. Count each (file, kind)
311    // relationship once, at its strongest evidence, so finer-grained edges
312    // cannot inflate a neighbour's score.
313    // Ordered maps: floating-point sums must accumulate in a fixed order so
314    // equal relationships always yield bit-identical scores (#498).
315    let mut strongest: BTreeMap<(String, String), f64> = BTreeMap::new();
316    let rows = stmt.query_map(params![file_path], |row| {
317        Ok((
318            row.get::<_, String>(0)?,
319            row.get::<_, String>(1)?,
320            row.get::<_, Option<String>>(2)?,
321        ))
322    })?;
323    for row in rows {
324        let (path, kind, metadata) = row?;
325        let w = evidence_weight(&kind, metadata.as_deref());
326        let slot = strongest.entry((path, kind)).or_insert(0.0);
327        if w > *slot {
328            *slot = w;
329        }
330    }
331
332    let mut scores: BTreeMap<String, f64> = BTreeMap::new();
333    for ((path, _), w) in strongest {
334        *scores.entry(path).or_default() += w;
335    }
336
337    let mut results: Vec<(String, f64)> = scores.into_iter().collect();
338    // Equal weights are common; break ties by path so the cut below does not
339    // depend on hash order (planning must be identical across processes).
340    results.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
341    Ok(results
342        .into_iter()
343        .filter(|(path, _)| served(path))
344        .take(limit)
345        .collect())
346}
347
348/// Kind weight scaled by the edge's evidence grade (see
349/// [`crate::core::semantic::EdgeEvidence`]); edges without evidence keep
350/// their full kind weight.
351pub(super) fn evidence_weight(kind: &str, metadata: Option<&str>) -> f64 {
352    edge_weight(kind) * crate::core::semantic::EdgeEvidence::weight_factor_of(metadata)
353}
354
355/// Graph connectivity stats for a file: incoming/outgoing edge counts by kind.
356///
357/// An edge is counted only while the file at its other end is one `served`
358/// admits (an end without a file — a commit, a fact — names no source and is
359/// counted); nothing is counted for a file that is itself not served.
360pub fn file_connectivity(
361    conn: &Connection,
362    file_path: &str,
363    served: Served<'_>,
364) -> anyhow::Result<HashMap<String, (usize, usize)>> {
365    let mut result: HashMap<String, (usize, usize)> = HashMap::new();
366    if !served(file_path) {
367        return Ok(result);
368    }
369    // (this file's end, the other end): outgoing first, then incoming.
370    for (outgoing, own, other) in [
371        (true, "source_id", "target_id"),
372        (false, "target_id", "source_id"),
373    ] {
374        let mut stmt = conn.prepare(&format!(
375            "SELECT e.kind, po.path
376             FROM edges e
377             JOIN nodes n ON e.{own} = n.id
378             JOIN paths p ON p.id = n.file_id
379             JOIN nodes o ON e.{other} = o.id
380             JOIN paths po ON po.id = o.file_id
381             WHERE p.path = ?1"
382        ))?;
383        let rows = stmt.query_map(params![file_path], |row| {
384            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
385        })?;
386        for row in rows {
387            let (kind, other_path) = row?;
388            if !(other_path.is_empty() || other_path == file_path || served(&other_path)) {
389                continue;
390            }
391            let slot = result.entry(kind).or_insert((0, 0));
392            if outgoing {
393                slot.0 += 1;
394            } else {
395                slot.1 += 1;
396            }
397        }
398    }
399    Ok(result)
400}
401
402/// A file depending on another, as seen from the dependency.
403struct Dependent {
404    file: String,
405    /// Strongest edge of the pair.
406    weight: f64,
407    /// Strongest edge of the pair that is not a name-match guess, if any.
408    strong_weight: Option<f64>,
409}
410
411/// Dependency → its dependents, both sorted by path.
412type ReverseGraph = BTreeMap<String, Vec<Dependent>>;
413
414fn build_weighted_reverse_graph(conn: &Connection) -> anyhow::Result<ReverseGraph> {
415    let sql = format!(
416        "SELECT p_tgt.path, p_src.path, e.kind, e.metadata
417         FROM edges e
418         JOIN nodes n_src ON e.source_id = n_src.id
419         JOIN nodes n_tgt ON e.target_id = n_tgt.id
420         JOIN paths p_src ON p_src.id = n_src.file_id
421         JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
422         WHERE e.kind IN ({STRUCTURAL_EDGE_KINDS})
423           AND p_src.path != p_tgt.path"
424    );
425    let mut stmt = conn.prepare(&sql)?;
426
427    let mut graph: BTreeMap<String, BTreeMap<String, (f64, Option<f64>)>> = BTreeMap::new();
428    let rows = stmt.query_map([], |row| {
429        Ok((
430            row.get::<_, String>(0)?,
431            row.get::<_, String>(1)?,
432            row.get::<_, String>(2)?,
433            row.get::<_, Option<String>>(3)?,
434        ))
435    })?;
436
437    for row in rows {
438        let (target, source, kind, metadata) = row?;
439        let w = evidence_weight(&kind, metadata.as_deref());
440        // Only a name-match guess is weak; scope-bound, verified and
441        // non-call structural edges (imports, type refs) are facts.
442        let strong = crate::core::semantic::EdgeEvidence::from_metadata(metadata.as_deref())
443            .is_none_or(|e| e.grade != crate::core::semantic::EvidenceGrade::HeuristicStructural);
444        let entry = graph
445            .entry(target)
446            .or_default()
447            .entry(source)
448            .or_insert((0.0, None));
449        entry.0 = entry.0.max(w);
450        if strong {
451            entry.1 = Some(entry.1.map_or(w, |s| s.max(w)));
452        }
453    }
454
455    Ok(graph
456        .into_iter()
457        .map(|(k, v)| {
458            (
459                k,
460                v.into_iter()
461                    .map(|(file, (weight, strong_weight))| Dependent {
462                        file,
463                        weight,
464                        strong_weight,
465                    })
466                    .collect(),
467            )
468        })
469        .collect())
470}
471
472fn build_forward_graph(conn: &Connection) -> anyhow::Result<HashMap<String, Vec<String>>> {
473    let sql = format!(
474        "SELECT DISTINCT p_src.path, p_tgt.path
475         FROM edges e
476         JOIN nodes n_src ON e.source_id = n_src.id
477         JOIN nodes n_tgt ON e.target_id = n_tgt.id
478         JOIN paths p_src ON p_src.id = n_src.file_id
479         JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
480         WHERE e.kind IN ({STRUCTURAL_EDGE_KINDS})
481           AND p_src.path != p_tgt.path"
482    );
483    let mut stmt = conn.prepare(&sql)?;
484
485    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
486    let rows = stmt.query_map([], |row| {
487        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
488    })?;
489
490    for row in rows {
491        let (source, target) = row?;
492        graph.entry(source).or_default().push(target);
493    }
494
495    for deps in graph.values_mut() {
496        deps.sort();
497        deps.dedup();
498    }
499    Ok(graph)
500}