Skip to main content

symforge/live_index/coupling/
mod.rs

1//! Co-change coupling store + ranker-signal integration.
2//!
3//! Populated by a bounded git-history walker; queried at rerank time to
4//! promote files and symbols that historically ride with the query's
5//! anchor. This module owns storage, cold-build, HEAD-delta updates, lazy
6//! preparation, and ranker-facing evidence for co-change signals.
7
8pub mod lifecycle;
9pub mod schema;
10pub mod store;
11pub mod walker;
12
13pub use lifecycle::{
14    LazyPrepareOutcome, coupling_prepare_policy_from_env, init_coupling_store,
15    open_existing_coupling_store, refresh_on_reconcile_tick, start_lazy_prepare,
16};
17pub use store::{CouplingRow, CouplingStore};
18pub use walker::{DeltaOutcome, WalkerConfig, WalkerStats, apply_head_delta, cold_build};
19
20/// `exp(-delta_secs * ln2 / half_life_secs)`. Shared by the walker and
21/// the store's delta routine so rescale math stays consistent.
22/// Returns 1.0 for non-positive half-life (guards against misconfig) or
23/// non-positive delta (future / simultaneous commit — no decay).
24pub(crate) fn decay_factor(delta_secs: i64, half_life_secs: i64) -> f64 {
25    if half_life_secs <= 0 || delta_secs <= 0 {
26        return 1.0;
27    }
28    (-(delta_secs as f64) * std::f64::consts::LN_2 / half_life_secs as f64).exp()
29}
30
31/// Granularity of a coupling edge. File-level and symbol-level rows coexist
32/// in the same table, distinguished by the `AnchorKey` prefix.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Granularity {
35    File,
36    Symbol,
37}
38
39/// Namespaced key identifying one endpoint of a coupling edge.
40///
41/// * File endpoint: `"file:<rel-path>"`
42/// * Symbol endpoint: `"symbol:<rel-path>#<name>#<kind>"`
43///
44/// Paths are normalised to forward slashes so Windows and POSIX paths
45/// hash to the same row.
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub struct AnchorKey(String);
48
49impl AnchorKey {
50    pub fn file(rel_path: &str) -> Self {
51        Self(format!("file:{}", normalize_path(rel_path)))
52    }
53
54    pub fn symbol(rel_path: &str, name: &str, kind: &str) -> Self {
55        Self(format!(
56            "symbol:{}#{}#{}",
57            normalize_path(rel_path),
58            name,
59            kind
60        ))
61    }
62
63    pub(crate) fn from_raw(raw: String) -> Self {
64        Self(raw)
65    }
66
67    pub fn as_str(&self) -> &str {
68        &self.0
69    }
70
71    pub fn granularity(&self) -> Granularity {
72        if self.0.starts_with("symbol:") {
73            Granularity::Symbol
74        } else {
75            Granularity::File
76        }
77    }
78}
79
80fn normalize_path(s: &str) -> String {
81    s.replace('\\', "/")
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn file_anchor_is_prefixed_and_slash_normalized() {
90        let a = AnchorKey::file("src\\live_index\\query.rs");
91        assert_eq!(a.as_str(), "file:src/live_index/query.rs");
92        assert_eq!(a.granularity(), Granularity::File);
93    }
94
95    #[test]
96    fn symbol_anchor_embeds_name_and_kind() {
97        let a = AnchorKey::symbol("src/live_index/query.rs", "capture_search_files_view", "fn");
98        assert_eq!(
99            a.as_str(),
100            "symbol:src/live_index/query.rs#capture_search_files_view#fn"
101        );
102        assert_eq!(a.granularity(), Granularity::Symbol);
103    }
104
105    #[test]
106    fn from_raw_preserves_unknown_prefix_as_file_granularity() {
107        // Defensive: DB rows with an unexpected shape still deserialize.
108        let a = AnchorKey::from_raw("weird:whatever".to_string());
109        assert_eq!(a.as_str(), "weird:whatever");
110        assert_eq!(a.granularity(), Granularity::File);
111    }
112}