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/// A hint about related data from another source.
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct CrossSourceHint {
15    pub source_uri: String,
16    pub relation: String,
17    pub weight: f32,
18}
19
20/// Find cross-source hints for a given file path by looking up
21/// edges in the graph index that connect to external URIs.
22/// Matches both absolute and project-relative paths since edges
23/// store relative paths while ctx_read passes absolute ones.
24pub fn hints_for_file(
25    file_path: &str,
26    edges: &[IndexEdge],
27    project_root: &str,
28) -> Vec<CrossSourceHint> {
29    hints_for_file_matching(file_path, edges, project_root, |_| true)
30}
31
32/// Find cross-source hints, applying `include` before the five-item output cap.
33///
34/// Range-scoped reads use this to discard file-level and out-of-window hints
35/// without allowing higher-weight irrelevant hints to hide a relevant one.
36pub(crate) fn hints_for_file_matching(
37    file_path: &str,
38    edges: &[IndexEdge],
39    project_root: &str,
40    mut include: impl FnMut(&CrossSourceHint) -> bool,
41) -> Vec<CrossSourceHint> {
42    let rel = crate::core::graph_index::graph_relative_key(file_path, project_root);
43
44    let matches_path = |edge_path: &str| -> bool { edge_path == file_path || edge_path == rel };
45
46    let mut hints: Vec<CrossSourceHint> = edges
47        .iter()
48        .filter(|e| {
49            (matches_path(&e.from) && is_external_uri(&e.to))
50                || (matches_path(&e.to) && is_external_uri(&e.from))
51        })
52        .map(|e| {
53            if matches_path(&e.from) {
54                CrossSourceHint {
55                    source_uri: e.to.clone(),
56                    relation: e.kind.clone(),
57                    weight: e.weight,
58                }
59            } else {
60                CrossSourceHint {
61                    source_uri: e.from.clone(),
62                    relation: e.kind.clone(),
63                    weight: e.weight,
64                }
65            }
66        })
67        .collect();
68
69    hints.retain(|hint| include(hint));
70    hints.sort_by(|a, b| {
71        b.weight
72            .partial_cmp(&a.weight)
73            .unwrap_or(std::cmp::Ordering::Equal)
74    });
75    hints.dedup_by(|a, b| a.source_uri == b.source_uri);
76    hints.truncate(5);
77    hints
78}
79
80/// Format hints as a compact string for appending to ctx_read output.
81pub fn format_hints(hints: &[CrossSourceHint]) -> String {
82    if hints.is_empty() {
83        return String::new();
84    }
85
86    let mut out = String::from("\n--- Cross-Source Hints ---\n");
87    for hint in hints {
88        out.push_str(&format!(
89            "  {} [{}] w={:.1}\n",
90            hint.source_uri, hint.relation, hint.weight
91        ));
92    }
93    out
94}
95
96fn is_external_uri(path: &str) -> bool {
97    path.contains("://")
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::core::graph_index::IndexEdge;
104
105    fn edge(from: &str, to: &str, kind: &str, weight: f32) -> IndexEdge {
106        IndexEdge {
107            from: from.into(),
108            to: to.into(),
109            kind: kind.into(),
110            weight,
111        }
112    }
113
114    const ROOT: &str = "/project";
115
116    #[test]
117    fn finds_hints_from_forward_edges() {
118        let edges = vec![
119            edge("src/auth.rs", "github://issues/42", "mentions", 1.0),
120            edge("src/auth.rs", "postgres://schemas/sessions", "queries", 1.2),
121        ];
122
123        let hints = hints_for_file("src/auth.rs", &edges, ROOT);
124        assert_eq!(hints.len(), 2);
125        assert!(hints.iter().any(|h| h.source_uri.contains("issues/42")));
126        assert!(
127            hints
128                .iter()
129                .any(|h| h.source_uri.contains("schemas/sessions"))
130        );
131    }
132
133    #[test]
134    fn finds_hints_from_reverse_edges() {
135        let edges = vec![edge(
136            "github://issues/42",
137            "src/auth.rs",
138            "mentioned_in",
139            0.8,
140        )];
141
142        let hints = hints_for_file("src/auth.rs", &edges, ROOT);
143        assert_eq!(hints.len(), 1);
144        assert!(hints[0].source_uri.contains("issues/42"));
145    }
146
147    #[test]
148    fn finds_hints_with_absolute_path() {
149        let edges = vec![edge("src/auth.rs", "github://issues/42", "mentions", 1.0)];
150        let hints = hints_for_file("/project/src/auth.rs", &edges, "/project");
151        assert_eq!(hints.len(), 1, "absolute path should match relative edge");
152    }
153
154    #[test]
155    fn ignores_code_to_code_edges() {
156        let edges = vec![edge("src/auth.rs", "src/db.rs", "imports", 1.0)];
157
158        let hints = hints_for_file("src/auth.rs", &edges, ROOT);
159        assert!(hints.is_empty());
160    }
161
162    #[test]
163    fn deduplicates_and_limits_to_5() {
164        let edges: Vec<IndexEdge> = (0..10)
165            .map(|i| {
166                edge(
167                    "src/auth.rs",
168                    &format!("github://issues/{i}"),
169                    "mentions",
170                    1.0,
171                )
172            })
173            .collect();
174
175        let hints = hints_for_file("src/auth.rs", &edges, ROOT);
176        assert_eq!(hints.len(), 5);
177    }
178
179    #[test]
180    fn range_filter_runs_before_output_limit() {
181        let mut edges: Vec<IndexEdge> = (0..5)
182            .map(|i| {
183                edge(
184                    "src/auth.rs",
185                    &format!("health://complexity/src/auth.rs#irrelevant_{i}"),
186                    "health_hotspot",
187                    10.0,
188                )
189            })
190            .collect();
191        edges.push(edge(
192            "src/auth.rs",
193            "health://complexity/src/auth.rs#requested",
194            "health_hotspot",
195            1.0,
196        ));
197
198        let hints = hints_for_file_matching("src/auth.rs", &edges, ROOT, |hint| {
199            hint.source_uri.ends_with("#requested")
200        });
201        assert_eq!(hints.len(), 1);
202        assert!(hints[0].source_uri.ends_with("#requested"));
203    }
204
205    #[test]
206    fn sorts_by_weight_descending() {
207        let edges = vec![
208            edge("src/auth.rs", "github://issues/1", "mentions", 0.5),
209            edge("src/auth.rs", "github://issues/2", "mentions", 1.5),
210            edge("src/auth.rs", "github://issues/3", "mentions", 1.0),
211        ];
212
213        let hints = hints_for_file("src/auth.rs", &edges, ROOT);
214        assert_eq!(hints[0].source_uri, "github://issues/2");
215        assert_eq!(hints[1].source_uri, "github://issues/3");
216        assert_eq!(hints[2].source_uri, "github://issues/1");
217    }
218
219    #[test]
220    fn format_hints_empty_returns_empty() {
221        assert!(format_hints(&[]).is_empty());
222    }
223
224    #[test]
225    fn format_hints_produces_readable_output() {
226        let hints = vec![CrossSourceHint {
227            source_uri: "github://issues/42".into(),
228            relation: "mentions".into(),
229            weight: 1.0,
230        }];
231
232        let output = format_hints(&hints);
233        assert!(output.contains("Cross-Source Hints"));
234        assert!(output.contains("github://issues/42"));
235        assert!(output.contains("[mentions]"));
236    }
237}