1use crate::config::TrainingConfig;
4use crate::observer::{TrainingObserver, TrainingStepEvent};
5use neuromod::{NeuroModulators, SpikingNetwork, StepError};
6use rand::Rng;
7use thiserror::Error;
8
9#[derive(Debug, Default, Clone, PartialEq)]
13pub struct TrainingSummary {
14 pub steps_processed: usize,
16 pub total_spikes: u64,
18 pub avg_reward: f32,
20 pub threshold_drifts: Vec<f32>,
22 pub weight_drifts: Vec<Vec<f32>>,
24 pub per_neuron_spikes: Vec<u64>,
26}
27
28#[derive(Debug, Clone)]
33pub struct TrainingExample {
34 pub stimuli: Vec<f32>,
36 pub reward: f32,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
54pub enum SampleInvariant {
55 #[error("stimulus length mismatch: expected {expected}, got {got}")]
57 StimulusLenMismatch { expected: usize, got: usize },
58 #[error("non-finite stimulus at channel {channel}")]
60 NonFiniteStimulus { channel: usize },
61 #[error("infinite reward")]
64 InfiniteReward,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Error)]
69pub enum TrainerError {
70 #[error("network step failed: {0:?}")]
72 Step(StepError),
73 #[error("empty training batch")]
78 EmptyBatch,
79 #[error(
85 "training observer failed at step {step_index} after {steps_processed} processed step(s): {cause}"
86 )]
87 Observer {
88 step_index: usize,
90 steps_processed: usize,
93 cause: String,
95 },
96 #[error("invalid training sample {index}: {reason}")]
100 InvalidSample {
101 index: usize,
103 reason: SampleInvariant,
105 },
106}
107
108pub struct PlasticityTrainer {
116 pub config: TrainingConfig,
118}
119
120impl PlasticityTrainer {
121 pub fn new(config: TrainingConfig) -> Self {
123 Self { config }
124 }
125
126 pub fn train_step(
138 &mut self,
139 network: &mut SpikingNetwork,
140 stimuli: &[f32],
141 reward: f32,
142 ) -> Result<Vec<usize>, StepError> {
143 let modulators = self.modulators_for_reward(network, reward);
144 network.step(stimuli, &modulators)
145 }
146
147 pub fn train_step_with_rng<R: Rng + ?Sized>(
172 &mut self,
173 network: &mut SpikingNetwork,
174 stimuli: &[f32],
175 reward: f32,
176 rng: &mut R,
177 ) -> Result<Vec<usize>, StepError> {
178 let modulators = self.modulators_for_reward(network, reward);
179 network.step_with_rng(stimuli, &modulators, rng)
180 }
181
182 pub fn train_step_with_modulators(
189 &mut self,
190 network: &mut SpikingNetwork,
191 stimuli: &[f32],
192 modulators: &NeuroModulators,
193 ) -> Result<Vec<usize>, StepError> {
194 network.step(stimuli, modulators)
195 }
196
197 pub fn train_step_with_modulators_and_rng<R: Rng + ?Sized>(
199 &mut self,
200 network: &mut SpikingNetwork,
201 stimuli: &[f32],
202 modulators: &NeuroModulators,
203 rng: &mut R,
204 ) -> Result<Vec<usize>, StepError> {
205 network.step_with_rng(stimuli, modulators, rng)
206 }
207
208 #[cfg(feature = "critic")]
213 pub fn train_step_from_critic(
214 &mut self,
215 network: &mut SpikingNetwork,
216 stimuli: &[f32],
217 vector: &limbic_critic::ModulatorVector,
218 ) -> Result<Vec<usize>, StepError> {
219 self.train_step_with_modulators(
220 network,
221 stimuli,
222 &crate::bridge::to_neuromodulators(vector),
223 )
224 }
225
226 pub fn run_session(
247 &mut self,
248 network: &mut SpikingNetwork,
249 data: &[TrainingExample],
250 ) -> Result<TrainingSummary, TrainerError> {
251 let mut session = start_session(network, data)?;
252 let mut total_reward = 0.0;
253 let mut valid_reward_count = 0;
254
255 for example in data {
256 let spikes = self
257 .train_step(network, &example.stimuli, example.reward)
258 .map_err(TrainerError::Step)?;
259 Self::accumulate_step(
260 &mut session.summary,
261 &mut total_reward,
262 &mut valid_reward_count,
263 example,
264 &spikes,
265 );
266 }
267
268 Ok(Self::finalize_summary(
269 session.summary,
270 network,
271 &session.initial_thresholds,
272 &session.initial_weights,
273 total_reward,
274 valid_reward_count,
275 ))
276 }
277
278 pub fn run_session_with_rng<R: Rng + ?Sized>(
292 &mut self,
293 network: &mut SpikingNetwork,
294 data: &[TrainingExample],
295 rng: &mut R,
296 ) -> Result<TrainingSummary, TrainerError> {
297 let mut session = start_session(network, data)?;
298 let mut total_reward = 0.0;
299 let mut valid_reward_count = 0;
300
301 for example in data {
302 let spikes = self
303 .train_step_with_rng(network, &example.stimuli, example.reward, rng)
304 .map_err(TrainerError::Step)?;
305 Self::accumulate_step(
306 &mut session.summary,
307 &mut total_reward,
308 &mut valid_reward_count,
309 example,
310 &spikes,
311 );
312 }
313
314 Ok(Self::finalize_summary(
315 session.summary,
316 network,
317 &session.initial_thresholds,
318 &session.initial_weights,
319 total_reward,
320 valid_reward_count,
321 ))
322 }
323
324 fn modulators_for_reward(&self, network: &SpikingNetwork, reward: f32) -> NeuroModulators {
326 let mut modulators: NeuroModulators = network.modulators;
327
328 if self.config.use_reward_modulation && reward.is_finite() {
333 if reward > 0.0 {
337 modulators.dopamine = (modulators.dopamine + reward * 0.1).clamp(0.0, 1.0);
338 modulators.norepinephrine =
339 (modulators.norepinephrine - reward * 0.05).clamp(0.0, 1.0);
340 } else {
341 modulators.norepinephrine =
342 (modulators.norepinephrine - reward * 0.2).clamp(0.0, 1.0);
343 modulators.dopamine = (modulators.dopamine + reward * 0.1).clamp(0.0, 1.0);
344 }
345 }
346
347 modulators
348 }
349
350 pub(crate) fn accumulate_step(
351 summary: &mut TrainingSummary,
352 total_reward: &mut f32,
353 valid_reward_count: &mut u32,
354 example: &TrainingExample,
355 spikes: &[usize],
356 ) {
357 if example.reward.is_finite() {
358 *total_reward += example.reward;
359 *valid_reward_count += 1;
360 }
361 summary.steps_processed += 1;
362
363 summary.total_spikes += spikes.len() as u64;
364 for &idx in spikes {
365 if idx < summary.per_neuron_spikes.len() {
366 summary.per_neuron_spikes[idx] += 1;
367 }
368 }
369 }
370
371 pub(crate) fn finalize_summary(
372 mut summary: TrainingSummary,
373 network: &SpikingNetwork,
374 initial_thresholds: &[f32],
375 initial_weights: &[Vec<f32>],
376 total_reward: f32,
377 valid_reward_count: u32,
378 ) -> TrainingSummary {
379 summary.avg_reward = if valid_reward_count > 0 {
380 total_reward / valid_reward_count as f32
381 } else {
382 0.0
383 };
384
385 let final_thresholds = network.get_thresholds();
386 for i in 0..network.neurons.len() {
387 summary
388 .threshold_drifts
389 .push(final_thresholds[i] - initial_thresholds[i]);
390
391 let mut w_deltas = Vec::new();
392 for (ch, &w) in network.neurons[i].weights.iter().enumerate() {
393 w_deltas.push(w - initial_weights[i][ch]);
394 }
395 summary.weight_drifts.push(w_deltas);
396 }
397
398 summary
399 }
400
401 pub fn run_session_with_observer<O: TrainingObserver>(
424 &mut self,
425 network: &mut SpikingNetwork,
426 data: &[TrainingExample],
427 observer: &mut O,
428 ) -> Result<TrainingSummary, TrainerError> {
429 let mut session = start_session(network, data)?;
430 let mut total_reward = 0.0;
431 let mut valid_reward_count = 0;
432
433 for (step_index, example) in data.iter().enumerate() {
434 let spikes = self
435 .train_step(network, &example.stimuli, example.reward)
436 .map_err(TrainerError::Step)?;
437 accumulate_reward(example, &mut total_reward, &mut valid_reward_count);
438 session.summary.steps_processed += 1;
439 record_step_spikes(&mut session.summary, &spikes);
440 observer
441 .on_step(step_event(
442 step_index,
443 example.reward,
444 &network.modulators,
445 &spikes,
446 session.summary.steps_processed,
447 session.summary.total_spikes,
448 ))
449 .map_err(|cause| {
450 observer_abort(
451 step_index,
452 session.summary.steps_processed,
453 cause.to_string(),
454 )
455 })?;
456 }
457
458 finish_summary(
459 &mut session.summary,
460 network,
461 &session.initial_thresholds,
462 &session.initial_weights,
463 total_reward,
464 valid_reward_count,
465 );
466 Ok(session.summary)
467 }
468}
469
470struct SessionPrep {
471 summary: TrainingSummary,
472 initial_thresholds: Vec<f32>,
473 initial_weights: Vec<Vec<f32>>,
474}
475
476fn start_session(
477 network: &SpikingNetwork,
478 data: &[TrainingExample],
479) -> Result<SessionPrep, TrainerError> {
480 admit_batch(network, data)?;
481 let mut summary = TrainingSummary::default();
482 let initial_thresholds = network.get_thresholds();
483 let initial_weights: Vec<Vec<f32>> =
484 network.neurons.iter().map(|n| n.weights.clone()).collect();
485 summary.per_neuron_spikes = vec![0; network.neurons.len()];
486 Ok(SessionPrep {
487 summary,
488 initial_thresholds,
489 initial_weights,
490 })
491}
492
493fn accumulate_reward(
494 example: &TrainingExample,
495 total_reward: &mut f32,
496 valid_reward_count: &mut usize,
497) {
498 if example.reward.is_finite() {
499 *total_reward += example.reward;
500 *valid_reward_count += 1;
501 }
502}
503
504fn record_step_spikes(summary: &mut TrainingSummary, spikes: &[usize]) {
505 summary.total_spikes += spikes.len() as u64;
506 for &idx in spikes {
507 if let Some(count) = summary.per_neuron_spikes.get_mut(idx) {
508 *count += 1;
509 }
510 }
511}
512
513fn step_event<'a>(
514 step_index: usize,
515 reward: f32,
516 modulators: &'a NeuroModulators,
517 spike_indices: &'a [usize],
518 steps_processed: usize,
519 total_spikes: u64,
520) -> TrainingStepEvent<'a> {
521 TrainingStepEvent {
522 step_index,
523 reward,
524 modulators,
525 spike_indices,
526 steps_processed,
527 total_spikes,
528 }
529}
530
531#[inline(never)]
532fn observer_abort(step_index: usize, steps_processed: usize, cause: String) -> TrainerError {
533 TrainerError::Observer {
534 step_index,
535 steps_processed,
536 cause,
537 }
538}
539
540fn finish_summary(
541 summary: &mut TrainingSummary,
542 network: &SpikingNetwork,
543 initial_thresholds: &[f32],
544 initial_weights: &[Vec<f32>],
545 total_reward: f32,
546 valid_reward_count: usize,
547) {
548 summary.avg_reward = if valid_reward_count > 0 {
549 total_reward / valid_reward_count as f32
550 } else {
551 0.0
552 };
553
554 let final_thresholds = network.get_thresholds();
555 for i in 0..network.neurons.len() {
556 summary
557 .threshold_drifts
558 .push(final_thresholds[i] - initial_thresholds[i]);
559
560 let mut w_deltas = Vec::new();
561 for (ch, &w) in network.neurons[i].weights.iter().enumerate() {
562 w_deltas.push(w - initial_weights[i][ch]);
563 }
564 summary.weight_drifts.push(w_deltas);
565 }
566}
567
568fn admit_batch(network: &SpikingNetwork, data: &[TrainingExample]) -> Result<(), TrainerError> {
573 if data.is_empty() {
574 return Err(TrainerError::EmptyBatch);
575 }
576
577 for (index, example) in data.iter().enumerate() {
578 if let Some(reason) = sample_invariant(network, example) {
579 return Err(TrainerError::InvalidSample { index, reason });
580 }
581 }
582 Ok(())
583}
584
585fn sample_invariant(
587 network: &SpikingNetwork,
588 example: &TrainingExample,
589) -> Option<SampleInvariant> {
590 if example.stimuli.len() != network.num_channels {
591 return Some(SampleInvariant::StimulusLenMismatch {
592 expected: network.num_channels,
593 got: example.stimuli.len(),
594 });
595 }
596 if let Some(channel) = example.stimuli.iter().position(|x| !x.is_finite()) {
597 return Some(SampleInvariant::NonFiniteStimulus { channel });
598 }
599 if example.reward.is_infinite() {
600 return Some(SampleInvariant::InfiniteReward);
601 }
602 None
603}
604
605#[deprecated(
611 note = "renamed to `PlasticityTrainer`; this alias will be removed in a future release"
612)]
613#[doc(hidden)]
614pub use self::PlasticityTrainer as SpikenautTrainer;
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619 use crate::config::TrainingConfig;
620 use crate::observer::{TrainingObserver, TrainingStepEvent};
621 use rand::{SeedableRng, rngs::StdRng};
622
623 fn small_network() -> SpikingNetwork {
624 SpikingNetwork::with_dimensions(4, 2, 8)
625 }
626
627 fn documented_learning_network() -> SpikingNetwork {
628 let mut network = SpikingNetwork::with_dimensions(4, 2, 8);
629 for neuron in &mut network.neurons {
630 neuron.weights.fill(2.0 / network.num_channels as f32);
631 }
632 network
633 }
634
635 fn network_snapshot(network: &SpikingNetwork) -> String {
636 serde_json::to_string(network).expect("serialize network snapshot")
637 }
638
639 fn seed_nonzero_network_state(network: &mut SpikingNetwork) {
642 network.global_step = 17;
643 network.modulators.dopamine = 0.42;
644 network.modulators.norepinephrine = 0.37;
645 network.modulators.serotonin = 0.21;
646 network.modulators.acetylcholine = 0.18;
647 for (i, value) in network.predictive_state.iter_mut().enumerate() {
648 *value = 0.05 * (i as f32 + 1.0);
649 }
650 for (i, t) in network.input_spike_times.iter_mut().enumerate() {
651 *t = i as i64;
652 }
653 for neuron in &mut network.neurons {
654 neuron.membrane_potential = 0.01;
655 neuron.last_spike_time = 3;
656 neuron.weights.fill(0.2);
657 for trace in &mut neuron.eligibility {
658 trace.value = 0.3;
659 }
660 }
661 }
662
663 fn valid_example() -> TrainingExample {
664 example(8, 0.25, 0.2)
665 }
666
667 fn example(stimuli_len: usize, fill: f32, reward: f32) -> TrainingExample {
668 TrainingExample {
669 stimuli: vec![fill; stimuli_len],
670 reward,
671 }
672 }
673
674 fn eligibility_values(network: &SpikingNetwork) -> Vec<Vec<f32>> {
675 network
676 .neurons
677 .iter()
678 .map(|n| n.eligibility.iter().map(|t| t.value).collect())
679 .collect()
680 }
681
682 fn weight_values(network: &SpikingNetwork) -> Vec<Vec<f32>> {
683 network.neurons.iter().map(|n| n.weights.clone()).collect()
684 }
685
686 #[test]
687 fn train_step_with_reward_modulation_succeeds() {
688 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
689 let mut network = small_network();
690 let stimuli = vec![0.2; 8];
691 let spikes = trainer
692 .train_step(&mut network, &stimuli, 0.5)
693 .expect("positive reward step");
694 let _ = spikes;
695 let _ = trainer
696 .train_step(&mut network, &stimuli, -0.3)
697 .expect("negative reward step");
698 }
699
700 #[test]
701 fn train_step_without_reward_modulation_succeeds() {
702 let config = TrainingConfig {
703 use_reward_modulation: false,
704 };
705 let mut trainer = PlasticityTrainer::new(config);
706 let mut network = small_network();
707 let stimuli = vec![0.2; 8];
708 trainer
709 .train_step(&mut network, &stimuli, 0.9)
710 .expect("step with modulation disabled");
711 }
712
713 #[test]
714 fn train_step_skips_nan_reward_modulation() {
715 let mut network = small_network();
716 network.modulators.dopamine = 0.4;
717 network.modulators.norepinephrine = 0.4;
718 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
719 trainer
720 .train_step(&mut network, &[0.2; 8], f32::NAN)
721 .expect("nan reward must not panic");
722 assert!((network.modulators.dopamine - 0.4).abs() < 1e-5);
723 assert!((network.modulators.norepinephrine - 0.4).abs() < 1e-5);
724 }
725
726 #[test]
727 fn train_step_skips_infinite_reward_modulation() {
728 let mut network = small_network();
729 network.modulators.dopamine = 0.4;
730 network.modulators.norepinephrine = 0.4;
731 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
732 trainer
733 .train_step(&mut network, &[0.2; 8], f32::INFINITY)
734 .expect("infinite reward must not panic");
735 assert!((network.modulators.dopamine - 0.4).abs() < 1e-5);
736 assert!((network.modulators.norepinephrine - 0.4).abs() < 1e-5);
737
738 trainer
739 .train_step(&mut network, &[0.2; 8], f32::NEG_INFINITY)
740 .expect("negative infinity must not panic");
741 assert!((network.modulators.dopamine - 0.4).abs() < 1e-5);
742 assert!((network.modulators.norepinephrine - 0.4).abs() < 1e-5);
743 assert!(network.modulators.dopamine.is_finite());
744 assert!(network.modulators.norepinephrine.is_finite());
745 }
746
747 #[test]
748 fn positive_reward_raises_dopamine_lowers_norepinephrine() {
749 let mut network = small_network();
750 network.modulators.dopamine = 0.5;
751 network.modulators.norepinephrine = 0.5;
752
753 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
754 trainer
755 .train_step(&mut network, &[0.2; 8], 1.0)
756 .expect("train_step");
757
758 assert!((network.modulators.dopamine - 0.6).abs() < 1e-5);
759 assert!((network.modulators.norepinephrine - 0.45).abs() < 1e-5);
760 }
761
762 #[test]
763 fn negative_reward_raises_norepinephrine_lowers_dopamine() {
764 let mut network = small_network();
765 network.modulators.dopamine = 0.5;
766 network.modulators.norepinephrine = 0.5;
767
768 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
769 trainer
770 .train_step(&mut network, &[0.2; 8], -1.0)
771 .expect("train_step");
772
773 assert!((network.modulators.dopamine - 0.4).abs() < 1e-5);
776 assert!((network.modulators.norepinephrine - 0.7).abs() < 1e-5);
777 }
778
779 #[test]
780 fn train_step_with_modulators_applies_explicit_state() {
781 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
782 let mut network = small_network();
783 let mods = NeuroModulators {
784 dopamine: 0.9,
785 serotonin: 0.1,
786 acetylcholine: 0.5,
787 norepinephrine: 0.3,
788 };
789 trainer
790 .train_step_with_modulators(&mut network, &[0.2; 8], &mods)
791 .expect("explicit modulators");
792 assert!((network.modulators.dopamine - 0.9).abs() < 1e-5);
793 assert!((network.modulators.norepinephrine - 0.3).abs() < 1e-5);
794 }
795
796 #[test]
797 fn run_session_empty_batch_errors() {
798 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
799 let mut network = small_network();
800 seed_nonzero_network_state(&mut network);
801 let before = network_snapshot(&network);
802 let config_before = trainer.config;
803
804 let err = trainer
805 .run_session(&mut network, &[])
806 .expect_err("empty batch");
807 assert!(matches!(err, TrainerError::EmptyBatch));
808 assert_eq!(network_snapshot(&network), before);
809 assert_eq!(trainer.config, config_before);
810 }
811
812 #[test]
813 fn run_session_reports_steps_and_avg_reward() {
814 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
815 let mut network = small_network();
816 let batch = vec![
817 TrainingExample {
818 stimuli: vec![0.25; 8],
819 reward: 0.2,
820 },
821 TrainingExample {
822 stimuli: vec![0.4; 8],
823 reward: -0.1,
824 },
825 ];
826 let summary = trainer.run_session(&mut network, &batch).expect("session");
827 assert_eq!(summary.steps_processed, 2);
828 assert!((summary.avg_reward - 0.05).abs() < 1e-5);
829 assert_eq!(summary.threshold_drifts.len(), network.neurons.len());
830 }
831
832 #[test]
833 fn documented_nonzero_initialization_spikes_and_changes_weights() {
834 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
835 let mut network = documented_learning_network();
836 let batch = vec![
837 TrainingExample {
838 stimuli: vec![1.0, 0.8, 0.6, 0.4, 0.2, 0.1, 0.05, 0.02],
839 reward: 1.0,
840 };
841 8
842 ];
843 let mut rng = StdRng::seed_from_u64(0x5EED);
844
845 let summary = trainer
846 .run_session_with_rng(&mut network, &batch, &mut rng)
847 .expect("documented learning session");
848
849 assert!(
850 summary.total_spikes > 0,
851 "the documented network must spike"
852 );
853 assert!(
854 summary
855 .weight_drifts
856 .iter()
857 .flatten()
858 .any(|delta| delta.abs() > 1e-5),
859 "the documented network must show a measurable weight change"
860 );
861 assert!(
862 network
863 .neurons
864 .iter()
865 .flat_map(|neuron| neuron.weights.iter())
866 .all(|weight| weight.is_finite())
867 );
868 }
869
870 #[test]
878 fn train_step_is_deterministic_for_subthreshold_stimuli() {
879 let stimuli = vec![0.005; 8];
880
881 let mut trainer_a = PlasticityTrainer::new(TrainingConfig::default());
882 let mut network_a = small_network();
883 let spikes_a = trainer_a
884 .train_step(&mut network_a, &stimuli, 0.4)
885 .expect("step a");
886
887 let mut trainer_b = PlasticityTrainer::new(TrainingConfig::default());
888 let mut network_b = small_network();
889 let spikes_b = trainer_b
890 .train_step(&mut network_b, &stimuli, 0.4)
891 .expect("step b");
892
893 assert_eq!(spikes_a, spikes_b);
894 assert_eq!(network_a.get_thresholds(), network_b.get_thresholds());
895 assert_eq!(network_a.modulators.dopamine, network_b.modulators.dopamine);
896 assert_eq!(
897 network_a.modulators.norepinephrine,
898 network_b.modulators.norepinephrine
899 );
900 }
901
902 #[test]
903 fn run_session_is_deterministic_for_subthreshold_stimuli() {
904 let batch = vec![
905 TrainingExample {
906 stimuli: vec![0.005; 8],
907 reward: 0.3,
908 },
909 TrainingExample {
910 stimuli: vec![-0.008; 8],
911 reward: -0.2,
912 },
913 TrainingExample {
914 stimuli: vec![0.0; 8],
915 reward: 0.0,
916 },
917 ];
918
919 let mut trainer_a = PlasticityTrainer::new(TrainingConfig::default());
920 let mut network_a = small_network();
921 let summary_a = trainer_a
922 .run_session(&mut network_a, &batch)
923 .expect("session a");
924
925 let mut trainer_b = PlasticityTrainer::new(TrainingConfig::default());
926 let mut network_b = small_network();
927 let summary_b = trainer_b
928 .run_session(&mut network_b, &batch)
929 .expect("session b");
930
931 assert_eq!(summary_a.steps_processed, summary_b.steps_processed);
932 assert_eq!(summary_a.total_spikes, summary_b.total_spikes);
933 assert_eq!(summary_a.per_neuron_spikes, summary_b.per_neuron_spikes);
934 assert_eq!(summary_a.threshold_drifts, summary_b.threshold_drifts);
935 assert_eq!(summary_a.weight_drifts, summary_b.weight_drifts);
936 assert_eq!(summary_a.avg_reward, summary_b.avg_reward);
937 }
938
939 #[test]
940 fn run_session_summary_shapes_match_network_topology() {
941 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
942 let mut network = small_network();
943 let batch = vec![
944 TrainingExample {
945 stimuli: vec![0.2; 8],
946 reward: 0.1,
947 },
948 TrainingExample {
949 stimuli: vec![0.3; 8],
950 reward: 0.2,
951 },
952 ];
953 let summary = trainer.run_session(&mut network, &batch).expect("session");
954
955 assert_eq!(summary.per_neuron_spikes.len(), network.neurons.len());
956 assert_eq!(summary.threshold_drifts.len(), network.neurons.len());
957 assert_eq!(summary.weight_drifts.len(), network.neurons.len());
958 for weights in &summary.weight_drifts {
959 assert_eq!(weights.len(), network.num_channels);
960 }
961 }
962
963 #[test]
964 fn run_session_total_spikes_matches_sum_of_per_neuron_spikes() {
965 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
966 let mut network = small_network();
967 let batch = vec![
968 TrainingExample {
969 stimuli: vec![0.5; 8],
970 reward: 0.5,
971 },
972 TrainingExample {
973 stimuli: vec![0.6; 8],
974 reward: -0.4,
975 },
976 TrainingExample {
977 stimuli: vec![0.1; 8],
978 reward: 0.0,
979 },
980 ];
981 let summary = trainer.run_session(&mut network, &batch).expect("session");
982
983 let summed: u64 = summary.per_neuron_spikes.iter().sum();
984 assert_eq!(summary.total_spikes, summed);
985 }
986
987 #[test]
988 fn run_session_avg_reward_ignores_nan_but_counts_the_step() {
989 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
990 let mut network = small_network();
991 let batch = vec![
992 TrainingExample {
993 stimuli: vec![0.005; 8],
994 reward: 0.4,
995 },
996 TrainingExample {
997 stimuli: vec![0.005; 8],
998 reward: f32::NAN,
999 },
1000 TrainingExample {
1001 stimuli: vec![0.005; 8],
1002 reward: 0.2,
1003 },
1004 ];
1005 let summary = trainer.run_session(&mut network, &batch).expect("session");
1006
1007 assert_eq!(summary.steps_processed, 3);
1008 assert!((summary.avg_reward - 0.3).abs() < 1e-5);
1009 assert!(summary.avg_reward.is_finite());
1010 assert!(
1011 summary
1012 .threshold_drifts
1013 .iter()
1014 .all(|value| value.is_finite())
1015 );
1016 assert!(
1017 summary
1018 .weight_drifts
1019 .iter()
1020 .flatten()
1021 .all(|value| value.is_finite())
1022 );
1023 }
1024
1025 #[test]
1026 fn run_session_avg_reward_defaults_to_zero_when_all_rewards_nan() {
1027 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1028 let mut network = small_network();
1029 let batch = vec![
1030 TrainingExample {
1031 stimuli: vec![0.005; 8],
1032 reward: f32::NAN,
1033 },
1034 TrainingExample {
1035 stimuli: vec![0.005; 8],
1036 reward: f32::NAN,
1037 },
1038 ];
1039 let summary = trainer.run_session(&mut network, &batch).expect("session");
1040
1041 assert_eq!(summary.steps_processed, 2);
1042 assert_eq!(summary.avg_reward, 0.0);
1043 }
1044
1045 #[derive(Default)]
1046 struct RecordingObserver {
1047 step_indices: Vec<usize>,
1048 rewards: Vec<f32>,
1049 spike_counts: Vec<usize>,
1050 running_totals: Vec<u64>,
1051 steps_processed: Vec<usize>,
1052 dopamine: Vec<f32>,
1053 norepinephrine: Vec<f32>,
1054 }
1055
1056 impl TrainingObserver for RecordingObserver {
1057 type Error = &'static str;
1058
1059 fn on_step(&mut self, event: TrainingStepEvent<'_>) -> Result<(), Self::Error> {
1060 self.step_indices.push(event.step_index);
1061 self.rewards.push(event.reward);
1062 self.spike_counts.push(event.spike_indices.len());
1063 self.running_totals.push(event.total_spikes);
1064 self.steps_processed.push(event.steps_processed);
1065 self.dopamine.push(event.modulators.dopamine);
1066 self.norepinephrine.push(event.modulators.norepinephrine);
1067 Ok(())
1068 }
1069 }
1070
1071 struct FailAt {
1072 fail_at: usize,
1073 seen: Vec<usize>,
1074 }
1075
1076 impl TrainingObserver for FailAt {
1077 type Error = &'static str;
1078
1079 fn on_step(&mut self, event: TrainingStepEvent<'_>) -> Result<(), Self::Error> {
1080 self.seen.push(event.step_index);
1081 if event.step_index == self.fail_at {
1082 Err("injected observer failure")
1083 } else {
1084 Ok(())
1085 }
1086 }
1087 }
1088
1089 fn subthreshold_batch() -> Vec<TrainingExample> {
1090 vec![
1091 TrainingExample {
1092 stimuli: vec![0.005; 8],
1093 reward: 0.3,
1094 },
1095 TrainingExample {
1096 stimuli: vec![-0.008; 8],
1097 reward: -0.2,
1098 },
1099 TrainingExample {
1100 stimuli: vec![0.0; 8],
1101 reward: 0.0,
1102 },
1103 ]
1104 }
1105
1106 fn assert_networks_match(a: &SpikingNetwork, b: &SpikingNetwork) {
1107 assert_eq!(a.get_thresholds(), b.get_thresholds());
1108 assert_eq!(a.modulators, b.modulators);
1109 assert_eq!(a.global_step, b.global_step);
1110 assert_eq!(a.neurons.len(), b.neurons.len());
1111 for (na, nb) in a.neurons.iter().zip(&b.neurons) {
1112 assert_eq!(na.weights, nb.weights);
1113 }
1114 }
1115
1116 fn assert_summaries_match(a: &TrainingSummary, b: &TrainingSummary) {
1117 assert_eq!(a.steps_processed, b.steps_processed);
1118 assert_eq!(a.total_spikes, b.total_spikes);
1119 assert_eq!(a.per_neuron_spikes, b.per_neuron_spikes);
1120 assert_eq!(a.threshold_drifts, b.threshold_drifts);
1121 assert_eq!(a.weight_drifts, b.weight_drifts);
1122 assert_eq!(a.avg_reward, b.avg_reward);
1123 }
1124
1125 #[test]
1126 fn run_session_with_observer_event_count_and_order_match_summary() {
1127 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1128 let mut network = small_network();
1129 let batch = subthreshold_batch();
1130 let mut observer = RecordingObserver::default();
1131
1132 let summary = trainer
1133 .run_session_with_observer(&mut network, &batch, &mut observer)
1134 .expect("session");
1135
1136 assert_eq!(summary.steps_processed, batch.len());
1137 assert_eq!(observer.step_indices, vec![0, 1, 2]);
1138 assert_eq!(observer.steps_processed, vec![1, 2, 3]);
1139 assert_eq!(observer.rewards.len(), summary.steps_processed);
1140 assert!((observer.rewards[0] - 0.3).abs() < 1e-5);
1141 assert!((observer.rewards[1] + 0.2).abs() < 1e-5);
1142 assert_eq!(observer.rewards[2], 0.0);
1143 }
1144
1145 #[test]
1146 fn run_session_with_observer_spike_totals_match_summary() {
1147 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1148 let mut network = small_network();
1149 let batch = vec![
1150 TrainingExample {
1151 stimuli: vec![0.5; 8],
1152 reward: 0.5,
1153 },
1154 TrainingExample {
1155 stimuli: vec![0.6; 8],
1156 reward: -0.4,
1157 },
1158 TrainingExample {
1159 stimuli: vec![0.1; 8],
1160 reward: 0.0,
1161 },
1162 ];
1163 let mut observer = RecordingObserver::default();
1164 let summary = trainer
1165 .run_session_with_observer(&mut network, &batch, &mut observer)
1166 .expect("session");
1167
1168 let captured: u64 = observer.spike_counts.iter().map(|&n| n as u64).sum();
1169 assert_eq!(captured, summary.total_spikes);
1170 assert_eq!(
1171 observer.running_totals.last().copied().unwrap_or(0),
1172 summary.total_spikes
1173 );
1174 let summed: u64 = summary.per_neuron_spikes.iter().sum();
1175 assert_eq!(summary.total_spikes, summed);
1176 }
1177
1178 #[test]
1179 fn observer_failure_at_step_n_reports_index_and_does_not_see_n_plus_one() {
1180 let batch = subthreshold_batch();
1181 let fail_at = 1usize;
1182
1183 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1184 let mut network = small_network();
1185 let mut observer = FailAt {
1186 fail_at,
1187 seen: Vec::new(),
1188 };
1189 let err = trainer
1190 .run_session_with_observer(&mut network, &batch, &mut observer)
1191 .expect_err("observer failure");
1192
1193 match &err {
1194 TrainerError::Observer {
1195 step_index,
1196 steps_processed,
1197 cause,
1198 } => {
1199 assert_eq!(*step_index, fail_at);
1200 assert_eq!(*steps_processed, fail_at + 1);
1201 assert!(cause.contains("injected observer failure"));
1202 }
1203 other => panic!("expected Observer error, got {other:?}"),
1204 }
1205 let displayed = err.to_string();
1206 assert!(displayed.contains("step 1"));
1207 assert!(displayed.contains("injected observer failure"));
1208 assert_eq!(observer.seen, vec![0, 1]);
1209 }
1210
1211 #[test]
1212 fn observer_failure_at_step_n_stops_before_n_plus_one() {
1213 let batch = subthreshold_batch();
1214 let fail_at = 1usize;
1215
1216 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1217 let mut network = small_network();
1218 let mut observer = FailAt {
1219 fail_at,
1220 seen: Vec::new(),
1221 };
1222 trainer
1223 .run_session_with_observer(&mut network, &batch, &mut observer)
1224 .expect_err("observer failure");
1225
1226 let mut prefix_trainer = PlasticityTrainer::new(TrainingConfig::default());
1227 let mut prefix_network = small_network();
1228 prefix_trainer
1229 .run_session(&mut prefix_network, &batch[..=fail_at])
1230 .expect("prefix session");
1231 assert_networks_match(&network, &prefix_network);
1232
1233 let mut extra_trainer = PlasticityTrainer::new(TrainingConfig::default());
1234 let mut extra_network = small_network();
1235 extra_trainer
1236 .run_session(&mut extra_network, &batch[..=fail_at + 1])
1237 .expect("prefix plus one");
1238 assert_ne!(network.global_step, extra_network.global_step);
1239 }
1240
1241 #[test]
1242 fn run_session_matches_noop_observer_path() {
1243 let batch = subthreshold_batch();
1244
1245 let mut trainer_a = PlasticityTrainer::new(TrainingConfig::default());
1246 let mut network_a = small_network();
1247 let summary_a = trainer_a
1248 .run_session(&mut network_a, &batch)
1249 .expect("no observer");
1250
1251 let mut trainer_b = PlasticityTrainer::new(TrainingConfig::default());
1252 let mut network_b = small_network();
1253 let mut observer = RecordingObserver::default();
1254 let summary_b = trainer_b
1255 .run_session_with_observer(&mut network_b, &batch, &mut observer)
1256 .expect("recording observer");
1257
1258 assert_summaries_match(&summary_a, &summary_b);
1259 assert_networks_match(&network_a, &network_b);
1260 assert_eq!(observer.step_indices.len(), summary_a.steps_processed);
1261 }
1262
1263 #[test]
1264 fn run_session_with_closure_observer_records_step_indices() {
1265 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1266 let mut network = small_network();
1267 let batch = subthreshold_batch();
1268 let mut seen = Vec::new();
1269 let mut observer = |event: TrainingStepEvent<'_>| -> Result<(), &'static str> {
1270 seen.push(event.step_index);
1271 Ok(())
1272 };
1273 let summary = trainer
1274 .run_session_with_observer(&mut network, &batch, &mut observer)
1275 .expect("closure observer session");
1276 assert_eq!(seen, vec![0, 1, 2]);
1277 assert_eq!(summary.steps_processed, 3);
1278 }
1279
1280 #[test]
1281 fn empty_batch_does_not_call_observer() {
1282 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1283 let mut network = small_network();
1284 let mut observer = RecordingObserver::default();
1285 let err = trainer
1286 .run_session_with_observer(&mut network, &[], &mut observer)
1287 .expect_err("empty batch");
1288 assert!(matches!(err, TrainerError::EmptyBatch));
1289 assert!(observer.step_indices.is_empty());
1290 }
1291
1292 #[test]
1293 fn failed_preflight_does_not_emit_observer_event() {
1294 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1295 let mut network = small_network();
1296 let batch = vec![
1297 TrainingExample {
1298 stimuli: vec![0.005; 8],
1299 reward: 0.1,
1300 },
1301 TrainingExample {
1302 stimuli: vec![0.005; 3],
1303 reward: 0.2,
1304 },
1305 ];
1306 let mut observer = RecordingObserver::default();
1307 let err = trainer
1308 .run_session_with_observer(&mut network, &batch, &mut observer)
1309 .expect_err("input length mismatch");
1310 assert!(matches!(
1311 err,
1312 TrainerError::InvalidSample {
1313 index: 1,
1314 reason: SampleInvariant::StimulusLenMismatch {
1315 expected: 8,
1316 got: 3,
1317 }
1318 }
1319 ));
1320 assert!(observer.step_indices.is_empty());
1321 }
1322
1323 #[test]
1324 fn observer_sees_modulators_applied_by_the_step() {
1325 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1326 let mut network = small_network();
1327 network.modulators.dopamine = 0.5;
1328 network.modulators.norepinephrine = 0.5;
1329 let batch = vec![TrainingExample {
1330 stimuli: vec![0.005; 8],
1331 reward: 1.0,
1332 }];
1333 let mut observer = RecordingObserver::default();
1334 trainer
1335 .run_session_with_observer(&mut network, &batch, &mut observer)
1336 .expect("session");
1337
1338 assert_eq!(observer.dopamine.len(), 1);
1339 assert!((observer.dopamine[0] - 0.6).abs() < 1e-5);
1340 assert!((observer.norepinephrine[0] - 0.45).abs() < 1e-5);
1341 assert!((network.modulators.dopamine - observer.dopamine[0]).abs() < 1e-5);
1342 assert!((network.modulators.norepinephrine - observer.norepinephrine[0]).abs() < 1e-5);
1343 }
1344
1345 #[cfg(feature = "critic")]
1346 #[test]
1347 fn train_step_from_critic_uses_bridge() {
1348 use limbic_critic::ModulatorVector;
1349
1350 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1351 let mut network = small_network();
1352 let vector = ModulatorVector {
1353 dopamine: 0.65,
1354 serotonin: 0.2,
1355 acetylcholine: 0.4,
1356 norepinephrine: 0.15,
1357 };
1358 trainer
1359 .train_step_from_critic(&mut network, &[0.2; 8], &vector)
1360 .expect("from critic");
1361 assert!((network.modulators.dopamine - 0.65).abs() < 1e-5);
1362 assert!((network.modulators.serotonin - 0.2).abs() < 1e-5);
1363 assert!((network.modulators.acetylcholine - 0.4).abs() < 1e-5);
1364 assert!((network.modulators.norepinephrine - 0.15).abs() < 1e-5);
1365 }
1366
1367 #[test]
1368 #[allow(deprecated)]
1369 fn spikenaut_trainer_module_path_alias_still_constructs() {
1370 let _trainer = super::SpikenautTrainer::new(TrainingConfig::default());
1371 }
1372
1373 #[test]
1374 fn record_step_spikes_ignores_out_of_range_indices() {
1375 let mut summary = TrainingSummary {
1376 per_neuron_spikes: vec![0, 0],
1377 ..TrainingSummary::default()
1378 };
1379 record_step_spikes(&mut summary, &[0, 99, 1]);
1380 assert_eq!(summary.total_spikes, 3);
1381 assert_eq!(summary.per_neuron_spikes, vec![1, 1]);
1382 }
1383
1384 #[test]
1385 fn observer_abort_preserves_step_index_and_cause() {
1386 let err = observer_abort(2, 3, "boom".to_string());
1387 match &err {
1388 TrainerError::Observer {
1389 step_index,
1390 steps_processed,
1391 cause,
1392 } => {
1393 assert_eq!(*step_index, 2);
1394 assert_eq!(*steps_processed, 3);
1395 assert_eq!(cause, "boom");
1396 }
1397 other => panic!("expected Observer error, got {other:?}"),
1398 }
1399 assert!(err.to_string().contains("step 2"));
1400 }
1401
1402 #[test]
1403 fn step_event_copies_fields() {
1404 let mods = NeuroModulators::default();
1405 let spikes = [1usize];
1406 let event = step_event(4, 0.5, &mods, &spikes, 5, 7);
1407 assert_eq!(event.step_index, 4);
1408 assert!((event.reward - 0.5).abs() < 1e-6);
1409 assert_eq!(event.spike_indices, &spikes);
1410 assert_eq!(event.steps_processed, 5);
1411 assert_eq!(event.total_spikes, 7);
1412 }
1413
1414 #[test]
1415 fn train_step_still_returns_step_error_on_length_mismatch() {
1416 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1417 let mut network = small_network();
1418 let err = trainer
1419 .train_step(&mut network, &[0.2; 3], 0.1)
1420 .expect_err("single-step API stays StepError, not batch preflight");
1421 assert!(matches!(
1422 err,
1423 StepError::InputLenMismatch {
1424 expected: 8,
1425 got: 3
1426 }
1427 ));
1428 }
1429
1430 #[test]
1431 fn batch_preflight_is_atomic_when_final_sample_is_invalid() {
1432 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1433 let mut network = small_network();
1434 seed_nonzero_network_state(&mut network);
1435 let before = network_snapshot(&network);
1436 let config_before = trainer.config;
1437 let global_step_before = network.global_step;
1438 let eligibility_before = eligibility_values(&network);
1439 let weights_before = weight_values(&network);
1440
1441 let batch = vec![
1442 example(8, 0.4, 0.5),
1443 example(8, 0.6, -0.2),
1444 example(3, 0.3, 0.1),
1445 ];
1446 let err = trainer
1447 .run_session(&mut network, &batch)
1448 .expect_err("late malformed sample must reject the batch");
1449
1450 assert_eq!(
1451 err,
1452 TrainerError::InvalidSample {
1453 index: 2,
1454 reason: SampleInvariant::StimulusLenMismatch {
1455 expected: 8,
1456 got: 3,
1457 },
1458 }
1459 );
1460 assert_eq!(
1461 err.to_string(),
1462 "invalid training sample 2: stimulus length mismatch: expected 8, got 3"
1463 );
1464 assert_eq!(network.global_step, global_step_before);
1465 assert_eq!(eligibility_values(&network), eligibility_before);
1466 assert_eq!(weight_values(&network), weights_before);
1467 assert_eq!(network_snapshot(&network), before);
1468 assert_eq!(trainer.config, config_before);
1469 }
1470
1471 #[test]
1472 fn batch_preflight_reports_first_invalid_sample() {
1473 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1474 let mut network = small_network();
1475 let batch = vec![
1476 valid_example(),
1477 TrainingExample {
1478 stimuli: vec![0.2; 5],
1479 reward: 0.1,
1480 },
1481 TrainingExample {
1482 stimuli: vec![f32::NAN; 8],
1483 reward: 0.1,
1484 },
1485 ];
1486 let err = trainer
1487 .run_session(&mut network, &batch)
1488 .expect_err("first invalid sample wins");
1489 assert!(matches!(
1490 err,
1491 TrainerError::InvalidSample {
1492 index: 1,
1493 reason: SampleInvariant::StimulusLenMismatch {
1494 expected: 8,
1495 got: 5
1496 },
1497 }
1498 ));
1499 }
1500
1501 #[test]
1502 fn batch_preflight_rejects_non_finite_stimulus() {
1503 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1504 let mut network = small_network();
1505 seed_nonzero_network_state(&mut network);
1506 let before = network_snapshot(&network);
1507
1508 let mut stimuli = vec![0.2; 8];
1509 stimuli[4] = f32::INFINITY;
1510 let batch = vec![
1511 valid_example(),
1512 TrainingExample {
1513 stimuli,
1514 reward: 0.1,
1515 },
1516 ];
1517 let err = trainer
1518 .run_session(&mut network, &batch)
1519 .expect_err("non-finite stimulus");
1520 assert_eq!(
1521 err,
1522 TrainerError::InvalidSample {
1523 index: 1,
1524 reason: SampleInvariant::NonFiniteStimulus { channel: 4 },
1525 }
1526 );
1527 assert_eq!(network_snapshot(&network), before);
1528 }
1529
1530 #[test]
1531 fn batch_preflight_rejects_infinite_reward() {
1532 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1533 let mut network = small_network();
1534 seed_nonzero_network_state(&mut network);
1535 let before = network_snapshot(&network);
1536
1537 let batch = vec![
1538 valid_example(),
1539 TrainingExample {
1540 stimuli: vec![0.2; 8],
1541 reward: f32::NEG_INFINITY,
1542 },
1543 ];
1544 let err = trainer
1545 .run_session(&mut network, &batch)
1546 .expect_err("infinite reward");
1547 assert_eq!(
1548 err,
1549 TrainerError::InvalidSample {
1550 index: 1,
1551 reason: SampleInvariant::InfiniteReward,
1552 }
1553 );
1554 assert_eq!(network_snapshot(&network), before);
1555 }
1556
1557 #[test]
1558 fn valid_batch_preserves_sample_ordering_and_avg_reward() {
1559 let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
1560 let mut network = small_network();
1561 let batch = vec![
1562 TrainingExample {
1563 stimuli: vec![0.005; 8],
1564 reward: 0.4,
1565 },
1566 TrainingExample {
1567 stimuli: vec![0.005; 8],
1568 reward: -0.1,
1569 },
1570 TrainingExample {
1571 stimuli: vec![0.005; 8],
1572 reward: 0.0,
1573 },
1574 ];
1575 let summary = trainer
1576 .run_session(&mut network, &batch)
1577 .expect("valid batch");
1578 assert_eq!(summary.steps_processed, batch.len());
1579 assert!((summary.avg_reward - 0.1).abs() < 1e-5);
1580 }
1581}