Skip to main content

plasticity_lab/
bridge.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Bridge between `limbic-critic` modulator vectors and `neuromod` types.
4//!
5//! After `limbic-critic` dropped its direct `neuromod` dependency, critics emit
6//! [`limbic_critic::ModulatorVector`]. This module owns the conversion into
7//! [`neuromod::NeuroModulators`] so training loops in this crate can drive
8//! `neuromod`'s reward-modulated STDP without reimplementing it.
9//!
10//! Enabled only with the `critic` feature (optional `limbic-critic` dep).
11
12use limbic_critic::ModulatorVector;
13use neuromod::{NeuroModulators, SpikingNetwork, StepError};
14
15/// Convert a critic [`ModulatorVector`] into neuromod [`NeuroModulators`].
16///
17/// Field mapping is 1:1 for the supported registry releases:
18/// `dopamine`, `serotonin`, `acetylcholine`, `norepinephrine`.
19#[inline]
20pub fn to_neuromodulators(v: &ModulatorVector) -> NeuroModulators {
21    NeuroModulators {
22        dopamine: v.dopamine,
23        serotonin: v.serotonin,
24        acetylcholine: v.acetylcholine,
25        norepinephrine: v.norepinephrine,
26    }
27}
28
29/// Convert neuromod [`NeuroModulators`] back to a critic [`ModulatorVector`].
30///
31/// Useful for round-trip tests and for feeding neuromod state into critic-side code.
32#[inline]
33pub fn from_neuromodulators(m: &NeuroModulators) -> ModulatorVector {
34    ModulatorVector {
35        dopamine: m.dopamine,
36        serotonin: m.serotonin,
37        acetylcholine: m.acetylcholine,
38        norepinephrine: m.norepinephrine,
39    }
40}
41
42/// Step a network using critic modulators converted via [`to_neuromodulators`].
43///
44/// Convenience for orchestration layers that hold a `ModulatorVector` from
45/// `SimpleCritic` / `TDCritic` and need a single call into `neuromod`.
46#[inline]
47pub fn apply_modulator_vector(
48    network: &mut SpikingNetwork,
49    stimuli: &[f32],
50    vector: &ModulatorVector,
51) -> Result<Vec<usize>, StepError> {
52    network.step(stimuli, &to_neuromodulators(vector))
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use limbic_critic::{Environment, SimpleCritic, TDCritic};
59
60    #[test]
61    fn mapping_is_one_to_one() {
62        let v = ModulatorVector {
63            dopamine: 0.7,
64            serotonin: 0.3,
65            acetylcholine: 0.55,
66            norepinephrine: 0.2,
67        };
68        let m = to_neuromodulators(&v);
69        assert_eq!(m.dopamine, 0.7);
70        assert_eq!(m.serotonin, 0.3);
71        assert_eq!(m.acetylcholine, 0.55);
72        assert_eq!(m.norepinephrine, 0.2);
73    }
74
75    #[test]
76    fn roundtrip_preserves_fields() {
77        let original = ModulatorVector {
78            dopamine: 0.11,
79            serotonin: 0.22,
80            acetylcholine: 0.33,
81            norepinephrine: 0.44,
82        };
83        let roundtrip = from_neuromodulators(&to_neuromodulators(&original));
84        assert_eq!(roundtrip, original);
85    }
86
87    #[test]
88    fn defaults_map_to_defaults() {
89        let m = to_neuromodulators(&ModulatorVector::default());
90        assert_eq!(m, NeuroModulators::default());
91        let v = from_neuromodulators(&NeuroModulators::default());
92        assert_eq!(v, ModulatorVector::default());
93    }
94
95    struct ConstEnv {
96        objective: f32,
97        stress: f32,
98        volatility: f32,
99        surprise: f32,
100    }
101
102    impl Environment for ConstEnv {
103        fn objective(&self) -> f32 {
104            self.objective
105        }
106        fn stress(&self) -> f32 {
107            self.stress
108        }
109        fn volatility(&self) -> f32 {
110            self.volatility
111        }
112        fn surprise(&self) -> f32 {
113            self.surprise
114        }
115    }
116
117    #[test]
118    fn simple_critic_assess_maps_into_neuromodulators() {
119        let env = ConstEnv {
120            objective: 0.8,
121            stress: 0.4,
122            volatility: 0.25,
123            surprise: 0.6,
124        };
125        let vector = SimpleCritic::assess(&env);
126        let mods = to_neuromodulators(&vector);
127
128        assert_eq!(mods.dopamine, 0.8);
129        assert_eq!(mods.norepinephrine, 0.4);
130        assert_eq!(mods.serotonin, 0.25);
131        assert_eq!(mods.acetylcholine, 0.6);
132    }
133
134    #[test]
135    /// Verifies that a time-difference critic's output maps to neuromodulators.
136    fn td_critic_assess_maps_into_neuromodulators() {
137        let env = ConstEnv {
138            objective: 0.5,
139            stress: 0.1,
140            volatility: 0.0,
141            surprise: 0.0,
142        };
143        let mut critic = TDCritic::new(0.1).expect("finite alpha in (0, 1]");
144        let vector = critic.assess(&env);
145        let mods = to_neuromodulators(&vector);
146
147        // First TD call: td_error = 0.5, ema = 0.05, dopamine = 0.05.tanh()
148        assert!((mods.dopamine - 0.05f32.tanh()).abs() < 1e-6);
149        assert!((mods.acetylcholine - 0.5f32.tanh()).abs() < 1e-6);
150        assert_eq!(mods.norepinephrine, 0.1);
151        assert_eq!(mods.serotonin, 0.0);
152    }
153
154    #[test]
155    fn apply_modulator_vector_steps_network() {
156        let mut network = SpikingNetwork::with_dimensions(4, 2, 8);
157        let stimuli = vec![0.25; 8];
158        let vector = ModulatorVector {
159            dopamine: 0.5,
160            serotonin: 0.1,
161            acetylcholine: 0.4,
162            norepinephrine: 0.2,
163        };
164        let spikes = apply_modulator_vector(&mut network, &stimuli, &vector)
165            .expect("step with bridged modulators");
166        let _ = spikes;
167        assert!((network.modulators.dopamine - 0.5).abs() < 1e-6);
168        assert!((network.modulators.norepinephrine - 0.2).abs() < 1e-6);
169    }
170}