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.99.0+ (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.
- Encoder guide — choose an encoder, run it in a streaming loop, interpret call-relative time, and manage state.
- Benchmark guide — run the included Criterion and allocation benches and compare results responsibly.
cargo run --example streaming_sensor— a sensor loop withTimeCursorand a reusable spike buffer.cargo run --example neuromodulated_encoding— deterministic streaming rate encoding controlled by public neuromodulator gain curves.
encode / encode_step create a fresh output vector per call; producing
spikes generally allocates storage. For a runtime stepping thousands of
channels, a reusable buffer can avoid those allocations. 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());
}
}The returning path (encode), step path (encode_step), and reusable path
(encode_into / encode_step_into) preserve the corresponding spikes, order,
and state advancement. Warm reusable path zero-allocation claims apply only to
rows reporting zero in cargo bench --bench allocations, for the measured
fixture and scale. See measured coverage and local regression procedure
for the matrix, warm-up details, and separate timing/allocation comparisons.
The local Rust 1.98.1 allocation report for these fixtures recorded zero
allocations and zero bytes for all 36 reusable rows, including the Delta
modulated smoke rows; no reusable path exception was observed. Unexpected
allocations should be documented here and in REVIEW.md with a separate issue.
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.
encode and encode_step hand every call's spikes straight to the caller.
StreamingEncoder inverts that: it wraps a borrowed encoder (&mut dyn Encoder
works), buffers each call's output in a bounded queue, and delivers whole
batches to a BatchSink under an explicit FlushPolicy you choose —
Manual, OnCapacity, or OnCapacityOrAge. The queue is hard-bounded: it
holds at most capacity spikes plus the one call in flight, and the wrapper
owns neither the encoder nor the delivery target.
Oversized calls may temporarily need more memory than capacity; their
staging or held allocation is released after delivery or reset. Call
flush_into before dropping the wrapper if pending batches matter: dropping
it cannot deliver them without a sink. After sequence u64::MAX, new inputs
return StreamingError::SequenceExhausted before reaching the encoder.
Each delivered SpikeBatch corresponds to one accepted call and carries that
call's monotonically increasing sequence number and its origin, the absolute
tick the call started at. Spike timestamps inside a batch stay the unchanged
call-relative offsets the encoder emitted — they are never rebased onto the
absolute timeline. Absolute time is reconstructed the same way as everywhere
else in the crate, from the batch origin and the untouched offset:
use axon_encoder::prelude::*;
fn main() -> Result<(), EncoderError> {
let mut encoder = RateEncoder::try_new(5.0, 100.0, (0.0, 1.0), 0.010)?;
let mut streaming = StreamingEncoder::try_new(&mut encoder, 32, FlushPolicy::OnCapacity)?;
let mut absolute_ticks: Vec<u64> = Vec::new();
let mut sink = |batch: SpikeBatch<'_>| {
for spike in batch.spikes() {
// origin is absolute; the offset is left exactly as emitted.
absolute_ticks.push(batch.origin() + spike.timestamp.ticks());
}
};
for _ in 0..8 {
streaming.encode_step(&[0.9], &mut sink)?;
}
streaming.flush_into(&mut sink); // deliver whatever is still queued
Ok(())
}The wrapper reads no clock: OnCapacityOrAge measures age in encoder ticks, so
a real-time deadline stays in caller code (check pending_age_ticks() or your
own timer, then call flush_into when you decide). StreamingEncoder does not
implement Encoder, because deferred, policy-driven delivery cannot honor the
trait's per-call output contract. See
cargo run --example streaming_encoder.
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 streaming_encoder
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.