Skip to main content

plasticity_lab/
trainer.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3use crate::config::TrainingConfig;
4use crate::observer::{TrainingObserver, TrainingStepEvent};
5use neuromod::{NeuroModulators, SpikingNetwork, StepError};
6use rand::Rng;
7use thiserror::Error;
8
9/// Summary metrics collected over a [`PlasticityTrainer::run_session`] call.
10///
11/// Drifts are relative to network state at the start of the session.
12#[derive(Debug, Default, Clone, PartialEq)]
13pub struct TrainingSummary {
14    /// Number of training examples processed.
15    pub steps_processed: usize,
16    /// Total spike events across all steps.
17    pub total_spikes: u64,
18    /// Mean reward over the batch.
19    pub avg_reward: f32,
20    /// Per-neuron change in firing threshold (final − initial).
21    pub threshold_drifts: Vec<f32>,
22    /// Per-neuron, per-channel weight change (final − initial).
23    pub weight_drifts: Vec<Vec<f32>>,
24    /// Spike count per neuron over the session.
25    pub per_neuron_spikes: Vec<u64>,
26}
27
28/// One input sample for a generic training session: stimuli plus scalar reward.
29///
30/// Encoding and reward shaping live outside this crate (`axon-encoder`,
31/// `limbic-critic`, or application code).
32#[derive(Debug, Clone)]
33pub struct TrainingExample {
34    /// Flat stimulus vector (length must match the network input size).
35    pub stimuli: Vec<f32>,
36    /// Scalar reward for this step (positive → dopamine-biased, negative →
37    /// norepinephrine-biased / stress-arousal).
38    pub reward: f32,
39}
40
41/// Batch-admission invariant violated by one [`TrainingExample`].
42///
43/// Produced by [`PlasticityTrainer::run_session`]'s preflight pass *before* any
44/// network, modulator, eligibility, or metric state mutates. Single-step APIs
45/// (`train_step`, `train_step_with_modulators`, and `train_step_from_critic`) do
46/// not run this check — they stay compatible with their existing `StepError`
47/// contracts, including NaN-reward skipping in `train_step`.
48///
49/// `TrainingExample` has no sample IDs, so ordering is the batch slice order
50/// (index `0` is the first example). `TrainingConfig` currently has no invalid
51/// encodings (`use_reward_modulation` is a `bool`); there is therefore no
52/// config-level rejection variant.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
54pub enum SampleInvariant {
55    /// `stimuli.len()` did not match [`SpikingNetwork::num_channels`].
56    #[error("stimulus length mismatch: expected {expected}, got {got}")]
57    StimulusLenMismatch { expected: usize, got: usize },
58    /// A stimulus component was NaN or ±infinity.
59    #[error("non-finite stimulus at channel {channel}")]
60    NonFiniteStimulus { channel: usize },
61    /// Reward was ±infinity (NaN remains allowed: `train_step` skips modulation
62    /// and `run_session` omits it from `avg_reward`).
63    #[error("infinite reward")]
64    InfiniteReward,
65}
66
67/// Errors from batch training sessions.
68#[derive(Debug, Clone, PartialEq, Eq, Error)]
69pub enum TrainerError {
70    /// Underlying network step failed.
71    #[error("network step failed: {0:?}")]
72    Step(StepError),
73    /// `run_session` was called with an empty batch.
74    ///
75    /// Empty is a batch-level condition (no sample index). The network, trainer
76    /// config, and any caller-owned metrics are left untouched.
77    #[error("empty training batch")]
78    EmptyBatch,
79    /// A per-step observer returned an error after a successful network step.
80    ///
81    /// The network update for `step_index` has already been applied. No further
82    /// example is processed (abort-before-next-step). `steps_processed` counts
83    /// completed network steps, including the one whose observer then failed.
84    #[error(
85        "training observer failed at step {step_index} after {steps_processed} processed step(s): {cause}"
86    )]
87    Observer {
88        /// 0-based index of the example whose observer call failed.
89        step_index: usize,
90        /// Number of successful network steps before aborting (includes the
91        /// failing observer's step).
92        steps_processed: usize,
93        /// Display form of the observer's error.
94        cause: String,
95    },
96    /// Preflight rejected the batch because sample `index` violated `reason`.
97    ///
98    /// No earlier sample has been applied; trainer and network state are unchanged.
99    #[error("invalid training sample {index}: {reason}")]
100    InvalidSample {
101        /// Zero-based index into the batch slice.
102        index: usize,
103        /// Which admission invariant failed.
104        reason: SampleInvariant,
105    },
106}
107
108/// Reward-modulated training loop over a [`SpikingNetwork`].
109///
110/// Applies scalar rewards to neuromodulators and steps the network. Domain-specific
111/// logic (mining, trading, distillation) does not belong here. For critic-shaped
112/// vectors under the `critic` feature, use `Self::train_step_from_critic`
113/// or `crate::bridge` (plain code spans, not doc links — both only exist
114/// with the `critic` feature enabled).
115pub struct PlasticityTrainer {
116    /// Active training configuration.
117    pub config: TrainingConfig,
118}
119
120impl PlasticityTrainer {
121    /// Creates a trainer with the given configuration.
122    pub fn new(config: TrainingConfig) -> Self {
123        Self { config }
124    }
125
126    /// Runs one training step with generic stimuli and an externally computed reward.
127    ///
128    /// When [`TrainingConfig::use_reward_modulation`] is `true` (default) and `reward`
129    /// is finite, positive values increase dopamine and decrease norepinephrine;
130    /// negative values do the opposite emphasis. Modulator values are clamped to
131    /// `[0.0, 1.0]`. Non-finite rewards (`NaN` and ±infinity) leave modulators
132    /// unchanged so invalid environment data cannot poison plasticity updates.
133    /// When the flag is `false`, the network steps with its current modulators
134    /// unchanged regardless of `reward`.
135    ///
136    /// Returns indices of neurons that spiked, or a [`StepError`] from neuromod.
137    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    /// Same as [`Self::train_step`], but drives neuromod's stochastic input
148    /// encoding from a caller-supplied RNG.
149    ///
150    /// Use this when a session must be replayable: the same network state,
151    /// config, stimuli, reward, and RNG stream produce identical spikes and
152    /// plasticity updates. `train_step` keeps the convenience path that uses
153    /// neuromod's thread-local RNG.
154    ///
155    /// # Examples
156    ///
157    /// ```
158    /// use neuromod::SpikingNetwork;
159    /// use plasticity_lab::{PlasticityTrainer, TrainingConfig};
160    /// use rand::SeedableRng;
161    /// use rand::rngs::StdRng;
162    ///
163    /// let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
164    /// let mut network = SpikingNetwork::with_dimensions(4, 2, 8);
165    /// let mut rng = StdRng::seed_from_u64(42);
166    /// let spikes = trainer
167    ///     .train_step_with_rng(&mut network, &[0.25; 8], 0.2, &mut rng)
168    ///     .unwrap();
169    /// assert!(spikes.iter().all(|&i| i < 4));
170    /// ```
171    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    /// Steps the network with explicit neuromodulators (e.g. from the limbic bridge).
183    ///
184    /// Does not apply scalar reward shaping; callers that already ran a critic
185    /// should convert via `crate::to_neuromodulators` (`critic` feature; a
186    /// plain code span, not a doc link — that item doesn't exist without the
187    /// feature) and pass the result here.
188    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    /// Same as [`Self::train_step_with_modulators`], with a caller-supplied RNG.
198    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    /// Steps the network with a critic [`limbic_critic::ModulatorVector`].
209    ///
210    /// Converts via [`crate::bridge::to_neuromodulators`] then steps. Available only
211    /// with the `critic` feature.
212    #[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    /// Replays a batch of generic training examples and returns aggregated metrics.
227    ///
228    /// Admission is atomic: every example is validated (dimensions, finite
229    /// stimuli, infinite-reward) *before* the first `train_step`. A malformed
230    /// sample at index `N` therefore cannot leave samples `0..N` applied.
231    /// Examples are then processed in slice order, matching the historical
232    /// sequential contract.
233    ///
234    /// This is the no-observer compatibility path: it does not construct
235    /// [`TrainingStepEvent`]s, format or serialize telemetry, or dynamically
236    /// dispatch. For per-step callbacks see [`Self::run_session_with_observer`].
237    ///
238    /// # Errors
239    ///
240    /// - [`TrainerError::EmptyBatch`] if `data` is empty (no sample index).
241    /// - [`TrainerError::InvalidSample`] if any example fails preflight; the
242    ///   error names the first failing index and invariant. Network and trainer
243    ///   state are unchanged.
244    /// - [`TrainerError::Step`] if a network step fails after admission (for
245    ///   example a `StepError` that cannot be seen from the example alone).
246    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    /// Replays a batch using a caller-supplied RNG for every network step.
279    ///
280    /// Identical to [`Self::run_session`] except stochastic input spikes are
281    /// drawn from `rng` instead of neuromod's thread-local generator. One RNG
282    /// stream is used for the whole batch — it is not reseeded per example.
283    /// A starting seed replays from the beginning; a mid-session resume needs
284    /// that same generator already advanced through the prefix, not a fresh
285    /// seed on a deserialized checkpoint.
286    ///
287    /// # Errors
288    ///
289    /// - [`TrainerError::EmptyBatch`] if `data` is empty.
290    /// - [`TrainerError::Step`] if any network step fails.
291    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    /// Computes the modulator vector `train_step` would pass into `network.step`.
325    fn modulators_for_reward(&self, network: &SpikingNetwork, reward: f32) -> NeuroModulators {
326        let mut modulators: NeuroModulators = network.modulators;
327
328        // Skip modulation on non-finite rewards: f32::clamp returns NaN
329        // unchanged rather than panicking, and ±infinity slams the clamped
330        // result to the 0/1 bounds. Either would silently corrupt subsequent
331        // STDP / homeostasis updates.
332        if self.config.use_reward_modulation && reward.is_finite() {
333            // Positive reward shifts toward dopamine; negative toward norepinephrine
334            // (stress/arousal). neuromod replaced the former cortisol field with
335            // norepinephrine (see neuromod::NeuroModulators).
336            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    /// Replays a batch like [`Self::run_session`], notifying `observer` after
402    /// each successful network step.
403    ///
404    /// Exactly one [`TrainingStepEvent`] is delivered per completed step, in
405    /// batch order. The event borrows spike indices and neuromodulator state;
406    /// it does not expose mutable network access.
407    ///
408    /// If `observer` returns an error at step `N` (0-based), the session
409    /// aborts before stepping example `N + 1`. The error reports `step_index`
410    /// and the number of network steps that completed
411    /// ([`TrainerError::Observer`]).
412    ///
413    /// The observer is a generic type parameter (monomorphized, not `dyn`), so
414    /// a simple callback has no dynamic dispatch on the hot path. The
415    /// no-observer [`Self::run_session`] path does not construct events.
416    ///
417    /// # Errors
418    ///
419    /// - [`TrainerError::EmptyBatch`] if `data` is empty (observer is not called).
420    /// - [`TrainerError::Step`] if a network step fails (observer is not called
421    ///   for that failed step; earlier steps have already been observed).
422    /// - [`TrainerError::Observer`] if `observer` returns an error.
423    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
568/// Validates the whole batch without mutating `network` or `self`.
569///
570/// Fails closed on the first violation so callers can report a single sample
571/// index. Empty batches are a distinct error (no index to name).
572fn 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
585/// Returns the first violated admission invariant for `example`, if any.
586fn 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 alias for [`PlasticityTrainer`], also reachable via the full module path.
606///
607/// The crate-root alias (`plasticity_lab::SpikenautTrainer`) doesn't cover code that
608/// imports via `plasticity_lab::trainer::SpikenautTrainer` directly — this re-export
609/// closes that gap so both paths keep working during the migration window.
610#[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    /// Non-default weights, traces, step counter, modulators, and EMA so a
640    /// missed preflight (which would run sample 0) cannot match by accident.
641    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        // dopamine += reward * 0.1 → 0.5 - 0.1 = 0.4
774        // norepinephrine -= reward * 0.2 → 0.5 - (-0.2) = 0.7
775        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    // neuromod's `SpikingNetwork::step` only consults RNG to decide, per channel,
871    // whether to stamp an input spike time — and only when `|stimulus| > 0.01`
872    // (see engine.rs). Below that magnitude, step() is a pure function of network
873    // state and inputs. These tests stay on that non-RNG path as a deterministic
874    // baseline. Above-threshold replay with a caller-injected RNG is covered in
875    // `crate::replay`.
876
877    #[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}