diff --git a/CHANGELOG.md b/CHANGELOG.md index f66bb54..b8947d5 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 c28aa2b..7e2de39 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 b928e06..6f4721e 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 d84ef7c..221d780 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 0000000..9b23a71 --- /dev/null +++ b/crates/multicalc/src/mlp_inference/mod.rs @@ -0,0 +1,217 @@ +//! Forward-pass inference for a multi-layer perceptron, over borrowed parameters. +//! +//! 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. +//! +//! 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. +//! +//! 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; +//! 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 f03f793..c5fd1d9 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 e07d4fe..d1acd70 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 0000000..052d853 --- /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 e52defc..c0fc899 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 0000000..e5ec0f8 --- /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.