Skip to main content

lean_ctx/core/
spreading_activation.rs

1//! Spreading activation — associative retrieval over a weighted graph.
2//!
3//! ## The idea (cognitive science → retrieval)
4//!
5//! In ACT-R and classic semantic-network models (Collins & Loftus 1975),
6//! recall works by *spreading activation*: cue concepts light up, and energy
7//! flows along associative links to related concepts, attenuating with distance.
8//! Items that many short, strong paths reach end up most activated — i.e. most
9//! relevant to the cue.
10//!
11//! We apply this to code: seed activation at the files/symbols a task names,
12//! then spread it across the project graph (imports, calls, co-access). The
13//! resulting activation is an associative relevance signal that complements
14//! lexical BM25 — it surfaces files that are *structurally* close to the seeds
15//! even when they share no query terms.
16//!
17//! ## Convergence
18//!
19//! Each node's outgoing edges are fan-out-normalised (they sum to 1), so a node
20//! re-emits at most `decay · energy` (with `decay < 1`). Total energy in the
21//! system is therefore strictly decreasing, guaranteeing termination; a firing
22//! threshold prunes negligible pulses so cost stays near the active frontier.
23
24use std::collections::HashMap;
25
26/// Pulses below this energy are not propagated further (keeps work bounded and
27/// the result sparse).
28const FIRING_THRESHOLD: f64 = 1e-4;
29
30/// Spread `seeds` over `adjacency` for up to `iterations` hops.
31///
32/// - `seeds`: initial activation per node (the query cues).
33/// - `adjacency`: `node → [(neighbour, weight)]`; weights need not be
34///   normalised (we fan-out-normalise internally).
35/// - `decay`: per-hop attenuation in `(0, 1)`; smaller ⇒ activation stays more
36///   local. Values outside the range are clamped.
37///
38/// Returns accumulated activation per reached node (including seeds). Nodes not
39/// reachable from any seed are absent (implicitly zero).
40pub fn spread(
41    seeds: &HashMap<String, f64>,
42    adjacency: &HashMap<String, Vec<(String, f64)>>,
43    decay: f64,
44    iterations: usize,
45) -> HashMap<String, f64> {
46    let decay = decay.clamp(0.0, 0.999);
47    let mut activation: HashMap<String, f64> = seeds.clone();
48    let mut frontier: HashMap<String, f64> = seeds.clone();
49
50    for _ in 0..iterations {
51        let mut next: HashMap<String, f64> = HashMap::new();
52        for (node, &energy) in &frontier {
53            if energy < FIRING_THRESHOLD {
54                continue;
55            }
56            let Some(edges) = adjacency.get(node) else {
57                continue;
58            };
59            let total: f64 = edges.iter().map(|(_, w)| w.max(0.0)).sum();
60            if total <= 0.0 {
61                continue;
62            }
63            for (nbr, w) in edges {
64                let w = w.max(0.0);
65                if w <= 0.0 {
66                    continue;
67                }
68                let delta = energy * decay * (w / total);
69                if delta >= FIRING_THRESHOLD {
70                    *next.entry(nbr.clone()).or_insert(0.0) += delta;
71                }
72            }
73        }
74        if next.is_empty() {
75            break;
76        }
77        for (node, e) in &next {
78            *activation.entry(node.clone()).or_insert(0.0) += e;
79        }
80        frontier = next;
81    }
82
83    activation
84}
85
86/// Convenience: spread from `seeds` and return the top-`k` *non-seed* nodes by
87/// activation, strongest first — the files most associatively related to the
88/// cues but not already named by them.
89pub fn related_ranked(
90    seeds: &HashMap<String, f64>,
91    adjacency: &HashMap<String, Vec<(String, f64)>>,
92    decay: f64,
93    iterations: usize,
94    top_k: usize,
95) -> Vec<(String, f64)> {
96    let activation = spread(seeds, adjacency, decay, iterations);
97    let mut ranked: Vec<(String, f64)> = activation
98        .into_iter()
99        .filter(|(node, _)| !seeds.contains_key(node))
100        .collect();
101    ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
102    ranked.truncate(top_k);
103    ranked
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn edge(adj: &mut HashMap<String, Vec<(String, f64)>>, from: &str, to: &str, w: f64) {
111        adj.entry(from.to_string())
112            .or_default()
113            .push((to.to_string(), w));
114        adj.entry(to.to_string())
115            .or_default()
116            .push((from.to_string(), w));
117    }
118
119    #[test]
120    fn activation_reaches_connected_nodes_only() {
121        let mut adj = HashMap::new();
122        edge(&mut adj, "a", "b", 1.0);
123        edge(&mut adj, "b", "c", 1.0);
124        // "island" is disconnected.
125        adj.entry("island".to_string()).or_default();
126
127        let seeds = HashMap::from([("a".to_string(), 1.0)]);
128        let act = spread(&seeds, &adj, 0.7, 5);
129
130        assert!(act.contains_key("b"));
131        assert!(act.contains_key("c"));
132        assert!(!act.contains_key("island"));
133    }
134
135    #[test]
136    fn closer_nodes_get_more_activation() {
137        let mut adj = HashMap::new();
138        edge(&mut adj, "a", "b", 1.0); // 1 hop from a
139        edge(&mut adj, "b", "c", 1.0); // 2 hops from a
140
141        let seeds = HashMap::from([("a".to_string(), 1.0)]);
142        let act = spread(&seeds, &adj, 0.7, 5);
143
144        // b (closer) must be more activated than c (farther).
145        assert!(act["b"] > act["c"]);
146    }
147
148    #[test]
149    fn stronger_edges_transmit_more() {
150        let mut adj = HashMap::new();
151        edge(&mut adj, "seed", "strong", 9.0);
152        edge(&mut adj, "seed", "weak", 1.0);
153
154        let seeds = HashMap::from([("seed".to_string(), 1.0)]);
155        let act = spread(&seeds, &adj, 0.7, 3);
156        assert!(act["strong"] > act["weak"]);
157    }
158
159    #[test]
160    fn terminates_and_stays_bounded_on_cycles() {
161        // A cycle would loop forever without decay + threshold.
162        let mut adj = HashMap::new();
163        edge(&mut adj, "a", "b", 1.0);
164        edge(&mut adj, "b", "c", 1.0);
165        edge(&mut adj, "c", "a", 1.0);
166
167        let seeds = HashMap::from([("a".to_string(), 1.0)]);
168        let act = spread(&seeds, &adj, 0.9, 1000);
169        // Total activation is finite (energy strictly decreases per hop).
170        let total: f64 = act.values().sum();
171        assert!(total.is_finite());
172        assert!(total < 100.0, "energy must not blow up on cycles: {total}");
173    }
174
175    #[test]
176    fn related_ranked_excludes_seeds() {
177        let mut adj = HashMap::new();
178        edge(&mut adj, "a", "b", 1.0);
179        edge(&mut adj, "a", "c", 1.0);
180
181        let seeds = HashMap::from([("a".to_string(), 1.0)]);
182        let ranked = related_ranked(&seeds, &adj, 0.7, 3, 10);
183        assert!(ranked.iter().all(|(n, _)| n != "a"));
184        assert_eq!(ranked.len(), 2);
185    }
186
187    #[test]
188    fn empty_seeds_yield_empty_result() {
189        let adj = HashMap::new();
190        let seeds = HashMap::new();
191        assert!(spread(&seeds, &adj, 0.7, 5).is_empty());
192    }
193}