From 1f218c5458bbcaed47e12d62db5dc649e0c9f06c Mon Sep 17 00:00:00 2001 From: t55844 Date: Fri, 28 Aug 2026 12:46:01 -0300 Subject: [PATCH 1/2] Run a learned MLP policy forward without copying its parameters A trained policy reaches a robot as one flat block of numbers. Two hidden layers 64 units wide over a 22-component observation come to about 5,900 of them, some 23 KB as `f32`, against the 64 KB of RAM a small Cortex-M has in total. Owning those weights would copy the block onto the stack every control cycle. `Layer` holds a `MatrixView` of its weights and a `VectorView` of its biases instead, so the coefficients are read where they were stored. `forward` writes only the activations, `OUTPUT` of them rather than `OUTPUT`x`INPUT`. `Activation` carries the scalar nonlinearity as an enum rather than a `fn` pointer, so the choice is inlinable and a layer stays inspectable; it is `#[non_exhaustive]`, leaving room for more. Widths are const parameters, so chaining a layer that produces three values into one that expects four fails to build rather than at runtime. Nothing allocates and nothing panics, so it runs under `no_std`. Inference only. Training belongs on a machine with room for it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 + README.md | 1 + crates/multicalc/README.md | 1 + crates/multicalc/src/lib.rs | 1 + crates/multicalc/src/mlp_inference/mod.rs | 224 ++++++++++++++++++ crates/multicalc/src/tutorial_examples.rs | 2 + crates/multicalc/tests/suite/main.rs | 1 + crates/multicalc/tests/suite/mlp_inference.rs | 87 +++++++ crates/multicalc/tutorials/README.md | 1 + crates/multicalc/tutorials/mlp-inference.md | 112 +++++++++ 10 files changed, 437 insertions(+) create mode 100644 crates/multicalc/src/mlp_inference/mod.rs create mode 100644 crates/multicalc/tests/suite/mlp_inference.rs create mode 100644 crates/multicalc/tutorials/mlp-inference.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f66bb545..b8947d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`no_std` MLP inference for learned policies.** `Layer` runs one dense layer of a multi-layer + perceptron — `activation(weights · input + biases)` — over a `MatrixView` of weights and a + `VectorView` of biases, so a policy exported as one flat buffer is read where it sits instead of + being copied onto the stack. `Activation` picks the scalar nonlinearity: `Relu`, `Tanh`, or + `Identity`. Layer widths are const parameters, so a mismatched chain fails to compile. + @Thiago316316 (#83) + - **Zero-copy matrix and vector views.** `Matrix::view` / `view_mut` and `Vector::view` / `view_mut` hand out `MatrixView` / `VectorView` and their `Mut` counterparts: a flat slice, an offset, and a stride per axis. Transpose, submatrix, row, column, diagonal, segment, and the diff --git a/README.md b/README.md index c28aa2b4..7e2de399 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ https://github.com/user-attachments/assets/ed45ccb5-ca95-4e4b-8399-27d09284b220 - [Collision checking](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/kinematics.md#collision-checking): `CollisionQuery` for sphere/capsule proximity — primitives on tree frames against each other and against world-fixed obstacles, with pair exclusions and fixed capacities. - [Motion](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/motion.md): `PolylinePath` for waypoint paths with arc-length, closest-point, and lookahead queries, `MinimumSnapPlanner` for the smoothest trajectory through them, and `MotionProfilePlanner` for jerk-limited point-to-point moves with multi-axis synchronization. - [Mapping](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/mapping.md): 2D `OccupancyGrid` and `ScanGeometry` +- [MLP inference](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/mlp-inference.md): `Layer` and `Activation` run a trained multi-layer-perceptron policy forward on the robot — the weights and biases stay in flash behind a `MatrixView` / `VectorView` and are never copied onto the stack, and layer widths are const parameters, so a mismatched chain is a build error. ### Core math diff --git a/crates/multicalc/README.md b/crates/multicalc/README.md index b928e068..6f4721ee 100644 --- a/crates/multicalc/README.md +++ b/crates/multicalc/README.md @@ -36,6 +36,7 @@ - [Collision checking](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/kinematics.md#collision-checking): `CollisionQuery` for sphere/capsule proximity — primitives on tree frames against each other and against world-fixed obstacles, with pair exclusions and fixed capacities. - [Motion](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/motion.md): `PolylinePath` for waypoint paths with arc-length, closest-point, and lookahead queries, `MinimumSnapPlanner` for the smoothest trajectory through them, and `MotionProfilePlanner` for jerk-limited point-to-point moves with multi-axis synchronization. - [Mapping](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/mapping.md): 2D `OccupancyGrid` and `ScanGeometry` +- [MLP inference](tutorials/mlp-inference.md): `Layer` and `Activation` run a trained multi-layer-perceptron policy forward on the robot — the weights and biases stay in flash behind a `MatrixView` / `VectorView` and are never copied onto the stack, and layer widths are const parameters, so a mismatched chain is a build error. ### Core math - [Automatic differentiation](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/scalars-and-automatic-differentiation.md): Exact autodiff of any order (total and partial), plus Jacobian and Hessian matrices. diff --git a/crates/multicalc/src/lib.rs b/crates/multicalc/src/lib.rs index d84ef7cd..221d780b 100644 --- a/crates/multicalc/src/lib.rs +++ b/crates/multicalc/src/lib.rs @@ -198,6 +198,7 @@ pub mod gaussian_tables; pub mod kinematics; pub mod linear_algebra; pub mod mapping; +pub mod mlp_inference; pub mod motion; pub mod numerical_derivative; pub mod numerical_integration; diff --git a/crates/multicalc/src/mlp_inference/mod.rs b/crates/multicalc/src/mlp_inference/mod.rs new file mode 100644 index 00000000..07761087 --- /dev/null +++ b/crates/multicalc/src/mlp_inference/mod.rs @@ -0,0 +1,224 @@ +//! Forward-pass inference for a multi-layer perceptron, over borrowed parameters. +//! +//! A learned policy is a stack of dense layers. Each takes the vector below it, forms one weighted +//! sum per output — `weights · input + biases` — and passes every sum through a scalar +//! [`Activation`]. One layer's output is the next layer's input, and the last layer's output is the +//! answer the policy was trained to give: joint torques, rotor commands, a steering angle. +//! +//! Only the forward pass lives here. Training happens elsewhere, on a machine with room for it; +//! what reaches the robot is a block of numbers to be read in order. +//! +//! That block is why the parameters are borrowed rather than owned. Two hidden layers 64 units wide +//! over a 22-component observation come to roughly 5,900 numbers — about 23 KB as `f32`, against +//! the 64 KB of RAM a small Cortex-M has in total. A [`Layer`] holds a +//! [`MatrixView`](crate::linear_algebra::MatrixView) of its weights and a +//! [`VectorView`](crate::linear_algebra::VectorView) of its biases, so those coefficients are read +//! where they were stored and never copied onto the stack. Only the activations are written, and +//! there are `OUTPUT` of those per layer rather than `OUTPUT`×`INPUT`. +//! +//! Widths are const parameters, so a network's shape is settled when it compiles: feeding a layer +//! that produces three values into one that expects four is a build error, not a runtime one. +//! Nothing is allocated and nothing panics, so this runs under `no_std`. +//! +//! ``` +//! use multicalc::linear_algebra::Vector; +//! use multicalc::mlp_inference::{Activation, Layer}; +//! +//! // One flat block, the way a trained policy arrives: a 2 -> 3 -> 1 network. +//! let parameters = [ +//! 0.5, -0.5, 1.0, 0.0, -1.0, 2.0, // 3x2 hidden weights, row-major +//! 0.0, 1.0, -1.0, // 3 hidden biases +//! 1.0, 1.0, 1.0, // 1x3 output weights +//! 0.5, // 1 output bias +//! ]; +//! let (hidden_weights, rest) = parameters.split_at(6); +//! let (hidden_biases, rest) = rest.split_at(3); +//! let (output_weights, output_biases) = rest.split_at(3); +//! +//! let hidden = Layer::<3, 2>::try_from_slices(hidden_weights, hidden_biases, Activation::Relu)?; +//! let output = +//! Layer::<1, 3>::try_from_slices(output_weights, output_biases, Activation::Identity)?; +//! +//! let observation = Vector::new([2.0, 1.0]); +//! let activations = hidden.forward(observation.view())?; +//! +//! // The third hidden unit sums to -1.0, so the rectifier switches it off. +//! assert_eq!(activations.into_array(), [0.5, 3.0, 0.0]); +//! assert_eq!(output.forward(activations.view())?.into_array(), [4.0]); +//! # Ok::<(), multicalc::error::LinalgError>(()) +//! ``` + +use crate::Numeric; +use crate::error::LinalgError; +use crate::linear_algebra::{MatrixView, Vector, VectorView}; + +/// The scalar function a layer applies to each of its outputs, shaping the raw weighted sum +/// into the range the next layer expects. +/// +/// It is also the layer's only nonlinear step: without one, a chain of layers collapses into a +/// single layer no matter how deep it is. +/// +/// ``` +/// use multicalc::mlp_inference::Activation; +/// assert_eq!(Activation::Relu.apply(-2.0), 0.0); +/// assert_eq!(Activation::Relu.apply(3.0), 3.0); +/// assert_eq!(Activation::Identity.apply(-2.0), -2.0); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Activation { + /// Clamps a negative sum to zero and passes a positive one through. The usual hidden-layer + /// choice: one comparison, no `libm` call. + Relu, + /// Squashes into `(-1, 1)`. Bounded, which matters when the value drives an actuator, at the + /// cost of a `libm` call per component. + Tanh, + /// Passes the value through unchanged. The usual output-layer choice, where the value is a + /// physical quantity to report rather than squash. + Identity, +} + +impl Activation { + /// Applies the activation to one value. + /// + /// ``` + /// use multicalc::mlp_inference::Activation; + /// assert_eq!(Activation::Tanh.apply(0.0), 0.0); + /// assert_eq!(Activation::Identity.apply(1.5), 1.5); + /// ``` + #[inline] + #[must_use] + pub fn apply(self, value: T) -> T { + match self { + Activation::Relu => { + if value > T::ZERO { + value + } else { + T::ZERO + } + } + Activation::Tanh => value.tanh(), + Activation::Identity => value, + } + } +} + +/// One dense layer of a multi-layer perceptron: `activation(weights · input + biases)`. +/// +/// The parameters are borrowed, not owned, so a policy exported as one flat buffer is read where +/// it sits. Only the intermediate activations are materialized, and those are `OUTPUT` values +/// rather than `OUTPUT`×`INPUT`. +/// +/// ``` +/// use multicalc::linear_algebra::Vector; +/// use multicalc::mlp_inference::{Activation, Layer}; +/// let weights = [0.5, -0.5, 1.0, 0.0, -1.0, 2.0]; +/// let biases = [0.0, 1.0, -1.0]; +/// let hidden = Layer::<3, 2>::try_from_slices(&weights, &biases, Activation::Relu).unwrap(); +/// let input = Vector::new([2.0, 1.0]); +/// assert_eq!(hidden.forward(input.view()).unwrap().into_array(), [0.5, 3.0, 0.0]); +/// ``` +#[derive(Debug)] +#[must_use] +pub struct Layer<'data, const OUTPUT: usize, const INPUT: usize, T = f64> { + weights: MatrixView<'data, OUTPUT, INPUT, T>, + biases: VectorView<'data, OUTPUT, T>, + activation: Activation, +} + +// Written out rather than derived: a derive would demand `T: Copy`, but what is copied is the pair +// of handles, not the parameters they point at. +impl<'data, const OUTPUT: usize, const INPUT: usize, T> Clone for Layer<'data, OUTPUT, INPUT, T> { + #[inline] + fn clone(&self) -> Self { + *self + } +} +impl<'data, const OUTPUT: usize, const INPUT: usize, T> Copy for Layer<'data, OUTPUT, INPUT, T> {} + +impl<'data, const OUTPUT: usize, const INPUT: usize, T> Layer<'data, OUTPUT, INPUT, T> { + /// A layer over parameters that are already viewed. + /// + /// ``` + /// use multicalc::linear_algebra::{MatrixView, Vector, VectorView}; + /// use multicalc::mlp_inference::{Activation, Layer}; + /// let weights = [1.0, 0.0, 0.0, 1.0]; + /// let biases = [0.0, 0.0]; + /// let layer = Layer::new( + /// MatrixView::<2, 2>::try_from_row_major_slice(&weights).unwrap(), + /// VectorView::<2>::try_from_slice(&biases).unwrap(), + /// Activation::Identity, + /// ); + /// // Identity weights, no bias, and no squashing, so the input comes back unchanged. + /// let input = Vector::new([2.0, -3.0]); + /// assert_eq!(layer.forward(input.view()).unwrap(), input); + /// ``` + #[inline] + pub const fn new( + weights: MatrixView<'data, OUTPUT, INPUT, T>, + biases: VectorView<'data, OUTPUT, T>, + activation: Activation, + ) -> Self { + Layer { + weights, + biases, + activation, + } + } + + /// A layer over two runs of a parameter buffer: `weights` read row-major as + /// `OUTPUT`×`INPUT`, `biases` as `OUTPUT` components. `OutOfBounds` if either is too short; + /// trailing elements in either slice are ignored. + /// + /// ``` + /// use multicalc::mlp_inference::{Activation, Layer}; + /// let weights = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; + /// let biases = [0.0, 0.0]; + /// assert!(Layer::<2, 3>::try_from_slices(&weights, &biases, Activation::Relu).is_ok()); + /// assert!(Layer::<3, 3>::try_from_slices(&weights, &biases, Activation::Relu).is_err()); + /// ``` + #[inline] + pub fn try_from_slices( + weights: &'data [T], + biases: &'data [T], + activation: Activation, + ) -> Result { + Ok(Layer::new( + MatrixView::try_from_row_major_slice(weights)?, + VectorView::try_from_slice(biases)?, + activation, + )) + } +} + +impl<'data, const OUTPUT: usize, const INPUT: usize, T: Numeric> Layer<'data, OUTPUT, INPUT, T> { + /// `activation(weights · input + biases)`, one output component at a time. + /// + /// Each output reads the whole input and nothing else, so the components are independent and + /// the order they are computed in does not matter. The activation is applied to the finished + /// sum, never to the individual products. + /// + /// ``` + /// use multicalc::linear_algebra::Vector; + /// use multicalc::mlp_inference::{Activation, Layer}; + /// let weights = [1.0, 1.0, 1.0]; + /// let biases = [0.5]; + /// let output = Layer::<1, 3>::try_from_slices(&weights, &biases, Activation::Identity).unwrap(); + /// let hidden = Vector::new([0.5, 3.0, 0.0]); + /// assert_eq!(output.forward(hidden.view()).unwrap().into_array(), [4.0]); + /// ``` + #[inline] + pub fn forward( + &self, + input: VectorView<'_, INPUT, T>, + ) -> Result, LinalgError> { + let mut result = Vector::::zeros(); + for row_index in 0..OUTPUT { + let weighted_sum = self.weights.try_row(row_index)?.dot(input); + let biased = weighted_sum + *self.biases.try_get(row_index)?; + let slot = result.get_mut(row_index).ok_or(LinalgError::OutOfBounds)?; + *slot = self.activation.apply(biased); + } + Ok(result) + } +} diff --git a/crates/multicalc/src/tutorial_examples.rs b/crates/multicalc/src/tutorial_examples.rs index f03f793f..c5fd1d92 100644 --- a/crates/multicalc/src/tutorial_examples.rs +++ b/crates/multicalc/src/tutorial_examples.rs @@ -47,6 +47,8 @@ pub struct Control; pub struct Motion; #[doc = include_str!("../tutorials/mapping.md")] pub struct Mapping; +#[doc = include_str!("../tutorials/mlp-inference.md")] +pub struct MlpInference; #[doc = include_str!("../tutorials/estimation.md")] pub struct Estimation; #[doc = include_str!("../tutorials/random.md")] diff --git a/crates/multicalc/tests/suite/main.rs b/crates/multicalc/tests/suite/main.rs index e07d4fe0..d1acd704 100644 --- a/crates/multicalc/tests/suite/main.rs +++ b/crates/multicalc/tests/suite/main.rs @@ -8,6 +8,7 @@ mod gaussian_tables; mod kinematics; mod linear_algebra; mod mapping; +mod mlp_inference; mod motion; mod numerical_derivative; mod numerical_integration; diff --git a/crates/multicalc/tests/suite/mlp_inference.rs b/crates/multicalc/tests/suite/mlp_inference.rs new file mode 100644 index 00000000..052d8532 --- /dev/null +++ b/crates/multicalc/tests/suite/mlp_inference.rs @@ -0,0 +1,87 @@ +use multicalc::linear_algebra::Vector; +use multicalc::mlp_inference::{Activation, Layer}; + +#[test] +fn relu_clamps_a_negative_sum_and_leaves_a_positive_one() { + assert_eq!(Activation::Relu.apply(-1.0), 0.0); + assert_eq!(Activation::Relu.apply(0.0), 0.0); + assert_eq!(Activation::Relu.apply(2.5), 2.5); +} + +#[test] +fn two_layers_chain_hidden_output_into_the_next_input() { + let hidden_weights = [0.5, -0.5, 1.0, 0.0, -1.0, 2.0]; // 3x2 + let hidden_biases = [0.0, 1.0, -1.0]; + let output_weights = [1.0, 1.0, 1.0]; // 1x3 + let output_biases = [0.5]; + + let hidden = + Layer::<3, 2>::try_from_slices(&hidden_weights, &hidden_biases, Activation::Relu).unwrap(); + let output = + Layer::<1, 3>::try_from_slices(&output_weights, &output_biases, Activation::Identity) + .unwrap(); + + let input = Vector::new([2.0, 1.0]); + let activations = hidden.forward(input.view()).unwrap(); + + // The third unit's sum is -1.0, so ReLU switches it off. + assert_eq!(activations.into_array(), [0.5, 3.0, 0.0]); + assert_eq!( + output.forward(activations.view()).unwrap().into_array(), + [4.0] + ); +} + +#[test] +fn zero_weights_leave_only_the_biases() { + let weights = [0.0; 6]; + let biases = [1.0, -2.0, 3.0]; + let layer = Layer::<3, 2>::try_from_slices(&weights, &biases, Activation::Identity).unwrap(); + + let output = layer.forward(Vector::new([7.0, -9.0]).view()).unwrap(); + + assert_eq!(output.into_array(), biases); +} + +#[test] +fn identity_weights_and_zero_biases_pass_the_input_through() { + let weights = [1.0, 0.0, 0.0, 1.0]; // 2x2 identity + let biases = [0.0, 0.0]; + let layer = Layer::<2, 2>::try_from_slices(&weights, &biases, Activation::Identity).unwrap(); + + let input = Vector::new([2.5, -4.0]); + + assert_eq!(layer.forward(input.view()).unwrap(), input); +} + +#[test] +fn a_row_reads_the_whole_input_rather_than_summing_its_own_weights() { + // Rows sum to 6 and 15, which the dot products must not be mistaken for. + let weights = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 2x3 + let biases = [0.0, 0.0]; + let layer = Layer::<2, 3>::try_from_slices(&weights, &biases, Activation::Identity).unwrap(); + + let output = layer + .forward(Vector::new([10.0, 20.0, 30.0]).view()) + .unwrap(); + + assert_eq!(output.into_array(), [140.0, 320.0]); +} + +#[test] +fn relu_never_returns_a_negative_activation() { + let weights = [-1.0, -1.0, -1.0, -1.0]; + let biases = [-5.0, -5.0]; + let layer = Layer::<2, 2>::try_from_slices(&weights, &biases, Activation::Relu).unwrap(); + + let output = layer.forward(Vector::new([3.0, 4.0]).view()).unwrap(); + + assert!(output.as_slice().iter().all(|value| *value >= 0.0)); +} + +#[test] +fn a_slice_too_short_for_the_declared_shape_is_rejected() { + let weights = [1.0, 2.0, 3.0]; + let biases = [0.0, 0.0]; + assert!(Layer::<2, 2>::try_from_slices(&weights, &biases, Activation::Relu).is_err()); +} diff --git a/crates/multicalc/tutorials/README.md b/crates/multicalc/tutorials/README.md index e52defcc..c0fc8999 100644 --- a/crates/multicalc/tutorials/README.md +++ b/crates/multicalc/tutorials/README.md @@ -37,6 +37,7 @@ Every fallible call returns a `Result`, and the error is the module family's own - [Control](control.md) - [Motion](motion.md) - [Mapping](mapping.md) +- [MLP inference](mlp-inference.md) - [Estimation](estimation.md) - [Random](random.md) - [Error handling](error-handling.md) diff --git a/crates/multicalc/tutorials/mlp-inference.md b/crates/multicalc/tutorials/mlp-inference.md new file mode 100644 index 00000000..e5ec0f8c --- /dev/null +++ b/crates/multicalc/tutorials/mlp-inference.md @@ -0,0 +1,112 @@ +# MLP inference + +Running a learned policy on the robot, over parameters that are never copied. + +- `Layer`: one dense layer — `activation(weights · input + biases)` — holding borrowed views of its + weights and biases rather than owning them. +- `Activation`: the scalar function applied to each output, `Relu`, `Tanh`, or `Identity`. + +A multi-layer perceptron is a stack of dense layers. Each takes the vector below it, forms one +weighted sum per output, and passes every sum through an activation. Row `i` of the weight matrix is +the recipe for output `i`: it is multiplied component by component with the whole input, summed, and +offset by bias `i`. One layer's output is the next layer's input, and the last layer's output is +whatever the policy was trained to produce — joint torques, rotor commands, a steering angle. + +The activation is the only nonlinear step, and without it depth buys nothing: two affine maps +composed are still one affine map, since `W₂·(W₁·x + b₁) + b₂` is `(W₂·W₁)·x + (W₂·b₁ + b₂)`. +`Relu` clamps a negative sum to zero, which costs one comparison and no `libm` call, and is the +usual choice inside a network. `Identity` is the usual choice on the output layer, where the value +is a physical quantity that should be reported rather than squashed into a range. + +Only inference lives here. Training happens on a machine with room for it; what arrives on the robot +is a block of numbers read in order. + +## Why the parameters are borrowed + +Two hidden layers 64 units wide over a 22-component observation come to about 5,900 numbers, some +23 KB as `f32`. A small Cortex-M has 64 KB of RAM in total, and the weights are in flash. Owning +them would copy that 23 KB onto the stack, and a control loop running at a kilohertz would do it a +thousand times a second. + +So a `Layer` holds a [`MatrixView`](linear-algebra.md) of its weights and a `VectorView` of its +biases: a slice, an offset, and a stride, pointing at wherever the parameters already are. Nothing +is copied to build a layer. Running one writes `OUTPUT` numbers — the activations — rather than +`OUTPUT × INPUT`. + +Widths are const parameters, so the shape of a network is settled when it compiles. Feeding a layer +that produces three values into one that expects four does not build, rather than failing on the +robot. Nothing is allocated and nothing panics, so this runs under `no_std`. + +```rust +use multicalc::linear_algebra::Vector; +use multicalc::mlp_inference::{Activation, Layer}; + +// A trained policy arrives as one flat block. This is a 2 -> 3 -> 1 network: each layer's weights +// row-major, then its biases, in the order the layers run. +let parameters = [ + 0.5, -0.5, 1.0, 0.0, -1.0, 2.0, // 3x2 hidden weights + 0.0, 1.0, -1.0, // 3 hidden biases + 1.0, 1.0, 1.0, // 1x3 output weights + 0.5, // 1 output bias +]; + +// Walking the block hands each layer its own run of it. Nothing is copied out. +let (hidden_weights, rest) = parameters.split_at(6); +let (hidden_biases, rest) = rest.split_at(3); +let (output_weights, output_biases) = rest.split_at(3); + +let hidden = Layer::<3, 2>::try_from_slices(hidden_weights, hidden_biases, Activation::Relu)?; +let output = Layer::<1, 3>::try_from_slices(output_weights, output_biases, Activation::Identity)?; + +// One control step: an observation in, an action out. +let observation = Vector::new([2.0, 1.0]); +let activations = hidden.forward(observation.view())?; + +// The third hidden unit sums to -1.0, so the rectifier switches it off. +assert_eq!(activations.into_array(), [0.5, 3.0, 0.0]); +assert_eq!(output.forward(activations.view())?.into_array(), [4.0]); +# Ok::<(), multicalc::CalcError>(()) +``` + +## Reading a layer's parameters yourself + +`try_from_slices` reads a run of the buffer row-major and is the shortest path from a flat export. +When the parameters are already viewed — a block of a larger matrix, or a transposed export — +`Layer::new` takes the views directly, and every reshaping the views offer is available first: + +```rust +use multicalc::linear_algebra::{MatrixView, Vector, VectorView}; +use multicalc::mlp_inference::{Activation, Layer}; + +// An exporter that writes columns first leaves the weights transposed. A view fixes that by +// swapping the strides, without moving a number. +let column_major = [1.0, 0.0, 0.0, 1.0]; +let biases = [0.0, 0.0]; +let layer = Layer::new( + MatrixView::<2, 2>::try_from_row_major_slice(&column_major)?.transposed(), + VectorView::<2>::try_from_slice(&biases)?, + Activation::Identity, +); + +let input = Vector::new([2.0, -3.0]); +assert_eq!(layer.forward(input.view())?, input); +# Ok::<(), multicalc::CalcError>(()) +``` + +Getting that orientation wrong is the failure worth guarding against: a transposed weight matrix +produces a network that runs cleanly and answers wrongly. A square layer will not even fail to +compile. Check an export against known input/output pairs rather than against the shapes alone. + +## The activations + +| Variant | Value | Where it belongs | +|---|---|---| +| `Relu` | `max(0, x)` | Hidden layers. One comparison, no `libm` call. | +| `Tanh` | `tanh(x)`, in `(-1, 1)` | Where a bounded output matters, at one `libm` call per component. | +| `Identity` | `x` | Output layers reporting a physical quantity. | + +`Activation` is `#[non_exhaustive]`, so more can be added without breaking a caller's `match`. + +Errors are [`LinalgError::OutOfBounds`](error-handling.md), returned when a slice is too short for +the shape a layer declares. A slice longer than the shape is fine — the trailing elements are simply +not part of the layer, which is what lets successive layers share one buffer. From 6a32e977a72a2a5e12159d24a8d1d96404c1a622 Mon Sep 17 00:00:00 2001 From: t55844 Date: Fri, 28 Aug 2026 12:56:48 -0300 Subject: [PATCH 2/2] Tighten the module doc and let its view links resolve on their own The overview ran three long paragraphs before the example, restating in prose what the example shows. Cut to what a reader cannot get from the code: what the layers do, why the parameters are borrowed, and what the const widths buy. The links go with it. `cargo doc` denies warnings in CI, and rustdoc counts a link whose label already names a path in scope as a redundant explicit target. Both view types are imported here, so the bare labels resolve to the same pages the spelled-out paths did. Co-Authored-By: Claude Opus 5 --- crates/multicalc/src/mlp_inference/mod.rs | 27 +++++++++-------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/crates/multicalc/src/mlp_inference/mod.rs b/crates/multicalc/src/mlp_inference/mod.rs index 07761087..9b23a714 100644 --- a/crates/multicalc/src/mlp_inference/mod.rs +++ b/crates/multicalc/src/mlp_inference/mod.rs @@ -1,24 +1,17 @@ //! Forward-pass inference for a multi-layer perceptron, over borrowed parameters. //! -//! A learned policy is a stack of dense layers. Each takes the vector below it, forms one weighted -//! sum per output — `weights · input + biases` — and passes every sum through a scalar -//! [`Activation`]. One layer's output is the next layer's input, and the last layer's output is the -//! answer the policy was trained to give: joint torques, rotor commands, a steering angle. +//! A learned policy is a stack of dense layers. Each forms one weighted sum per output — +//! `weights · input + biases` — and passes every sum through a scalar [`Activation`]. One layer's +//! output is the next layer's input, and the last one's is the action the policy was trained to +//! produce. Only inference lives here; training belongs on a machine with room for it. //! -//! Only the forward pass lives here. Training happens elsewhere, on a machine with room for it; -//! what reaches the robot is a block of numbers to be read in order. +//! The parameters are borrowed rather than owned, because a policy is large next to the board +//! running it: two 64-wide hidden layers over a 22-component observation is some 23 KB as `f32`, +//! against a small Cortex-M's 64 KB of RAM. A [`Layer`] holds a [`MatrixView`] of its weights and +//! a [`VectorView`] of its biases, so nothing is copied and only the activations are written. //! -//! That block is why the parameters are borrowed rather than owned. Two hidden layers 64 units wide -//! over a 22-component observation come to roughly 5,900 numbers — about 23 KB as `f32`, against -//! the 64 KB of RAM a small Cortex-M has in total. A [`Layer`] holds a -//! [`MatrixView`](crate::linear_algebra::MatrixView) of its weights and a -//! [`VectorView`](crate::linear_algebra::VectorView) of its biases, so those coefficients are read -//! where they were stored and never copied onto the stack. Only the activations are written, and -//! there are `OUTPUT` of those per layer rather than `OUTPUT`×`INPUT`. -//! -//! Widths are const parameters, so a network's shape is settled when it compiles: feeding a layer -//! that produces three values into one that expects four is a build error, not a runtime one. -//! Nothing is allocated and nothing panics, so this runs under `no_std`. +//! Widths are const parameters, so a mismatched chain is a build error. Nothing allocates and +//! nothing panics, so this runs under `no_std`. //! //! ``` //! use multicalc::linear_algebra::Vector;