Skip to main content

plasticity_lab/
config.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3use serde::{Deserialize, Serialize};
4
5/// Configuration knobs for [`crate::PlasticityTrainer`].
6///
7/// Every field here drives an explicit runtime code path in
8/// [`crate::trainer::PlasticityTrainer::train_step`] — see that method's rustdoc
9/// for exactly how each field is consumed. `TrainingConfig` intentionally does
10/// not expose knobs without a consumer (learning rate, homeostasis setpoints,
11/// and batch size were removed for this reason; see `CHANGELOG.md`). Low-level
12/// STDP / homeostasis tuning lives in `neuromod::SpikingNetwork`, which derives
13/// its own learning rate and thresholds from the neuromodulator state passed
14/// into `step`.
15///
16/// Values are serializable (serde) so callers can persist them as part of their
17/// own experiment configs or checkpointing setup — this crate does not implement
18/// checkpointing itself. Missing fields deserialize via [`Default`]
19/// (`#[serde(default)]` on the struct), and unknown fields (for example from an
20/// older config that still carries a since-removed knob) are ignored rather
21/// than rejected, since the struct does not use `deny_unknown_fields`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(default)]
24pub struct TrainingConfig {
25    /// When `true` (default), `train_step` adjusts neuromodulators from the reward.
26    /// When `false`, the network steps with its current modulators unchanged.
27    pub use_reward_modulation: bool,
28}
29
30impl Default for TrainingConfig {
31    fn default() -> Self {
32        Self {
33            use_reward_modulation: true,
34        }
35    }
36}
37
38#[cfg(test)]
39mod tests {
40    use super::TrainingConfig;
41
42    #[test]
43    fn default_enables_reward_modulation() {
44        assert!(TrainingConfig::default().use_reward_modulation);
45    }
46
47    #[test]
48    fn missing_fields_deserialize_to_default() {
49        let cfg: TrainingConfig =
50            serde_json::from_str("{}").expect("deserialize empty config object");
51        assert_eq!(cfg, TrainingConfig::default());
52    }
53
54    #[test]
55    fn serialization_round_trip_preserves_false() {
56        let cfg = TrainingConfig {
57            use_reward_modulation: false,
58        };
59        let json = serde_json::to_string(&cfg).expect("serialize config");
60        let round_tripped: TrainingConfig =
61            serde_json::from_str(&json).expect("deserialize config");
62        assert_eq!(cfg, round_tripped);
63    }
64
65    #[test]
66    fn serialization_round_trip_preserves_default() {
67        let cfg = TrainingConfig::default();
68        let json = serde_json::to_string(&cfg).expect("serialize default config");
69        let round_tripped: TrainingConfig =
70            serde_json::from_str(&json).expect("deserialize default config");
71        assert_eq!(cfg, round_tripped);
72    }
73
74    #[test]
75    fn stale_fields_from_pre_0_2_configs_are_ignored_not_rejected() {
76        // Configs serialized before the fields were removed (see CHANGELOG.md)
77        // may still carry `learning_rate`, `target_spikes_per_step`,
78        // `homeostasis_strength`, and `batch_size` in stored checkpoints or
79        // experiment configs. None of those fields were ever read by the
80        // trainer; deserialization must keep ignoring them rather than error.
81        let cfg: TrainingConfig = serde_json::from_str(
82            r#"{
83                "learning_rate": 0.02,
84                "target_spikes_per_step": 0.1,
85                "homeostasis_strength": 0.001,
86                "batch_size": 4,
87                "use_reward_modulation": false
88            }"#,
89        )
90        .expect("deserialize config carrying removed fields");
91        assert!(!cfg.use_reward_modulation);
92    }
93}