axon_encoder/lib.rs
1//! # axon-encoder
2//!
3//! Flexible sensory encoding for spiking neural networks: continuous signals
4//! in, spike events out. Optional [`EncodingGains`] scale rate / threshold /
5//! latency / sensitivity without requiring an external neuromodulator runtime.
6
7pub mod encoder;
8pub mod encoders;
9pub mod error;
10pub mod modulators;
11#[cfg(feature = "ndarray")]
12pub mod ndarray_ext;
13pub mod poisson;
14pub mod rng;
15pub mod types;
16
17pub use error::EncoderError;
18#[cfg(feature = "ndarray")]
19pub use ndarray_ext::NdarrayEncoderExt;
20
21pub mod prelude {
22 pub use crate::Encoder;
23 pub use crate::ModulatedEncoder;
24 pub use crate::encoder::*;
25 pub use crate::encoders::*;
26 pub use crate::error::*;
27 pub use crate::modulators::*;
28 #[cfg(feature = "ndarray")]
29 pub use crate::ndarray_ext::NdarrayEncoderExt;
30 pub use crate::poisson::*;
31 pub use crate::types::*;
32}
33
34use modulators::{EncodingGains, NeuroModulators, NeuromodulatorGainCurves};
35use types::EncodedOutput;
36
37/// Encoders that can apply neuromodulator-driven gain curves.
38///
39/// Object-safe so callers can use `&mut dyn ModulatedEncoder` when the concrete
40/// encoder type is not known at compile time. Implementations map the relevant
41/// component of [`EncodingGains`] to encoder-specific scaling; public modulator
42/// helpers are provided once here.
43///
44/// Concrete encoders also keep inherent `encode_with_modulators` /
45/// `encode_step_with_modulators` wrappers so existing call sites need not import
46/// this trait.
47///
48/// # Examples
49///
50/// Prefer the **streaming** path for doctests: batch `encode_with_modulators` is
51/// stochastic, while `encode_step_with_modulators` on rate encoders is deterministic.
52///
53/// ```rust
54/// use axon_encoder::prelude::*;
55/// # fn main() -> Result<(), EncoderError> {
56/// let mut enc = RateEncoder::try_new(0.0, 100.0, (0.0, 1.0), 0.01)?;
57/// let mods = NeuroModulators {
58/// dopamine: 1.0,
59/// ..Default::default()
60/// };
61/// let curves = NeuromodulatorGainCurves {
62/// dopamine: ModulatorGainCurves {
63/// firing_rate: Some(GainCurve::new((0.0, 1.0), (1.0, 2.0))),
64/// ..Default::default()
65/// },
66/// ..Default::default()
67/// };
68/// // Accumulates rate_hz * dt; at unit input with elevated gain, a spike fires soon.
69/// let mut saw_spike = false;
70/// for _ in 0..20 {
71/// if !enc
72/// .encode_step_with_modulators(&[1.0], &mods, &curves)
73/// .spikes
74/// .is_empty()
75/// {
76/// saw_spike = true;
77/// break;
78/// }
79/// }
80/// assert!(saw_spike);
81/// # Ok(())
82/// # }
83/// ```
84pub trait ModulatedEncoder: Encoder {
85 /// Encodes input using already evaluated encoding gains.
86 ///
87 /// Implementations must sanitize `gains` (or the component they use) before
88 /// applying them.
89 fn encode_with_gains(&mut self, input: &[f32], gains: EncodingGains) -> EncodedOutput;
90
91 /// Encodes one streaming step using already evaluated encoding gains.
92 ///
93 /// Stateful encoders should override this when streaming requires distinct
94 /// state handling from the batch path.
95 fn encode_step_with_gains(&mut self, input: &[f32], gains: EncodingGains) -> EncodedOutput {
96 self.encode_with_gains(input, gains)
97 }
98
99 /// Encodes input using neuromodulator-driven gain curves.
100 fn encode_with_modulators(
101 &mut self,
102 input: &[f32],
103 modulators: &NeuroModulators,
104 gain_curves: &NeuromodulatorGainCurves,
105 ) -> EncodedOutput {
106 self.encode_with_gains(input, gain_curves.evaluate(modulators))
107 }
108
109 /// Encodes one streaming step using neuromodulator-driven gain curves.
110 fn encode_step_with_modulators(
111 &mut self,
112 input: &[f32],
113 modulators: &NeuroModulators,
114 gain_curves: &NeuromodulatorGainCurves,
115 ) -> EncodedOutput {
116 self.encode_step_with_gains(input, gain_curves.evaluate(modulators))
117 }
118}
119
120/// The core trait for all encoders in this crate.
121///
122/// Encoders convert continuous analog values into discrete spike events for
123/// spiking neural networks (SNNs). Two modes are supported:
124///
125/// - **Batch mode** (`encode`): Process a complete input vector at once.
126/// - **Streaming mode** (`encode_step`): Process incrementally, one step at a time.
127///
128/// # Example
129///
130/// ```rust
131/// use axon_encoder::prelude::*;
132/// # fn main() -> Result<(), EncoderError> {
133///
134/// let mut encoder = RateEncoder::try_new(5.0, 50.0, (0.0, 1.0), 0.010)?;
135/// let input = [0.25, 0.75, 0.5];
136///
137/// // Batch encoding
138/// let output = encoder.encode(&input);
139///
140/// // Reset for streaming (if using stateful encoder)
141/// encoder.reset();
142/// # Ok(())
143/// # }
144/// ```
145pub trait Encoder {
146 /// Encodes a slice of analog values into spike events (batch mode).
147 fn encode(&mut self, input: &[f32]) -> EncodedOutput;
148
149 /// Encodes a single step incrementally (streaming mode).
150 ///
151 /// By default, this delegates to `encode()` for stateless encoders.
152 /// Stateful encoders should override this to maintain state between calls.
153 ///
154 /// # Arguments
155 ///
156 /// * `input` - A slice of analog values to encode
157 ///
158 /// # Returns
159 ///
160 /// An `EncodedOutput` containing any spike events generated in this step
161 fn encode_step(&mut self, input: &[f32]) -> EncodedOutput {
162 self.encode(input)
163 }
164
165 /// Resets the encoder to its initial state
166 fn reset(&mut self);
167}
168
169#[cfg(test)]
170mod tests {
171 #[test]
172 fn test_lib_prelude_imports() {
173 use crate::prelude::*;
174 let _ = EncoderConfig::default();
175 }
176
177 /// Guard: `axon-encoder` must not depend on the neuromod crate (#21).
178 ///
179 /// Uses `cargo metadata` so table syntax, workspace inheritance, rename/
180 /// package aliases, and normal/dev/build/target scopes are all covered
181 /// without matching description prose.
182 #[test]
183 fn cargo_toml_has_no_neuromod_crate_dependency() {
184 // `CARGO` is always set when this crate is built by cargo (no fallback branch).
185 let output = std::process::Command::new(env!("CARGO"))
186 .args(["metadata", "--no-deps", "--locked", "--format-version", "1"])
187 .current_dir(env!("CARGO_MANIFEST_DIR"))
188 .output()
189 .expect("spawn cargo metadata");
190 // Always materialize stderr so a --locked/offline failure is actionable
191 // and codecov does not see a cold format arm.
192 let metadata_detail = format!(
193 "cargo metadata failed (status={:?}): {}",
194 output.status.code(),
195 String::from_utf8_lossy(&output.stderr)
196 );
197 assert!(output.status.success(), "{metadata_detail}");
198
199 let meta: serde_json::Value =
200 serde_json::from_slice(&output.stdout).expect("parse cargo metadata json");
201 let packages = meta["packages"].as_array().expect("packages array");
202 let deps = packages
203 .iter()
204 .find(|p| p["name"] == "axon-encoder")
205 .expect("axon-encoder package in metadata")["dependencies"]
206 .as_array()
207 .expect("dependencies array");
208
209 // Collect offenders so a failure names them; build the message on the
210 // success path too so codecov patch does not see cold format arms.
211 let forbidden: Vec<&serde_json::Value> =
212 deps.iter().filter(|d| d["name"] == "neuromod").collect();
213 let detail = format!(
214 "forbidden neuromod deps (name/kind): {:?}",
215 forbidden
216 .iter()
217 .map(|d| (&d["name"], &d["kind"]))
218 .collect::<Vec<_>>()
219 );
220 assert!(forbidden.is_empty(), "{detail}");
221 }
222
223 #[test]
224 fn test_encoder_default_encode_step_delegates_to_encode() {
225 use crate::prelude::*;
226
227 struct PassThrough;
228 impl Encoder for PassThrough {
229 fn encode(&mut self, input: &[f32]) -> EncodedOutput {
230 let mut out = EncodedOutput::new();
231 for (i, &v) in input.iter().enumerate() {
232 out.spikes.push(SpikeEvent {
233 channel: i as u16,
234 timestamp: v as u64,
235 polarity: true,
236 });
237 }
238 out
239 }
240 fn reset(&mut self) {}
241 }
242
243 let mut enc = PassThrough;
244 let out = enc.encode_step(&[1.0, 2.0]);
245 assert_eq!(out.spikes.len(), 2);
246 }
247}