Skip to main content

optirs_core/neuromorphic/
spike_based.rs

1// Spike-Based Optimization Algorithms
2//
3// This module implements optimization algorithms that operate on spike trains
4// and temporal spike patterns, designed for neuromorphic computing platforms.
5
6use super::{
7    to_generic_or, MembraneDynamicsConfig, NeuromorphicMetrics, PlasticityModel, STDPConfig, Spike,
8    SpikeTrain,
9};
10
11use crate::error::Result;
12use scirs2_core::ndarray::{Array1, Array2};
13use scirs2_core::numeric::Float;
14use scirs2_core::random::thread_rng;
15use std::collections::{HashMap, VecDeque};
16use std::fmt::Debug;
17
18/// Spike-based optimization configuration
19#[derive(Debug, Clone)]
20pub struct SpikingConfig<T: Float + Debug + Send + Sync + 'static> {
21    /// Simulation time step (ms)
22    pub time_step: T,
23
24    /// Total simulation time (ms)
25    pub simulation_time: T,
26
27    /// Encoding method for input data
28    pub encoding_method: SpikeEncodingMethod,
29
30    /// Decoding method for output spikes
31    pub decoding_method: SpikeDecodingMethod,
32
33    /// Spike train learning rate
34    pub spike_learning_rate: T,
35
36    /// Temporal window for spike correlation (ms)
37    pub temporal_window: T,
38
39    /// Enable lateral inhibition
40    pub lateral_inhibition: bool,
41
42    /// Homeostatic scaling parameters
43    pub homeostatic_config: HomeostaticConfig<T>,
44
45    /// Noise parameters for spike generation
46    pub noise_config: SpikeNoiseConfig<T>,
47}
48
49/// Spike encoding methods for converting continuous values to spike trains
50#[derive(Debug, Clone, Copy)]
51pub enum SpikeEncodingMethod {
52    /// Rate coding (firing rate proportional to value)
53    RateCoding,
54
55    /// Temporal coding (spike time proportional to value)
56    TemporalCoding,
57
58    /// Population vector coding
59    PopulationVectorCoding,
60
61    /// Sparse coding
62    SparseCoding,
63
64    /// Phase coding
65    PhaseCoding,
66
67    /// Burst coding
68    BurstCoding,
69
70    /// Rank order coding
71    RankOrderCoding,
72}
73
74/// Spike decoding methods for converting spike trains to continuous values
75#[derive(Debug, Clone, Copy)]
76pub enum SpikeDecodingMethod {
77    /// Rate decoding (spike count in time window)
78    RateDecoding,
79
80    /// Temporal decoding (first spike time)
81    TemporalDecoding,
82
83    /// Population vector decoding
84    PopulationVectorDecoding,
85
86    /// Weighted spike count
87    WeightedSpikeCount,
88
89    /// Moving average filter
90    MovingAverageFilter,
91
92    /// Exponential decay filter
93    ExponentialDecayFilter,
94}
95
96/// Homeostatic plasticity configuration
97#[derive(Debug, Clone)]
98pub struct HomeostaticConfig<T: Float + Debug + Send + Sync + 'static> {
99    /// Enable homeostatic scaling
100    pub enable_homeostatic_scaling: bool,
101
102    /// Target firing rate (Hz)
103    pub target_firing_rate: T,
104
105    /// Scaling time constant (ms)
106    pub scaling_time_constant: T,
107
108    /// Scaling factor
109    pub scaling_factor: T,
110
111    /// Enable intrinsic plasticity
112    pub enable_intrinsic_plasticity: bool,
113
114    /// Threshold adaptation rate
115    pub threshold_adaptation_rate: T,
116}
117
118/// Spike noise configuration
119#[derive(Debug, Clone)]
120pub struct SpikeNoiseConfig<T: Float + Debug + Send + Sync + 'static> {
121    /// Background firing rate (Hz)
122    pub background_rate: T,
123
124    /// Jitter standard deviation (ms)
125    pub jitter_std: T,
126
127    /// Enable Poisson noise
128    pub poisson_noise: bool,
129
130    /// Noise amplitude
131    pub noise_amplitude: T,
132
133    /// Correlation noise
134    pub correlation_noise: T,
135}
136
137impl<T: Float + Debug + Send + Sync + 'static> Default for SpikingConfig<T> {
138    fn default() -> Self {
139        Self {
140            time_step: T::from(0.1).unwrap_or_else(|| T::zero()),
141            simulation_time: T::from(1000.0).unwrap_or_else(|| T::zero()),
142            encoding_method: SpikeEncodingMethod::RateCoding,
143            decoding_method: SpikeDecodingMethod::RateDecoding,
144            spike_learning_rate: T::from(0.01).unwrap_or_else(|| T::zero()),
145            temporal_window: T::from(20.0).unwrap_or_else(|| T::zero()),
146            lateral_inhibition: false,
147            homeostatic_config: HomeostaticConfig::default(),
148            noise_config: SpikeNoiseConfig::default(),
149        }
150    }
151}
152
153impl<T: Float + Debug + Send + Sync + 'static> Default for HomeostaticConfig<T> {
154    fn default() -> Self {
155        Self {
156            enable_homeostatic_scaling: false,
157            target_firing_rate: T::from(10.0).unwrap_or_else(|| T::zero()),
158            scaling_time_constant: T::from(1000.0).unwrap_or_else(|| T::zero()),
159            scaling_factor: T::from(0.01).unwrap_or_else(|| T::zero()),
160            enable_intrinsic_plasticity: false,
161            threshold_adaptation_rate: T::from(0.001).unwrap_or_else(|| T::zero()),
162        }
163    }
164}
165
166/// Shared rate-coding parameters (F54): `rate_encode` and `rate_decode`
167/// must agree on both the encoding window and the max firing rate, or
168/// decoding introduces a systematic gain error. Both now derive their
169/// window from [`SpikingOptimizer::rate_coding_window`] and their max
170/// rate from this single constant.
171const RATE_CODING_MAX_RATE_HZ: f64 = 100.0;
172
173impl<T: Float + Debug + Send + Sync + 'static> Default for SpikeNoiseConfig<T> {
174    fn default() -> Self {
175        Self {
176            background_rate: T::from(1.0).unwrap_or_else(|| T::zero()),
177            jitter_std: T::from(0.5).unwrap_or_else(|| T::zero()),
178            poisson_noise: false,
179            noise_amplitude: T::from(0.1).unwrap_or_else(|| T::zero()),
180            correlation_noise: T::zero(),
181        }
182    }
183}
184
185/// Spike-based optimizer
186pub struct SpikingOptimizer<
187    T: Float + Debug + Send + Sync + scirs2_core::ndarray::ScalarOperand + 'static,
188> {
189    /// Configuration
190    config: SpikingConfig<T>,
191
192    /// STDP configuration
193    stdp_config: STDPConfig<T>,
194
195    /// Membrane dynamics configuration
196    membrane_config: MembraneDynamicsConfig<T>,
197
198    /// Current simulation time
199    current_time: T,
200
201    /// Spike trains for each neuron
202    spike_trains: HashMap<usize, SpikeTrain<T>>,
203
204    /// Current membrane potentials
205    membrane_potentials: Array1<T>,
206
207    /// Synaptic weights
208    synaptic_weights: Array2<T>,
209
210    /// Last spike times for each neuron
211    last_spike_times: Array1<T>,
212
213    /// Refractory state
214    refractory_until: Array1<T>,
215
216    /// Per-neuron synaptic current `I_syn`, accumulated from external
217    /// input spikes and from internal spikes propagated through
218    /// `synaptic_weights` (F52), then consumed each step by
219    /// `update_membrane_potential`'s `R * I_syn` term.
220    synaptic_current: Array1<T>,
221
222    /// Homeostatic scaling factors
223    homeostatic_scales: Array1<T>,
224
225    /// Spike buffer for temporal processing
226    spike_buffer: VecDeque<Spike<T>>,
227
228    /// Performance metrics
229    metrics: NeuromorphicMetrics<T>,
230
231    /// Plasticity model
232    plasticity_model: PlasticityModel,
233}
234
235impl<
236        T: Float
237            + Debug
238            + Send
239            + Sync
240            + scirs2_core::ndarray::ScalarOperand
241            + 'static
242            + std::iter::Sum,
243    > SpikingOptimizer<T>
244{
245    /// Create a new spiking optimizer
246    pub fn new(
247        config: SpikingConfig<T>,
248        stdp_config: STDPConfig<T>,
249        membrane_config: MembraneDynamicsConfig<T>,
250        num_neurons: usize,
251    ) -> Self {
252        let resting_potential = membrane_config.resting_potential;
253        Self {
254            config,
255            stdp_config,
256            membrane_config,
257            current_time: T::zero(),
258            spike_trains: HashMap::new(),
259            membrane_potentials: Array1::from_elem(num_neurons, resting_potential),
260            synaptic_weights: Array2::ones((num_neurons, num_neurons))
261                * T::from(0.1).unwrap_or_else(|| T::zero()),
262            last_spike_times: Array1::from_elem(
263                num_neurons,
264                T::from(-1000.0).unwrap_or_else(|| T::zero()),
265            ),
266            refractory_until: Array1::zeros(num_neurons),
267            synaptic_current: Array1::zeros(num_neurons),
268            homeostatic_scales: Array1::ones(num_neurons),
269            spike_buffer: VecDeque::new(),
270            metrics: NeuromorphicMetrics::default(),
271            plasticity_model: PlasticityModel::STDP,
272        }
273    }
274
275    /// Encode continuous input as spike trains
276    pub fn encode_input(&self, input: &Array1<T>) -> Result<Vec<SpikeTrain<T>>> {
277        let mut spike_trains = Vec::new();
278
279        for (neuron_id, &value) in input.iter().enumerate() {
280            let spike_train = match self.config.encoding_method {
281                SpikeEncodingMethod::RateCoding => self.rate_encode(neuron_id, value)?,
282                SpikeEncodingMethod::TemporalCoding => self.temporal_encode(neuron_id, value)?,
283                SpikeEncodingMethod::PopulationVectorCoding => {
284                    self.population_vector_encode(neuron_id, value)?
285                }
286                SpikeEncodingMethod::SparseCoding => self.sparse_encode(neuron_id, value)?,
287                _ => {
288                    // Fallback to rate coding
289                    self.rate_encode(neuron_id, value)?
290                }
291            };
292
293            spike_trains.push(spike_train);
294        }
295
296        Ok(spike_trains)
297    }
298
299    /// The time window (ms) that rate coding integrates spikes over.
300    /// Shared by [`Self::rate_encode`] and [`Self::rate_decode`] (F54):
301    /// using two different windows (e.g. encoding over `simulation_time`
302    /// but decoding over the much shorter `temporal_window`) introduces a
303    /// systematic gain error between the two.
304    fn rate_coding_window(&self) -> T {
305        self.config.simulation_time
306    }
307
308    /// Rate encoding: firing rate proportional to input value
309    fn rate_encode(&self, neuron_id: usize, value: T) -> Result<SpikeTrain<T>> {
310        let max_rate = to_generic_or(RATE_CODING_MAX_RATE_HZ, T::one()); // Hz
311        let firing_rate = value.abs() * max_rate;
312
313        let mut spike_times = Vec::new();
314        let dt = self.config.time_step;
315        let total_time = self.rate_coding_window();
316
317        let mut time = T::zero();
318        while time < total_time {
319            // Poisson process: probability of spike in dt
320            let spike_prob = firing_rate * dt / to_generic_or(1000.0, T::one());
321
322            if thread_rng().random::<f64>() < spike_prob.to_f64().unwrap_or(0.0) {
323                spike_times.push(time);
324            }
325
326            time = time + dt;
327        }
328
329        Ok(SpikeTrain::new(neuron_id, spike_times))
330    }
331
332    /// Temporal encoding: spike time inversely proportional to input value
333    fn temporal_encode(&self, neuron_id: usize, value: T) -> Result<SpikeTrain<T>> {
334        let max_delay = T::from(20.0).unwrap_or_else(|| T::zero()); // 20 ms max delay
335        let spike_time = if value > T::zero() {
336            max_delay * (T::one() - value.min(T::one()))
337        } else {
338            max_delay // No spike for negative values
339        };
340
341        let spike_times = if spike_time < max_delay {
342            vec![spike_time]
343        } else {
344            Vec::new()
345        };
346
347        Ok(SpikeTrain::new(neuron_id, spike_times))
348    }
349
350    /// Population vector encoding
351    fn population_vector_encode(&self, neuron_id: usize, value: T) -> Result<SpikeTrain<T>> {
352        // Simplified population vector encoding
353        self.rate_encode(neuron_id, value)
354    }
355
356    /// Sparse encoding: only strong inputs generate spikes
357    fn sparse_encode(&self, neuron_id: usize, value: T) -> Result<SpikeTrain<T>> {
358        let threshold = T::from(0.5).unwrap_or_else(|| T::zero());
359
360        if value.abs() > threshold {
361            self.rate_encode(neuron_id, value)
362        } else {
363            Ok(SpikeTrain::new(neuron_id, Vec::new()))
364        }
365    }
366
367    /// Decode spike trains to continuous output
368    pub fn decode_output(&self, spike_trains: &[SpikeTrain<T>]) -> Result<Array1<T>> {
369        let mut output = Array1::zeros(spike_trains.len());
370
371        for (i, spike_train) in spike_trains.iter().enumerate() {
372            output[i] = match self.config.decoding_method {
373                SpikeDecodingMethod::RateDecoding => self.rate_decode(spike_train)?,
374                SpikeDecodingMethod::TemporalDecoding => self.temporal_decode(spike_train)?,
375                SpikeDecodingMethod::WeightedSpikeCount => {
376                    self.weighted_spike_count_decode(spike_train)?
377                }
378                _ => {
379                    // Fallback to rate decoding
380                    self.rate_decode(spike_train)?
381                }
382            };
383        }
384
385        Ok(output)
386    }
387
388    /// Rate decoding: spike count normalized by time window. Uses the
389    /// *same* window and max rate as [`Self::rate_encode`] (F54) — this
390    /// used to normalize by `temporal_window` (20ms default) while encode
391    /// spiked over `simulation_time` (1000ms default), a 50x mismatch.
392    fn rate_decode(&self, spike_train: &SpikeTrain<T>) -> Result<T> {
393        let window_duration = self.rate_coding_window();
394        let spike_count = to_generic_or(spike_train.spike_count as f64, T::zero());
395        let window_seconds = window_duration / to_generic_or(1000.0, T::one());
396        if window_seconds <= T::zero() {
397            return Ok(T::zero());
398        }
399        let rate = spike_count / window_seconds;
400        let max_rate = to_generic_or(RATE_CODING_MAX_RATE_HZ, T::one());
401        Ok(rate / max_rate) // Normalize by the same max rate used to encode
402    }
403
404    /// Temporal decoding: use first spike time
405    fn temporal_decode(&self, spike_train: &SpikeTrain<T>) -> Result<T> {
406        if spike_train.spike_times.is_empty() {
407            Ok(T::zero())
408        } else {
409            let first_spike = spike_train.spike_times[0];
410            let max_delay = T::from(20.0).unwrap_or_else(|| T::zero());
411            Ok(T::one() - (first_spike / max_delay).min(T::one()))
412        }
413    }
414
415    /// Weighted spike count decoding
416    fn weighted_spike_count_decode(&self, spike_train: &SpikeTrain<T>) -> Result<T> {
417        if spike_train.spike_times.is_empty() {
418            return Ok(T::zero());
419        }
420
421        let mut weighted_sum = T::zero();
422        let current_time = self.current_time;
423
424        for &spike_time in &spike_train.spike_times {
425            let time_diff = current_time - spike_time;
426            let weight = (-time_diff / T::from(10.0).unwrap_or_else(|| T::zero())).exp(); // Exponential decay
427            weighted_sum = weighted_sum + weight;
428        }
429
430        Ok(weighted_sum)
431    }
432
433    /// Simulate membrane dynamics for one time step
434    pub fn simulate_step(&mut self, input_spikes: &[Spike<T>]) -> Result<Vec<Spike<T>>> {
435        let mut output_spikes = Vec::new();
436        let dt = self.config.time_step;
437
438        // Process input _spikes
439        for spike in input_spikes {
440            self.process_input_spike(spike)?;
441        }
442
443        // Update membrane potentials
444        for neuron_id in 0..self.membrane_potentials.len() {
445            if self.current_time >= self.refractory_until[neuron_id] {
446                self.update_membrane_potential(neuron_id, dt)?;
447
448                // Check for spike threshold
449                if self.membrane_potentials[neuron_id] >= self.membrane_config.threshold_potential {
450                    let spike = self.generate_spike(neuron_id)?;
451                    output_spikes.push(spike);
452                }
453            }
454        }
455
456        // Apply plasticity updates
457        self.update_plasticity(&output_spikes)?;
458
459        // Update homeostatic mechanisms
460        if self.config.homeostatic_config.enable_homeostatic_scaling {
461            self.update_homeostatic_scaling()?;
462        }
463
464        self.current_time = self.current_time + dt;
465
466        Ok(output_spikes)
467    }
468
469    /// Process an input spike (F52): external input is accumulated as
470    /// synaptic current rather than jumping the membrane potential
471    /// directly, so it flows through the same `R * I_syn` leaky-integrator
472    /// term as internally-propagated spikes.
473    fn process_input_spike(&mut self, spike: &Spike<T>) -> Result<()> {
474        let target_neuron = spike.postsynaptic_id.unwrap_or(spike.neuron_id);
475
476        if target_neuron < self.synaptic_current.len() {
477            let synaptic_current = spike.weight * spike.amplitude;
478            self.synaptic_current[target_neuron] =
479                self.synaptic_current[target_neuron] + synaptic_current;
480        }
481
482        Ok(())
483    }
484
485    /// Update membrane potential using a leaky integrate-and-fire model
486    /// with a synaptic drive term (F52):
487    /// `tau * dV/dt = (V_rest - V) + R * I_syn`, where `R = 1 /
488    /// leak_conductance`. Previously this dropped `I_syn` entirely, so
489    /// `synaptic_weights` (built up by STDP/Hebbian learning) never
490    /// actually influenced the dynamics it was supposed to shape.
491    fn update_membrane_potential(&mut self, neuron_id: usize, dt: T) -> Result<()> {
492        let v = self.membrane_potentials[neuron_id];
493        let v_rest = self.membrane_config.resting_potential;
494        let tau = self.membrane_config.tau_membrane;
495        let leak_conductance = self.membrane_config.leak_conductance;
496        let membrane_resistance = if leak_conductance > T::zero() {
497            T::one() / leak_conductance
498        } else {
499            T::zero()
500        };
501        let i_syn = self.synaptic_current[neuron_id];
502
503        let dv_dt = if tau > T::zero() {
504            ((v_rest - v) + membrane_resistance * i_syn) / tau
505        } else {
506            T::zero()
507        };
508        let new_v = v + dv_dt * dt;
509
510        self.membrane_potentials[neuron_id] = new_v;
511
512        // The injected current is consumed by this integration step (a
513        // simple pulse model); new input/network spikes re-inject it.
514        self.synaptic_current[neuron_id] = T::zero();
515
516        Ok(())
517    }
518
519    /// Generate a spike when threshold is reached
520    fn generate_spike(&mut self, neuron_id: usize) -> Result<Spike<T>> {
521        // Reset membrane potential
522        self.membrane_potentials[neuron_id] = self.membrane_config.reset_potential;
523
524        // Set refractory period
525        self.refractory_until[neuron_id] =
526            self.current_time + self.membrane_config.refractory_period;
527
528        // Update last spike time
529        self.last_spike_times[neuron_id] = self.current_time;
530
531        // Create spike
532        let spike = Spike {
533            neuron_id,
534            time: self.current_time,
535            amplitude: to_generic_or(1.0, T::one()),
536            width: Some(to_generic_or(1.0, T::one())),
537            weight: T::one(),
538            presynaptic_id: None,
539            postsynaptic_id: None,
540        };
541
542        // Propagate this spike to every postsynaptic target through the
543        // real synaptic weight matrix (F52): this is what makes
544        // `synaptic_weights` (shaped by STDP/Hebbian plasticity) actually
545        // affect network dynamics instead of being a write-only matrix.
546        for target_id in 0..self.synaptic_weights.ncols() {
547            if target_id != neuron_id {
548                let w = self.synaptic_weights[[neuron_id, target_id]];
549                self.synaptic_current[target_id] = self.synaptic_current[target_id] + w;
550            }
551        }
552
553        // Update spike train, recomputing firing_rate/duration from the
554        // updated history (F51) rather than leaving them permanently
555        // stale.
556        self.spike_trains
557            .entry(neuron_id)
558            .or_insert_with(|| SpikeTrain::new(neuron_id, Vec::new()))
559            .record_spike(self.current_time);
560
561        // Update metrics
562        self.metrics.total_spikes += 1;
563
564        Ok(spike)
565    }
566
567    /// Update synaptic plasticity
568    fn update_plasticity(&mut self, output_spikes: &[Spike<T>]) -> Result<()> {
569        match self.plasticity_model {
570            PlasticityModel::STDP => {
571                self.update_stdp(output_spikes)?;
572            }
573            PlasticityModel::Hebbian => {
574                self.update_hebbian(output_spikes)?;
575            }
576            _ => {
577                // Default to STDP
578                self.update_stdp(output_spikes)?;
579            }
580        }
581
582        Ok(())
583    }
584
585    /// Update STDP (Spike Timing Dependent Plasticity)
586    fn update_stdp(&mut self, output_spikes: &[Spike<T>]) -> Result<()> {
587        let long_ago = to_generic_or(-1000.0, T::zero());
588
589        for spike in output_spikes {
590            let fired_id = spike.neuron_id;
591            let fired_time = spike.time;
592
593            for other_id in 0..self.last_spike_times.len() {
594                if other_id == fired_id {
595                    continue;
596                }
597                let other_time = self.last_spike_times[other_id];
598                if other_time <= long_ago {
599                    continue; // no valid spike history for `other_id` yet
600                }
601
602                // `other_id` fired before `fired_id` (now): it is
603                // PRE, `fired_id` is POST, dt = t_post - t_pre > 0
604                // => potentiation (LTP) on other_id -> fired_id.
605                let dt_ltp = fired_time - other_time;
606                let ltp = self.compute_stdp_update(dt_ltp);
607                self.synaptic_weights[[other_id, fired_id]] =
608                    (self.synaptic_weights[[other_id, fired_id]] + ltp)
609                        .max(self.stdp_config.weight_min)
610                        .min(self.stdp_config.weight_max);
611
612                // `fired_id` is firing NOW, arriving after `other_id`'s
613                // last spike: from `other_id`'s perspective as POST, this
614                // is a PRE spike arriving late, dt = t_post - t_pre =
615                // other_time - fired_time < 0 => depression (LTD) on
616                // fired_id -> other_id. This is the presynaptic-trace
617                // side of STDP that was previously unreachable (F50):
618                // without it, `dt` computed from "post's own time minus
619                // pre's last (necessarily past) spike time" was always
620                // >= 0, so LTD never fired.
621                let dt_ltd = other_time - fired_time;
622                let ltd = self.compute_stdp_update(dt_ltd);
623                self.synaptic_weights[[fired_id, other_id]] =
624                    (self.synaptic_weights[[fired_id, other_id]] + ltd)
625                        .max(self.stdp_config.weight_min)
626                        .min(self.stdp_config.weight_max);
627            }
628        }
629
630        Ok(())
631    }
632
633    /// Compute STDP weight update
634    fn compute_stdp_update(&self, dt: T) -> T {
635        if dt > T::zero() {
636            // Post-before-pre: LTP (potentiation)
637            let exp_arg = -dt / self.stdp_config.tau_pot;
638            self.stdp_config.learning_rate_pot * exp_arg.exp()
639        } else {
640            // Pre-before-post: LTD (depression)
641            let exp_arg = dt / self.stdp_config.tau_dep;
642            -self.stdp_config.learning_rate_dep * exp_arg.exp()
643        }
644    }
645
646    /// Update Hebbian plasticity. Presynaptic activity is the normalized
647    /// depolarization fraction `((v - v_rest) / (v_thresh - v_rest))
648    /// .max(0)` — 0 at rest, 1 at threshold (F53). The previous `v /
649    /// v_threshold` ratio of two negative mV values was inverted: a
650    /// neuron sitting at rest (no activity) produced a *larger* ratio
651    /// than one nearly at threshold (maximal activity).
652    fn update_hebbian(&mut self, output_spikes: &[Spike<T>]) -> Result<()> {
653        let v_rest = self.membrane_config.resting_potential;
654        let v_thresh = self.membrane_config.threshold_potential;
655        let range = v_thresh - v_rest;
656
657        for spike in output_spikes {
658            let post_id = spike.neuron_id;
659
660            for pre_id in 0..self.membrane_potentials.len() {
661                if pre_id != post_id {
662                    let pre_activity = if range != T::zero() {
663                        ((self.membrane_potentials[pre_id] - v_rest) / range).max(T::zero())
664                    } else {
665                        T::zero()
666                    };
667
668                    let weight_change = self.stdp_config.learning_rate_pot * pre_activity;
669
670                    self.synaptic_weights[[pre_id, post_id]] =
671                        (self.synaptic_weights[[pre_id, post_id]] + weight_change)
672                            .max(self.stdp_config.weight_min)
673                            .min(self.stdp_config.weight_max);
674                }
675            }
676        }
677
678        Ok(())
679    }
680
681    /// Update homeostatic scaling (F51).
682    ///
683    /// Two bugs made this diverge geometrically: `firing_rate` was read
684    /// from the spike train but never recomputed as spikes accumulated
685    /// (fixed by [`SpikeTrain::record_spike`] in `generate_spike`), and
686    /// the *cumulative, unbounded* `homeostatic_scales` value was
687    /// multiplied into every weight on *every* call — so corrections
688    /// compounded on top of corrections indefinitely. This now applies a
689    /// single, clamped per-step multiplier each call, and separately
690    /// clamps the cumulative scale record so it cannot drift without
691    /// bound even over very long runs.
692    fn update_homeostatic_scaling(&mut self) -> Result<()> {
693        let target_rate = self.config.homeostatic_config.target_firing_rate;
694        let time_constant = self.config.homeostatic_config.scaling_time_constant;
695        let dt = self.config.time_step;
696        if time_constant <= T::zero() {
697            return Ok(());
698        }
699
700        let min_step = to_generic_or(0.9, T::one());
701        let max_step = to_generic_or(1.1, T::one());
702        let min_cumulative = to_generic_or(0.1, T::zero());
703        let max_cumulative = to_generic_or(10.0, T::one());
704
705        for neuron_id in 0..self.homeostatic_scales.len() {
706            if let Some(spike_train) = self.spike_trains.get(&neuron_id) {
707                let current_rate = spike_train.firing_rate;
708                let rate_error = target_rate - current_rate;
709
710                // Bounded per-step multiplicative correction toward the
711                // target rate.
712                let raw_step_scale = T::one() + rate_error * dt / time_constant;
713                let step_multiplier = raw_step_scale.max(min_step).min(max_step);
714
715                // Track the cumulative scale purely for observability,
716                // clamped so it cannot grow or collapse without bound.
717                self.homeostatic_scales[neuron_id] = (self.homeostatic_scales[neuron_id]
718                    * step_multiplier)
719                    .max(min_cumulative)
720                    .min(max_cumulative);
721
722                // Apply only the bounded per-step multiplier to weights,
723                // not the (potentially very different) cumulative value.
724                for pre_id in 0..self.synaptic_weights.nrows() {
725                    self.synaptic_weights[[pre_id, neuron_id]] =
726                        (self.synaptic_weights[[pre_id, neuron_id]] * step_multiplier)
727                            .max(self.stdp_config.weight_min)
728                            .min(self.stdp_config.weight_max);
729                }
730            }
731        }
732
733        Ok(())
734    }
735
736    /// Get current neuromorphic metrics
737    pub fn get_metrics(&self) -> &NeuromorphicMetrics<T> {
738        &self.metrics
739    }
740
741    /// Reset the optimizer state
742    pub fn reset(&mut self) {
743        self.current_time = T::zero();
744        self.membrane_potentials
745            .fill(self.membrane_config.resting_potential);
746        self.last_spike_times
747            .fill(T::from(-1000.0).unwrap_or_else(|| T::zero()));
748        self.refractory_until.fill(T::zero());
749        self.synaptic_current.fill(T::zero());
750        self.spike_trains.clear();
751        self.spike_buffer.clear();
752        self.metrics = NeuromorphicMetrics::default();
753    }
754}
755
756/// Spike train optimizer for temporal pattern learning
757pub struct SpikeTrainOptimizer<
758    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + std::fmt::Debug + Send + Sync,
759> {
760    /// Configuration
761    config: SpikingConfig<T>,
762
763    /// Spike pattern templates
764    pattern_templates: Vec<SpikePattern<T>>,
765
766    /// Pattern matching threshold
767    matching_threshold: T,
768
769    /// Learning rate for pattern adaptation
770    pattern_learning_rate: T,
771
772    /// Temporal kernel for pattern comparison
773    temporal_kernel: TemporalKernel<T>,
774}
775
776/// Spike pattern template
777#[derive(Debug, Clone)]
778pub struct SpikePattern<T: Float + Debug + Send + Sync + 'static> {
779    /// Pattern ID
780    pub pattern_id: usize,
781
782    /// Spike times relative to pattern start
783    pub relative_spike_times: Vec<T>,
784
785    /// Pattern duration
786    pub duration: T,
787
788    /// Pattern weight/importance
789    pub weight: T,
790
791    /// Number of times pattern was observed
792    pub observation_count: usize,
793}
794
795/// Temporal kernel for pattern matching
796#[derive(Debug, Clone)]
797pub struct TemporalKernel<T: Float + Debug + Send + Sync + 'static> {
798    /// Kernel type
799    pub kernel_type: TemporalKernelType,
800
801    /// Kernel width (ms)
802    pub width: T,
803
804    /// Kernel parameters
805    pub parameters: Vec<T>,
806}
807
808/// Types of temporal kernels
809#[derive(Debug, Clone, Copy)]
810pub enum TemporalKernelType {
811    /// Gaussian kernel
812    Gaussian,
813
814    /// Exponential kernel
815    Exponential,
816
817    /// Alpha function kernel
818    Alpha,
819
820    /// Rectangular kernel
821    Rectangular,
822}
823
824impl<T: Float + Debug + Send + Sync + scirs2_core::ndarray::ScalarOperand + std::fmt::Debug>
825    SpikeTrainOptimizer<T>
826{
827    /// Create a new spike train optimizer
828    pub fn new(config: SpikingConfig<T>) -> Self {
829        // The kernel width tracks the configured spike-correlation window: a
830        // pattern-matching kernel wider than the correlation window compares
831        // spikes the rest of the model already treats as unrelated. This used to
832        // be a fixed 5 ms regardless of configuration.
833        let kernel_width = config.temporal_window;
834        let pattern_learning_rate = config.spike_learning_rate;
835        Self {
836            config,
837            pattern_templates: Vec::new(),
838            matching_threshold: to_generic_or(0.8, T::zero()),
839            pattern_learning_rate,
840            temporal_kernel: TemporalKernel {
841                kernel_type: TemporalKernelType::Gaussian,
842                width: kernel_width,
843                parameters: vec![T::one()],
844            },
845        }
846    }
847
848    /// Learn spike patterns from training data
849    pub fn learn_patterns(&mut self, spike_trains: &[SpikeTrain<T>]) -> Result<()> {
850        for spike_train in spike_trains {
851            self.extract_and_learn_patterns(spike_train)?;
852        }
853
854        Ok(())
855    }
856
857    /// Extract patterns from a spike train
858    fn extract_and_learn_patterns(&mut self, spike_train: &SpikeTrain<T>) -> Result<()> {
859        // Window and step come from the configured temporal window and
860        // simulation time step rather than fixed 50 ms / 10 ms constants, so a
861        // model simulated at a different resolution segments its spike trains
862        // at that resolution. Both are floored at one time step so the loop
863        // below always advances.
864        let step_size = self.config.time_step.max(to_generic_or(1e-6, T::one()));
865        let window_size = self.config.temporal_window.max(step_size);
866
867        let mut window_start = T::zero();
868
869        while window_start < spike_train.duration {
870            let window_end = window_start + window_size;
871
872            // Extract spikes in current window
873            let window_spikes: Vec<T> = spike_train
874                .spike_times
875                .iter()
876                .filter(|&&t| t >= window_start && t < window_end)
877                .map(|&t| t - window_start) // Make relative to window start
878                .collect();
879
880            if !window_spikes.is_empty() {
881                let pattern = SpikePattern {
882                    pattern_id: self.pattern_templates.len(),
883                    relative_spike_times: window_spikes,
884                    duration: window_size,
885                    weight: T::one(),
886                    observation_count: 1,
887                };
888
889                // Check if similar pattern exists
890                if let Some(similar_pattern_id) = self.find_similar_pattern(&pattern) {
891                    self.update_pattern(similar_pattern_id, &pattern)?;
892                } else {
893                    self.pattern_templates.push(pattern);
894                }
895            }
896
897            window_start = window_start + step_size;
898        }
899
900        Ok(())
901    }
902
903    /// Find similar existing pattern
904    fn find_similar_pattern(&self, new_pattern: &SpikePattern<T>) -> Option<usize> {
905        for (i, existing_pattern) in self.pattern_templates.iter().enumerate() {
906            let similarity = self.compute_pattern_similarity(new_pattern, existing_pattern);
907            if similarity > self.matching_threshold {
908                return Some(i);
909            }
910        }
911
912        None
913    }
914
915    /// Compute similarity between two spike patterns
916    fn compute_pattern_similarity(
917        &self,
918        pattern1: &SpikePattern<T>,
919        pattern2: &SpikePattern<T>,
920    ) -> T {
921        // Use Victor-Purpura distance or similar metric
922        let max_spikes = pattern1
923            .relative_spike_times
924            .len()
925            .max(pattern2.relative_spike_times.len());
926        if max_spikes == 0 {
927            return T::one();
928        }
929
930        // Simplified similarity based on spike count and timing
931        let count_diff = (pattern1.relative_spike_times.len() as i32
932            - pattern2.relative_spike_times.len() as i32)
933            .abs() as f64;
934        let count_similarity =
935            T::one() - T::from(count_diff / max_spikes as f64).unwrap_or_else(|| T::zero());
936
937        // Add temporal similarity if both patterns have spikes
938        if !pattern1.relative_spike_times.is_empty() && !pattern2.relative_spike_times.is_empty() {
939            let temporal_similarity = self.compute_temporal_similarity(
940                &pattern1.relative_spike_times,
941                &pattern2.relative_spike_times,
942            );
943            (count_similarity + temporal_similarity) / T::from(2.0).unwrap_or_else(|| T::zero())
944        } else {
945            count_similarity
946        }
947    }
948
949    /// Compute temporal similarity between spike time sequences
950    fn compute_temporal_similarity(&self, spikes1: &[T], spikes2: &[T]) -> T {
951        // Use cross-correlation or DTW-like measure
952        let mut max_correlation = T::zero();
953        let max_shift = T::from(10.0).unwrap_or_else(|| T::zero()); // 10 ms max shift
954        let shift_step = T::from(1.0).unwrap_or_else(|| T::zero());
955
956        let mut shift = -max_shift;
957        while shift <= max_shift {
958            let correlation = self.compute_spike_correlation(spikes1, spikes2, shift);
959            max_correlation = max_correlation.max(correlation);
960            shift = shift + shift_step;
961        }
962
963        max_correlation
964    }
965
966    /// Compute spike correlation with time shift
967    fn compute_spike_correlation(&self, spikes1: &[T], spikes2: &[T], shift: T) -> T {
968        let mut correlation = T::zero();
969        let kernel_width = self.temporal_kernel.width;
970
971        for &t1 in spikes1 {
972            for &t2 in spikes2 {
973                let dt = (t1 - (t2 + shift)).abs();
974                let kernel_value = (-dt * dt
975                    / (T::from(2.0).unwrap_or_else(|| T::zero()) * kernel_width * kernel_width))
976                    .exp();
977                correlation = correlation + kernel_value;
978            }
979        }
980
981        // Normalize by number of spike pairs
982        if !spikes1.is_empty() && !spikes2.is_empty() {
983            correlation / to_generic_or((spikes1.len() * spikes2.len()) as f64, T::one())
984        } else {
985            T::zero()
986        }
987    }
988
989    /// Update existing pattern with new observation
990    fn update_pattern(&mut self, pattern_id: usize, new_pattern: &SpikePattern<T>) -> Result<()> {
991        if let Some(existing_pattern) = self.pattern_templates.get_mut(pattern_id) {
992            // Update _pattern using exponential moving average
993            let alpha = self.pattern_learning_rate;
994
995            // Update spike times (simplified)
996            if existing_pattern.relative_spike_times.len() == new_pattern.relative_spike_times.len()
997            {
998                for (existing_time, &new_time) in existing_pattern
999                    .relative_spike_times
1000                    .iter_mut()
1001                    .zip(new_pattern.relative_spike_times.iter())
1002                {
1003                    *existing_time = *existing_time * (T::one() - alpha) + new_time * alpha;
1004                }
1005            }
1006
1007            existing_pattern.observation_count += 1;
1008            existing_pattern.weight =
1009                existing_pattern.weight * (T::one() - alpha) + new_pattern.weight * alpha;
1010        }
1011
1012        Ok(())
1013    }
1014
1015    /// Recognize patterns in new spike train
1016    pub fn recognize_patterns(&self, spike_train: &SpikeTrain<T>) -> Result<Vec<(usize, T, T)>> {
1017        let mut recognized_patterns = Vec::new();
1018        let window_size = T::from(50.0).unwrap_or_else(|| T::zero());
1019        let step_size = T::from(5.0).unwrap_or_else(|| T::zero());
1020
1021        let mut window_start = T::zero();
1022
1023        while window_start < spike_train.duration {
1024            let window_end = window_start + window_size;
1025
1026            let window_spikes: Vec<T> = spike_train
1027                .spike_times
1028                .iter()
1029                .filter(|&&t| t >= window_start && t < window_end)
1030                .map(|&t| t - window_start)
1031                .collect();
1032
1033            if !window_spikes.is_empty() {
1034                let test_pattern = SpikePattern {
1035                    pattern_id: 0,
1036                    relative_spike_times: window_spikes,
1037                    duration: window_size,
1038                    weight: T::one(),
1039                    observation_count: 1,
1040                };
1041
1042                // Find best matching pattern
1043                let mut best_match = (0, T::zero());
1044                for (i, template) in self.pattern_templates.iter().enumerate() {
1045                    let similarity = self.compute_pattern_similarity(&test_pattern, template);
1046                    if similarity > best_match.1 {
1047                        best_match = (i, similarity);
1048                    }
1049                }
1050
1051                if best_match.1 > self.matching_threshold {
1052                    recognized_patterns.push((best_match.0, window_start, best_match.1));
1053                }
1054            }
1055
1056            window_start = window_start + step_size;
1057        }
1058
1059        Ok(recognized_patterns)
1060    }
1061
1062    /// Get learned patterns
1063    pub fn get_patterns(&self) -> &[SpikePattern<T>] {
1064        &self.pattern_templates
1065    }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071
1072    fn make_optimizer(num_neurons: usize) -> SpikingOptimizer<f64> {
1073        SpikingOptimizer::new(
1074            SpikingConfig::default(),
1075            STDPConfig::default(),
1076            MembraneDynamicsConfig::default(),
1077            num_neurons,
1078        )
1079    }
1080
1081    fn dummy_spike(neuron_id: usize, time: f64) -> Spike<f64> {
1082        Spike {
1083            neuron_id,
1084            time,
1085            amplitude: 1.0,
1086            width: None,
1087            weight: 1.0,
1088            presynaptic_id: None,
1089            postsynaptic_id: None,
1090        }
1091    }
1092
1093    /// F50: STDP must produce both potentiation (LTP) and depression
1094    /// (LTD), not just LTP.
1095    #[test]
1096    fn stdp_produces_both_potentiation_and_depression() {
1097        let mut optimizer = make_optimizer(2);
1098        optimizer.last_spike_times[0] = 5.0;
1099
1100        optimizer
1101            .update_stdp(&[dummy_spike(1, 15.0)])
1102            .expect("update_stdp failed");
1103
1104        let initial = 0.1;
1105        assert!(
1106            optimizer.synaptic_weights[[0, 1]] > initial,
1107            "LTP (0->1) did not fire: {}",
1108            optimizer.synaptic_weights[[0, 1]]
1109        );
1110        assert!(
1111            optimizer.synaptic_weights[[1, 0]] < initial,
1112            "LTD (1->0) did not fire (F50 regression): {}",
1113            optimizer.synaptic_weights[[1, 0]]
1114        );
1115    }
1116
1117    /// F51: homeostatic scaling must not diverge — weights and scale
1118    /// factors stay bounded over many steps.
1119    #[test]
1120    fn homeostatic_scaling_does_not_blow_up() {
1121        let mut optimizer = make_optimizer(3);
1122        optimizer
1123            .config
1124            .homeostatic_config
1125            .enable_homeostatic_scaling = true;
1126
1127        for step in 0..500 {
1128            optimizer.current_time = step as f64 * 0.1;
1129            let train = optimizer
1130                .spike_trains
1131                .entry(0)
1132                .or_insert_with(|| SpikeTrain::new(0, Vec::new()));
1133            if step % 5 == 0 {
1134                let t = optimizer.current_time;
1135                train.record_spike(t);
1136            }
1137            optimizer
1138                .update_homeostatic_scaling()
1139                .expect("update_homeostatic_scaling failed");
1140        }
1141
1142        for &w in optimizer.synaptic_weights.iter() {
1143            assert!(w.is_finite(), "weight diverged: {w}");
1144            assert!(
1145                (0.0..=1.0).contains(&w),
1146                "weight left [weight_min, weight_max]: {w}"
1147            );
1148        }
1149        for &s in optimizer.homeostatic_scales.iter() {
1150            assert!(
1151                s.is_finite() && (0.1..=10.0).contains(&s),
1152                "homeostatic scale diverged (F51 regression): {s}"
1153            );
1154        }
1155    }
1156
1157    /// F52: a spike propagated through a strong synaptic weight must
1158    /// actually move the postsynaptic membrane potential.
1159    #[test]
1160    fn synaptic_weights_propagate_into_membrane_dynamics() {
1161        let mut optimizer = make_optimizer(2);
1162        optimizer.synaptic_weights[[0, 1]] = 50.0;
1163        optimizer.membrane_potentials[1] = optimizer.membrane_config.resting_potential;
1164        optimizer.membrane_potentials[0] = optimizer.membrane_config.threshold_potential;
1165
1166        optimizer.generate_spike(0).expect("generate_spike failed");
1167        let dt = optimizer.config.time_step;
1168        optimizer
1169            .update_membrane_potential(1, dt)
1170            .expect("update_membrane_potential failed");
1171
1172        assert!(
1173            optimizer.membrane_potentials[1] > optimizer.membrane_config.resting_potential,
1174            "postsynaptic potential did not respond to the propagated synaptic weight (F52 regression)"
1175        );
1176    }
1177
1178    /// F53: Hebbian presynaptic activity must increase monotonically with
1179    /// depolarization (0 at rest, up to 1 near threshold), not the
1180    /// inverted `v / v_threshold` ratio.
1181    #[test]
1182    fn hebbian_activity_increases_with_depolarization() {
1183        let run = |pre_potential: f64| -> f64 {
1184            let mut optimizer = make_optimizer(2);
1185            optimizer.plasticity_model = PlasticityModel::Hebbian;
1186            optimizer.membrane_potentials[0] = pre_potential;
1187            optimizer
1188                .update_hebbian(&[dummy_spike(1, 1.0)])
1189                .expect("update_hebbian failed");
1190            optimizer.synaptic_weights[[0, 1]]
1191        };
1192
1193        let membrane_config = MembraneDynamicsConfig::<f64>::default();
1194        let weight_at_rest = run(membrane_config.resting_potential);
1195        let weight_near_threshold = run(membrane_config.threshold_potential);
1196
1197        assert!(
1198            (weight_at_rest - 0.1).abs() < 1e-9,
1199            "resting potential should contribute zero Hebbian activity: {weight_at_rest}"
1200        );
1201        assert!(
1202            weight_near_threshold > weight_at_rest,
1203            "activity did not increase with depolarization (F53 regression): \
1204             rest={weight_at_rest}, near_threshold={weight_near_threshold}"
1205        );
1206    }
1207
1208    /// F54: `decode(encode(v))` must recover `v` (averaged over trials to
1209    /// cancel Poisson spiking noise), not be off by the previous 50x
1210    /// window mismatch between `rate_encode` and `rate_decode`.
1211    #[test]
1212    fn rate_encode_decode_round_trip_within_noise_tolerance() {
1213        let optimizer = make_optimizer(1);
1214        let true_value = 0.5_f64;
1215        let trials = 20;
1216
1217        let mut sum = 0.0;
1218        for _ in 0..trials {
1219            let train = optimizer
1220                .rate_encode(0, true_value)
1221                .expect("rate_encode failed");
1222            sum += optimizer.rate_decode(&train).expect("rate_decode failed");
1223        }
1224        let avg_decoded = sum / trials as f64;
1225
1226        assert!(
1227            (avg_decoded - true_value).abs() < 0.08,
1228            "decode(encode(v)) did not recover v within noise tolerance (F54 regression): \
1229             v={true_value}, avg_decoded={avg_decoded}"
1230        );
1231    }
1232}