Skip to main content

synaptic_wiring/
router.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Generic multi-channel SNN router with neuromodulatory adaptation.
4//!
5//! A domain-agnostic SNN router that integrates signal pulses across a bank
6//! of neuromodulatory neurons to produce a sparse routing mask.
7//!
8//! The router is generic over channel count and supports adaptive
9//! neuromodulatory routing — channels strengthen with use (dopamine-gated)
10//! and weaken when idle (use-it-or-lose-it plasticity).
11//!
12//! This module is **optional and self-contained**: it neither uses nor is
13//! used by [`SynapticMesh`](crate::mesh::SynapticMesh). Reach for it when you
14//! need to pick a few active channels out of many inputs; ignore it entirely
15//! if you only need wiring, topology, and delays.
16
17use crate::error::{MeshError, Result};
18use serde::de::{Deserializer, Error as DeError};
19use serde::{Deserialize, Serialize};
20
21/// Integration timesteps per routing decision (more → more stable).
22const ROUTING_TIMESTEPS: usize = 16;
23
24/// Minimum firing rate (spikes / `ROUTING_TIMESTEPS`) to activate a channel.
25const MIN_FIRE_RATE: f32 = 0.1875;
26
27/// Largest channel count [`ChannelRouter`] will allocate.
28///
29/// The router stores a dense `N × N` weight matrix plus a baseline copy, so
30/// this cap is checked in [`RouterConfig::validate`] *before* any of those
31/// vectors are allocated.
32pub const MAX_ROUTER_CHANNELS: usize = 1024;
33
34/// Largest `routing_timesteps` accepted by [`RouterConfig::validate`].
35///
36/// The routing loop is `O(timesteps × channel_count)`; this cap rejects
37/// values that would hang a restore/route on untrusted input.
38pub const MAX_ROUTING_TIMESTEPS: usize = 4096;
39
40/// Neuromodulatory Integrative Fixed-threshold (NIF) neuron.
41///
42/// This is a **router-internal integration primitive** for [`ChannelRouter`],
43/// not a general-purpose neuron model — canonical neuron models (LIF,
44/// Izhikevich, Hodgkin-Huxley, GIF, FitzHugh-Nagumo, Lapicque) live in the
45/// separate `neuromod` crate, which `synaptic-wiring` intentionally does not
46/// depend on. See the crate-level docs for the full boundary rationale.
47///
48/// $V_{t+1} = V_t + (G \cdot I_{syn}) - \lambda(V_t - V_{rest})$
49/// where $G$ is the modulation gain and $\lambda$ is the leak rate.
50#[derive(Clone, Serialize, Deserialize, Debug)]
51#[serde(default)]
52pub struct NeuromodNeuron {
53    /// Current membrane potential.
54    pub v: f32,
55    /// Resting membrane potential.
56    pub v_rest: f32,
57    /// Reset potential after a spike.
58    pub v_reset: f32,
59    /// Passive leak rate per timestep.
60    pub leak: f32,
61    /// Firing threshold.
62    pub threshold: f32,
63
64    /// Neuromodulatory gain (scales incoming stimulus).
65    pub gain: f32,
66
67    /// Synaptic weights — one per input channel.
68    pub weights: Vec<f32>,
69    /// Whether the neuron fired in the last timestep.
70    pub last_spike: bool,
71}
72
73impl Default for NeuromodNeuron {
74    fn default() -> Self {
75        Self {
76            v: 0.0,
77            v_rest: 0.0,
78            v_reset: 0.0,
79            leak: 0.12,
80            threshold: 0.25,
81            gain: 1.0,
82            weights: Vec::new(),
83            last_spike: false,
84        }
85    }
86}
87
88impl NeuromodNeuron {
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Advance neuron dynamics by one timestep.
94    ///
95    /// The `stimulus` is scaled by the neuron's current `gain`.
96    pub fn integrate(&mut self, stimulus: f32) {
97        // Apply modulated integration
98        self.v += stimulus * self.gain;
99        // Apply leak towards resting potential
100        self.v -= (self.v - self.v_rest) * self.leak;
101    }
102
103    /// Check if the neuron spikes. Resets V on fire.
104    pub fn check_fire(&mut self) -> Option<f32> {
105        if self.v >= self.threshold {
106            let peak = self.v;
107            self.v = self.v_reset;
108            self.last_spike = true;
109            return Some(peak);
110        }
111        self.last_spike = false;
112        None
113    }
114
115    /// Update the modulation gain.
116    pub fn set_gain(&mut self, new_gain: f32) {
117        self.gain = new_gain;
118    }
119}
120
121/// Configuration for a generic channel router.
122///
123/// Direct struct literals can hold out-of-range values; they are rejected by
124/// [`RouterConfig::validate`], [`ChannelRouter::try_with_config`], and by
125/// deserialization of both this type and [`ChannelRouter`].
126///
127/// # Valid ranges
128///
129/// | Field | Constraint |
130/// |-------|------------|
131/// | `channel_count` | `1..=`[`MAX_ROUTER_CHANNELS`] |
132/// | `routing_timesteps` | `1..=`[`MAX_ROUTING_TIMESTEPS`] |
133/// | `self_weight`, `cross_weight`, `threshold` | finite; **signed weights are allowed** |
134/// | `leak`, `min_fire_rate`, `plasticity_decay`, `plasticity_speed`, `fatigue_accumulation`, `fatigue_recovery` | finite, in `0.0..=1.0` |
135/// | `plasticity_potentiate` | finite, `>= 0` (scale factor, not a probability) |
136///
137/// `cross_weight` is typically negative (lateral inhibition). That sign is
138/// allowed and intended; it is not required. Positive self-weights are the
139/// usual self-affinity pattern, but a signed self-weight is also accepted.
140#[derive(Debug, Clone, PartialEq, Serialize)]
141pub struct RouterConfig {
142    /// Number of input/output channels (`1..=`[`MAX_ROUTER_CHANNELS`]).
143    pub channel_count: usize,
144    /// Self-affinity weight (diagonal of the dense channel matrix).
145    ///
146    /// Must be finite. Signed values are allowed.
147    pub self_weight: f32,
148    /// Cross-channel weight (off-diagonal).
149    ///
150    /// Must be finite. Signed values are allowed; **negative values are the
151    /// intended lateral-inhibition pattern** and are not rejected.
152    pub cross_weight: f32,
153    /// Firing threshold for neuromodulatory neurons.
154    ///
155    /// Must be finite. Typically positive; zero and negative values are
156    /// accepted here (modulated routing later clamps the *effective*
157    /// threshold).
158    pub threshold: f32,
159    /// Passive leak rate per timestep.
160    ///
161    /// Fraction of `(V − V_rest)` removed each tick. Valid range: `0.0..=1.0`.
162    /// `0.0` = no leak; `1.0` = membrane snaps to rest in one tick.
163    pub leak: f32,
164    /// Integration timesteps per routing decision.
165    ///
166    /// Must be in `1..=`[`MAX_ROUTING_TIMESTEPS`]. More timesteps → more
167    /// stable firing-rate estimates.
168    pub routing_timesteps: usize,
169    /// Minimum firing rate to activate a channel.
170    ///
171    /// Fraction of `routing_timesteps` that must spike. Valid range: `0.0..=1.0`.
172    pub min_fire_rate: f32,
173    /// Weight decay rate for inactive channels (use-it-or-lose-it).
174    ///
175    /// Mix toward baseline: `current + (baseline − current) * decay`.
176    /// Valid range: `0.0..=1.0`.
177    pub plasticity_decay: f32,
178    /// Weight potentiation rate for active channels (dopamine-gated).
179    ///
180    /// Non-negative finite scale factor, not a probability. The amplified
181    /// target is `baseline * (1 + potentiate * (1 + dopamine))`.
182    pub plasticity_potentiate: f32,
183    /// Smoothing factor for active-channel weight potentiation
184    /// (`0.0` = no change, `1.0` = snap to amplified target). Tunable so
185    /// callers can trade off adaptation speed vs. numerical stability.
186    /// Valid range: `0.0..=1.0`.
187    pub plasticity_speed: f32,
188    /// Fatigue accumulation per activation.
189    ///
190    /// Added to per-channel fatigue (itself in `0.0..=1.0`). Valid range:
191    /// `0.0..=1.0`.
192    pub fatigue_accumulation: f32,
193    /// Fatigue recovery per idle routing decision.
194    ///
195    /// Subtracted from per-channel fatigue. Valid range: `0.0..=1.0`.
196    pub fatigue_recovery: f32,
197}
198
199#[derive(Deserialize)]
200struct RawRouterConfig {
201    channel_count: usize,
202    self_weight: f32,
203    cross_weight: f32,
204    threshold: f32,
205    leak: f32,
206    routing_timesteps: usize,
207    min_fire_rate: f32,
208    #[serde(default = "default_plasticity_decay")]
209    plasticity_decay: f32,
210    #[serde(default = "default_plasticity_potentiate")]
211    plasticity_potentiate: f32,
212    #[serde(default = "default_plasticity_speed")]
213    plasticity_speed: f32,
214    #[serde(default = "default_fatigue_accumulation")]
215    fatigue_accumulation: f32,
216    #[serde(default = "default_fatigue_recovery")]
217    fatigue_recovery: f32,
218}
219
220impl RawRouterConfig {
221    fn into_config(self) -> Result<RouterConfig> {
222        let config = RouterConfig {
223            channel_count: self.channel_count,
224            self_weight: self.self_weight,
225            cross_weight: self.cross_weight,
226            threshold: self.threshold,
227            leak: self.leak,
228            routing_timesteps: self.routing_timesteps,
229            min_fire_rate: self.min_fire_rate,
230            plasticity_decay: self.plasticity_decay,
231            plasticity_potentiate: self.plasticity_potentiate,
232            plasticity_speed: self.plasticity_speed,
233            fatigue_accumulation: self.fatigue_accumulation,
234            fatigue_recovery: self.fatigue_recovery,
235        };
236        config.validate()?;
237        Ok(config)
238    }
239}
240
241impl<'de> Deserialize<'de> for RouterConfig {
242    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
243    where
244        D: Deserializer<'de>,
245    {
246        RawRouterConfig::deserialize(deserializer)?
247            .into_config()
248            .map_err(DeError::custom)
249    }
250}
251
252fn default_plasticity_decay() -> f32 {
253    0.02
254}
255fn default_plasticity_potentiate() -> f32 {
256    0.05
257}
258fn default_plasticity_speed() -> f32 {
259    0.1
260}
261fn default_fatigue_accumulation() -> f32 {
262    0.15
263}
264fn default_fatigue_recovery() -> f32 {
265    0.05
266}
267
268impl Default for RouterConfig {
269    fn default() -> Self {
270        Self {
271            channel_count: 3,
272            self_weight: 0.9,
273            cross_weight: -0.15,
274            threshold: 0.22,
275            leak: 0.12,
276            routing_timesteps: ROUTING_TIMESTEPS,
277            min_fire_rate: MIN_FIRE_RATE,
278            plasticity_decay: 0.02,
279            plasticity_potentiate: 0.05,
280            plasticity_speed: 0.1,
281            fatigue_accumulation: 0.15,
282            fatigue_recovery: 0.05,
283        }
284    }
285}
286
287impl RouterConfig {
288    /// Check that every field is in its documented range.
289    ///
290    /// This is the single validation path used by
291    /// [`ChannelRouter::try_with_config`], [`ChannelRouter::with_config`],
292    /// and by `Deserialize` for both [`RouterConfig`] and [`ChannelRouter`].
293    /// It does not allocate.
294    pub fn validate(&self) -> Result<()> {
295        if self.channel_count == 0 || self.channel_count > MAX_ROUTER_CHANNELS {
296            return Err(MeshError::invalid_router_config(
297                "channel_count",
298                format!(
299                    "must be in 1..={MAX_ROUTER_CHANNELS}, got {}",
300                    self.channel_count
301                ),
302            ));
303        }
304        if self.routing_timesteps == 0 || self.routing_timesteps > MAX_ROUTING_TIMESTEPS {
305            return Err(MeshError::invalid_router_config(
306                "routing_timesteps",
307                format!(
308                    "must be in 1..={MAX_ROUTING_TIMESTEPS}, got {}",
309                    self.routing_timesteps
310                ),
311            ));
312        }
313        require_finite("self_weight", self.self_weight)?;
314        require_finite("cross_weight", self.cross_weight)?;
315        require_finite("threshold", self.threshold)?;
316        require_unit_interval("leak", self.leak)?;
317        require_unit_interval("min_fire_rate", self.min_fire_rate)?;
318        require_unit_interval("plasticity_decay", self.plasticity_decay)?;
319        require_non_negative("plasticity_potentiate", self.plasticity_potentiate)?;
320        require_unit_interval("plasticity_speed", self.plasticity_speed)?;
321        require_unit_interval("fatigue_accumulation", self.fatigue_accumulation)?;
322        require_unit_interval("fatigue_recovery", self.fatigue_recovery)?;
323        Ok(())
324    }
325}
326
327fn require_finite(field: &'static str, value: f32) -> Result<()> {
328    if value.is_finite() {
329        Ok(())
330    } else {
331        Err(MeshError::invalid_router_config(
332            field,
333            format!("must be finite, got {value}"),
334        ))
335    }
336}
337
338fn require_unit_interval(field: &'static str, value: f32) -> Result<()> {
339    require_finite(field, value)?;
340    if (0.0..=1.0).contains(&value) {
341        Ok(())
342    } else {
343        Err(MeshError::invalid_router_config(
344            field,
345            format!("must be finite and in 0.0..=1.0, got {value}"),
346        ))
347    }
348}
349
350fn require_non_negative(field: &'static str, value: f32) -> Result<()> {
351    require_finite(field, value)?;
352    if value >= 0.0 {
353        Ok(())
354    } else {
355        Err(MeshError::invalid_router_config(
356            field,
357            format!("must be finite and >= 0, got {value}"),
358        ))
359    }
360}
361
362/// Inclusive unit interval required of every [`NeuromodState`] field.
363const NEUROMOD_MIN: f32 = 0.0;
364const NEUROMOD_MAX: f32 = 1.0;
365
366/// Neuromodulatory state for adaptive routing.
367///
368/// Cortisol (stress) increases resistance — channels become harder to activate.
369/// Dopamine (reward) increases conductance — channels become easier to activate.
370/// Serotonin (patience) reduces persistence — faster decay of activation.
371///
372/// Every field must be **finite** and in `[0.0, 1.0]` (IEEE signed zero is
373/// accepted). [`ChannelRouter::route_modulated`] rejects NaN, ±infinity, and
374/// finite out-of-range values **before** any routing timestep; they are not
375/// sanitized to zero and not silently clamped. Derived thresholds and leaks
376/// may still clamp internally — that is a stability bound on the neuron
377/// parameters, not an ingress policy for these fields.
378#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
379pub struct NeuromodState {
380    /// Stress level (0.0 = calm, 1.0 = max stress).
381    /// Raises firing thresholds (always — even on fresh / zero-fatigue channels)
382    /// and additionally amplifies the effect of accumulated fatigue.
383    pub cortisol: f32,
384    /// Reward level (0.0 = no reward, 1.0 = high reward).
385    /// Lowers thresholds, strengthens active synapses, counteracts fatigue.
386    pub dopamine: f32,
387    /// Patience/risk-aversion level (0.0 = impulsive, 1.0 = patient).
388    /// Increases leak/decay rate, making activations less persistent.
389    pub serotonin: f32,
390}
391
392impl NeuromodState {
393    /// Create a balanced neuromodulatory state (no modulation).
394    pub fn balanced() -> Self {
395        Self {
396            cortisol: 0.0,
397            dopamine: 0.0,
398            serotonin: 0.0,
399        }
400    }
401
402    /// Create a stressed state (high cortisol).
403    pub fn stressed() -> Self {
404        Self {
405            cortisol: 0.8,
406            dopamine: 0.0,
407            serotonin: 0.0,
408        }
409    }
410
411    /// Create a rewarded state (high dopamine).
412    pub fn rewarded() -> Self {
413        Self {
414            cortisol: 0.0,
415            dopamine: 0.8,
416            serotonin: 0.0,
417        }
418    }
419
420    /// Reject non-finite fields and values outside `[0, 1]`.
421    ///
422    /// Finite out-of-range values are **rejected**, not clamped, so a caller
423    /// cannot smuggle `cortisol = 2.0` (or a negative field) past ingress and
424    /// rely on the downstream threshold/leak clamp to hide it.
425    pub fn validate(&self) -> Result<()> {
426        for (field, value) in [
427            ("cortisol", self.cortisol),
428            ("dopamine", self.dopamine),
429            ("serotonin", self.serotonin),
430        ] {
431            if !value.is_finite() {
432                return Err(MeshError::NonFiniteNeuromodulator { field });
433            }
434            if !(NEUROMOD_MIN..=NEUROMOD_MAX).contains(&value) {
435                return Err(MeshError::OutOfRangeNeuromodulator { field, value });
436            }
437        }
438        Ok(())
439    }
440}
441
442/// Sparse activation decision from the SNN router.
443#[derive(Debug, Clone, Default)]
444pub struct RoutingDecision {
445    /// Indices of the channels that were activated.
446    pub active_channels: Vec<usize>,
447    /// Per-channel firing rates (for diagnostics and feedback).
448    pub firing_rates: Vec<f32>,
449    /// Raw input signals fed into the router.
450    pub input_signals: Vec<f32>,
451}
452
453impl RoutingDecision {
454    pub fn is_active(&self, channel: usize) -> bool {
455        self.active_channels.contains(&channel)
456    }
457
458    /// True when no channel was activated.
459    pub fn is_empty(&self) -> bool {
460        self.active_channels.is_empty()
461    }
462}
463
464/// Shared ingress checks for [`ChannelRouter::route`] and
465/// [`ChannelRouter::route_modulated`]. Length, signal finiteness, and
466/// neuromodulator validity are all decided before any router field changes.
467fn validate_route_ingress(
468    signals: &[f32],
469    mods: &NeuromodState,
470    expected_len: usize,
471    error_context: &str,
472) -> Result<()> {
473    if signals.len() != expected_len {
474        return Err(MeshError::NeuronCountMismatch {
475            expected: expected_len,
476            got: signals.len(),
477            context: error_context.into(),
478        });
479    }
480    for (index, &value) in signals.iter().enumerate() {
481        if !value.is_finite() {
482            return Err(MeshError::NonFiniteSignal {
483                index,
484                context: error_context.into(),
485            });
486        }
487    }
488    mods.validate()
489}
490
491/// Generic multi-channel SNN Router.
492///
493/// Integrates multi-channel signals over `ROUTING_TIMESTEPS` to produce
494/// a sparse activation mask. The number of channels is configurable at
495/// construction time via [`RouterConfig`].
496///
497/// Supports adaptive neuromodulatory routing via [`ChannelRouter::route_modulated`]:
498/// - Channels strengthen with use (dopamine-gated potentiation)
499/// - Channels weaken when idle (use-it-or-lose-it decay)
500/// - Fatigue accumulates with activation, cortisol amplifies it
501/// - The router naturally seeks the least-resistance pathway
502///
503/// Deserialization re-runs [`RouterConfig::validate`] and requires neuron,
504/// weight, fatigue, and baseline-weight vector shapes to match
505/// `channel_count`. Missing `config` / `channel_fatigue` / `baseline_weights`
506/// (legacy snapshots) are filled from the neuron bank rather than rejected.
507///
508/// Ingress is atomic: non-finite signals or invalid [`NeuromodState`] values
509/// return a structured [`MeshError`] and leave neurons, fatigue, adaptive
510/// weights, and `total_routes` unchanged.
511#[derive(Clone, Debug, Serialize)]
512pub struct ChannelRouter {
513    neurons: Vec<NeuromodNeuron>,
514    config: RouterConfig,
515    /// Cumulative routing decisions since creation.
516    pub total_routes: u64,
517    /// Per-channel fatigue (0.0 = fresh, 1.0 = fully exhausted).
518    pub channel_fatigue: Vec<f32>,
519    /// Baseline weights for plasticity decay reference.
520    baseline_weights: Vec<Vec<f32>>,
521}
522
523#[derive(Debug, Clone, PartialEq, Eq, Default)]
524enum LegacyField<T> {
525    #[default]
526    Omitted,
527    Null,
528    Value(T),
529}
530
531impl<'de, T: Deserialize<'de>> Deserialize<'de> for LegacyField<T> {
532    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
533    where
534        D: serde::Deserializer<'de>,
535    {
536        struct LegacyFieldVisitor<T>(std::marker::PhantomData<T>);
537
538        impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for LegacyFieldVisitor<T> {
539            type Value = LegacyField<T>;
540
541            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
542                formatter.write_str("a value or null")
543            }
544
545            fn visit_unit<E>(self) -> std::result::Result<Self::Value, E>
546            where
547                E: serde::de::Error,
548            {
549                Ok(LegacyField::Null)
550            }
551
552            fn visit_none<E>(self) -> std::result::Result<Self::Value, E>
553            where
554                E: serde::de::Error,
555            {
556                Ok(LegacyField::Null)
557            }
558
559            fn visit_some<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
560            where
561                D: serde::Deserializer<'de>,
562            {
563                T::deserialize(deserializer).map(LegacyField::Value)
564            }
565        }
566
567        deserializer.deserialize_option(LegacyFieldVisitor(std::marker::PhantomData))
568    }
569}
570
571#[derive(Deserialize)]
572struct RawChannelRouter {
573    neurons: Vec<NeuromodNeuron>,
574    #[serde(default)]
575    config: LegacyField<RouterConfig>,
576    #[serde(default)]
577    total_routes: u64,
578    #[serde(default)]
579    channel_fatigue: LegacyField<Vec<f32>>,
580    #[serde(default)]
581    baseline_weights: LegacyField<Vec<Vec<f32>>>,
582}
583
584impl RawChannelRouter {
585    fn into_router(self) -> Result<ChannelRouter> {
586        let n_neurons = self.neurons.len();
587        let config = match self.config {
588            LegacyField::Omitted => RouterConfig {
589                channel_count: n_neurons,
590                ..RouterConfig::default()
591            },
592            LegacyField::Null => {
593                return Err(MeshError::invalid_router_config(
594                    "config",
595                    "null value is not allowed; omit field for legacy format",
596                ));
597            }
598            LegacyField::Value(config) => config,
599        };
600        // Present configs were already validated by `RouterConfig`'s
601        // `Deserialize`. Re-run the same path so a `Raw` built in tests, or a
602        // future constructor that skips serde, cannot bypass it.
603        config.validate()?;
604
605        let n = config.channel_count;
606        validate_neuron_bank(n, &self.neurons)?;
607        let channel_fatigue = match self.channel_fatigue {
608            LegacyField::Omitted => vec![0.0; n],
609            LegacyField::Null => {
610                return Err(MeshError::invalid_router_config(
611                    "channel_fatigue",
612                    "null value is not allowed; omit field for legacy format",
613                ));
614            }
615            LegacyField::Value(fatigue) => fatigue,
616        };
617        let baseline_weights = match self.baseline_weights {
618            LegacyField::Omitted => self.neurons.iter().map(|neu| neu.weights.clone()).collect(),
619            LegacyField::Null => {
620                return Err(MeshError::invalid_router_config(
621                    "baseline_weights",
622                    "null value is not allowed; omit field for legacy format",
623                ));
624            }
625            LegacyField::Value(weights) => weights,
626        };
627        validate_fatigue_and_baseline(n, &channel_fatigue, &baseline_weights)?;
628        Ok(ChannelRouter {
629            neurons: self.neurons,
630            config,
631            total_routes: self.total_routes,
632            channel_fatigue,
633            baseline_weights,
634        })
635    }
636}
637
638/// Neuron bank must be length `n`, each row length `n`, with finite
639/// membrane / gain / weight parameters. Called before cloning a missing
640/// baseline table so malformed snapshots cannot double peak memory.
641fn validate_neuron_bank(n: usize, neurons: &[NeuromodNeuron]) -> Result<()> {
642    if neurons.len() != n {
643        return Err(MeshError::invalid_router_config(
644            "neurons",
645            format!("length {} does not match channel_count {n}", neurons.len()),
646        ));
647    }
648    for (i, neu) in neurons.iter().enumerate() {
649        require_finite_named("v", neu.v, i)?;
650        require_finite_named("v_rest", neu.v_rest, i)?;
651        require_finite_named("v_reset", neu.v_reset, i)?;
652        require_finite_named("leak", neu.leak, i)?;
653        require_finite_named("threshold", neu.threshold, i)?;
654        require_finite_named("gain", neu.gain, i)?;
655        if neu.weights.len() != n {
656            return Err(MeshError::invalid_router_config(
657                "weights",
658                format!(
659                    "neuron {i} weights length {} does not match channel_count {n}",
660                    neu.weights.len()
661                ),
662            ));
663        }
664        if neu.weights.iter().any(|w| !w.is_finite()) {
665            return Err(MeshError::invalid_router_config(
666                "weights",
667                format!("neuron {i} weights contain a non-finite value"),
668            ));
669        }
670    }
671    Ok(())
672}
673
674fn require_finite_named(field: &'static str, value: f32, neuron: usize) -> Result<()> {
675    if value.is_finite() {
676        Ok(())
677    } else {
678        Err(MeshError::invalid_router_config(
679            field,
680            format!("neuron {neuron} {field} must be finite, got {value}"),
681        ))
682    }
683}
684
685fn validate_fatigue_and_baseline(
686    n: usize,
687    channel_fatigue: &[f32],
688    baseline_weights: &[Vec<f32>],
689) -> Result<()> {
690    if channel_fatigue.len() != n {
691        return Err(MeshError::invalid_router_config(
692            "channel_fatigue",
693            format!(
694                "length {} does not match channel_count {n}",
695                channel_fatigue.len()
696            ),
697        ));
698    }
699    for (i, &fatigue) in channel_fatigue.iter().enumerate() {
700        if !fatigue.is_finite() || !(0.0..=1.0).contains(&fatigue) {
701            return Err(MeshError::invalid_router_config(
702                "channel_fatigue",
703                format!("index {i} must be finite and in 0.0..=1.0, got {fatigue}"),
704            ));
705        }
706    }
707    if baseline_weights.len() != n {
708        return Err(MeshError::invalid_router_config(
709            "baseline_weights",
710            format!(
711                "length {} does not match channel_count {n}",
712                baseline_weights.len()
713            ),
714        ));
715    }
716    for (i, row) in baseline_weights.iter().enumerate() {
717        if row.len() != n {
718            return Err(MeshError::invalid_router_config(
719                "baseline_weights",
720                format!(
721                    "row {i} length {} does not match channel_count {n}",
722                    row.len()
723                ),
724            ));
725        }
726        if row.iter().any(|w| !w.is_finite()) {
727            return Err(MeshError::invalid_router_config(
728                "baseline_weights",
729                format!("row {i} contains a non-finite value"),
730            ));
731        }
732    }
733    Ok(())
734}
735
736impl<'de> Deserialize<'de> for ChannelRouter {
737    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
738    where
739        D: Deserializer<'de>,
740    {
741        RawChannelRouter::deserialize(deserializer)?
742            .into_router()
743            .map_err(DeError::custom)
744    }
745}
746
747impl Default for ChannelRouter {
748    fn default() -> Self {
749        Self::new()
750    }
751}
752
753impl ChannelRouter {
754    /// Create a new router with default configuration (3 channels).
755    pub fn new() -> Self {
756        Self::try_with_config(RouterConfig::default()).expect("default RouterConfig is valid")
757    }
758
759    /// Create a new router with a custom configuration.
760    ///
761    /// Invalid configs panic. Prefer [`ChannelRouter::try_with_config`] when
762    /// the caller needs a recoverable error.
763    ///
764    /// # Panics
765    ///
766    /// Panics if [`RouterConfig::validate`] fails (zero `routing_timesteps`,
767    /// out-of-range channel count, non-finite parameters, or rates outside
768    /// their documented interval).
769    pub fn with_config(config: RouterConfig) -> Self {
770        Self::try_with_config(config).unwrap_or_else(|err| panic!("{err}"))
771    }
772
773    /// Fallible counterpart of [`ChannelRouter::with_config`].
774    ///
775    /// Validates `config` before allocating the dense weight tables, so an
776    /// oversized `channel_count` cannot exhaust memory on the way to an error.
777    pub fn try_with_config(config: RouterConfig) -> Result<Self> {
778        config.validate()?;
779        let n = config.channel_count;
780        let neurons: Vec<NeuromodNeuron> = (0..n)
781            .map(|i| {
782                let mut neu = NeuromodNeuron::new();
783                // Strong self-affinity; weak cross-channel inhibition.
784                neu.weights = vec![config.cross_weight; n];
785                neu.weights[i] = config.self_weight;
786                neu.threshold = config.threshold;
787                neu.leak = config.leak;
788                neu
789            })
790            .collect();
791
792        let baseline_weights = neurons.iter().map(|neu| neu.weights.clone()).collect();
793
794        Ok(Self {
795            neurons,
796            config,
797            total_routes: 0,
798            channel_fatigue: vec![0.0; n],
799            baseline_weights,
800        })
801    }
802
803    /// Route raw channel signals through the SNN (non-modulated).
804    ///
805    /// `signals` must have length equal to `config.channel_count`. Every
806    /// sample must be **finite**; NaN and ±infinity are rejected with
807    /// [`MeshError::NonFiniteSignal`] naming the channel index. Finite
808    /// **signed** samples are accepted and participate in the weighted sum
809    /// (a negative input inhibits).
810    ///
811    /// Validation runs **before** any neuron, fatigue, weight, or
812    /// `total_routes` mutation, including the deserialized-state self-heal.
813    ///
814    /// Backward-compatible thin wrapper around [`ChannelRouter::route_modulated`]. The error
815    /// context reported on a signal-length mismatch is `"route signals"`,
816    /// matching the original pre-neuromodulation API — callers using this
817    /// public method see the same error message they did before, even though
818    /// the implementation now delegates to `route_modulated` internally.
819    pub fn route<S: AsRef<[f32]>>(&mut self, signals: S) -> Result<RoutingDecision> {
820        self.route_modulated_with_context(signals, &NeuromodState::balanced(), "route signals")
821    }
822
823    /// Route with neuromodulatory modulation.
824    ///
825    /// Seeks the least-resistance pathway by dynamically adjusting thresholds
826    /// and applying use-it-or-lose-it plasticity:
827    /// - Cortisol raises effective thresholds (resistance)
828    /// - Dopamine lowers thresholds and strengthens active channels (conductance)
829    /// - Serotonin increases leak (reduces persistence)
830    /// - Inactive channels decay toward baseline weights
831    /// - Active channels potentiate (dopamine-gated)
832    ///
833    /// `signals` follow the same finite/signed rules as [`ChannelRouter::route`].
834    /// `mods` is validated via [`NeuromodState::validate`] before any routing
835    /// timestep: non-finite fields become [`MeshError::NonFiniteNeuromodulator`],
836    /// and finite values outside `[0, 1]` become
837    /// [`MeshError::OutOfRangeNeuromodulator`]. Both this method and
838    /// [`ChannelRouter::route`] share that ingress check.
839    pub fn route_modulated<S: AsRef<[f32]>>(
840        &mut self,
841        signals: S,
842        mods: &NeuromodState,
843    ) -> Result<RoutingDecision> {
844        self.route_modulated_with_context(signals, mods, "route_modulated signals")
845    }
846
847    /// Internal routing implementation. The `error_context` argument is the
848    /// string used in length-mismatch and non-finite-signal errors so each
849    /// public entry point can report the method the caller actually invoked.
850    fn route_modulated_with_context<S: AsRef<[f32]>>(
851        &mut self,
852        signals: S,
853        mods: &NeuromodState,
854        error_context: &str,
855    ) -> Result<RoutingDecision> {
856        let signals = signals.as_ref();
857        let n = self.config.channel_count;
858        validate_route_ingress(signals, mods, n, error_context)?;
859
860        // Self-heal: keep neuromod state vectors aligned with the current
861        // channel count if a caller mutated the public `channel_fatigue`
862        // field (serde restore already rejects inconsistent shapes).
863        self.ensure_neuromod_state_synced();
864        let (effective_thresholds, effective_leaks) = self.compute_effective_params(mods);
865
866        // Reset membrane potentials for a fresh routing decision.
867        for neu in &mut self.neurons {
868            neu.v = 0.0;
869        }
870
871        let timesteps = self.config.routing_timesteps;
872        let min_rate = self.config.min_fire_rate;
873        let spike_counts =
874            self.integrate_signals(signals, &effective_thresholds, &effective_leaks, timesteps);
875
876        let mut firing_rates = vec![0.0f32; n];
877        let mut active_channels = Vec::new();
878        for i in 0..n {
879            firing_rates[i] = spike_counts[i] as f32 / timesteps as f32;
880            if firing_rates[i] >= min_rate {
881                active_channels.push(i);
882            }
883        }
884
885        // Apply use-it-or-lose-it plasticity.
886        self.apply_plasticity(&active_channels, mods);
887
888        self.total_routes += 1;
889        Ok(RoutingDecision {
890            active_channels,
891            firing_rates,
892            input_signals: signals.to_vec(),
893        })
894    }
895
896    /// Run the per-timestep integration loop and return spike counts per channel.
897    ///
898    /// For each timestep, every neuron computes its stimulus from the signal
899    /// vector, applies the serotonin-modulated leak, integrates the stimulus,
900    /// and checks against the dopamine/cortisol-modulated threshold. Neuron
901    /// membrane potentials (`v`) are updated in place; per-channel spike counts
902    /// are incremented on each fire.
903    fn integrate_signals(
904        &mut self,
905        signals: &[f32],
906        effective_thresholds: &[f32],
907        effective_leaks: &[f32],
908        timesteps: usize,
909    ) -> Vec<u32> {
910        let n = self.config.channel_count;
911        let mut spike_counts = vec![0u32; n];
912        for _ in 0..timesteps {
913            // Iterate only up to n (channel_count) so we never index beyond
914            // spike_counts, effective_thresholds, or effective_leaks. If
915            // neurons.len() > n (malformed state), the extra neurons are
916            // skipped. If neurons.len() < n, those channels produce no spikes.
917            for (i, neu) in self.neurons.iter_mut().enumerate().take(n) {
918                debug_assert_eq!(
919                    neu.weights.len(),
920                    signals.len(),
921                    "Neuron weights length mismatch"
922                );
923                let stimulus: f32 = signals
924                    .iter()
925                    .zip(neu.weights.iter())
926                    .map(|(sig, w)| sig * w)
927                    .sum();
928                neu.leak = effective_leaks[i];
929                neu.integrate(stimulus);
930                neu.threshold = effective_thresholds[i];
931                if neu.check_fire().is_some() {
932                    spike_counts[i] += 1;
933                }
934            }
935        }
936        spike_counts
937    }
938
939    /// Lazily (re)initialize `channel_fatigue`, `baseline_weights`, and
940    /// individual neuron weight vectors so that their lengths match the
941    /// current channel count. Called at the top of `route_modulated` so
942    /// that mutating the public `channel_fatigue` field (or any other
943    /// post-construction length drift) cannot trigger out-of-bounds
944    /// indexing. Deserialization already rejects inconsistent shapes.
945    ///
946    /// Repairs three layers of state:
947    /// 1. `channel_fatigue` — resized to `n` (zero-filled).
948    /// 2. Each neuron's `weights` vector — truncated or zero-padded to `n`
949    ///    so that `integrate_signals`, `apply_plasticity`, and
950    ///    `apply_feedback` can safely index `neurons[i].weights[j]`.
951    /// 3. `baseline_weights` — rebuilt from the (now-repaired) neuron
952    ///    weights if any row is the wrong size.
953    fn ensure_neuromod_state_synced(&mut self) {
954        let n = self.config.channel_count;
955
956        // 1. Repair channel_fatigue length.
957        if self.channel_fatigue.len() != n {
958            self.channel_fatigue.resize(n, 0.0);
959        }
960
961        // 2. Repair individual neuron weight vectors.
962        //    A deserialized neuron may have weights.len() != n (e.g.
963        //    serialized with an older channel_count). Truncate or
964        //    zero-pad each to exactly n.
965        //
966        //    Zero-padding gives new channels weight 0.0, which differs
967        //    from the constructor's self_weight/cross_weight pattern.
968        //    This degrades routing on new channels until weights are
969        //    explicitly set — acceptable as a self-heal path (the
970        //    alternative is a panic). Callers who need proper weights
971        //    after a channel_count change should reconstruct via
972        //    with_config rather than relying on this repair.
973        let mut weights_repaired = false;
974        for neu in &mut self.neurons {
975            if neu.weights.len() != n {
976                weights_repaired = true;
977                neu.weights.resize(n, 0.0);
978            }
979        }
980
981        // 3. Repair baseline_weights (rebuild from neuron weights if
982        //    any row is the wrong size, or if weights were repaired
983        //    in step 2).
984        let baseline_ok = !weights_repaired
985            && self.baseline_weights.len() == n
986            && self.baseline_weights.iter().all(|row| row.len() == n);
987        if !baseline_ok {
988            self.baseline_weights = self.neurons.iter().map(|neu| neu.weights.clone()).collect();
989        }
990    }
991
992    /// Per-channel effective thresholds and leaks under neuromodulation.
993    ///
994    /// - Cortisol: baseline stress component (always raises threshold, even on
995    ///   fresh channels) + fatigue amplification (further raises it on
996    ///   fatigued channels).
997    /// - Dopamine: lowers threshold (conductance).
998    /// - Serotonin: raises leak (faster decay).
999    fn compute_effective_params(&self, mods: &NeuromodState) -> (Vec<f32>, Vec<f32>) {
1000        let n = self.config.channel_count;
1001        let mut thresholds = vec![0.0f32; n];
1002        let mut leaks = vec![0.0f32; n];
1003        for i in 0..n {
1004            let baseline_stress = 1.0 + mods.cortisol * 0.5;
1005            let fatigue_amplification = 1.0 + mods.cortisol * self.channel_fatigue[i];
1006            let fatigue_factor = baseline_stress * fatigue_amplification;
1007            let dopamine_factor = 1.0 - mods.dopamine * 0.5;
1008            thresholds[i] =
1009                (self.config.threshold * fatigue_factor * dopamine_factor).clamp(0.05, 2.0);
1010            leaks[i] = (self.config.leak * (1.0 + mods.serotonin)).clamp(0.0, 1.0);
1011        }
1012        (thresholds, leaks)
1013    }
1014
1015    /// Apply use-it-or-lose-it plasticity.
1016    ///
1017    /// - Active channels: strengthen (dopamine-gated), accumulate fatigue
1018    /// - Inactive channels: decay toward baseline weights, recover fatigue
1019    ///
1020    /// Weights are clamped to a unified range that covers both the
1021    /// self-affinity range ([0.1, 2.0]) and the cross-channel range
1022    /// ([-1.0, 1.5]) used by `apply_feedback`. This prevents the
1023    /// use-it-or-lose-it decay from drifting into a regime where the
1024    /// downstream `apply_feedback` clamp would suddenly snap a weight.
1025    fn apply_plasticity(&mut self, active_channels: &[usize], mods: &NeuromodState) {
1026        let n = self.config.channel_count;
1027        // Guard against malformed deserialized state: check both outer lengths
1028        // AND inner row lengths of neurons and baseline_weights, matching the
1029        // belt-and-suspenders pattern in sync_baseline_after_feedback.
1030        debug_assert!(
1031            self.neurons.len() >= n
1032                && self.neurons.iter().all(|neu| neu.weights.len() >= n)
1033                && self.baseline_weights.len() >= n
1034                && self.baseline_weights.iter().all(|row| row.len() >= n),
1035            "apply_plasticity invariant violation — ensure_neuromod_state_synced should have rebuilt"
1036        );
1037        if n > self.neurons.len()
1038            || self.neurons.iter().any(|neu| neu.weights.len() < n)
1039            || self.baseline_weights.len() < n
1040            || self.baseline_weights.iter().any(|row| row.len() < n)
1041        {
1042            return;
1043        }
1044        let decay = self.config.plasticity_decay;
1045        let potentiate = self.config.plasticity_potentiate;
1046        let plasticity_speed = self.config.plasticity_speed;
1047        let fatigue_acc = self.config.fatigue_accumulation;
1048        let fatigue_rec = self.config.fatigue_recovery;
1049
1050        for i in 0..n {
1051            if active_channels.contains(&i) {
1052                // Active channel: strengthen (dopamine-gated), accumulate fatigue.
1053                let strengthen = potentiate * (1.0 + mods.dopamine);
1054                for j in 0..n {
1055                    let baseline = self.baseline_weights[i][j];
1056                    let current = self.neurons[i].weights[j];
1057                    // Move toward amplified baseline.
1058                    let next_weight = if plasticity_speed == 0.0 {
1059                        current
1060                    } else {
1061                        let target = baseline * (1.0 + strengthen);
1062                        let update = if !target.is_finite() {
1063                            let extreme = if (baseline > 0.0 && strengthen >= -1.0)
1064                                || (baseline < 0.0 && strengthen < -1.0)
1065                            {
1066                                2.0
1067                            } else {
1068                                -1.5
1069                            };
1070                            current + (extreme - current) * plasticity_speed
1071                        } else {
1072                            current + (target - current) * plasticity_speed
1073                        };
1074                        if update.is_finite() {
1075                            update.clamp(-1.5, 2.0)
1076                        } else if update.is_sign_positive() {
1077                            2.0
1078                        } else {
1079                            -1.5
1080                        }
1081                    };
1082                    self.neurons[i].weights[j] = next_weight;
1083                }
1084                self.channel_fatigue[i] = (self.channel_fatigue[i] + fatigue_acc).min(1.0);
1085            } else {
1086                // Inactive channel: decay toward baseline, recover fatigue.
1087                for j in 0..n {
1088                    let baseline = self.baseline_weights[i][j];
1089                    let current = self.neurons[i].weights[j];
1090                    let update = if decay == 0.0 || current == baseline {
1091                        current
1092                    } else {
1093                        let diff = baseline - current;
1094                        if !diff.is_finite() {
1095                            if baseline > current { 2.0 } else { -1.5 }
1096                        } else {
1097                            current + diff * decay
1098                        }
1099                    };
1100                    self.neurons[i].weights[j] = if update.is_finite() {
1101                        update.clamp(-1.5, 2.0)
1102                    } else if update.is_sign_positive() {
1103                        2.0
1104                    } else {
1105                        -1.5
1106                    };
1107                }
1108                self.channel_fatigue[i] = (self.channel_fatigue[i] - fatigue_rec).max(0.0);
1109            }
1110        }
1111    }
1112
1113    /// Apply feedback to adjust synaptic weights for a specific channel.
1114    ///
1115    /// Self-heals `channel_fatigue` and `baseline_weights` before any indexing,
1116    /// so calling `apply_feedback` on a freshly deserialized router (where
1117    /// the lazy repair in `route_modulated` has not yet run) is safe. See
1118    /// `ensure_neuromod_state_synced` for the exact shape check.
1119    pub fn apply_feedback(&mut self, channel_idx: usize, reward: f32) {
1120        self.ensure_neuromod_state_synced();
1121        let n = self.config.channel_count;
1122        // Guard all indexing: channel_idx bounds, neuron vector length,
1123        // and individual neuron weight vector lengths. A malformed
1124        // deserialized state could have short weight rows even when
1125        // neurons.len() >= n.
1126        debug_assert!(
1127            channel_idx < n
1128                && self.neurons.len() >= n
1129                && self.neurons.iter().all(|neu| neu.weights.len() >= n),
1130            "apply_feedback invariant violation — ensure_neuromod_state_synced should have rebuilt"
1131        );
1132        if channel_idx >= n
1133            || n > self.neurons.len()
1134            || self.neurons.iter().any(|neu| neu.weights.len() < n)
1135        {
1136            return;
1137        }
1138
1139        let delta = reward * 0.01;
1140
1141        self.neurons[channel_idx].weights[channel_idx] =
1142            (self.neurons[channel_idx].weights[channel_idx] + delta).clamp(0.1, 2.0);
1143
1144        if reward > 0.0 {
1145            for j in 0..n {
1146                if j != channel_idx {
1147                    self.neurons[j].weights[channel_idx] =
1148                        (self.neurons[j].weights[channel_idx] - delta * 0.3).clamp(-1.0, 1.5);
1149                }
1150            }
1151        }
1152
1153        // Keep plasticity baseline in sync with feedback-driven learning.
1154        self.sync_baseline_after_feedback(channel_idx, reward);
1155    }
1156
1157    /// Sync `baseline_weights` for the rows affected by a feedback call so that
1158    /// the new feedback-adjusted weights become the reference point for future
1159    /// use-it-or-lose-it decay. Skipped if `baseline_weights` hasn't been
1160    /// initialized yet (e.g. before the first `route_modulated` call).
1161    fn sync_baseline_after_feedback(&mut self, channel_idx: usize, reward: f32) {
1162        let n = self.config.channel_count;
1163        // Belt-and-suspenders: ensure_neuromod_state_synced() in apply_feedback
1164        // should have already rebuilt baseline_weights if rows were malformed,
1165        // but guard anyway to avoid a panic if the call ordering invariant
1166        // is ever violated.
1167        debug_assert!(
1168            self.baseline_weights.len() == n
1169                && self.baseline_weights.iter().all(|row| row.len() == n),
1170            "baseline_weights shape mismatch — ensure_neuromod_state_synced should have rebuilt"
1171        );
1172        if self.baseline_weights.len() != n
1173            || self.baseline_weights.iter().any(|row| row.len() != n)
1174        {
1175            return;
1176        }
1177        self.baseline_weights[channel_idx][channel_idx] =
1178            self.neurons[channel_idx].weights[channel_idx];
1179        if reward > 0.0 {
1180            for j in 0..n {
1181                if j != channel_idx {
1182                    self.baseline_weights[j][channel_idx] = self.neurons[j].weights[channel_idx];
1183                }
1184            }
1185        }
1186    }
1187
1188    /// Apply global neuromodulatory gain to all neurons.
1189    pub fn set_global_gain(&mut self, gain: f32) {
1190        for neu in &mut self.neurons {
1191            neu.set_gain(gain);
1192        }
1193    }
1194
1195    /// Current routing weight matrix (row = neuron, col = input channel).
1196    pub fn weight_matrix(&self) -> Vec<Vec<f32>> {
1197        self.neurons.iter().map(|neu| neu.weights.clone()).collect()
1198    }
1199
1200    /// Access the router configuration.
1201    pub fn config(&self) -> &RouterConfig {
1202        &self.config
1203    }
1204
1205    /// Access per-channel fatigue levels.
1206    pub fn fatigue(&self) -> &[f32] {
1207        &self.channel_fatigue
1208    }
1209}
1210
1211#[cfg(test)]
1212mod validate_tests {
1213    use super::*;
1214    use serde_json::json;
1215
1216    fn router_field(err: &MeshError) -> &'static str {
1217        match err {
1218            MeshError::InvalidRouterConfig { field, .. } => field,
1219            other => panic!("expected InvalidRouterConfig, got {other:?}"),
1220        }
1221    }
1222
1223    fn serde_field_from_config_json(value: serde_json::Value) -> String {
1224        let err = serde_json::from_value::<RouterConfig>(value).expect_err("config should fail");
1225        err.to_string()
1226    }
1227
1228    fn serde_field_from_router_json(value: serde_json::Value) -> String {
1229        let err = serde_json::from_value::<ChannelRouter>(value).expect_err("router should fail");
1230        err.to_string()
1231    }
1232
1233    enum BadValue {
1234        Count(usize),
1235        Float(f32),
1236    }
1237
1238    struct InvalidCase {
1239        name: &'static str,
1240        field: &'static str,
1241        value: BadValue,
1242    }
1243
1244    fn apply_bad(config: &mut RouterConfig, field: &str, value: &BadValue) {
1245        match (field, value) {
1246            ("channel_count", BadValue::Count(v)) => config.channel_count = *v,
1247            ("routing_timesteps", BadValue::Count(v)) => config.routing_timesteps = *v,
1248            ("self_weight", BadValue::Float(v)) => config.self_weight = *v,
1249            ("cross_weight", BadValue::Float(v)) => config.cross_weight = *v,
1250            ("threshold", BadValue::Float(v)) => config.threshold = *v,
1251            ("leak", BadValue::Float(v)) => config.leak = *v,
1252            ("min_fire_rate", BadValue::Float(v)) => config.min_fire_rate = *v,
1253            ("plasticity_decay", BadValue::Float(v)) => config.plasticity_decay = *v,
1254            ("plasticity_potentiate", BadValue::Float(v)) => config.plasticity_potentiate = *v,
1255            ("plasticity_speed", BadValue::Float(v)) => config.plasticity_speed = *v,
1256            ("fatigue_accumulation", BadValue::Float(v)) => config.fatigue_accumulation = *v,
1257            ("fatigue_recovery", BadValue::Float(v)) => config.fatigue_recovery = *v,
1258            _ => panic!("unhandled invalid-case field {field}"),
1259        }
1260    }
1261
1262    fn json_representable(value: &BadValue) -> bool {
1263        match *value {
1264            BadValue::Count(_) => true,
1265            BadValue::Float(v) => v.is_finite(),
1266        }
1267    }
1268
1269    fn invalid_cases() -> Vec<InvalidCase> {
1270        let counts = [
1271            ("channel_count_zero", "channel_count", 0usize),
1272            (
1273                "channel_count_over_max",
1274                "channel_count",
1275                MAX_ROUTER_CHANNELS + 1,
1276            ),
1277            ("channel_count_usize_max", "channel_count", usize::MAX),
1278            ("routing_timesteps_zero", "routing_timesteps", 0),
1279            (
1280                "routing_timesteps_over_max",
1281                "routing_timesteps",
1282                MAX_ROUTING_TIMESTEPS + 1,
1283            ),
1284        ];
1285        let floats = [
1286            ("self_weight_nan", "self_weight", f32::NAN),
1287            ("self_weight_inf", "self_weight", f32::INFINITY),
1288            ("cross_weight_neg_inf", "cross_weight", f32::NEG_INFINITY),
1289            ("threshold_nan", "threshold", f32::NAN),
1290            ("leak_below", "leak", -0.01),
1291            ("leak_above", "leak", 1.01),
1292            ("leak_nan", "leak", f32::NAN),
1293            ("leak_inf", "leak", f32::INFINITY),
1294            ("min_fire_rate_below", "min_fire_rate", -0.01),
1295            ("min_fire_rate_above", "min_fire_rate", 1.01),
1296            ("min_fire_rate_nan", "min_fire_rate", f32::NAN),
1297            ("plasticity_decay_below", "plasticity_decay", -0.01),
1298            ("plasticity_decay_above", "plasticity_decay", 1.01),
1299            ("plasticity_decay_nan", "plasticity_decay", f32::NAN),
1300            (
1301                "plasticity_potentiate_negative",
1302                "plasticity_potentiate",
1303                -0.01,
1304            ),
1305            (
1306                "plasticity_potentiate_nan",
1307                "plasticity_potentiate",
1308                f32::NAN,
1309            ),
1310            (
1311                "plasticity_potentiate_inf",
1312                "plasticity_potentiate",
1313                f32::INFINITY,
1314            ),
1315            ("plasticity_speed_below", "plasticity_speed", -0.01),
1316            ("plasticity_speed_above", "plasticity_speed", 1.01),
1317            ("plasticity_speed_nan", "plasticity_speed", f32::NAN),
1318            ("fatigue_accumulation_below", "fatigue_accumulation", -0.01),
1319            ("fatigue_accumulation_above", "fatigue_accumulation", 1.01),
1320            ("fatigue_accumulation_nan", "fatigue_accumulation", f32::NAN),
1321            ("fatigue_recovery_below", "fatigue_recovery", -0.01),
1322            ("fatigue_recovery_above", "fatigue_recovery", 1.01),
1323            ("fatigue_recovery_inf", "fatigue_recovery", f32::INFINITY),
1324        ];
1325        counts
1326            .into_iter()
1327            .map(|(name, field, value)| InvalidCase {
1328                name,
1329                field,
1330                value: BadValue::Count(value),
1331            })
1332            .chain(floats.into_iter().map(|(name, field, value)| InvalidCase {
1333                name,
1334                field,
1335                value: BadValue::Float(value),
1336            }))
1337            .collect()
1338    }
1339    #[test]
1340    fn default_config_is_valid() {
1341        RouterConfig::default().validate().unwrap();
1342    }
1343
1344    #[test]
1345    fn table_rejects_every_invalid_config_field() {
1346        for case in invalid_cases() {
1347            let mut config = RouterConfig::default();
1348            apply_bad(&mut config, case.field, &case.value);
1349            let err = match config.validate() {
1350                Err(err) => err,
1351                Ok(()) => panic!("{}: validate should fail", case.name),
1352            };
1353            assert_eq!(
1354                router_field(&err),
1355                case.field,
1356                "{}: unexpected field in {err}",
1357                case.name
1358            );
1359
1360            let ctor_err = ChannelRouter::try_with_config(config.clone()).expect_err(case.name);
1361            assert_eq!(
1362                router_field(&ctor_err),
1363                case.field,
1364                "{}: constructor field mismatch",
1365                case.name
1366            );
1367            assert_eq!(
1368                ctor_err, err,
1369                "{}: validate and try_with_config must return the same error",
1370                case.name
1371            );
1372
1373            if !json_representable(&case.value) {
1374                continue;
1375            }
1376            let mut mutated = RouterConfig::default();
1377            apply_bad(&mut mutated, case.field, &case.value);
1378            let config_msg = serde_field_from_config_json(serde_json::to_value(&mutated).unwrap());
1379            assert!(
1380                config_msg.contains(case.field),
1381                "{}: RouterConfig serde missing field name: {config_msg}",
1382                case.name
1383            );
1384            assert!(
1385                config_msg.contains(&err.to_string()),
1386                "{}: RouterConfig serde should carry the same MeshError Display, got {config_msg}, expected {}",
1387                case.name,
1388                err
1389            );
1390
1391            let mut router_json = serde_json::to_value(ChannelRouter::default()).unwrap();
1392            router_json["config"] = serde_json::to_value(&mutated).unwrap();
1393            let router_msg = serde_field_from_router_json(router_json);
1394            assert!(
1395                router_msg.contains(case.field),
1396                "{}: ChannelRouter serde missing field name: {router_msg}",
1397                case.name
1398            );
1399            assert!(
1400                router_msg.contains(&err.to_string()),
1401                "{}: ChannelRouter serde should carry the same MeshError Display, got {router_msg}, expected {}",
1402                case.name,
1403                err
1404            );
1405        }
1406    }
1407
1408    type BoundaryMutator = fn(&mut RouterConfig);
1409
1410    fn valid_boundary_mutators() -> Vec<(&'static str, BoundaryMutator)> {
1411        vec![
1412            ("min_channels", |c| c.channel_count = 1),
1413            ("max_channels_validate_only", |c| {
1414                c.channel_count = MAX_ROUTER_CHANNELS
1415            }),
1416            ("leak_0", |c| c.leak = 0.0),
1417            ("leak_1", |c| c.leak = 1.0),
1418            ("min_fire_0", |c| c.min_fire_rate = 0.0),
1419            ("min_fire_1", |c| c.min_fire_rate = 1.0),
1420            ("decay_0", |c| c.plasticity_decay = 0.0),
1421            ("decay_1", |c| c.plasticity_decay = 1.0),
1422            ("potentiate_0", |c| c.plasticity_potentiate = 0.0),
1423            ("speed_0", |c| c.plasticity_speed = 0.0),
1424            ("speed_1", |c| c.plasticity_speed = 1.0),
1425            ("fatigue_acc_0", |c| c.fatigue_accumulation = 0.0),
1426            ("fatigue_acc_1", |c| c.fatigue_accumulation = 1.0),
1427            ("fatigue_rec_0", |c| c.fatigue_recovery = 0.0),
1428            ("fatigue_rec_1", |c| c.fatigue_recovery = 1.0),
1429            ("signed_cross_inhibition", |c| c.cross_weight = -1.0),
1430            ("positive_cross_weight", |c| c.cross_weight = 0.25),
1431            ("signed_self_weight", |c| c.self_weight = -0.3),
1432            ("zero_threshold", |c| c.threshold = 0.0),
1433            ("one_timestep", |c| c.routing_timesteps = 1),
1434            ("max_timesteps", |c| {
1435                c.routing_timesteps = MAX_ROUTING_TIMESTEPS
1436            }),
1437        ]
1438    }
1439
1440    #[test]
1441    fn valid_boundaries_round_trip() {
1442        for (name, mutate) in valid_boundary_mutators() {
1443            let mut config = RouterConfig::default();
1444            mutate(&mut config);
1445            config
1446                .validate()
1447                .unwrap_or_else(|err| panic!("{name}: valid boundary rejected: {err}"));
1448            let json = serde_json::to_value(&config).unwrap();
1449            let restored: RouterConfig = serde_json::from_value(json)
1450                .unwrap_or_else(|err| panic!("{name}: valid config failed to deserialize: {err}"));
1451            assert_eq!(restored, config, "{name}: config round-trip");
1452
1453            if config.channel_count == MAX_ROUTER_CHANNELS {
1454                continue;
1455            }
1456            let router = ChannelRouter::try_with_config(config.clone())
1457                .unwrap_or_else(|err| panic!("{name}: try_with_config: {err}"));
1458            let router_json = serde_json::to_value(&router).unwrap();
1459            let restored_router: ChannelRouter = serde_json::from_value(router_json)
1460                .unwrap_or_else(|err| panic!("{name}: router deserialize: {err}"));
1461            assert_eq!(restored_router.config(), router.config(), "{name}");
1462            assert_eq!(
1463                restored_router.weight_matrix(),
1464                router.weight_matrix(),
1465                "{name}"
1466            );
1467            assert_eq!(restored_router.fatigue(), router.fatigue(), "{name}");
1468            assert_eq!(restored_router.total_routes, router.total_routes, "{name}");
1469        }
1470    }
1471
1472    #[test]
1473    fn custom_and_default_routers_round_trip() {
1474        let default_router = ChannelRouter::default();
1475        let json = serde_json::to_value(&default_router).unwrap();
1476        let restored: ChannelRouter = serde_json::from_value(json).unwrap();
1477        assert_eq!(restored.config(), default_router.config());
1478        assert_eq!(restored.weight_matrix(), default_router.weight_matrix());
1479
1480        let custom = RouterConfig {
1481            channel_count: 4,
1482            self_weight: 1.1,
1483            cross_weight: -0.2,
1484            threshold: 0.3,
1485            leak: 0.05,
1486            routing_timesteps: 8,
1487            min_fire_rate: 0.25,
1488            plasticity_decay: 0.03,
1489            plasticity_potentiate: 0.2,
1490            plasticity_speed: 0.4,
1491            fatigue_accumulation: 0.2,
1492            fatigue_recovery: 0.1,
1493        };
1494        let router = ChannelRouter::try_with_config(custom.clone()).unwrap();
1495        let json = serde_json::to_value(&router).unwrap();
1496        let restored: ChannelRouter = serde_json::from_value(json).unwrap();
1497        assert_eq!(restored.config(), &custom);
1498        assert_eq!(restored.weight_matrix(), router.weight_matrix());
1499    }
1500
1501    #[test]
1502    fn try_with_config_rejects_oversized_channel_count_without_allocating() {
1503        // If validation ran after `vec![0.0; n]`, usize::MAX would OOM/hang
1504        // this test rather than returning.
1505        let config = RouterConfig {
1506            channel_count: usize::MAX,
1507            ..RouterConfig::default()
1508        };
1509        let err = ChannelRouter::try_with_config(config).unwrap_err();
1510        assert_eq!(router_field(&err), "channel_count");
1511    }
1512
1513    #[test]
1514    fn max_channel_count_is_accepted_by_validate_and_constructor() {
1515        let config = RouterConfig {
1516            channel_count: MAX_ROUTER_CHANNELS,
1517            ..RouterConfig::default()
1518        };
1519        config.validate().unwrap();
1520        let router = ChannelRouter::try_with_config(config).unwrap();
1521        assert_eq!(router.config().channel_count, MAX_ROUTER_CHANNELS);
1522        assert_eq!(router.weight_matrix().len(), MAX_ROUTER_CHANNELS);
1523        assert_eq!(router.weight_matrix()[0].len(), MAX_ROUTER_CHANNELS);
1524        assert_eq!(router.fatigue().len(), MAX_ROUTER_CHANNELS);
1525    }
1526
1527    #[test]
1528    fn malformed_shapes_are_rejected_with_matching_fields() {
1529        let router = ChannelRouter::default();
1530        let base = serde_json::to_value(&router).unwrap();
1531
1532        let mut empty_inner = base.clone();
1533        empty_inner["baseline_weights"] = json!([[], [], []]);
1534        let msg = serde_field_from_router_json(empty_inner);
1535        assert!(msg.contains("baseline_weights"), "empty inner rows: {msg}");
1536
1537        let mut short_fatigue = base.clone();
1538        short_fatigue["channel_fatigue"] = json!([0.0, 0.0]);
1539        let msg = serde_field_from_router_json(short_fatigue);
1540        assert!(msg.contains("channel_fatigue"), "short fatigue: {msg}");
1541
1542        let mut extra_neurons = base.clone();
1543        extra_neurons["neurons"] = json!([]);
1544        let msg = serde_field_from_router_json(extra_neurons);
1545        assert!(msg.contains("neurons"), "empty neurons: {msg}");
1546
1547        let mut missing_weights = base.clone();
1548        missing_weights["neurons"][0]
1549            .as_object_mut()
1550            .unwrap()
1551            .remove("weights");
1552        let msg = serde_field_from_router_json(missing_weights);
1553        assert!(msg.contains("weights"), "missing neuron weights: {msg}");
1554
1555        let mut ragged_baseline = base.clone();
1556        ragged_baseline["baseline_weights"] = json!([[0.9, -0.15, -0.15], [0.0], [0.0, 0.0, 0.0]]);
1557        let msg = serde_field_from_router_json(ragged_baseline);
1558        assert!(msg.contains("baseline_weights"), "ragged baseline: {msg}");
1559
1560        let mut out_of_range_fatigue = base;
1561        out_of_range_fatigue["channel_fatigue"] = json!([0.0, 1.5, 0.0]);
1562        let msg = serde_field_from_router_json(out_of_range_fatigue);
1563        assert!(
1564            msg.contains("channel_fatigue"),
1565            "fatigue out of range: {msg}"
1566        );
1567    }
1568
1569    #[test]
1570    fn legacy_snapshot_without_optional_fields_deserializes() {
1571        let router = ChannelRouter::default();
1572        let mut json = serde_json::to_value(&router).unwrap();
1573        let obj = json.as_object_mut().unwrap();
1574        obj.remove("config");
1575        obj.remove("channel_fatigue");
1576        obj.remove("baseline_weights");
1577
1578        let restored: ChannelRouter =
1579            serde_json::from_value(json).expect("legacy snapshot must deserialize");
1580        assert_eq!(restored.config().channel_count, 3);
1581        assert_eq!(restored.weight_matrix().len(), 3);
1582        assert_eq!(restored.fatigue(), &[0.0, 0.0, 0.0]);
1583        let mut restored = restored;
1584        restored
1585            .route_modulated([0.5, 0.0, 0.0], &NeuromodState::balanced())
1586            .unwrap();
1587    }
1588
1589    #[test]
1590    fn signed_zero_rates_are_accepted() {
1591        let config = RouterConfig {
1592            leak: -0.0,
1593            min_fire_rate: -0.0,
1594            plasticity_decay: -0.0,
1595            plasticity_potentiate: -0.0,
1596            plasticity_speed: -0.0,
1597            fatigue_accumulation: -0.0,
1598            fatigue_recovery: -0.0,
1599            ..RouterConfig::default()
1600        };
1601        config.validate().unwrap();
1602        ChannelRouter::try_with_config(config).unwrap();
1603    }
1604
1605    #[test]
1606    fn explicit_null_fields_are_rejected() {
1607        for field in ["config", "channel_fatigue", "baseline_weights"] {
1608            let router = ChannelRouter::default();
1609            let mut json = serde_json::to_value(&router).unwrap();
1610            json[field] = serde_json::Value::Null;
1611            let err = serde_json::from_value::<ChannelRouter>(json)
1612                .expect_err("explicit null field must be rejected");
1613            let msg = err.to_string();
1614            assert!(
1615                msg.contains(field),
1616                "error should mention field {field}, got: {msg}"
1617            );
1618        }
1619    }
1620
1621    #[test]
1622    fn extreme_weights_and_potentiate_with_zero_speed_do_not_produce_nan() {
1623        let config = RouterConfig {
1624            self_weight: 1.0,
1625            cross_weight: -0.5,
1626            plasticity_potentiate: f32::MAX,
1627            plasticity_speed: 0.0,
1628            ..RouterConfig::default()
1629        };
1630        config.validate().unwrap();
1631        let mut router = ChannelRouter::try_with_config(config).unwrap();
1632        let initial_weights = router.weight_matrix();
1633        let res = router.route_modulated([1.0, 0.0, 0.0], &NeuromodState::balanced());
1634        assert!(res.is_ok());
1635        for (row_idx, row) in router.weight_matrix().iter().enumerate() {
1636            for (col_idx, &w) in row.iter().enumerate() {
1637                assert!(
1638                    w.is_finite(),
1639                    "weight at [{row_idx}][{col_idx}] must remain finite, got {w}"
1640                );
1641                assert_eq!(
1642                    w, initial_weights[row_idx][col_idx],
1643                    "weight with plasticity_speed 0.0 must remain unchanged"
1644                );
1645            }
1646        }
1647
1648        // Also verify non-zero plasticity_speed with extreme potentiation clamps without NaN
1649        let config_speed = RouterConfig {
1650            self_weight: 1.0,
1651            cross_weight: -0.5,
1652            plasticity_potentiate: f32::MAX,
1653            plasticity_speed: 0.5,
1654            ..RouterConfig::default()
1655        };
1656        let mut router_speed = ChannelRouter::try_with_config(config_speed).unwrap();
1657        let res_speed = router_speed.route_modulated([1.0, 0.0, 0.0], &NeuromodState::balanced());
1658        assert!(res_speed.is_ok());
1659        for (row_idx, row) in router_speed.weight_matrix().iter().enumerate() {
1660            for (col_idx, &w) in row.iter().enumerate() {
1661                assert!(
1662                    w.is_finite(),
1663                    "weight at [{row_idx}][{col_idx}] must remain finite, got {w}"
1664                );
1665                assert!(
1666                    (-1.5..=2.0).contains(&w),
1667                    "weight {w} at [{row_idx}][{col_idx}] must stay within [-1.5, 2.0]"
1668                );
1669            }
1670        }
1671    }
1672}