A flexible sensory encoding library for spiking neural networks (SNNs).
axon-encoder turns continuous data—sensor readings, telemetry, control
signals—into spikes, the event-based signals SNNs process. Use it as the
front-end of a neuromorphic pipeline without pulling in a full SNN simulator.
0.5.x is experimental (pre-1.0). Cargo treats axon-encoder = "0.5" as
^0.5 (that is >= 0.5.0, < 0.6.0): compatible patch updates only.
The 0.5 release is a breaking line from 0.4; pin "=0.5.0" if you need an
exact crate version.
[dependencies]
axon-encoder = "0.5"Optional features:
| Feature | Purpose |
|---|---|
serde |
Serialize configs, gain types, and live encoder state. Deterministic encode_step paths resume exactly from a JSON-serializable checkpoint; stochastic paths that draw a thread-local RNG are not replay-stable. |
ndarray |
Encode from ndarray views (ArrayView1 / ArrayView2) |
wasm-js |
Enable JavaScript-host entropy for wasm32-unknown-unknown through getrandom's wasm_js backend. Intended for browsers, Web Workers, and supported Node.js hosts. |
[dependencies]
axon-encoder = { version = "0.5", features = ["ndarray"] }
ndarray = "0.16" # declare yourself so you can build ArrayView valuesRequires Rust 1.98.1+ (edition 2024). See rust-version in Cargo.toml.
With features = ["serde"], encoder structs serialize configuration plus
live mutable state (history, membrane, phase, rate accumulators, pending
spike backlog). Restore a JSON-serializable checkpoint and keep calling
encode_step with the remaining inputs: deterministic encoders emit the same
spikes and finish in the same state as the original.
That contract covers Delta, Derivative, Temporal, Predictive, Phase,
EmbeddingRateEncoder, and RateEncoder::encode_step, provided the live
floats are finite. JSON cannot represent NaN/Inf (serde_json writes
null), and deserialize rejects that payload, so a snapshot taken after
DerivativeEncoder stores a non-finite last_values entry is not
restorable via serde_json.
It does not cover stochastic paths: RateEncoder::encode (batch), and
both batch and streaming PopulationEncoder / PoissonEncoder. Those methods
build a thread-local generator internally; serde does not capture it, and they
do not take caller-owned RNG state.
use axon_encoder::prelude::*;
fn main() {
// Prefer try_new: typed validation instead of panics on bad config.
// Range is (min, max); values are clamped to that span. Endpoints map to
// base_rate / max_rate (here 5–100 Hz at a 10 ms sampling interval).
let mut encoder = RateEncoder::try_new(5.0, 100.0, (0.0, 1.0), 0.010)
.expect("valid RateEncoder configuration");
// Inclusive endpoints 0.0 ..= 1.0 (matches the range above).
let input: Vec<f32> = (0..64).map(|i| i as f32 / 63.0).collect();
let output = encoder.encode(&input);
println!(
"Input of {} values produced {} spikes.",
input.len(),
output.spikes.len()
);
}Full API docs: docs.rs/axon-encoder.
encode / encode_step allocate a fresh Vec<SpikeEvent> per call. That is
the right default for exploration, but a runtime stepping thousands of channels
wants one buffer, allocated once. encode_into / encode_step_into write into
any SpikeSink you own instead:
use axon_encoder::prelude::*;
fn main() {
let mut encoder = DeltaEncoder::try_new(0.1, 3).expect("valid DeltaEncoder");
let mut buffer: Vec<SpikeEvent> = Vec::new();
for step in [[0.5, 0.0, 0.0], [0.5, 0.9, 0.0]] {
buffer.clear(); // keeps the capacity, drops last step's spikes
encoder.encode_step_into(&step, &mut buffer);
println!("{} spikes", buffer.len());
}
}Same spikes, same order, same state advancement as the returning APIs — and
zero allocations per step once the buffer is warm. Vec<SpikeEvent> and
EncodedOutput implement SpikeSink out of the box; a downstream event
buffer, ring queue, or hardware adapter implements the one-method trait itself,
so no Vec<SpikeEvent> is ever built — it keeps spikes in whatever form it
already wants:
use axon_encoder::prelude::*;
struct EventQueue {
events: Vec<(u16, u64)>,
}
impl SpikeSink for EventQueue {
fn push(&mut self, event: SpikeEvent) {
self.events.push((event.channel, event.timestamp.ticks()));
}
fn reserve(&mut self, additional: usize) {
self.events.reserve(additional);
}
}SpikeSink has a third, optional method — extend_from_slice — which defaults
to push in a loop. Override it when your sink can take a slice more cheaply
than repeated pushes; encoders deliver spikes through it in fixed-size runs, so
writing through a trait object costs one virtual call per run rather than per
spike.
Encoders append to a sink and never clear it, so the caller decides where
step boundaries are. The trait is object-safe, so &mut dyn Encoder and
&mut dyn ModulatedEncoder still work; ModulatedEncoder has the matching
encode_with_gains_into / encode_with_modulators_into. PoissonEncoder is
a documented exception — it does not implement Encoder. See
cargo run --example encode_into_sink.
EncodedOutput is deliberately small and framework-agnostic: it carries the
emitted spikes and, for embedding-producing encoders, an optional dense
embeddings vector. Source identifiers, biological state, tracing data, and
other domain-specific telemetry belong in downstream adapters rather than the
core encoding result.
Each concrete encoder owns its configuration through its constructor
parameters (and, where applicable, a focused encoder-specific config such as
EmbeddingEncoderConfig). There is no shared EncoderConfig; this avoids
forcing unrelated algorithms into one oversized configuration object.
Every encoder here shares one time model, so a consumer can integrate any of
them — or a &mut dyn Encoder — without special cases:
A
SpikeEvent::timestampis aTickOffset: a count of encoder ticks measured from the start of theencode/encode_stepcall that emitted it.
Timestamps are call-relative. They are never absolute and never wall-clock,
because this crate owns no clock and no scheduler. The caller keeps absolute
time in a TimeCursor and advances it once per call:
use axon_encoder::prelude::*;
fn main() -> Result<(), EncoderError> {
let mut encoder = LatencyEncoder::try_new(9, (0.0, 1.0))?;
let mut cursor = TimeCursor::new(encoder.time_model());
for _ in 0..3 {
let output = encoder.encode_step(&[0.9, 0.1]);
for spike in &output.spikes {
// Absolute tick on your timeline; absolute_nanos(..) when a
// Timebase is available.
let _tick = cursor.absolute(spike.timestamp);
}
cursor.advance(); // by time_model().step_ticks()
}
Ok(())
}Encoder::time_model() reports the three things a consumer needs:
| Meaning | |
|---|---|
step_ticks() |
How far your origin advances per call |
span_ticks() |
Exclusive bound on offsets a single call can emit |
timebase() |
Physical duration of one tick, when the encoder knows it |
Per encoder:
| Encoder | step_ticks |
span_ticks |
timebase |
|---|---|---|---|
RateEncoder |
1 | 1 | dt_seconds |
LatencyEncoder |
max_latency + 1 |
max_latency + 1 |
none |
PhaseEncoder |
1 | cycle_steps |
none |
PopulationEncoder, DeltaEncoder, DerivativeEncoder, TemporalEncoder, PredictiveEncoder, EmbeddingRateEncoder |
1 | 1 | none |
Batch versus streaming. Both modes follow the same rule, once per call —
encode is not a longer window than encode_step. PhaseEncoder advances its
oscillation by one tick in either mode; the stateful encoders update history in
either mode; LatencyEncoder is stateless, so the two are identical.
Ordering. Within one spikes slice: channel IDs are non-decreasing, offsets
are non-decreasing within a channel, and repeated spikes from one channel at one
offset (a RateEncoder burst) are contiguous and mutually unordered — the run
length is a spike count, not a sequence. Note this is channel-major, not
globally time-sorted: sort by timestamp if you need a chronological stream.
PhaseEncoder is the one encoder whose calls overlap (span_ticks > step_ticks), since a call can place a spike anywhere in the ongoing cycle.
Run cargo run --example spike_timebase for a worked integration: two encoders,
two cursors, one merged nanosecond-timed stream.
The 0.5 line removes public placeholders that had no authoritative consumer:
EncoderConfigwas removed. Configure each encoder through its own constructor parameters or focused config type. The former 256-channel defaults did not control the concrete encoders.EncodingMetadataandEncodedOutput::metadatawere removed. The type was empty, so it provided no stable semantics. Keep application or framework-specific telemetry in a downstream adapter. Common timebase semantics are handled by the explicit time types introduced in issue #62, rather than a catch-all metadata bag.EncodedOutput::embeddingsremains. It is an optional dense-vector slot for custom encoders and downstream adapters.EmbeddingRateEncoderno longer normalizes its input or populates this field; its standardized output contains spikes and leavesembeddingsasNone.
Additional breaking changes in the 0.5 line:
SpikeEvent::timestampis nowTickOffset, notu64. The type converts both ways and compares againstu64, so reads likeassert_eq!(spike.timestamp, 5)andspike.timestamp <= otherstill work. Construction sites needSpikeEvent::new(channel, 5u64, true),SpikeEvent::at_step_start(channel, true), orTickOffset::new(5)in the struct literal; usespike.timestamp.ticks()where a rawu64is required. The serde representation is unchanged —TickOffsetis#[serde(transparent)], so 0.4 payloads still deserialize.PhaseEncoderemits call-relative offsets. It previously emittedcurrent_phase + phase_offset, an absolute value that no other encoder used. The old number iscursor.absolute(spike.timestamp), orphase_before_the_call + spike.timestamp.ticks()if you track the oscillation yourself; cycle position staysabsolute % cycle_steps. Capturecurrent_phase()before the emitting call — every encode call advances it afterward, so a read taken after the call is one tick ahead.
Two smaller behavior changes, both in service of making span_ticks() a hard
bound rather than an advisory one:
- A neuromodulated
latency_scaleabove1.0no longer stretches spikes pastmax_latency. Latency gains still shorten the window. LatencyEncoder::try_newrejectsmax_latency == u64::MAXwithEncoderError::WindowTooLarge, since the presentation window ismax_latency + 1ticks and that value has no representable window. TheserdeDeserializeimpl routes throughtry_new, so a 0.4 payload persisted with thatmax_latencynow fails to load rather than round-tripping.
Encoder::time_model() has a default implementation, so out-of-crate Encoder
impls keep compiling and inherit TimeModel::INSTANT.
RateEncoder treats base_rate and max_rate as firing rates in hertz.
Prefer RateEncoder::try_new(base_rate_hz, max_rate_hz, range, dt_seconds) so the
sampling interval is explicit (finite and strictly positive). Stochastic batch
encoding uses p = 1 - exp(-rate_hz * dt_seconds); streaming accumulates
phase += rate_hz * dt_seconds.
RateEncoder::new(base_rate, max_rate, range) remains for compatibility and
uses dt_seconds = 0.1.
Most encoders expose try_new(...) -> Result<Self, EncoderError> for invalid
rates, ranges, windows, thresholds, or channel counts. Prefer those over
panicking new(...) in libraries and applications. PredictiveEncoder is the
exception: its new(...) already returns a Result.
EmbeddingRateEncoder is a general-purpose integrate-and-fire Encoder: it
accumulates each call's input into a persistent per-channel membrane
potential and fires whenever a channel crosses config.v_th, so it fits any
fixed-width numeric vector — not just embeddings.
use axon_encoder::prelude::*;
fn main() -> Result<(), EncoderError> {
let drive = [0.2_f32, 0.9, 0.5];
let mut encoder = EmbeddingRateEncoder::try_new(drive.len(), EmbeddingEncoderConfig {
v_th: 0.4,
})?;
for _ in 0..3 {
let output = encoder.encode(&drive);
println!("{} channels fired", output.spikes.len());
}
encoder.reset(); // zero the membrane potentials before reusing the encoder
Ok(())
}Migrating from forward / EncoderState (0.4, removed in 0.5). 0.4
threaded state explicitly and normalized a fixed embedding vector once at
construction:
let enc = EmbeddingRateEncoder::new(&embeddings, config);
let (out, next) = enc.forward(&EncoderState::new_zeros(embeddings.len()));
0.5 owns its membrane state internally and takes the drive vector as
encode's input, matching every other Encoder in the crate:
let mut enc = EmbeddingRateEncoder::try_new(embeddings.len(), config)?;
let out = enc.encode(&embeddings);
The built-in min-max normalization is also removed, since it was a hidden
construction-time transform whose result depended on the full embedding
distribution rather than on any one call's input. Callers that relied on it
should normalize before calling encode, using the former formula
(x - min) / (max - min + 1e-5).
- Encoders for different signal structures:
RateEncoder— spike rate tracks input magnitudeDerivativeEncoder— fires on change (jumps / drops)TemporalEncoder— patterns over timePopulationEncoder— value distributed across a population of unitsDeltaEncoder— spike when the signal moves by a thresholdLatencyEncoder— stronger input → earlier spike in a windowPoissonEncoder— Poisson-process style samplingEmbeddingRateEncoder— general-purpose integrate-and-fire over a fixed-width numeric vector
Encoder/ModulatedEncodertraits — plug in custom encoders or apply gain scales (EncodingGains) without owning a full neuromodulator runtimeSpikeSink+encode_into— write spikes into caller-owned storage and reuse one buffer across steps, or translate straight into your own event type- Optional
ndarrayhelpers —NdarrayEncoderExtfor view-based batch input - Small dependency surface — easy to embed in larger systems
RateEncoder, PopulationEncoder, and PoissonEncoder sample unit floats in
[0, 1) via axon_encoder::rng:
- Default:
gen_unit_f32()uses a thread-localrandgenerator (not reproducible across runs). - Reproducible runs:
gen_unit_f32_with_rng(&mut rng)with a seeded RNG (for examplerand::rngs::StdRng). - For encoding only — not cryptographic use.
Consumers targeting browsers, Web Workers, or supported Node.js hosts (Node.js
19+) on wasm32-unknown-unknown must enable the wasm-js feature to select
getrandom's supported wasm_js backend:
[dependencies]
axon-encoder = { version = "0.5", features = ["wasm-js"] }The feature is opt-in because wasm32-unknown-unknown also supports non-JS and
non-Web runtimes where a JavaScript backend is unavailable. Leave wasm-js
disabled for those targets and select a randomness backend appropriate to the
runtime at the final binary or application layer.
Clone the repository and run:
cargo run --example rate_encoding
cargo run --example delta_encoding
cargo run --example embedding_encoding
cargo run --example spike_timebase
cargo run --example encode_into_sink
cargo run --example ndarray_encoding --features ndarrayOther examples live under examples/ (latency, population, temporal,
predictive, gain-adapter patterns, and more).
- Sensory / signal → spike encoding algorithms
- Deterministic and stochastic encoding pipelines
- Generic gain controls (
EncodingGains, gain curves) used only for scaling rate, threshold, latency, or sensitivity at encode time
- Full SNN simulation, network topology, or synaptic plasticity (STDP)
- Long-horizon biological neuromodulator dynamics or reward loops (this crate only provides encoding-local gain helpers)
- FPGA / ASIC / GPU device bindings
The library is intentionally unopinionated about which simulator or hardware stack you plug the spikes into.
Issues and pull requests are welcome—new encoders, fixes, and docs improvements
alike. Development notes and CI conventions live in the repository
(REVIEW.md, .github/).
The .devcontainer/ configuration is available for VS Code Dev Containers
and Codespaces contributor workflows. It is an editor development environment,
not a published or supported distribution artifact; consumers should use the
crate from Cargo as described in Installation.
Dual-licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE-2.0 or http://www.apache.org/licenses/LICENSE-2.0)
- MIT License (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.