Reusable SNN learning/training orchestration layer: reward-modulated training loops above neuromod's plasticity primitives.
- Overview
- Ecosystem overview
- Getting started
- Choosing features
- Common patterns
- Architecture brief
- Scope and ownership boundaries
- Cross-language notes
- Contributing
- License / REUSE
plasticity-lab is the reusable SNN learning/training orchestration layer for the Limen-Neural stack. It provides a small training loop around neuromod::SpikingNetwork — the crate that owns neuron/network dynamics, neuromodulator state, and the foundational classical and reward-modulated STDP primitives. plasticity-lab calls and configures those primitives through neuromod's public API; it does not reimplement them.
It is intentionally domain-agnostic:
- Neuron/network dynamics and low-level plasticity primitives (STDP, R-STDP) belong to
neuromod - Input encoding belongs to
axon-encoder(not a dependency of this crate — see Choosing features) - Reward shaping belongs to
limbic-critic - This crate orchestrates the training/session loop, maps rewards or modulator vectors into training steps, and tracks training summaries
If you are new to the Limen-Neural stack, start with Getting started, then skim Ecosystem overview and Scope and ownership boundaries so you know which crate owns which piece.
| Crate | Role | Language | When to use it |
|---|---|---|---|
| plasticity-lab (this crate) | Training/session orchestration (train_step, run_session) |
Rust | You need a reward-modulated SNN training loop and session metrics |
| neuromod | Core SNN dynamics, neuromodulator types, and foundational (classical + reward-modulated) STDP primitives (SpikingNetwork, NeuroModulators) |
Rust | You need the network, step dynamics, modulator state, or the underlying plasticity rules |
| limbic-critic | Reward shaping | Rust | You need shaped / multi-signal rewards instead of raw scalars |
| axon-encoder | Input encoding | Rust | You need to turn raw features into spike stimuli — not a dependency of this crate; wire it in yourself |
| SynapticDistill.jl | Distillation / knowledge transfer | Julia only | Teacher–student or differentiable distillation — not STDP |
Typical Rust data path:
raw inputs
→ axon-encoder (your own glue code; not a dependency of this crate)
→ plasticity-lab::train_step / run_session
→ neuromod::SpikingNetwork
← limbic-critic reward (optional, feature = "critic")
See also the ownership boundary with SynapticDistill.jl below.
- Rust 1.98.1 toolchain (rustup) — pinned in
rust-toolchain.toml - Access to crates.io
- Optional: a VS Code Dev Container setup is included under
.devcontainer/
CI-tested platforms: Linux, macOS, and Windows (ubuntu-latest, macos-latest, windows-latest in .github/workflows/ci.yml). Formatting, cargo deny, rustdoc, and coverage (tarpaulin → Codecov) stay Linux-only.
[dependencies]
plasticity-lab = "0.2.1"
neuromod = "0.6.0"
rand = "0.10"Version 0.2.1 was published to crates.io on 2026-09-19.
use neuromod::SpikingNetwork;
use plasticity_lab::{PlasticityTrainer, TrainingConfig, TrainingExample};
use rand::{rngs::StdRng, SeedableRng};
fn main() {
let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
let mut network = SpikingNetwork::with_dimensions(4, 2, 8);
for neuron in &mut network.neurons {
// `with_dimensions` intentionally creates blank weights. Seed the
// documented L1 budget equally across input channels before training.
neuron.weights.fill(2.0 / network.num_channels as f32);
}
let batch = vec![TrainingExample {
stimuli: vec![1.0, 0.8, 0.6, 0.4, 0.2, 0.1, 0.05, 0.02],
reward: 1.0,
}; 8];
let mut rng = StdRng::seed_from_u64(0x5EED);
let summary = trainer
.run_session_with_rng(&mut network, &batch, &mut rng)
.unwrap();
assert!(summary.total_spikes > 0);
assert!(summary.weight_drifts.iter().flatten().any(|delta| delta.abs() > 1e-5));
println!(
"processed={}, avg_reward={}, total_spikes={}",
summary.steps_processed, summary.avg_reward, summary.total_spikes
);
}SpikingNetwork::with_dimensions deliberately starts with all-zero weights.
That blank topology can step, but it has no active weighted path and therefore
cannot demonstrate learning. The equal-share initialization above distributes
neuromod's current 2.0 L1 weight budget across the input channels; applications
remain responsible for choosing their own topology and initialization policy.
run_session returns a TrainingSummary with step counts, average reward, spike totals, and threshold/weight drift relative to the session start.
For a single network step with an external reward, call train_step directly (see Architecture brief).
For per-step telemetry without copying the network, use run_session_with_observer (see Per-step session observer).
To pull in limbic-critic as an optional dep and enable the critic → neuromodulator bridge, enable the critic feature — see Choosing features.
| Feature | Default? | What it enables |
|---|---|---|
| (none) / default | yes | Core loop only: depends on neuromod + serde/thiserror/rand |
critic |
no | Optional dep on limbic-critic, plus the bridge module that converts limbic_critic::ModulatorVector into neuromod::NeuroModulators |
wasm-js |
no | Forwards to neuromod/wasm-js, selecting getrandom's JavaScript entropy backend for browsers and Web Workers |
# Core only (recommended first step)
plasticity-lab = "0.2.1"
# With the critic bridge
plasticity-lab = { version = "0.2.1", features = ["critic"] }
limbic-critic = "0.3.0"
# In a browser or Web Worker (combine with `critic` when needed)
plasticity-lab = { version = "0.2.1", features = ["wasm-js"] }When to use default: you already shape rewards and encode inputs yourself (or use plain f32 stimuli and scalar rewards, as in the getting-started example). This includes any input-encoding needs — axon-encoder is a standalone sibling crate you wire in yourself; this crate never depends on it (see Architecture brief).
When to enable critic: you want Cargo to resolve limbic-critic alongside this crate and use the bridge adapter to turn a ModulatorVector into a training step via train_step_from_critic/apply_modulator_vector. The core trainer API does not require the feature; it always takes precomputed stimuli: &[f32] and reward: f32.
When to enable wasm-js: your wasm32-unknown-unknown application runs
in a browser or Web Worker and should obtain entropy through JavaScript. The
feature only forwards to neuromod/wasm-js; it does not change this crate's
training, reward, plasticity, critic, or observer APIs, but it does change the
entropy source used by neuromod's thread-local RNG to the JavaScript backend.
It is not a default because JavaScript bindings are inappropriate for native
consumers and for non-Web WebAssembly hosts. Consumers targeting WASI or
another non-Web host must leave wasm-js disabled and select an entropy
backend suitable for their runtime.
Exercise the native configurations locally:
cargo test
cargo test --all-featuresCI additionally checks the opt-in browser configurations, both with and
without critic, against wasm32-unknown-unknown using the lockfile. It also
verifies that getrandom/wasm_js appears only when wasm-js is enabled.
Use TrainingExample batches and run_session when you have a fixed list of stimuli/reward pairs (the Getting started example).
run_session only returns a final TrainingSummary. To receive step index, reward, effective modulators, and spike indices after each successful network step, call run_session_with_observer. The event is borrowed (no network copy, no logging crate). Returning an error aborts before the next example; the failing step index and processed count are in TrainerError::Observer.
use neuromod::SpikingNetwork;
use plasticity_lab::{
PlasticityTrainer, TrainingConfig, TrainingExample, TrainingObserver, TrainingStepEvent,
};
struct SpikeCounter(u64);
impl TrainingObserver for SpikeCounter {
type Error = &'static str;
fn on_step(&mut self, event: TrainingStepEvent<'_>) -> Result<(), Self::Error> {
self.0 += event.spike_indices.len() as u64;
Ok(())
}
}
fn main() {
let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
let mut network = SpikingNetwork::with_dimensions(32, 8, 64);
let batch = vec![TrainingExample {
stimuli: vec![0.25; 64],
reward: 0.2,
}];
let mut observer = SpikeCounter(0);
let summary = trainer
.run_session_with_observer(&mut network, &batch, &mut observer)
.unwrap();
assert_eq!(observer.0, summary.total_spikes);
}A JSONL writer belongs in application code, not this crate — see examples/jsonl_session_observer.rs.
Drive the network yourself when rewards are online or adaptive:
use neuromod::{SpikingNetwork, StepError};
use plasticity_lab::{PlasticityTrainer, TrainingConfig};
fn main() -> Result<(), StepError> {
let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
let mut network = SpikingNetwork::with_dimensions(32, 8, 64);
let stimuli = vec![0.3; 64];
let reward = 0.15; // from your environment or limbic-critic
let spikes = trainer.train_step(&mut network, &stimuli, reward)?;
println!("spikes this step: {:?}", spikes);
Ok(())
}plasticity-lab never computes rewards. Pass a scalar f32 from your environment or critic:
- Finite positive → dopamine up / norepinephrine down (clamped)
- Finite negative → norepinephrine up / dopamine adjusted (clamped)
NaNor ±infinity → no neuromodulator update intrain_step(NaNis omitted from sessionavg_reward; ±infinity is rejected byrun_sessionpreflight)
Shape rewards in application code or via limbic-critic when using the critic feature.
train_step / TrainingExample.stimuli expect a flat &[f32] (or Vec<f32>) matching the network’s input size. Encode with your own code or axon-encoder.
use plasticity_lab::TrainingConfig;
let config = TrainingConfig {
use_reward_modulation: true,
};TrainingConfig::default() matches the value above. Set use_reward_modulation: false to step the network without adjusting neuromodulators from the reward (stimuli still apply).
train_step / run_session still use neuromod's convenience thread-local RNG. For a replayable above-threshold session, pass a seeded generator:
use neuromod::SpikingNetwork;
use plasticity_lab::{PlasticityTrainer, TrainingConfig, TrainingExample};
use rand::SeedableRng;
use rand::rngs::StdRng;
fn main() {
let mut trainer = PlasticityTrainer::new(TrainingConfig::default());
let mut network = SpikingNetwork::with_dimensions(32, 8, 64);
let mut rng = StdRng::seed_from_u64(42);
let batch = vec![TrainingExample {
stimuli: vec![0.25; 64],
reward: 0.2,
}];
let summary = trainer
.run_session_with_rng(&mut network, &batch, &mut rng)
.unwrap();
println!("processed={}", summary.steps_processed);
}A starting seed replays a session from the beginning given the same network checkpoint, config, and data. Mid-run resume needs the generator's already-advanced state, or a catch-up pass that replays every prior draw; reseeding from the original seed after a mid-session snapshot does not continue the same stream. StdRng traces are for a given rand version and target, not a portable cross-platform byte stream.
Checkpointing is still application-owned. Persist the network (neuromod already serde's SpikingNetwork) together with that RNG state. This crate does not ingest replay files.
TrainingConfig only exposes fields that drive an explicit code path in train_step. It does not expose a learning_rate, homeostasis setpoint, or batch_size knob: low-level STDP / homeostasis tuning is owned by neuromod::SpikingNetwork, which derives its own learning rate and thresholds from neuromodulator state, and batches are passed directly as &[TrainingExample] slices to run_session rather than configured. See CHANGELOG.md for the migration note if you are upgrading from a config that set those fields.
This section describes this crate only. Network dynamics, neuromodulator state, and the underlying classical / reward-modulated STDP primitives live in neuromod — see its own ownership documentation (neuromod#readme) for that crate's boundary commitments.
| Item | Role |
|---|---|
PlasticityTrainer |
Holds TrainingConfig; owns train_step, run_session, seeded *_with_rng variants, and run_session_with_observer |
TrainingConfig |
Serializable knobs (currently just the reward-modulation flag) |
TrainingExample |
One sample: stimuli: Vec<f32> + reward: f32 |
TrainingSummary |
Session metrics after run_session |
TrainingStepEvent |
Borrowed per-step snapshot for observers (no mutable network access) |
TrainingObserver |
Generic callback invoked after each successful session step |
TrainerError |
EmptyBatch, InvalidSample { index, reason }, wrapped StepError from neuromod, or Observer abort |
SampleInvariant |
Which batch-admission check failed (length, non-finite stimulus, infinite reward) |
- Reads current neuromodulators from the network.
- If
use_reward_modulationistrue(default), adjusts dopamine / norepinephrine from a finite scalarreward(clamped to[0, 1]); non-finite rewards (NaNand ±infinity) skip modulation. Otherwise leaves modulators unchanged. - Calls
network.step(stimuli, &modulators). - Returns spike indices (
Vec<usize>) orStepError.
- Rejects empty batches (
TrainerError::EmptyBatch) without mutating the network. - Preflights every example (stimulus length vs
num_channels, finite stimuli, infinite reward) and returnsTrainerError::InvalidSample { index, reason }on the first failure — still with no mutation. - Snapshots thresholds and weights.
- Calls
train_stepfor eachTrainingExamplein slice order. - Aggregates spikes and average reward (non-finite rewards are omitted from the mean, matching
train_step; infinite rewards never reach this step because preflight rejects them). - Records per-neuron threshold and weight drifts vs. session start.
- Returns
TrainingSummary.
No per-step event is constructed on this path.
Same as run_session, plus one TrainingStepEvent after each successful train_step. Observer failure returns TrainerError::Observer and does not step the next example. A failed train_step does not emit an event for that example.
| Field | Meaning |
|---|---|
steps_processed |
Number of examples run |
total_spikes |
Sum of spike events across steps |
avg_reward |
Mean of finite example rewards (0.0 when none are finite) |
threshold_drifts |
Per-neuron Δthreshold over the session |
weight_drifts |
Per-neuron per-channel Δweight over the session |
per_neuron_spikes |
Spike counts per neuron |
API docs: run cargo doc --open (or cargo doc --no-deps in CI-friendly environments).
plasticity-lab is the reusable SNN learning/training orchestration layer above the low-level plasticity primitives in neuromod. It is intentionally domain-agnostic, and it does not reimplement plasticity algorithms that neuromod already owns.
application / supervisor
│
│ drives experiments, reads TrainingSummary
▼
plasticity-lab (this crate)
│ training/session orchestration: train_step,
│ run_session, run_session_with_observer,
│ reward/modulator-vector mapping,
│ batches, metrics, critic bridge adapter
▼
neuromod
SpikingNetwork, neuron/network dynamics,
neuromodulator state, foundational classical
and reward-modulated STDP primitives
- Training/session orchestration (
train_step,run_session,run_session_with_observer) - Mapping externally supplied scalar rewards or modulator vectors (e.g. from
limbic-critic) into a training step - Training examples / batches (
TrainingExample) - Progress and training summaries (
TrainingSummary) - Optional per-step session telemetry (
TrainingObserver/TrainingStepEvent) — not logging, metrics, or storage backends - Training/session metrics and invariants (spike counts, threshold/weight drift, empty-batch rejection, atomic batch preflight)
- The critic → neuromodulator adapter between independently owned crates (the
bridgemodule,criticfeature) - Checkpoint/session orchestration, if/when it is actually implemented — not implemented today (see Does Not Own)
- Neuron and network dynamics, and
SpikingNetworkitself — owned byneuromod - Neuromodulator state/types — owned by
neuromod - Foundational classical STDP primitives — owned by
neuromod - Foundational reward-modulated STDP / eligibility-trace primitives — owned by
neuromod - Low-level plasticity configuration applied by the network engine — owned by
neuromod - Reward shaping — owned by
limbic-critic - Input encoding — owned by
axon-encoder; this crate does not depend on it - Differentiable or online distillation and teacher-student knowledge transfer — owned by
SynapticDistill.jl - Domain-specific training logic (mining, trading, etc.)
- Checkpointing and model serialization — not currently implemented in this crate; do not assume it exists
- Additional project-specific trainer type names beyond the public
PlasticityTrainerAPI
plasticity-lab calls and configures neuromod's plasticity rules through its public API (SpikingNetwork::step, NeuroModulators) rather than copying the algorithms here. Changes to how STDP or reward-modulated STDP behaves belong in neuromod, not in this crate. See neuromod's own ownership documentation for its boundary commitments.
plasticity-lab(Rust): training/session orchestration aboveneuromod's reward-modulated STDP / Hebbian plasticity primitives; it does not implement those primitives itself.SynapticDistill.jl(Julia): differentiable or online distillation and teacher-student knowledge transfer.SynapticDistill.jlmust not become the home for STDP logic;plasticity-labmust not absorb distillation logic.- A corresponding note should be aligned in
SynapticDistill.jl.
neuromod(network dynamics, neuromodulator state, and low-level plasticity primitives)limbic-critic(criticfeature only — for reward shaping via the bridge)- Serialization libraries
rand(publicRngbound for seeded*_with_rngreplay)
axon-encoder is intentionally not a dependency: this crate has no code that consumes it, so it isn't retained just to make Cargo resolve it (see #67). Re-add it only if a concrete API surface with tests needs it.
- Domain-specific training logic
- Project-specific naming conventions
- Duplicated STDP/R-STDP rule implementations (call into
neuromodinstead)
(See issues #2, #3, #6, #64 for full planning context and migration notes.)
| Language | Status | Notes |
|---|---|---|
| Rust | Supported | This crate; use Getting started |
| Julia | Sister project | Distillation only in SynapticDistill.jl — not a binding of this crate |
| Python | Not planned | No PyO3/maturin bindings exist; tracking issue #13 was closed as a duplicate without being implemented |
Do not expect a Python package from this repository. There is currently no active plan or open issue tracking Python bindings; if that changes, this README will add a parallel getting-started path.
For coding agents and human contributors:
- AGENTS.md — project conventions, setup commands, architecture map, allowed deps
- REVIEW.md — PR review checklist and bot-response expectations
- RELEASE.md — release preflight checklist and tag/publish process
(Absolute links: these files are excluded from the packaged crate, so a relative link would be dead when README is read from crates.io/docs.rs.)
Quick local checks:
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo test --all-features
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-featuresGitHub Actions runs clippy, build, and test on Linux, macOS, and Windows. cargo fmt --check, cargo deny, rustdoc, and tarpaulin/Codecov stay Linux-only.
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.
This repository follows the REUSE specification: SPDX identifiers appear in source headers and bulk path annotations in REUSE.toml; canonical license texts live under LICENSES/.