Skip to main content

axon_encoder/encoders/
population.rs

1use crate::prelude::*;
2
3/// Encodes a single analog value across a population of neurons
4///
5/// Each neuron in the population is "tuned" to a specific preferred value within
6/// the input range. The neuron fires based on a Gaussian-like tuning curve centered
7/// on its preferred value. This creates a distributed representation where multiple
8/// neurons contribute to encoding a single input value
9///
10/// # Mathematical Model
11///
12/// Uses a Gaussian tuning curve to determine each neuron's firing rate:
13///
14/// ```text
15/// preferred_value[i] = range_min + (i / num_neurons) * (range_max - range_min)
16/// distance = |input - preferred_value[i]|
17/// rate = exp(-distance² / (2 * tuning_width²))
18/// spike if random() < rate
19/// ```
20///
21/// # When to Use
22///
23/// - Encoding position or continuous values with distributed representation
24/// - When multiple neurons should contribute to representing a single value
25/// - Creating more robust encoding that doesn't rely on a single neuron
26///
27/// # Parameters
28///
29/// - `num_neurons`: Number of neurons in the population per input channel
30/// - `input_range`: Tuple of (min, max) input values
31/// - `tuning_width`: Controls how broadly neurons respond (larger = wider spread)
32///
33/// # Examples
34///
35/// ```rust
36/// use axon_encoder::prelude::*;
37/// # fn main() -> Result<(), EncoderError> {
38/// let mut enc = PopulationEncoder::try_new(8, (0.0, 1.0), 0.15)?;
39/// // Population encoders take a single scalar in the first channel.
40/// let out = enc.encode(&[0.5]);
41/// assert!(out.spikes.len() <= 8);
42/// # Ok(())
43/// # }
44/// ```
45#[derive(Clone, Debug, PartialEq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize))]
47pub struct PopulationEncoder {
48    num_neurons: usize,
49    input_range: (f32, f32),
50    tuning_width: f32, // Controls how broadly a neuron responds to stimuli
51}
52
53impl PopulationEncoder {
54    /// Creates a new `PopulationEncoder`, panicking if configuration is invalid.
55    ///
56    /// Prefer [`try_new`](Self::try_new) for typed validation errors.
57    pub fn new(num_neurons: usize, input_range: (f32, f32), tuning_width: f32) -> Self {
58        Self::try_new(num_neurons, input_range, tuning_width)
59            .expect("invalid PopulationEncoder configuration")
60    }
61
62    /// Creates a new `PopulationEncoder`, returning an [`EncoderError`] for invalid configuration.
63    pub fn try_new(
64        num_neurons: usize,
65        input_range: (f32, f32),
66        tuning_width: f32,
67    ) -> Result<Self, EncoderError> {
68        if num_neurons == 0 {
69            return Err(EncoderError::CountMustBePositive {
70                parameter: "num_neurons",
71            });
72        }
73        crate::error::validate_channel_count(num_neurons)?;
74        crate::error::validate_range_f32_span("input_range", input_range)?;
75        if !tuning_width.is_finite() || tuning_width <= 0.0 {
76            return Err(EncoderError::NonPositiveOrNonFinite {
77                parameter: "tuning_width",
78            });
79        }
80        Ok(Self {
81            num_neurons,
82            input_range,
83            tuning_width,
84        })
85    }
86
87    /// Returns the number of neurons in the population
88    pub fn num_neurons(&self) -> usize {
89        self.num_neurons
90    }
91
92    fn get_rate_with_tuning_width(
93        &self,
94        input: f32,
95        neuron_index: usize,
96        tuning_width: f32,
97    ) -> f32 {
98        let range_span = self.input_range.1 - self.input_range.0;
99        let preferred_value =
100            self.input_range.0 + (neuron_index as f32 / self.num_neurons as f32) * range_span;
101
102        let distance = (input - preferred_value).abs();
103        // Gaussian-like response curve
104        (-(distance * distance) / (2.0 * tuning_width * tuning_width)).exp()
105    }
106
107    /// Effective tuning width under a sensitivity gain
108    ///
109    /// Scales **≥ 1** narrow the Gaussian (`width / scale`) so high sensitivity is
110    /// more selective. Scales in **(0, 1)** keep the base width and rely on rate
111    /// scaling in `encode_with_sensitivity_scale` so low (but nonzero) gain
112    /// *suppresses* activity instead of widening toward universal firing
113    fn effective_tuning_width(&self, sensitivity_scale: f32) -> f32 {
114        if !sensitivity_scale.is_finite() || sensitivity_scale <= 0.0 {
115            return self.tuning_width.max(f32::EPSILON);
116        }
117        if sensitivity_scale >= 1.0 {
118            return (self.tuning_width / sensitivity_scale).max(f32::EPSILON);
119        }
120        // Sub-unity: do not widen; rate scaling handles suppression.
121        self.tuning_width.max(f32::EPSILON)
122    }
123
124    fn encode_with_sensitivity_scale(
125        &mut self,
126        input: &[f32],
127        sensitivity_scale: f32,
128    ) -> EncodedOutput {
129        let mut output = EncodedOutput::new();
130        // Zero/negative/non-finite sensitivity fully suppresses population responses.
131        if !sensitivity_scale.is_finite() || sensitivity_scale <= 0.0 {
132            return output;
133        }
134        let tuning_width = self.effective_tuning_width(sensitivity_scale);
135        // Rate gain: scales > 1 also narrow width; scales in (0, 1) only reduce rate
136        // so small positive gains never produce near-universal firing.
137        let rate_gain = sensitivity_scale.min(1.0);
138
139        // This encoder expects a single value in the input slice
140        if let Some(&value) = input.first() {
141            let mut rng = rand::rng();
142            for i in 0..self.num_neurons {
143                let Ok(channel) = u16::try_from(i) else {
144                    // Remaining neurons exceed u16::MAX; stop rather than wrap.
145                    break;
146                };
147                let rate = self.get_rate_with_tuning_width(value, i, tuning_width) * rate_gain;
148                if crate::rng::gen_unit_f32_with_rng(&mut rng) < rate {
149                    output.spikes.push(SpikeEvent {
150                        channel,
151                        timestamp: 0, // Simplified
152                        polarity: true,
153                    });
154                }
155            }
156        }
157        output
158    }
159
160    /// Encodes input using neuromodulator-driven gain curves.
161    ///
162    /// Inherent wrapper so callers need not import [`ModulatedEncoder`].
163    pub fn encode_with_modulators(
164        &mut self,
165        input: &[f32],
166        modulators: &NeuroModulators,
167        gain_curves: &NeuromodulatorGainCurves,
168    ) -> EncodedOutput {
169        <Self as ModulatedEncoder>::encode_with_modulators(self, input, modulators, gain_curves)
170    }
171
172    /// Step-wise variant of [`encode_with_modulators`](Self::encode_with_modulators).
173    pub fn encode_step_with_modulators(
174        &mut self,
175        input: &[f32],
176        modulators: &NeuroModulators,
177        gain_curves: &NeuromodulatorGainCurves,
178    ) -> EncodedOutput {
179        <Self as ModulatedEncoder>::encode_step_with_modulators(
180            self,
181            input,
182            modulators,
183            gain_curves,
184        )
185    }
186}
187
188#[cfg(feature = "serde")]
189impl<'de> serde::Deserialize<'de> for PopulationEncoder {
190    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
191    where
192        D: serde::Deserializer<'de>,
193    {
194        #[derive(serde::Deserialize)]
195        struct Helper {
196            num_neurons: usize,
197            input_range: (f32, f32),
198            tuning_width: f32,
199        }
200        let helper = Helper::deserialize(deserializer)?;
201        Self::try_new(helper.num_neurons, helper.input_range, helper.tuning_width)
202            .map_err(serde::de::Error::custom)
203    }
204}
205
206impl Encoder for PopulationEncoder {
207    fn encode(&mut self, input: &[f32]) -> EncodedOutput {
208        self.encode_with_sensitivity_scale(input, 1.0)
209    }
210
211    fn encode_step(&mut self, input: &[f32]) -> EncodedOutput {
212        self.encode(input)
213    }
214
215    fn reset(&mut self) {
216        // No state to reset
217    }
218}
219
220impl ModulatedEncoder for PopulationEncoder {
221    fn encode_with_gains(&mut self, input: &[f32], gains: EncodingGains) -> EncodedOutput {
222        self.encode_with_sensitivity_scale(input, gains.sanitize().sensitivity_scale)
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_population_encoder() {
232        let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
233        // Encode a value in the middle of the range.
234        let input = [50.0];
235        let output = encoder.encode(&input);
236
237        // The neuron whose preferred value is closest to 50.0 should have the highest chance of firing.
238        // We can't guarantee a spike due to the probabilistic nature, but we can check the rates.
239        let rates: Vec<f32> = (0..10)
240            .map(|i| encoder.get_rate_with_tuning_width(50.0, i, encoder.tuning_width))
241            .collect();
242        let max_rate_index = rates
243            .iter()
244            .enumerate()
245            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
246            .unwrap()
247            .0;
248
249        // For a 10-neuron setup over a 0-100 range, the 5th neuron (index 4 or 5) should be near the max.
250        assert!(
251            max_rate_index == 4 || max_rate_index == 5,
252            "Peak activity should be near the middle neuron for an input of 50."
253        );
254        assert!(output.spikes.len() <= 10);
255    }
256
257    #[test]
258    fn test_population_encoder_empty_input() {
259        let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
260        let empty: [f32; 0] = [];
261        let via_encode = encoder.encode(&empty);
262        assert!(
263            via_encode.spikes.is_empty(),
264            "empty input must yield no spikes through encode"
265        );
266        let via_scale = encoder.encode_with_sensitivity_scale(&empty, 1.0);
267        assert!(
268            via_scale.spikes.is_empty(),
269            "empty input must yield no spikes through encode_with_sensitivity_scale"
270        );
271        let via_step = encoder.encode_step(&empty);
272        assert!(via_step.spikes.is_empty());
273    }
274
275    #[test]
276    fn test_effective_tuning_width_sub_unity() {
277        let encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
278        // Sub-unity sensitivity should NOT widen the tuning width
279        let width = encoder.effective_tuning_width(0.5);
280        assert_eq!(width, encoder.tuning_width.max(f32::EPSILON));
281    }
282
283    #[test]
284    fn test_effective_tuning_width_zero_and_negative() {
285        let encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
286        assert_eq!(
287            encoder.effective_tuning_width(0.0),
288            encoder.tuning_width.max(f32::EPSILON)
289        );
290        assert_eq!(
291            encoder.effective_tuning_width(-1.0),
292            encoder.tuning_width.max(f32::EPSILON)
293        );
294        assert_eq!(
295            encoder.effective_tuning_width(f32::NAN),
296            encoder.tuning_width.max(f32::EPSILON)
297        );
298    }
299
300    #[test]
301    fn test_encode_with_zero_sensitivity_returns_empty() {
302        let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
303        let output = encoder.encode_with_sensitivity_scale(&[50.0], 0.0);
304        assert!(output.spikes.is_empty());
305    }
306
307    #[test]
308    fn test_encode_with_negative_sensitivity_returns_empty() {
309        let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
310        let output = encoder.encode_with_sensitivity_scale(&[50.0], -1.0);
311        assert!(output.spikes.is_empty());
312    }
313
314    #[test]
315    fn test_encode_with_nan_sensitivity_returns_empty() {
316        let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
317        let output = encoder.encode_with_sensitivity_scale(&[50.0], f32::NAN);
318        assert!(output.spikes.is_empty());
319    }
320
321    #[test]
322    fn test_sub_unity_sensitivity_suppresses_firing() {
323        let encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
324        // Sub-unity scale should NOT widen tuning width (that's handled by effective_tuning_width)
325        // but the rate_gain = scale.min(1.0) should suppress firing probability.
326        let baseline_width = encoder.effective_tuning_width(1.0);
327        let suppressed_width = encoder.effective_tuning_width(0.1);
328        // Widths should be equal (sub-unity doesn't widen)
329        assert_eq!(baseline_width, suppressed_width);
330
331        // Rate gain at 0.1 should be 0.1x the baseline rate
332        let baseline_rate = encoder.get_rate_with_tuning_width(50.0, 5, baseline_width);
333        let suppressed_rate = encoder.get_rate_with_tuning_width(50.0, 5, suppressed_width) * 0.1;
334        // Suppressed rate should be substantially lower
335        assert!(
336            suppressed_rate < baseline_rate * 0.15,
337            "suppressed_rate {} should be < 15% of baseline_rate {}",
338            suppressed_rate,
339            baseline_rate
340        );
341    }
342
343    #[test]
344    fn test_encode_with_modulators_uses_gain_curves() {
345        let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
346        let mods = NeuroModulators::default();
347        let curves = NeuromodulatorGainCurves::default();
348        // With identity gains, should produce similar output to plain encode
349        let output = encoder.encode_with_modulators(&[50.0], &mods, &curves);
350        assert!(output.spikes.len() <= 10);
351    }
352
353    #[test]
354    fn test_encode_step_with_modulators() {
355        let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
356        let mods = NeuroModulators::default();
357        let curves = NeuromodulatorGainCurves::default();
358        let output = encoder.encode_step_with_modulators(&[50.0], &mods, &curves);
359        assert!(output.spikes.len() <= 10);
360    }
361
362    #[test]
363    fn test_population_encoder_modulators_adjust_sensitivity() {
364        let encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
365        let modulators = NeuroModulators {
366            tempo: 1.0,
367            ..Default::default()
368        };
369        let gain_curves = NeuromodulatorGainCurves {
370            tempo: ModulatorGainCurves {
371                sensitivity: Some(GainCurve::new((0.0, 1.0), (1.0, 2.0))),
372                ..Default::default()
373            },
374            ..Default::default()
375        };
376
377        let baseline_width = encoder.effective_tuning_width(1.0);
378        let modulated_width =
379            encoder.effective_tuning_width(gain_curves.evaluate(&modulators).sensitivity_scale);
380        let baseline_rate = encoder.get_rate_with_tuning_width(50.0, 0, baseline_width);
381        let modulated_rate = encoder.get_rate_with_tuning_width(50.0, 0, modulated_width);
382
383        assert!(modulated_width < baseline_width);
384        assert!(modulated_rate < baseline_rate);
385    }
386
387    #[test]
388    fn test_population_encoder_step_and_accessors() {
389        let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);
390        assert_eq!(encoder.num_neurons(), 10);
391
392        let step_output = encoder.encode_step(&[50.0]);
393        assert!(step_output.spikes.len() <= 10);
394
395        encoder.reset();
396        assert_eq!(encoder.num_neurons(), 10);
397    }
398    #[test]
399    fn test_population_encoder_try_new_validation() {
400        assert_eq!(
401            PopulationEncoder::try_new(0, (0.0, 1.0), 0.1).err(),
402            Some(EncoderError::CountMustBePositive {
403                parameter: "num_neurons"
404            })
405        );
406        assert_eq!(
407            PopulationEncoder::try_new(u16::MAX as usize + 2, (0.0, 1.0), 0.1).err(),
408            Some(EncoderError::NumChannelsTooLarge)
409        );
410        assert_eq!(
411            PopulationEncoder::try_new(1, (1.0, 1.0), 0.1).err(),
412            Some(EncoderError::InvalidRange {
413                parameter: "input_range"
414            })
415        );
416        assert_eq!(
417            PopulationEncoder::try_new(1, (0.0, 1.0), 0.0).err(),
418            Some(EncoderError::NonPositiveOrNonFinite {
419                parameter: "tuning_width"
420            })
421        );
422    }
423}
424
425/// Property-style suites for population rate / silence / bound contracts
426/// (#69 / LIM-1016).
427///
428/// Configurations and inputs are sampled with a seeded [`StdRng`] so failures
429/// replay. Spike Bernoulli draws use the process RNG; structural bounds hold
430/// regardless.
431#[cfg(test)]
432mod property_tests {
433    use super::*;
434    use crate::encoders::property_support::{
435        TRIALS, assert_unique_channel_spikes, sample_gain_scale, sample_input_value,
436        sample_positive_finite, scale_is_inactive,
437    };
438    use rand::rngs::StdRng;
439    use rand::{RngExt, SeedableRng};
440
441    const SEED: u64 = 0xAE69_0002;
442
443    fn sample_valid_encoder(rng: &mut StdRng) -> PopulationEncoder {
444        loop {
445            let num_neurons = rng.random_range(1usize..=32);
446            let lo = rng.random_range(-100.0_f32..100.0);
447            let hi = lo + sample_positive_finite(rng);
448            let width = sample_positive_finite(rng);
449            if let Ok(enc) = PopulationEncoder::try_new(num_neurons, (lo, hi), width) {
450                return enc;
451            }
452        }
453    }
454
455    fn assert_active_population_spikes(trial: usize, out: &EncodedOutput, n_neurons: usize) {
456        assert!(
457            out.spikes.len() <= n_neurons,
458            "trial {trial}: spikes {} > num_neurons {n_neurons}",
459            out.spikes.len()
460        );
461        assert_unique_channel_spikes(&out.spikes, n_neurons);
462    }
463
464    #[test]
465    fn prop_population_silence_and_spike_bounds() {
466        let mut rng = StdRng::seed_from_u64(SEED);
467        for trial in 0..TRIALS {
468            let mut encoder = sample_valid_encoder(&mut rng);
469            let n_neurons = encoder.num_neurons();
470            let sensitivity = sample_gain_scale(&mut rng);
471
472            let empty = encoder.encode_with_sensitivity_scale(&[], sensitivity);
473            assert!(
474                empty.spikes.is_empty(),
475                "trial {trial}: empty input must silence"
476            );
477
478            let value = sample_input_value(&mut rng, (0.0, 100.0));
479            let out = encoder.encode_with_sensitivity_scale(&[value], sensitivity);
480
481            if scale_is_inactive(sensitivity) {
482                assert!(
483                    out.spikes.is_empty(),
484                    "trial {trial}: inactive sensitivity={sensitivity:?} must silence"
485                );
486                continue;
487            }
488            assert_active_population_spikes(trial, &out, n_neurons);
489        }
490    }
491
492    #[test]
493    fn prop_population_tuning_rates_in_unit_interval() {
494        let mut rng = StdRng::seed_from_u64(SEED ^ 0x51A7);
495        for trial in 0..TRIALS {
496            let encoder = sample_valid_encoder(&mut rng);
497            let value = sample_input_value(&mut rng, (0.0, 100.0));
498            if !value.is_finite() {
499                continue;
500            }
501            let sens = sample_gain_scale(&mut rng);
502            let width =
503                encoder.effective_tuning_width(if scale_is_inactive(sens) { 1.0 } else { sens });
504            assert!(
505                width.is_finite() && width > 0.0,
506                "trial {trial}: effective width {width}"
507            );
508            for i in 0..encoder.num_neurons() {
509                let rate = encoder.get_rate_with_tuning_width(value, i, width);
510                assert!(
511                    rate.is_finite() && (0.0..=1.0).contains(&rate),
512                    "trial {trial}: neuron {i} rate {rate} outside [0,1]"
513                );
514            }
515        }
516    }
517
518    #[test]
519    fn prop_population_encode_never_panics_on_sampled_inputs() {
520        let mut rng = StdRng::seed_from_u64(SEED ^ 0xBAD5);
521        for _ in 0..TRIALS {
522            let mut encoder = sample_valid_encoder(&mut rng);
523            let value = sample_input_value(&mut rng, (-50.0, 50.0));
524            let sens = sample_gain_scale(&mut rng);
525            let _ = encoder.encode_with_sensitivity_scale(&[value], sens);
526            let _ = encoder.encode(&[value]);
527            let _ = encoder.encode_step(&[value]);
528            encoder.reset();
529        }
530    }
531}