diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 709af3b..678b758 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,14 +20,52 @@ jobs: with: persist-credentials: false - - name: Install Rust stable - # Not pinned to SHA: dtolnay/rust-toolchain uses rolling tags (stable, 1.96, etc.) - # rather than versioned releases. The `stable` ref always points to the latest - # action revision that supports the current stable Rust toolchain. - uses: dtolnay/rust-toolchain@stable + # Keep Cargo.toml rust-version, rust-toolchain.toml channel, and this + # job's toolchain: string identical (MSRV single source of truth — #35). + - name: Install Rust 1.97.1 + uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 with: + toolchain: 1.97.1 components: clippy, rustfmt + - name: Verify MSRV pins agree + shell: bash + run: | + set -euo pipefail + strip_cr() { tr -d '\r'; } + cargo_rv=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/' | strip_cr) + toolchain_ch=$(grep -m1 '^channel' rust-toolchain.toml | sed -E 's/.*"([^"]+)".*/\1/' | strip_cr) + ci_rv=$(awk ' + BEGIN { want = 0; in_rt = 0; lines = 0 } + { + sub(/\r$/, "") + } + /^[[:space:]]*- name:[[:space:]]*Install Rust/ { want = 1; next } + want && /uses: dtolnay\/rust-toolchain@/ { in_rt = 1; next } + in_rt && /^[[:space:]]*toolchain:[[:space:]]*/ { + sub(/^[[:space:]]*toolchain:[[:space:]]*/, "") + sub(/[[:space:]]*$/, "") + print + exit 0 + } + want { + lines++ + if (lines > 20) exit 1 + if (/^[[:space:]]*- name:/) exit 1 + } + ' .github/workflows/ci.yml) || true + if [ -z "${ci_rv:-}" ]; then + echo "ERROR: Failed to extract toolchain: from Install Rust step in .github/workflows/ci.yml" >&2 + exit 1 + fi + echo "Cargo.toml rust-version=$cargo_rv" + echo "rust-toolchain.toml channel=$toolchain_ch" + echo "ci.yml pin=$ci_rv" + if [ "$cargo_rv" != "$toolchain_ch" ] || [ "$cargo_rv" != "$ci_rv" ]; then + echo "ERROR: MSRV pins disagree (must be identical)." >&2 + exit 1 + fi + - name: Cache cargo registry uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a767c2..180ad3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ First versioned history. Nothing before this tag was published or tagged. - **CI**: GitHub Actions workflow (`.github/workflows/ci.yml`) running `cargo fmt --check`, `cargo clippy -D warnings`, `cargo build`, and `cargo test` on every push/PR to `main`. +- MSRV pin **1.97.1** in `Cargo.toml` `rust-version`, `rust-toolchain.toml`, + and CI (`dtolnay/rust-toolchain` + pin-agreement check). +- `PartialEq` on `SynapticGraph` and `SynapseDescriptor`, plus a JSON + serde round-trip test. - `inhibitory_fraction` range validation to all topology generators (`generate_random`, `generate_small_world`, `generate_scale_free`, `generate_layered`) — rejects values outside `[0, 1]`. diff --git a/Cargo.toml b/Cargo.toml index 961ce24..5a2f0fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,9 @@ name = "synaptic-mesh" version = "0.1.0" edition = "2024" +# Keep identical to rust-toolchain.toml `channel` and the CI workflow +# `toolchain:` pin (see REVIEW.md "MSRV pin rule"). +rust-version = "1.97.1" license = "MIT OR Apache-2.0" description = "An SNN based mesh for managing the wiring, topology, and temporal delays between neurons." repository = "https://github.com/Limen-Neural/synaptic-mesh" diff --git a/REVIEW.md b/REVIEW.md index 2e0ca56..12f1597 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -4,6 +4,21 @@ These commands are the **human quality bar** beyond GitHub Actions. Run them before claiming a PR is ready when the change touches `src/`, `Cargo.toml`, or public APIs. +## MSRV pin rule + +`Cargo.toml` `rust-version`, `rust-toolchain.toml` `channel`, and the +`toolchain:` string in `.github/workflows/ci.yml` must stay **identical** +(currently **1.97.1**). CI fails if they drift (issue #35 / LIM-1042). + +To bump MSRV: + +1. Set the new version in Cargo.toml, rust-toolchain.toml, and ci.yml. +2. Run the mandatory commands below on that toolchain + (`rustup run cargo test --locked`, etc.). +3. Confirm GitHub Actions is green. + +Do not bump only one pin. + ## When to run - Before every push that changes core mesh, topology, or neuromodulation code. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..587ca71 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +# Keep identical to Cargo.toml `rust-version` and the CI workflow +# `toolchain:` pin (see REVIEW.md "MSRV pin rule"). +channel = "1.97.1" +components = ["clippy", "rustfmt"] diff --git a/src/topology/graph.rs b/src/topology/graph.rs index 54b716f..8a8e10a 100644 --- a/src/topology/graph.rs +++ b/src/topology/graph.rs @@ -6,6 +6,7 @@ //! axonal delay and polarity information. This is the core "wiring diagram" //! that the `SynapticMesh` orchestrator uses for spike propagation. +use serde::de::{Deserializer, Error as DeError}; use serde::{Deserialize, Serialize}; use crate::error::{MeshError, Result}; @@ -25,7 +26,7 @@ use crate::types::{DelayTicks, NeuronId, Polarity, SynapseDescriptor}; /// delays: [2, 5, 1, ...] — axonal delay in ticks /// polarities: [Exc, Inh, Exc, ...] — Dale's law polarity /// ``` -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] pub struct SynapticGraph { /// Number of neurons in the graph. neuron_count: usize, @@ -42,6 +43,116 @@ pub struct SynapticGraph { polarities: Vec, } +#[derive(Deserialize)] +struct RawSynapticGraph { + neuron_count: usize, + row_ptr: Vec, + targets: Vec, + weights: Vec, + delays: Vec, + polarities: Vec, +} + +impl RawSynapticGraph { + /// Re-validates the CSR invariants that [`SynapticGraph::from_descriptors`] + /// enforces at construction time, since a derived `Deserialize` would + /// accept arbitrary field values (mismatched `row_ptr` length, + /// non-monotonic offsets, out-of-range targets) that later panic in + /// [`SynapticGraph::outgoing`] or [`SynapticGraph::out_degree`]. + fn into_graph(self) -> std::result::Result { + let nnz = validate_row_ptr(&self.row_ptr, self.neuron_count)?; + validate_edge_array_lengths( + nnz, + [ + ("targets", self.targets.len()), + ("weights", self.weights.len()), + ("delays", self.delays.len()), + ("polarities", self.polarities.len()), + ], + )?; + validate_targets_in_bounds(&self.targets, self.neuron_count)?; + validate_weights_finite(&self.weights)?; + + Ok(SynapticGraph { + neuron_count: self.neuron_count, + row_ptr: self.row_ptr, + targets: self.targets, + weights: self.weights, + delays: self.delays, + polarities: self.polarities, + }) + } +} + +/// Checks `row_ptr` has `neuron_count + 1` entries starting at 0 and +/// non-decreasing, returning the total edge count (its final entry). +fn validate_row_ptr(row_ptr: &[usize], neuron_count: usize) -> std::result::Result { + let expected_len = neuron_count + .checked_add(1) + .ok_or_else(|| format!("neuron_count {neuron_count} is too large"))?; + if row_ptr.len() != expected_len { + return Err(format!( + "row_ptr length {} does not match neuron_count + 1 ({})", + row_ptr.len(), + expected_len + )); + } + if row_ptr.first().copied() != Some(0) { + return Err("row_ptr must start at 0".to_string()); + } + if !row_ptr.windows(2).all(|w| w[0] <= w[1]) { + return Err("row_ptr must be non-decreasing".to_string()); + } + Ok(*row_ptr.last().expect("row_ptr is non-empty")) +} + +/// Checks that every named edge array has exactly `nnz` entries. +fn validate_edge_array_lengths( + nnz: usize, + arrays: [(&str, usize); 4], +) -> std::result::Result<(), String> { + for (name, len) in arrays { + if len != nnz { + return Err(format!( + "{name} length {len} does not match row_ptr's final offset {nnz}" + )); + } + } + Ok(()) +} + +/// Checks that every target neuron id is within `[0, neuron_count)`. +fn validate_targets_in_bounds( + targets: &[NeuronId], + neuron_count: usize, +) -> std::result::Result<(), String> { + if targets.iter().any(|&t| t as usize >= neuron_count) { + return Err("target neuron id out of bounds".to_string()); + } + Ok(()) +} + +/// Checks that every weight is finite, so a non-JSON format (which, unlike +/// JSON, can represent NaN/Inf) can't smuggle in a value that poisons +/// current sums in [`crate::mesh::SynapticMesh::propagate`]. +fn validate_weights_finite(weights: &[f32]) -> std::result::Result<(), String> { + if weights.iter().any(|w| !w.is_finite()) { + return Err("synapse weight must be finite".to_string()); + } + Ok(()) +} + +impl<'de> Deserialize<'de> for SynapticGraph { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + RawSynapticGraph::deserialize(deserializer)? + .into_graph() + .map_err(DeError::custom) + } +} + impl SynapticGraph { /// Create an empty graph with `n` neurons and no connections. pub fn new(n: usize) -> Self { @@ -275,6 +386,39 @@ mod tests { assert!(inh_edge.1 < 0.0); } + #[test] + fn synaptic_graph_json_roundtrip() { + let descs = vec![ + SynapseDescriptor { + source: 0, + target: 1, + weight: 0.9, + delay: 3, + polarity: Polarity::Excitatory, + }, + SynapseDescriptor { + source: 0, + target: 2, + weight: 0.15, + delay: 1, + polarity: Polarity::Inhibitory, + }, + SynapseDescriptor { + source: 2, + target: 1, + weight: 0.4, + delay: 5, + polarity: Polarity::Excitatory, + }, + ]; + let graph = SynapticGraph::from_descriptors(3, &descs) + .expect("hand-written descriptors must build a graph"); + let json = serde_json::to_string(&graph).expect("serialize SynapticGraph to JSON"); + let restored: SynapticGraph = + serde_json::from_str(&json).expect("deserialize SynapticGraph from JSON"); + assert_eq!(restored, graph); + } + #[test] fn max_delay_reports_correctly() { let descs = vec![ @@ -297,6 +441,48 @@ mod tests { assert_eq!(graph.max_delay(), 7); } + #[test] + fn deserialize_rejects_row_ptr_length_mismatch() { + let json = r#"{"neuron_count":2,"row_ptr":[0,1],"targets":[],"weights":[],"delays":[],"polarities":[]}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn deserialize_rejects_non_monotonic_row_ptr() { + let json = r#"{"neuron_count":2,"row_ptr":[0,3,1],"targets":[0,0,0],"weights":[0.1,0.1,0.1],"delays":[1,1,1],"polarities":["Excitatory","Excitatory","Excitatory"]}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn deserialize_rejects_edge_array_length_mismatch() { + let json = r#"{"neuron_count":1,"row_ptr":[0,2],"targets":[0],"weights":[0.1,0.1],"delays":[1,1],"polarities":["Excitatory","Excitatory"]}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn validate_weights_finite_rejects_nan_and_infinite() { + // JSON has no literal for NaN/Infinity, so this exercises the + // validator directly rather than through a JSON round-trip; a + // non-JSON serde format (bincode, postcard, cbor) could otherwise + // smuggle these values into a deserialized graph. + assert!(validate_weights_finite(&[0.1, f32::NAN]).is_err()); + assert!(validate_weights_finite(&[0.1, f32::INFINITY]).is_err()); + assert!(validate_weights_finite(&[0.1, f32::NEG_INFINITY]).is_err()); + assert!(validate_weights_finite(&[0.1, -0.4, 2.0]).is_ok()); + } + + #[test] + fn deserialize_rejects_usize_max_neuron_count_without_overflow_panic() { + let json = r#"{"neuron_count":18446744073709551615,"row_ptr":[0,1],"targets":[0],"weights":[0.1],"delays":[1],"polarities":["Excitatory"]}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn deserialize_rejects_out_of_range_target() { + let json = r#"{"neuron_count":1,"row_ptr":[0,1],"targets":[5],"weights":[0.1],"delays":[1],"polarities":["Excitatory"]}"#; + assert!(serde_json::from_str::(json).is_err()); + } + #[test] fn out_of_bounds_source_rejected() { let descs = vec![SynapseDescriptor { diff --git a/src/types.rs b/src/types.rs index cbc1bef..ad9d5af 100644 --- a/src/types.rs +++ b/src/types.rs @@ -5,6 +5,7 @@ //! Shared scalar types, measurement units, and configuration structs used //! across the topology, delay, and mesh modules. +use serde::de::{Deserializer, Error as DeError}; use serde::{Deserialize, Serialize}; // ── Scalar aliases ──────────────────────────────────────────────────────────── @@ -47,13 +48,14 @@ impl Polarity { // ── Synapse descriptor ──────────────────────────────────────────────────────── /// A fully-described synaptic connection with weight, delay, and polarity. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct SynapseDescriptor { /// Source neuron. pub source: NeuronId, /// Target neuron. pub target: NeuronId, /// Absolute synaptic weight (always ≥ 0; sign comes from polarity). + #[serde(deserialize_with = "deserialize_nonnegative_weight")] pub weight: f32, /// Axonal propagation delay in ticks. pub delay: DelayTicks, @@ -68,6 +70,22 @@ impl SynapseDescriptor { } } +/// Rejects negative or non-finite weights so deserialized descriptors keep +/// the documented `weight >= 0` invariant that [`SynapseDescriptor::effective_weight`] +/// relies on to apply polarity correctly. +fn deserialize_nonnegative_weight<'de, D>(deserializer: D) -> std::result::Result +where + D: Deserializer<'de>, +{ + let weight = f32::deserialize(deserializer)?; + if !weight.is_finite() || weight < 0.0 { + return Err(DeError::custom(format!( + "synapse weight must be finite and non-negative, got {weight}" + ))); + } + Ok(weight) +} + // ── Topology parameters ────────────────────────────────────────────────────── /// Configuration for network topology generation. @@ -152,3 +170,22 @@ impl Default for DelayModel { Self::Fixed { delay: 1 } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_rejects_negative_weight() { + let json = r#"{"source":0,"target":1,"weight":-0.5,"delay":1,"polarity":"Excitatory"}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn deserialize_accepts_valid_weight() { + let json = r#"{"source":0,"target":1,"weight":0.5,"delay":1,"polarity":"Inhibitory"}"#; + let desc: SynapseDescriptor = serde_json::from_str(json).unwrap(); + assert_eq!(desc.weight, 0.5); + assert!((desc.effective_weight() + 0.5).abs() < 1e-6); + } +}