diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..3b9a10b --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,31 @@ +name: Verify + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + model-and-build: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install pinned Rust toolchain + run: rustup toolchain install 1.92.0 --profile minimal --target wasm32-unknown-unknown + - name: Test numerical models + run: cargo +1.92.0 test -p doke-core --locked + - name: Check browser Rust target + run: cargo +1.92.0 check -p doke-web --target wasm32-unknown-unknown --locked + - name: Validate scientific references + run: python3 tools/check_content.py + - name: Check browser script syntax + run: | + for file in public/*.js tests/*.mjs tools/*.mjs; do + node --check "$file" + done diff --git a/crates/doke-core/src/lib.rs b/crates/doke-core/src/lib.rs index edee7c9..87c2929 100644 --- a/crates/doke-core/src/lib.rs +++ b/crates/doke-core/src/lib.rs @@ -1,6 +1,8 @@ //! Deterministic educational timelines and a separate, uncalibrated rate model. use serde::{Deserialize, Serialize}; +pub mod plasticity; + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Model { pub e: f64, diff --git a/crates/doke-core/src/plasticity.rs b/crates/doke-core/src/plasticity.rs new file mode 100644 index 0000000..28abea9 --- /dev/null +++ b/crates/doke-core/src/plasticity.rs @@ -0,0 +1,625 @@ +//! Controlled spike-pair induction and a separate, learning-frozen LIF probe. +//! +//! Equation families: Gerstner et al., Neuronal Dynamics §§1.3 and 19.2. +//! All constants below are pedagogical, not fitted human physiology. Training +//! imposes target spikes and clamps its passive voltage between those events; +//! the probe removes that clamp. Shape, chemistry and action-potential waveform +//! are outside this model. Time is biological milliseconds, voltage is mV, and +//! weights/traces are dimensionless. Axonal arrival defines the STDP clock. + +use serde::Serialize; +use std::collections::VecDeque; + +pub const CUE_COUNT: usize = 3; +pub const TARGET: usize = 3; +pub const REST_MV: f64 = -65.0; +pub const THRESHOLD_MV: f64 = -50.0; +pub const TAU_MEMBRANE_MS: f64 = 20.0; +pub const TAU_TRACE_MS: f64 = 20.0; +pub const REFRACTORY_MS: u64 = 2; +pub const AXON_DELAY_MS: u64 = 10; +pub const INITIAL_WEIGHT: f64 = 0.32; +pub const MIN_WEIGHT: f64 = 0.0; +pub const MAX_WEIGHT: f64 = 1.0; +pub const INPUT_GAIN_MV: f64 = 25.0; +pub const A_PLUS: f64 = 0.07; +pub const A_MINUS: f64 = 0.075; +pub const PAIR_INTERVAL_MS: u64 = 200; +pub const PROBE_DURATION_MS: u64 = 100; +const MAX_EVENTS: usize = 64; +const MAX_PAIRS: u32 = 32; +const MAX_ADVANCE_MS: f64 = 250.0; +const PAIR_LEAD_MS: u64 = 100; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + Idle, + Training, + Probe, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + Spike, + Arrival, + Weight, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct TimingEvent { + pub time_ms: f64, + pub kind: EventKind, + /// For arrival/weight events this identifies the presynaptic cue/edge. + pub cell: usize, + pub weight: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct ProbeResult { + pub cue: usize, + /// Largest pre-reset membrane value reached by the delta-current probe. + pub peak_mv: f64, + pub spiked: bool, + pub weight: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct TimingSnapshot { + pub time_ms: f64, + pub axon_delay_ms: u64, + pub phase: Phase, + pub weights: [f64; CUE_COUNT], + pub voltages_mv: [f64; 4], + pub spike_counts: [u64; 4], + pub last_spike_ms: [Option; 4], + pub last_arrival_ms: [Option; CUE_COUNT], + pub selected_cue: Option, + pub gap_ms: i32, + pub completed_pairs: u32, + pub total_pairs: u32, + pub last_probe: Option, + pub events: Vec, +} + +#[derive(Clone, Copy, Debug)] +enum Action { + CueSpike, + Arrival, + TargetSpike, + PairComplete, +} + +#[derive(Clone, Debug)] +pub struct TimingModel { + tick: u64, + remainder_ms: f64, + phase: Phase, + weights: [f64; CUE_COUNT], + voltages: [f64; 4], + refractory_until: [u64; 4], + spike_counts: [u64; 4], + last_spike: [Option; 4], + last_arrival: [Option; CUE_COUNT], + pre_trace: [f64; CUE_COUNT], + post_trace: f64, + selected_cue: Option, + gap_ms: i32, + completed_pairs: u32, + total_pairs: u32, + schedule: VecDeque<(u64, Action)>, + end_tick: u64, + pending_probe: Option, + last_probe: Option, + events: VecDeque, +} + +impl Default for TimingModel { + fn default() -> Self { + Self { + tick: 0, + remainder_ms: 0.0, + phase: Phase::Idle, + weights: [INITIAL_WEIGHT; CUE_COUNT], + voltages: [REST_MV; 4], + refractory_until: [0; 4], + spike_counts: [0; 4], + last_spike: [None; 4], + last_arrival: [None; CUE_COUNT], + pre_trace: [0.0; CUE_COUNT], + post_trace: 0.0, + selected_cue: None, + gap_ms: 0, + completed_pairs: 0, + total_pairs: 0, + schedule: VecDeque::new(), + end_tick: 0, + pending_probe: None, + last_probe: None, + events: VecDeque::new(), + } + } +} + +impl TimingModel { + pub fn reset(&mut self) { + *self = Self::default(); + } + + /// Stop a protocol, preserving acquired weights, selection and model time. + /// Transient voltage, traces, event history and trial results are cleared. + pub fn cancel(&mut self) { + self.phase = Phase::Idle; + self.remainder_ms = 0.0; + self.voltages = [REST_MV; 4]; + self.refractory_until = [0; 4]; + self.spike_counts = [0; 4]; + self.last_spike = [None; 4]; + self.last_arrival = [None; CUE_COUNT]; + self.pre_trace = [0.0; CUE_COUNT]; + self.post_trace = 0.0; + self.completed_pairs = 0; + self.total_pairs = 0; + self.schedule.clear(); + self.end_tick = self.tick; + self.pending_probe = None; + self.last_probe = None; + self.events.clear(); + } + + fn validate_start(&self, cue: usize) -> Result<(), &'static str> { + if cue >= CUE_COUNT { + return Err("Cue must be 0, 1 or 2"); + } + if self.phase != Phase::Idle { + return Err("Finish or cancel the current trial first"); + } + if self.tick > u64::MAX - 10_000 { + return Err("Reset the model clock before starting another trial"); + } + Ok(()) + } + + /// Schedule a controlled all-to-all pair-STDP induction assay. Positive gap + /// means the imposed target spike follows cue arrival at the synapse. + /// The passive target is clamped during induction, so learned efficacy never + /// adds unscheduled target spikes. Equal-time spikes use pre-event traces. + pub fn start_training( + &mut self, + cue: usize, + gap_ms: i32, + pairs: u32, + ) -> Result<(), &'static str> { + self.validate_start(cue)?; + if !(-50..=50).contains(&gap_ms) || !(1..=MAX_PAIRS).contains(&pairs) { + return Err("Use a gap between -50 and 50 ms and 1 to 32 pairs"); + } + self.cancel(); + self.phase = Phase::Training; + self.selected_cue = Some(cue); + self.gap_ms = gap_ms; + self.total_pairs = pairs; + let mut scheduled = Vec::with_capacity(pairs as usize * 4); + for pair in 0..pairs { + let arrival = self.tick + PAIR_LEAD_MS + u64::from(pair) * PAIR_INTERVAL_MS; + let post = if gap_ms >= 0 { + arrival + gap_ms as u64 + } else { + arrival - u64::from(gap_ms.unsigned_abs()) + }; + scheduled.push((arrival - AXON_DELAY_MS, Action::CueSpike)); + scheduled.push((arrival, Action::Arrival)); + scheduled.push((post, Action::TargetSpike)); + scheduled.push((arrival.max(post), Action::PairComplete)); + self.end_tick = arrival.max(post) + 100; + } + scheduled.sort_by_key(|event| event.0); + self.schedule = scheduled.into(); + Ok(()) + } + + /// Standardized cue-only probe: clear all transient state, retain weights, + /// remove the target clamp, and freeze plasticity for the whole trial. + pub fn start_probe(&mut self, cue: usize) -> Result<(), &'static str> { + self.validate_start(cue)?; + self.cancel(); + self.phase = Phase::Probe; + self.selected_cue = Some(cue); + self.gap_ms = 0; + self.schedule.push_back((self.tick + 2, Action::CueSpike)); + self.schedule + .push_back((self.tick + 2 + AXON_DELAY_MS, Action::Arrival)); + self.end_tick = self.tick + PROBE_DURATION_MS; + self.pending_probe = Some(ProbeResult { + cue, + peak_mv: REST_MV, + spiked: false, + weight: self.weights[cue], + }); + Ok(()) + } + + /// Advance at most 250 biological ms per call on a fixed 1 ms clock. + /// Substeps survive frame batching. Idle time can advance without learning; + /// callers pause by not calling this method. Invalid input never mutates. + pub fn advance(&mut self, dt_ms: f64) -> Result<(), &'static str> { + if !dt_ms.is_finite() || !(0.0..=MAX_ADVANCE_MS).contains(&dt_ms) { + return Err("Advance must be finite and between 0 and 250 ms"); + } + let elapsed = self.remainder_ms + dt_ms; + let steps = (elapsed + 1e-9).floor() as u64; + if self.tick.checked_add(steps).is_none() { + return Err("Model clock overflow; reset required"); + } + self.remainder_ms = (elapsed - steps as f64).max(0.0); + for _ in 0..steps { + self.step(); + } + Ok(()) + } + + fn event(&mut self, kind: EventKind, cell: usize, weight: Option) { + if self.events.len() == MAX_EVENTS { + self.events.pop_front(); + } + self.events.push_back(TimingEvent { + time_ms: self.tick as f64, + kind, + cell, + weight, + }); + } + + fn spike(&mut self, cell: usize) { + self.spike_counts[cell] += 1; + self.last_spike[cell] = Some(self.tick as f64); + self.refractory_until[cell] = self.tick + REFRACTORY_MS; + self.voltages[cell] = REST_MV; + self.event(EventKind::Spike, cell, None); + } + + fn probe_input(&mut self, cue: usize) { + if self.tick < self.refractory_until[TARGET] { + return; + } + self.voltages[TARGET] += INPUT_GAIN_MV * self.weights[cue]; + let spiked = self.voltages[TARGET] >= THRESHOLD_MV; + if let Some(probe) = &mut self.pending_probe { + probe.peak_mv = probe.peak_mv.max(self.voltages[TARGET]); + probe.spiked |= spiked; + } + if spiked { + self.spike(TARGET); + } + } + + fn step(&mut self) { + self.tick += 1; + for cell in 0..4 { + self.voltages[cell] = if self.tick < self.refractory_until[cell] + || (self.phase == Phase::Training && cell == TARGET) + { + REST_MV + } else { + REST_MV + (self.voltages[cell] - REST_MV) * (-1.0 / TAU_MEMBRANE_MS).exp() + }; + } + let decay = (-1.0 / TAU_TRACE_MS).exp(); + self.pre_trace.iter_mut().for_each(|trace| *trace *= decay); + self.post_trace *= decay; + let mut arrival = false; + let mut post = false; + while self + .schedule + .front() + .is_some_and(|event| event.0 == self.tick) + { + match self.schedule.pop_front().unwrap().1 { + Action::CueSpike => self.spike(self.selected_cue.unwrap()), + Action::Arrival => { + arrival = true; + let cue = self.selected_cue.unwrap(); + self.last_arrival[cue] = Some(self.tick as f64); + self.event(EventKind::Arrival, cue, Some(self.weights[cue])); + if self.phase == Phase::Probe { + self.probe_input(cue); + } + } + Action::TargetSpike => { + post = true; + self.spike(TARGET); + } + Action::PairComplete => self.completed_pairs += 1, + } + } + if self.phase == Phase::Training { + // Read all old traces before any same-time increment. This gives + // zero-lag pairs no within-pair LTP/LTD or insertion-order bias. + for cue in 0..CUE_COUNT { + let incoming = arrival && self.selected_cue == Some(cue); + let delta = if post { + A_PLUS * self.pre_trace[cue] + } else { + 0.0 + } - if incoming { + A_MINUS * self.post_trace + } else { + 0.0 + }; + if delta != 0.0 { + self.weights[cue] = (self.weights[cue] + delta).clamp(MIN_WEIGHT, MAX_WEIGHT); + self.event(EventKind::Weight, cue, Some(self.weights[cue])); + } + if incoming { + self.pre_trace[cue] += 1.0; + } + } + if post { + self.post_trace += 1.0; + } + } + if self.phase != Phase::Idle && self.tick >= self.end_tick { + if self.phase == Phase::Probe { + self.last_probe = self.pending_probe.take(); + } + self.phase = Phase::Idle; + self.pre_trace = [0.0; CUE_COUNT]; + self.post_trace = 0.0; + } + } + + pub fn snapshot(&self) -> TimingSnapshot { + TimingSnapshot { + time_ms: self.tick as f64, + axon_delay_ms: AXON_DELAY_MS, + phase: self.phase, + weights: self.weights, + voltages_mv: self.voltages, + spike_counts: self.spike_counts, + last_spike_ms: self.last_spike, + last_arrival_ms: self.last_arrival, + selected_cue: self.selected_cue, + gap_ms: self.gap_ms, + completed_pairs: self.completed_pairs, + total_pairs: self.total_pairs, + last_probe: self.last_probe.clone(), + events: self.events.iter().cloned().collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn finish(model: &mut TimingModel) { + for _ in 0..100 { + if model.phase == Phase::Idle { + return; + } + model.advance(100.0).unwrap(); + } + panic!("bounded protocol failed to finish"); + } + + #[test] + fn isolated_pairs_follow_analytic_window_and_arrival_clock() { + for gap in [-50, -10, 0, 10, 50] { + let mut model = TimingModel::default(); + model.start_training(0, gap, 1).unwrap(); + finish(&mut model); + let expected = INITIAL_WEIGHT + + if gap > 0 { + A_PLUS * (-f64::from(gap) / TAU_TRACE_MS).exp() + } else if gap < 0 { + -A_MINUS * (f64::from(gap) / TAU_TRACE_MS).exp() + } else { + 0.0 + }; + assert!((model.weights[0] - expected).abs() < 1e-12); + assert_eq!(model.last_arrival[0], Some(100.0)); + assert_eq!(model.last_spike[0], Some(90.0)); + assert_eq!(model.last_spike[TARGET], Some(f64::from(100 + gap))); + assert_eq!(model.weights[1..], [INITIAL_WEIGHT; 2]); + } + } + + #[test] + fn eight_close_pairs_change_frozen_probe_but_distant_pairs_do_not() { + for (gap, expected_spike) in [(10, true), (50, false), (-10, false), (-50, false)] { + let mut model = TimingModel::default(); + model.start_probe(0).unwrap(); + finish(&mut model); + assert!(!model.last_probe.as_ref().unwrap().spiked); + assert_eq!(model.last_probe.as_ref().unwrap().peak_mv, -57.0); + model.start_training(0, gap, 8).unwrap(); + finish(&mut model); + let weights = model.weights; + model.start_probe(0).unwrap(); + finish(&mut model); + assert_eq!(model.last_probe.as_ref().unwrap().spiked, expected_spike); + assert_eq!(model.weights, weights); + model.start_probe(1).unwrap(); + finish(&mut model); + assert!(!model.last_probe.as_ref().unwrap().spiked); + assert_eq!(model.last_probe.as_ref().unwrap().peak_mv, -57.0); + } + } + + #[test] + fn reversed_pairing_weakens_a_previously_responsive_pathway() { + let mut model = TimingModel::default(); + model.start_training(2, 10, 8).unwrap(); + finish(&mut model); + assert!(model.weights[2] > 0.6); + model.start_training(2, -10, 8).unwrap(); + finish(&mut model); + assert!(model.weights[2] < INITIAL_WEIGHT); + model.start_probe(2).unwrap(); + finish(&mut model); + assert!(!model.last_probe.unwrap().spiked); + } + + #[test] + fn induction_clamp_prevents_extra_spikes_even_at_saturated_strength() { + for gap in [-10, 0, 10] { + let mut model = TimingModel::default(); + model.weights[0] = MAX_WEIGHT; + model.start_training(0, gap, 32).unwrap(); + finish(&mut model); + assert_eq!(model.spike_counts, [32, 0, 0, 32]); + assert_eq!(model.completed_pairs, 32); + assert_eq!(model.voltages[TARGET], REST_MV); + assert!(model.events.len() <= MAX_EVENTS); + } + } + + #[test] + fn all_to_all_cross_pair_terms_are_explicit_and_zero_lag_has_no_order_bias() { + let mut model = TimingModel::default(); + model.start_training(0, 0, 2).unwrap(); + finish(&mut model); + let expected = + INITIAL_WEIGHT + (A_PLUS - A_MINUS) * (-(PAIR_INTERVAL_MS as f64) / TAU_TRACE_MS).exp(); + assert!((model.weights[0] - expected).abs() < 1e-12); + } + + #[test] + fn free_leak_and_refractory_match_the_declared_equations() { + let mut model = TimingModel::default(); + model.voltages[TARGET] = -55.0; + model.advance(20.0).unwrap(); + assert!((model.voltages[TARGET] - (REST_MV + 10.0 * (-1.0_f64).exp())).abs() < 1e-12); + assert_eq!(model.spike_counts[TARGET], 0); + model.weights[0] = MAX_WEIGHT; + model.probe_input(0); + model.probe_input(0); + assert_eq!(model.spike_counts[TARGET], 1); + model.advance(1.0).unwrap(); + model.probe_input(0); + assert_eq!(model.spike_counts[TARGET], 1); + model.advance(1.0).unwrap(); + model.probe_input(0); + assert_eq!(model.spike_counts[TARGET], 2); + } + + #[test] + fn frame_batching_and_pause_do_not_change_the_experiment() { + let mut large = TimingModel::default(); + let mut small = large.clone(); + large.start_training(1, 10, 8).unwrap(); + small.start_training(1, 10, 8).unwrap(); + for _ in 0..8 { + large.advance(250.0).unwrap(); + } + for _ in 0..20_000 { + small.advance(0.1).unwrap(); + } + assert_eq!(large.snapshot(), small.snapshot()); + let paused = small.snapshot(); + small.advance(0.0).unwrap(); + assert_eq!(small.snapshot(), paused); + } + + #[test] + fn cancel_clears_transients_preserves_learning_and_reset_restores_everything() { + let mut model = TimingModel::default(); + model.start_training(2, 10, 8).unwrap(); + for _ in 0..4 { + model.advance(200.0).unwrap(); + } + let learned = model.weights; + assert!(learned[2] > INITIAL_WEIGHT); + model.cancel(); + assert_eq!(model.weights, learned); + assert_eq!(model.selected_cue, Some(2)); + assert_eq!(model.tick, 800); + assert!(model.schedule.is_empty()); + assert_eq!(model.voltages, [REST_MV; 4]); + assert_eq!(model.pre_trace, [0.0; 3]); + assert_eq!(model.post_trace, 0.0); + model.advance(200.0).unwrap(); + assert_eq!(model.weights, learned); + model.reset(); + assert_eq!(model.snapshot(), TimingModel::default().snapshot()); + } + + #[test] + fn cancellation_keeps_only_updates_that_have_actually_occurred() { + for (gap, stop_ms, completed, changed) in [ + (10, 100.0, 0, false), + (-10, 99.0, 0, false), + (-10, 100.0, 1, true), + ] { + let mut model = TimingModel::default(); + model.start_training(0, gap, 8).unwrap(); + model.advance(stop_ms).unwrap(); + assert_eq!(model.completed_pairs, completed); + assert_eq!(model.weights[0] != INITIAL_WEIGHT, changed); + let weight = model.weights[0]; + model.cancel(); + model.advance(250.0).unwrap(); + assert_eq!(model.weights[0], weight); + assert_eq!(model.spike_counts, [0; 4]); + } + + // In all-to-all STDP, a later pre arrival already depresses efficacy + // against the previous post trace, before this pair's target stimulus. + let mut model = TimingModel::default(); + model.start_training(0, 10, 8).unwrap(); + model.advance(250.0).unwrap(); + let after_first_pair = model.weights[0]; + model.advance(50.0).unwrap(); + assert_eq!(model.completed_pairs, 1); + let at_second_arrival = model.weights[0]; + let expected_depression = A_MINUS * (-190.0 / TAU_TRACE_MS).exp(); + assert!((after_first_pair - at_second_arrival - expected_depression).abs() < 1e-12); + model.cancel(); + model.advance(250.0).unwrap(); + assert_eq!(model.weights[0], at_second_arrival); + assert_eq!(model.weights[1..], [INITIAL_WEIGHT; 2]); + } + + #[test] + fn invalid_inputs_are_atomic_and_active_trials_cannot_be_overwritten() { + let mut model = TimingModel::default(); + let baseline = model.snapshot(); + for (cue, gap, pairs) in [(3, 10, 8), (0, 51, 8), (0, -51, 8), (0, 10, 0), (0, 10, 33)] { + assert!(model.start_training(cue, gap, pairs).is_err()); + assert_eq!(model.snapshot(), baseline); + } + assert!(model.start_probe(3).is_err()); + for dt in [f64::NAN, f64::INFINITY, -1.0, 251.0] { + assert!(model.advance(dt).is_err()); + assert_eq!(model.snapshot(), baseline); + } + model.start_training(0, 10, 8).unwrap(); + model.advance(100.0).unwrap(); + let busy = model.snapshot(); + assert!(model.start_probe(1).is_err()); + assert!(model.start_training(1, -10, 8).is_err()); + assert_eq!(model.snapshot(), busy); + } + + #[test] + fn long_training_is_bounded_and_finite_at_both_weight_limits() { + let mut model = TimingModel::default(); + for gap in [10, -10] { + for _ in 0..30 { + model.start_training(0, gap, 32).unwrap(); + finish(&mut model); + assert!(model + .weights + .iter() + .all(|w| w.is_finite() && (0.0..=1.0).contains(w))); + assert!(model.voltages.iter().all(|v| v.is_finite())); + assert!(model.schedule.is_empty()); + } + assert_eq!( + model.weights[0], + if gap > 0 { MAX_WEIGHT } else { MIN_WEIGHT } + ); + } + } +} diff --git a/docs/NEXT_PHASE.md b/docs/NEXT_PHASE.md new file mode 100644 index 0000000..21ef5f7 --- /dev/null +++ b/docs/NEXT_PHASE.md @@ -0,0 +1,109 @@ +# From exploration to experiment + +Research and design: 14 October 2026. This is the next-phase specification; later phases below are proposals, not implemented features. The [guide](GUIDE.md) remains the usage and build reference. + +## The direction + +doke should let someone change a small simulated system, predict what will happen, and test the prediction. A beautiful response to input is only the beginning: the system must retain a meaningful history, and the interface must explain what changed. + +[Phloem](https://github.com/thcp/phloem) supplied the interaction inspiration. Its [architecture at the inspected revision](https://github.com/thcp/phloem/blob/706d74bc049c9f12f75b546eec53ceebaa72ef91/docs/ARCHITECTURE.md) maps repository events to an adaptive network. The transferable principle is a visible consequence of accumulated input. doke uses its own neuroscience experiments, models, geometry, controls and rendering implementation. No Phloem code, visual assets or audio are incorporated. + +The first experiment is **Teach a pathway**: three cues connect to one target. Probe a cue, pair its arrival with a prescribed target spike, and probe again. Reverse the order or choose an untouched cue to investigate the result. It is a small numerical specimen, separate from the atlas and the existing excitatory–inhibitory population model. + +Four modeled cells are intentional. Visitors can identify every input and see why a response changes. Increasing the cell count before establishing this causal clarity would make a more elaborate picture without necessarily making a better experiment. + +## Scientific basis and boundaries + +| Source | What it supports | What doke may claim | +| --- | --- | --- | +| [Bi & Poo, 1998](https://pubmed.ncbi.nlm.nih.gov/9852584/) | Repeated spike pairings changed glutamatergic synaptic efficacy in cultured rat hippocampal neurons; timing, initial strength and target cell type mattered. | A timing-dependent learning experiment motivated by a particular preparation. Not a universal rule for every neuron or a simulation of a visitor's memory. | +| [Gerstner et al., §19.2](https://neuronaldynamics.epfl.ch/online/Ch19.S2.html) | Pair-based STDP rules, trace implementations, pairing conventions and weight bounds. | A declared mathematical rule with testable consequences. The authored learning rate is chosen for a short lesson. | +| [Gerstner et al., §1.3](https://neuronaldynamics.epfl.ch/online/Ch1.S3.html) | Passive membrane integration with discrete threshold/reset events. | Calculated point-neuron responses. No ion-channel waveform, reconstructed dendritic integration or molecule dynamics. | +| [Pfister & Gerstner, 2006](https://pmc.ncbi.nlm.nih.gov/articles/PMC6674434/) | Basic pair rules miss certain frequency and higher-order spike-pattern effects. | This experiment illustrates selected pair-model behavior; it does not explain all biological learning. | + +The authors' [Neuronal Dynamics lectures and teaching materials](https://neuronaldynamics.epfl.ch/lectures.html) provide additional explanations. Papers and explicitly declared equations define the implementation, rather than visual analogies from talks. + +Keep the calculated and illustrative parts distinguishable: + +- **Calculated:** model time, membrane potential, threshold events, delayed transmission, trace-based weight changes, and the cue-only probe result. +- **Prescribed:** training spike times and the target clamp used to isolate their timing relationship. +- **Visual encodings:** cell shapes, dendrites, light persistence, contact size and slowed playback. Larger contacts indicate modeled efficacy; no anatomical growth is simulated. +- **Not estimated:** human neurotransmitter concentrations, mood, intelligence, health, personal memory or a whole-brain connectome. + +## First-minute experience + +1. Enter **Experiment**. Four cells float in an uncluttered field. A short question asks whether a cue can make the target respond. +2. **Probe cue A.** A pulse travels to the target. The target's calculated response stays below threshold; a baseline is recorded. +3. Choose **cue before target**, with a 10 ms arrival-to-target interval. **Pair ×8** prescribes eight spike pairs. The selected connection changes visibly and numerically. +4. **Probe A again.** Under the default model, the stronger input now crosses threshold. Probe B to observe an untrained control. +5. Reverse the order or separate the pair by 50 ms, then investigate the next result. Reset restores the initial specimen. + +The user chooses a protocol rather than trying to click with millisecond precision. A probe freezes learning and standardizes transient state so residual voltage cannot masquerade as a lasting weight change. The interface states: **Pairing sets both spike times. Probing tests the cue alone.** + +Success is understanding a contrast, not maximizing a score. There is no intelligence, brain-health or chemical-level meter. A weaker connection can be the intended outcome of an experiment. + +## Model contract + +The authoritative implementation is `crates/doke-core/src/plasticity.rs`. All values below are pedagogical defaults, not a fitted dataset or biological measurements. + +| Quantity | Default / convention | +| --- | --- | +| Topology | Cue cells A/B/C → one target; three excitatory plastic connections | +| Clock | Fixed 1 ms event ticks; caller batching must not change the result | +| Rest and reset | −65 mV | +| Threshold | −50 mV | +| Membrane time constant | 20 ms | +| Refractory interval | 2 ms | +| Transmission delay | 10 ms | +| Initial weight | 0.32, dimensionless; bounded to [0, 1] | +| Probe input | Voltage jump of 25 mV × weight at synaptic arrival | +| STDP traces | Exponential decay with 20 ms time constants | +| Learning amplitudes | +0.07 and −0.075, dimensionless | +| Default induction | Eight imposed pairs, 200 ms between pairs | +| Timing parameter | Target spike time minus presynaptic **arrival** time | + +Between events, membrane voltage follows exact passive leak: + +`V(t+h) = E_L + (V(t) - E_L) exp(-h / tau_m)` + +Probe input adds its voltage jump; threshold crossing emits a spike, resets voltage and starts the refractory interval. The model does not calculate the voltage waveform of an action potential. + +For an isolated spike pair, positive timing gives `Δw = A+ exp(-Δt / tau+)`; negative timing gives `Δw = -A- exp(Δt / tau-)`. The implementation uses decaying traces to account for all eligible pairs, including small cross-pair effects. Hard clipping keeps weights bounded. A hard bound is a numerical/model choice, not biological homeostasis. + +Same-tick arrivals and target spikes read the previous traces before either trace is incremented, so a coincident pair does not receive an arbitrary ordering advantage. The ordinary UI presets avoid coincident events, but the API and tests must define them. + +**Controlled induction matters.** If a strengthening cue were allowed to fire the target early during training, the actual pair timing would differ from the requested timing. Training therefore holds the target at rest between imposed spikes. Probe mode removes that control, freezes plasticity, clears transient membrane/refractory/trace/event state, and preserves the weights. This is an abstract pairing assay, not a claim to reproduce the complete experimental stimulation apparatus. + +Starting from weight 0.32, eight +10 ms pairings should cross the probe threshold near weight 0.60; +50 ms pairings should have a substantially smaller effect. Reversed pairs should decrease weight. These are consequences to verify from the engine, not outcomes the interface may manufacture. + +## Rendering and interaction contract + +The Rust model owns events and time. Browser animation frames request bounded batches of ticks; they do not decide when a biological event occurs. Simulation and playback pause immediately, stop in a hidden document, and preserve state when leaving the scene. A stalled frame may slow playback but must not skip model events or cause an unbounded catch-up burst. + +The renderer receives small fixed-size state arrays. A traveling pulse uses its emission and arrival times. A soma's residual glow is derived from the latest spike and is explicitly a visual persistence effect. Persistent contact efficacy must remain visually distinct from a transient flash. Geometry is built once, with state-driven uniforms; the first experiment needs neither GPU compute nor a new rendering engine. + +Preserve WebGPU, WebGL2 fallback, HDR, SDR glow and grayscale preferences. Maintain semantic cue/protocol buttons, visible focus, text equivalents of outcomes, comfortable touch controls, and a reduced-motion presentation. Information and citations use the existing left-side tabs. Tools and evidence must not overlap on narrow displays. No account, microphone, personal data, network inference or backend is required. + +Session state stays in memory in this first iteration. Reload resets it. No share button is reintroduced. A future replay format needs a model version and strict validation before it can represent meaningful comparisons. + +## Iterative roadmap and gates + +| Phase | New user question | Evidence and release gate | +| --- | --- | --- | +| **1 · Teach a pathway** | Does changing spike order change the next response? | Deterministic LIF/pair-STDP core; controlled training versus frozen probe; untouched-cue control; both rendering backends; pause/reset/scene retention; desktop/mobile/reduced-motion review. | +| **2 · Rhythm and recovery** | Why can the same pulse produce a different response moments later? | Add short-term resource use/recovery inside the synapse. [Tsodyks & Markram, 1997](https://pmc.ncbi.nlm.nih.gov/articles/PMC19580/) motivates a reduced resource model; [Markram et al., 1998](https://pmc.ncbi.nlm.nih.gov/articles/PMC20259/) motivates contrasting target-dependent facilitation/depression. Keep temporary resources separate from persistent weight. Test recovery limits and paired-pulse behavior. | +| **3 · A signal arriving later** | Can feedback influence a connection after its activity has ended? | An eligibility-trace experiment grounded in [Izhikevich, 2007](https://izhikevich.org/publications/dastdp.htm), with preparation-specific context from [Yagishita et al., 2014](https://pmc.ncbi.nlm.nih.gov/articles/PMC4225776/). Test absent, timely and delayed modulation. No dopamine-equals-pleasure claim. | +| **4 · Keep a circuit responsive** | What prevents repeated strengthening from overwhelming a circuit? | Distinguish model stability from physiological homeostasis. [Turrigiano et al., 1998](https://www.nature.com/articles/36103) reports adaptation over chronic activity changes. Explicitly label accelerated hours/days and test the declared feedback law. | +| **5 · Compare, predict, challenge** | Which model explains this result, and what experiment distinguishes them? | Compare pair/triplet rules or plastic/frozen controls under identical inputs. Introduce local prediction challenges and versioned replay before considering shared scores. Online accounts/leaderboards need a separate hosting, privacy and abuse-control design. | + +Each phase should earn its complexity through observed usability and a reviewable PR. Later phases are not promised biological capabilities. The present delivery is the first complete experiment and a foundation for those iterations. + +## Review and acceptance + +The first two PRs separate the scientific engine/specification from the dependent visual experience. Neither is merged until the project owner approves it. + +Core checks must verify leak against its closed form; threshold/reset/refractory behavior; transmission delay; timing sign/window and simultaneous-event convention; bounds under long runs; untouched controls; learning-frozen probes; invalid-input atomicity; bounded queues; exact reset; and identical results under different batching. + +Integration checks must exercise the visible baseline → pair → probe path, reverse-order contrast, pause/resume/cancel, scene switching, source-tab return, keyboard/touch controls, reduced motion, both GPU backends and existing scene regressions. Review actual screenshots and recorded interactions. Report frame intervals as measurements of the test machine, not a universal frame-rate guarantee. + +Every public claim should correspond to a source or an explicitly described design/model choice. Test outcomes and screenshot reviews belong in the PR validation record; raw captures remain ignored.