Skip to main content

lean_ctx/core/
cross_source_hints.rs

1//! Cross-source hints — lateral connections between cortical columns.
2//!
3//! When `ctx_read` delivers a file, this module appends hints about related
4//! data from other sources (issues, PRs, DB schemas, wiki pages) discovered
5//! via the graph index's cross-source edges.
6//!
7//! Scientific basis: Lateral connections in V1 cortex (Stettler et al., 2002)
8//! enable feature integration across cortical columns.
9
10use crate::core::graph_index::IndexEdge;
11
12/// Weight factor of a hint reached through a directory reference rather than
13/// the file itself.
14const DIRECTORY_MATCH_FACTOR: f32 = 0.9;
15
16/// A hint about related data from another source.
17#[derive(Debug, Clone, serde::Serialize)]
18pub struct CrossSourceHint {
19    pub source_uri: String,
20    pub relation: String,
21    pub weight: f32,
22}
23
24/// Find cross-source hints for a given file path by looking up
25/// edges in the graph index that connect to external URIs.
26/// Matches both absolute and project-relative paths since edges
27/// store relative paths while ctx_read passes absolute ones.
28pub fn hints_for_file(
29    file_path: &str,
30    edges: &[IndexEdge],
31    project_root: &str,
32) -> Vec<CrossSourceHint> {
33    hints_for_file_matching(file_path, edges, project_root, |_| true)
34}
35
36/// Find cross-source hints, applying `include` before the five-item output cap.
37///
38/// Range-scoped reads use this to discard file-level and out-of-window hints
39/// without allowing higher-weight irrelevant hints to hide a relevant one.
40pub fn hints_for_file_matching(
41    file_path: &str,
42    edges: &[IndexEdge],
43    project_root: &str,
44    mut include: impl FnMut(&CrossSourceHint) -> bool,
45) -> Vec<CrossSourceHint> {
46    let rel = crate::core::graph_index::graph_relative_key(file_path, project_root);
47
48    // The file itself, or a directory a provider declared the item to be
49    // about (an emspace page pinned to `rust/src/dashboard`). A directory
50    // match ranks below an exact one of the same weight.
51    let path_weight = |edge_path: &str| -> Option<f32> {
52        if edge_path == file_path || edge_path == rel {
53            Some(1.0)
54        } else if !edge_path.is_empty()
55            && !edge_path.contains("://")
56            // Separator-agnostic: Windows keys may carry `\`.
57            && rel
58                .replace('\\', "/")
59                .strip_prefix(edge_path.replace('\\', "/").as_str())
60                .is_some_and(|rest| rest.starts_with('/'))
61        {
62            Some(DIRECTORY_MATCH_FACTOR)
63        } else {
64            None
65        }
66    };
67
68    let mut hints: Vec<CrossSourceHint> = edges
69        .iter()
70        .filter_map(|e| {
71            if is_external_uri(&e.to)
72                && let Some(factor) = path_weight(&e.from)
73            {
74                Some(CrossSourceHint {
75                    source_uri: e.to.clone(),
76                    relation: e.kind.clone(),
77                    weight: e.weight * factor,
78                })
79            } else if is_external_uri(&e.from)
80                && let Some(factor) = path_weight(&e.to)
81            {
82                Some(CrossSourceHint {
83                    source_uri: e.from.clone(),
84                    relation: e.kind.clone(),
85                    weight: e.weight * factor,
86                })
87            } else {
88                None
89            }
90        })
91        .collect();
92
93    hints.retain(|hint| include(hint));
94    // Total order (weight, then URI, then relation) so the output is a function
95    // of the graph's contents, not of the order its rows come back in (#498).
96    hints.sort_by(|a, b| {
97        b.weight
98            .partial_cmp(&a.weight)
99            .unwrap_or(std::cmp::Ordering::Equal)
100            .then_with(|| a.source_uri.cmp(&b.source_uri))
101            .then_with(|| a.relation.cmp(&b.relation))
102    });
103    // One hint per source, at its strongest relation: a provider edge is
104    // stored in both directions (`documents` and `mentioned_in`), and the two
105    // need not be adjacent after the sort.
106    let mut seen = std::collections::HashSet::new();
107    hints.retain(|hint| seen.insert(hint.source_uri.clone()));
108    hints.truncate(5);
109    hints
110}
111
112/// Format hints as a compact string for appending to ctx_read output.
113pub fn format_hints(hints: &[CrossSourceHint]) -> String {
114    if hints.is_empty() {
115        return String::new();
116    }
117
118    let mut out = String::from("\n--- Cross-Source Hints ---\n");
119    for hint in hints {
120        out.push_str(&format!(
121            "  {} [{}] w={:.1}\n",
122            hint.source_uri, hint.relation, hint.weight
123        ));
124    }
125    out
126}
127
128fn is_external_uri(path: &str) -> bool {
129    path.contains("://")
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::core::graph_index::IndexEdge;
136
137    fn edge(from: &str, to: &str, kind: &str, weight: f32) -> IndexEdge {
138        IndexEdge {
139            from: from.into(),
140            to: to.into(),
141            kind: kind.into(),
142            weight,
143        }
144    }
145
146    const ROOT: &str = "/project";
147
148    #[test]
149    fn a_directory_reference_hints_files_below_it_after_exact_ones() {
150        let edges = vec![
151            edge("emspace://pages/core", "src/core", "documents", 1.0),
152            edge(
153                "emspace://pages/core/auth",
154                "src/core/auth.rs",
155                "documents",
156                1.0,
157            ),
158            edge("emspace://pages/corelib", "src/corelib", "documents", 1.0),
159        ];
160
161        let hints = hints_for_file("src/core/auth.rs", &edges, ROOT);
162        let uris: Vec<&str> = hints.iter().map(|h| h.source_uri.as_str()).collect();
163        // `src/corelib` is not a parent of `src/core/auth.rs`.
164        assert_eq!(
165            uris,
166            vec!["emspace://pages/core/auth", "emspace://pages/core"]
167        );
168        assert_eq!(hints_for_file("src/core", &edges, ROOT).len(), 1);
169    }
170
171    #[test]
172    fn finds_hints_from_forward_edges() {
173        let edges = vec![
174            edge("src/auth.rs", "github://issues/42", "mentions", 1.0),
175            edge("src/auth.rs", "postgres://schemas/sessions", "queries", 1.2),
176        ];
177
178        let hints = hints_for_file("src/auth.rs", &edges, ROOT);
179        assert_eq!(hints.len(), 2);
180        assert!(hints.iter().any(|h| h.source_uri.contains("issues/42")));
181        assert!(
182            hints
183                .iter()
184                .any(|h| h.source_uri.contains("schemas/sessions"))
185        );
186    }
187
188    #[test]
189    fn finds_hints_from_reverse_edges() {
190        let edges = vec![edge(
191            "github://issues/42",
192            "src/auth.rs",
193            "mentioned_in",
194            0.8,
195        )];
196
197        let hints = hints_for_file("src/auth.rs", &edges, ROOT);
198        assert_eq!(hints.len(), 1);
199        assert!(hints[0].source_uri.contains("issues/42"));
200    }
201
202    #[test]
203    fn finds_hints_with_absolute_path() {
204        let edges = vec![edge("src/auth.rs", "github://issues/42", "mentions", 1.0)];
205        let hints = hints_for_file("/project/src/auth.rs", &edges, "/project");
206        assert_eq!(hints.len(), 1, "absolute path should match relative edge");
207    }
208
209    #[test]
210    fn ignores_code_to_code_edges() {
211        let edges = vec![edge("src/auth.rs", "src/db.rs", "imports", 1.0)];
212
213        let hints = hints_for_file("src/auth.rs", &edges, ROOT);
214        assert!(hints.is_empty());
215    }
216
217    #[test]
218    fn deduplicates_and_limits_to_5() {
219        let edges: Vec<IndexEdge> = (0..10)
220            .map(|i| {
221                edge(
222                    "src/auth.rs",
223                    &format!("github://issues/{i}"),
224                    "mentions",
225                    1.0,
226                )
227            })
228            .collect();
229
230        let hints = hints_for_file("src/auth.rs", &edges, ROOT);
231        assert_eq!(hints.len(), 5);
232    }
233
234    #[test]
235    fn hint_output_is_byte_stable_for_the_same_graph_in_any_row_order() {
236        let edges = vec![
237            edge("emspace://pages/core", "src/a.rs", "documents", 0.8),
238            edge("src/a.rs", "emspace://pages/core", "mentioned_in", 0.64),
239            edge("github://issues/9", "src/a.rs", "mentions", 0.8),
240            edge("github://issues/3", "src/a.rs", "mentions", 0.8),
241            edge("emspace://pages/src", "src", "documents", 0.8),
242            edge("jira://tickets/A-1", "src/a.rs", "mentions", 0.5),
243        ];
244        let expected = format_hints(&hints_for_file("src/a.rs", &edges, ROOT));
245        assert!(expected.contains("emspace://pages/core [documents]"));
246        assert_eq!(expected.matches("emspace://pages/core ").count(), 1);
247        let mut rotated = edges.clone();
248        for _ in 0..edges.len() {
249            rotated.rotate_left(1);
250            let mut reversed = rotated.clone();
251            reversed.reverse();
252            for order in [&rotated, &reversed] {
253                assert_eq!(
254                    format_hints(&hints_for_file("src/a.rs", order, ROOT)),
255                    expected
256                );
257            }
258        }
259    }
260
261    #[test]
262    fn a_source_linked_both_ways_is_hinted_once_at_its_strongest_relation() {
263        let edges = vec![
264            edge("emspace://pages/core", "src/a.rs", "documents", 0.8),
265            edge("github://issues/7", "src/a.rs", "mentions", 0.7),
266            edge("src/a.rs", "emspace://pages/core", "mentioned_in", 0.64),
267        ];
268        let hints = hints_for_file("src/a.rs", &edges, ROOT);
269        let seen: Vec<(&str, &str)> = hints
270            .iter()
271            .map(|h| (h.source_uri.as_str(), h.relation.as_str()))
272            .collect();
273        assert_eq!(
274            seen,
275            vec![
276                ("emspace://pages/core", "documents"),
277                ("github://issues/7", "mentions")
278            ]
279        );
280    }
281
282    #[test]
283    fn range_filter_runs_before_output_limit() {
284        let mut edges: Vec<IndexEdge> = (0..5)
285            .map(|i| {
286                edge(
287                    "src/auth.rs",
288                    &format!("health://complexity/src/auth.rs#irrelevant_{i}"),
289                    "health_hotspot",
290                    10.0,
291                )
292            })
293            .collect();
294        edges.push(edge(
295            "src/auth.rs",
296            "health://complexity/src/auth.rs#requested",
297            "health_hotspot",
298            1.0,
299        ));
300
301        let hints = hints_for_file_matching("src/auth.rs", &edges, ROOT, |hint| {
302            hint.source_uri.ends_with("#requested")
303        });
304        assert_eq!(hints.len(), 1);
305        assert!(hints[0].source_uri.ends_with("#requested"));
306    }
307
308    #[test]
309    fn sorts_by_weight_descending() {
310        let edges = vec![
311            edge("src/auth.rs", "github://issues/1", "mentions", 0.5),
312            edge("src/auth.rs", "github://issues/2", "mentions", 1.5),
313            edge("src/auth.rs", "github://issues/3", "mentions", 1.0),
314        ];
315
316        let hints = hints_for_file("src/auth.rs", &edges, ROOT);
317        assert_eq!(hints[0].source_uri, "github://issues/2");
318        assert_eq!(hints[1].source_uri, "github://issues/3");
319        assert_eq!(hints[2].source_uri, "github://issues/1");
320    }
321
322    #[test]
323    fn format_hints_empty_returns_empty() {
324        {
325            let found = &format_hints(&[]);
326            assert!(found.is_empty(), "{found:?}");
327        }
328    }
329
330    #[test]
331    fn format_hints_produces_readable_output() {
332        let hints = vec![CrossSourceHint {
333            source_uri: "github://issues/42".into(),
334            relation: "mentions".into(),
335            weight: 1.0,
336        }];
337
338        let output = format_hints(&hints);
339        assert!(output.contains("Cross-Source Hints"));
340        assert!(output.contains("github://issues/42"));
341        assert!(output.contains("[mentions]"));
342    }
343}