Skip to main content

petgraph_live/hebbian/
stdp.rs

1//! STDP (Spike-Timing Dependent Plasticity) — temporal Hebbian rule.
2//!
3//! Strengthening depends on *order* of activation: causal pairs (pre fires
4//! before post) are strengthened, anti-causal pairs are weakened.
5
6use petgraph::EdgeType;
7use petgraph::graph::NodeIndex;
8use petgraph::stable_graph::StableGraph;
9
10/// STDP configuration.
11#[derive(Debug, Clone, Copy)]
12pub struct StdpConfig {
13    /// Strengthening amplitude for causal order (pre fires before post).
14    pub a_plus: f64,
15    /// Weakening amplitude for anti-causal order (post fires before pre).
16    pub a_minus: f64,
17    /// Time constant for causal window (ticks).
18    pub tau_plus: f64,
19    /// Time constant for anti-causal window (ticks).
20    pub tau_minus: f64,
21}
22
23impl Default for StdpConfig {
24    fn default() -> Self {
25        Self {
26            a_plus: 0.01,
27            a_minus: 0.005,
28            tau_plus: 5.0,
29            tau_minus: 5.0,
30        }
31    }
32}
33
34/// Activation with timing: (node, score, tick_fired).
35pub type TimedActivation = (NodeIndex, f64, u64);
36
37/// Apply STDP rule to edges between nodes that fired in the given window.
38///
39/// For each directed edge (pre → post) where both endpoints fired:
40/// - If pre fired before post (causal): Δw = +a_plus × exp(-Δt / tau_plus)
41/// - If post fired before pre (anti-causal): Δw = -a_minus × exp(-Δt / tau_minus)
42/// - If same tick: no change
43///
44/// Creates edges for causal pairs if none exists. Returns number of edges modified.
45///
46/// # Examples
47///
48/// ```
49/// use petgraph::stable_graph::StableDiGraph;
50/// use petgraph_live::hebbian::{stdp_update, StdpConfig};
51///
52/// let mut g = StableDiGraph::<(), f64>::new();
53/// let a = g.add_node(());
54/// let b = g.add_node(());
55/// g.add_edge(a, b, 0.5);
56///
57/// // a fires at tick 1, b fires at tick 3 → a→b is causal (strengthened)
58/// let activations = vec![(a, 1.0, 1), (b, 1.0, 3)];
59/// let modified = stdp_update(&mut g, &activations, &StdpConfig::default());
60/// assert!(modified > 0);
61/// assert!(*g.edge_weight(0.into()).unwrap() > 0.5);
62/// ```
63pub fn stdp_update<N, Ty: EdgeType>(
64    graph: &mut StableGraph<N, f64, Ty>,
65    activations: &[TimedActivation],
66    config: &StdpConfig,
67) -> usize {
68    let mut modified = 0;
69    for i in 0..activations.len() {
70        let j_start = if Ty::is_directed() { 0 } else { i + 1 };
71        for j in j_start..activations.len() {
72            if i == j {
73                continue;
74            }
75            let (ni, _si, ti) = activations[i];
76            let (nj, _sj, tj) = activations[j];
77            if ti == tj {
78                continue;
79            }
80
81            // For directed: consider edge i→j
82            // pre=i, post=j: if ti < tj → causal, else anti-causal
83            let dt = (tj as f64) - (ti as f64);
84            let delta_w = if dt > 0.0 {
85                config.a_plus * (-dt / config.tau_plus).exp()
86            } else {
87                -config.a_minus * (dt / config.tau_minus).exp()
88            };
89
90            if let Some(idx) = graph.find_edge(ni, nj) {
91                if let Some(w) = graph.edge_weight_mut(idx) {
92                    *w += delta_w;
93                    modified += 1;
94                }
95            } else if delta_w > 0.0 {
96                graph.add_edge(ni, nj, delta_w);
97                modified += 1;
98            }
99
100            // For directed graphs, also process j→i edge
101            if Ty::is_directed() {
102                let delta_w_rev = -delta_w; // reversed direction
103                if let Some(idx) = graph.find_edge(nj, ni) {
104                    if let Some(w) = graph.edge_weight_mut(idx) {
105                        *w += delta_w_rev;
106                        modified += 1;
107                    }
108                } else if delta_w_rev > 0.0 {
109                    graph.add_edge(nj, ni, delta_w_rev);
110                    modified += 1;
111                }
112            }
113        }
114    }
115    modified
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use petgraph::stable_graph::{StableDiGraph, StableUnGraph};
122
123    #[test]
124    fn causal_pair_strengthened() {
125        let mut g = StableDiGraph::<(), f64>::new();
126        let a = g.add_node(());
127        let b = g.add_node(());
128        g.add_edge(a, b, 0.5);
129
130        // a fires at tick 1, b fires at tick 2 → a→b is causal
131        stdp_update(&mut g, &[(a, 1.0, 1), (b, 1.0, 2)], &StdpConfig::default());
132        assert!(*g.edge_weight(0.into()).unwrap() > 0.5);
133    }
134
135    #[test]
136    fn anti_causal_pair_weakened() {
137        let mut g = StableDiGraph::<(), f64>::new();
138        let a = g.add_node(());
139        let b = g.add_node(());
140        g.add_edge(a, b, 0.5);
141
142        // b fires at tick 1, a fires at tick 3 → a→b is anti-causal (post fired first)
143        stdp_update(&mut g, &[(a, 1.0, 3), (b, 1.0, 1)], &StdpConfig::default());
144        assert!(*g.edge_weight(0.into()).unwrap() < 0.5);
145    }
146
147    #[test]
148    fn strength_depends_on_time_difference() {
149        let config = StdpConfig::default();
150
151        // Close in time → stronger effect
152        let mut g1 = StableDiGraph::<(), f64>::new();
153        let a1 = g1.add_node(());
154        let b1 = g1.add_node(());
155        g1.add_edge(a1, b1, 0.5);
156        stdp_update(&mut g1, &[(a1, 1.0, 1), (b1, 1.0, 2)], &config);
157        let w_close = *g1.edge_weight(0.into()).unwrap();
158
159        // Far in time → weaker effect
160        let mut g2 = StableDiGraph::<(), f64>::new();
161        let a2 = g2.add_node(());
162        let b2 = g2.add_node(());
163        g2.add_edge(a2, b2, 0.5);
164        stdp_update(&mut g2, &[(a2, 1.0, 1), (b2, 1.0, 10)], &config);
165        let w_far = *g2.edge_weight(0.into()).unwrap();
166
167        assert!(w_close > w_far);
168    }
169
170    #[test]
171    fn beyond_tau_window_minimal_effect() {
172        let config = StdpConfig {
173            tau_plus: 2.0,
174            ..StdpConfig::default()
175        };
176
177        let mut g = StableDiGraph::<(), f64>::new();
178        let a = g.add_node(());
179        let b = g.add_node(());
180        g.add_edge(a, b, 0.5);
181
182        // dt=20, tau=2 → exp(-10) ≈ 0.0000454 → negligible
183        stdp_update(&mut g, &[(a, 1.0, 1), (b, 1.0, 21)], &config);
184        let w = *g.edge_weight(0.into()).unwrap();
185        assert!((w - 0.5).abs() < 0.0001);
186    }
187
188    #[test]
189    fn same_tick_no_change() {
190        let mut g = StableDiGraph::<(), f64>::new();
191        let a = g.add_node(());
192        let b = g.add_node(());
193        g.add_edge(a, b, 0.5);
194
195        let modified = stdp_update(&mut g, &[(a, 1.0, 5), (b, 1.0, 5)], &StdpConfig::default());
196        assert_eq!(modified, 0);
197        assert_eq!(*g.edge_weight(0.into()).unwrap(), 0.5);
198    }
199
200    #[test]
201    fn undirected_stdp() {
202        let mut g = StableUnGraph::<(), f64>::with_capacity(0, 0);
203        let a = g.add_node(());
204        let b = g.add_node(());
205        g.add_edge(a, b, 0.5);
206
207        // a fires before b → causal → strengthen
208        let modified = stdp_update(&mut g, &[(a, 1.0, 1), (b, 1.0, 3)], &StdpConfig::default());
209        assert_eq!(modified, 1);
210        assert!(*g.edge_weight(0.into()).unwrap() > 0.5);
211    }
212}