rustyml 0.16.0

A high-performance machine learning & deep learning library in pure Rust, offering ML algorithms and neural network support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# 3.9. Saving and Loading Weights

RustyML persists a trained [`Sequential`](./3.1._The_Sequential_Model.md) model with 3 methods: `save_to_path`, `load_from_path`, and the opt-in lenient `load_partial_from_path`. These methods are narrow by design. RustyML saves the layer *weights* alone, and the file does not carry enough information to rebuild a model. Loading takes 2 steps. First, collect the identical layer stack in a `SequentialBuilder` and build it against the same input shape. Second, load the saved arrays into that built model.

This section describes what the file stores, why the boundary sits there, the failure modes, and workflow recipes for a weights-only model.

## 3.9.1. What actually gets written to disk

`save_to_path` walks the layers. For each layer, it records the layer type name and every named array the layer holds. It then serializes the whole checkpoint with [postcard](https://docs.rs/postcard) into a compact binary blob, and a buffered writer writes that blob to disk. Nothing is copied on the way, because each record borrows the live array until postcard reads it. `load_from_path` reads the file back, deserializes it, checks that the built model matches the file, and applies the arrays layer by layer. The signatures:

```rust,ignore
pub fn save_to_path(&self, path: impl AsRef<std::path::Path>) -> RustymlResult<()>;
pub fn load_from_path(&mut self, path: impl AsRef<std::path::Path>) -> RustymlResult<()>;
pub fn load_partial_from_path(&mut self, path: impl AsRef<std::path::Path>) -> RustymlResult<LoadReport>;
```

All 3 methods accept any type that implements `AsRef<Path>`: `&str`, `String`, `Path`, or `PathBuf`. The `"model.bin"` literals in the examples below are only the common case. The file extension has no effect on the format, because postcard writes raw bytes whatever the file name is. `save_to_path` uses `File::create`, which truncates and overwrites the file, so a second save to the same path replaces the previous checkpoint. This behavior fits a "keep only the best" loop.

The per-layer metadata serves 1 purpose: validation, and not reconstruction. Each layer contributes a type-name string, for example `"Dense"` or `"Conv2D"`. Each array contributes its own name and its kind. These are validation tags, and not a recipe for building a layer. The file does not record the activation function of a layer.

It does not record `epsilon`, `momentum`, stride, kernel size, dilation, or group count. An empty `Sequential` handed to `load_from_path` does not come back as a working model. RustyML calls this format "weights-only" for that reason. The architecture lives in the source code, and the file carries only the numbers that fill it.

The file also holds **the shapes each layer was built for**. A layer allocates its arrays in `build`, from the shapes of its inputs, so those shapes decide every extent the layer holds. The build record holds **1 shape per input of the layer**. A layer with 1 input therefore records 1 shape, and a merge layer with 3 inputs records 3. Recording them lets a load refuse a model that was built for another input, and the refusal names the layer and both sides.

The batch axis of that record is free. A layer serves every batch size, so a checkpoint written by a model built for 1 sample loads into a model built for 32. A layer that owns no array and reads no extent of its input carries no build record at all. An activation and `Dropout` are such layers, and a load skips the comparison for them.

| Persisted in the file | Not persisted |
| --- | --- |
| Per-layer type-name tag, and the build shapes (both validated on load) | Activation functions, hyperparameters (`epsilon`, `momentum`, stride, ...) |
| The name and the kind of every array (validated on load) | Optimizer and its accumulated state (Adam moments, SGD momentum) |
| The weight arrays of every layer (see [3.9.2](#392-the-named-checkpoint-1-address-per-array)) | Loss function and compile state |
| BatchNormalization `moving_mean` and `moving_variance` | Fit-time shuffle seed, training-mode flag |

## 3.9.2. The named checkpoint: 1 address per array

The file addresses every array by a dotted path, `<scope>.<name>`. `scope` is the position of the layer, counted from the input. `name` is the name the layer gives the array. The kernel of the first layer of a model is therefore `0.kernel`. The moving mean of a `BatchNormalization` at position 1 is `1.moving_mean`. This is the same pair the optimizer keys its state on, so 1 address serves the training loop and the file alike (see [3.4. Optimizers](./3.4._Optimizers.md)).

A `Graph` model writes the same format under the same paths. Its `scope` is the position of the layer in the layer arena, and not the position of a node. A layer that several nodes call therefore holds 1 set of paths, whatever number of nodes read it.

Each record also carries a `WeightKind`. `Trainable` says an optimizer updates the array, and it covers every kernel, every bias, and every normalization scale and shift. `NonTrainable` says the layer keeps the array and no optimizer writes it. The 2 moving statistics of `BatchNormalization` are the only non-trainable arrays in the crate today. A load compares the kind next to the shape. A file that offers a trainable array where the layer keeps state is therefore refused, and not applied.

`weight_paths()` lists every address of a model, in the order a file holds them. `weight(path)` reads 1 of them, as a view that borrows the live array:

```rust
use rustyml::neural_network::Shape;
use rustyml::neural_network::layers::activation::linear::Linear;
use rustyml::neural_network::layers::dense::Dense;
use rustyml::neural_network::layers::regularization::normalization::batch_normalization::BatchNormalization;
use rustyml::neural_network::sequential::SequentialBuilder;

fn main() {
    let model = SequentialBuilder::new()
        .add(Dense::new(3, Linear::new()).unwrap())
        .add(BatchNormalization::new(0.9, 1e-5).unwrap())
        .add(Dense::new(2, Linear::new()).unwrap().with_use_bias(false))
        .build(&Shape::with_free_batch(&[2, 4]))
        .unwrap();

    // Every array of the model, under the address a saved file gives it.
    for path in model.weight_paths() {
        println!("{path:<18} {:?}", model.weight(&path).unwrap().shape());
    }
}
```

```text
0.kernel           [4, 3]
0.bias             [1, 3]
1.gamma            [3]
1.beta             [3]
1.moving_mean      [3]
1.moving_variance  [3]
2.kernel           [3, 2]
```

The third layer of that model shows what an optional array does to the list. `with_use_bias(false)` takes `2.bias` out of the model, so it is out of the file as well. The remaining paths do not move, because a path names an array and never a position inside a layer. `with_center(false)` and `with_scale(false)` do the same to `beta` and `gamma` on the 4 normalization layers.

The arrays each layer holds:

| Layer | Named arrays |
| --- | --- |
| `Dense` | `kernel (built width, units)`, `bias (1, units)` |
| `Embedding` | `embeddings (input_dim, output_dim)` |
| `SimpleRNN` | `kernel`, `recurrent_kernel`, `bias` |
| `LSTM` / `GRU` | fused `kernel`, `recurrent_kernel`, `bias` (gate blocks `[i\|f\|g\|o]` / `[z\|r\|h]`) |
| `Conv1D` / `Conv2D` / `Conv3D` | `kernel` and `bias` |
| `Conv1DTranspose` / `Conv2DTranspose` / `Conv3DTranspose` | `kernel` and `bias` (the kernel carries its filter axis before its channel axis) |
| `SeparableConv1D` / `SeparableConv2D` | `depthwise_kernel`, `pointwise_kernel`, and `bias` |
| `DepthwiseConv1D` / `DepthwiseConv2D` | `kernel` and `bias` (no pointwise kernel) |
| `BatchNormalization` | `gamma`, `beta`, **`moving_mean`, `moving_variance`** |
| `LayerNormalization` / `InstanceNormalization` / `GroupNormalization` | `gamma` and `beta` only |
| `PReLU` | `alpha`, whose rank follows the input, with `1` on every shared axis |
| Dropout, pooling, flatten, merge, pure activation layers | none, so they add no path |

This difference among the normalization layers is correct by design. [BatchNormalization](./3.8._Regularization_and_Normalization_Layers.md) accumulates moving statistics during training and *uses them at inference*. Those 2 arrays are part of the trained state, so they must survive serialization. If a reload dropped them, the model would normalize eval-mode inputs with default statistics and produce wrong output.

Layer, instance, and group normalization compute their statistics from the current input on every forward pass, so they hold no moving state. `gamma` and `beta` are all these layers need to save. [3.9.4](#394-normalization-running-statistics-survive-the-round-trip) shows the `BatchNormalization` case in detail.

## 3.9.3. A complete round-trip

The full loop has 5 steps: build, train briefly, save, rebuild the *same* stack, and load. The last step confirms that the restored model predicts the same values. Note the `make_arch` function. It defines the architecture once, the input shape included, and both the live model and the reload target call it. This habit is the most useful practice for weights-only persistence, because it guarantees that the 2 stacks cannot drift apart. The build shapes travel in the file as well, so the load, and not the first prediction, catches a drift there.

```rust
use ndarray::Array;
use rustyml::error::Error;
use rustyml::neural_network::Tensor;
use rustyml::neural_network::layers::activation::linear::Linear;
use rustyml::neural_network::layers::dense::Dense;
use rustyml::neural_network::losses::MeanSquaredError;
use rustyml::neural_network::optimizers::SGD;
use rustyml::neural_network::Shape;
use rustyml::neural_network::sequential::{Sequential, SequentialBuilder};

// Define the architecture once, input shape included. Reuse it for the live model and the
// reload target.
fn make_arch() -> Sequential {
    SequentialBuilder::new()
        .add(Dense::new(3, Linear::new()).unwrap())
        .add(Dense::new(2, Linear::new()).unwrap())
        .build(&Shape::with_free_batch(&[2, 4]))
        .unwrap()
}

fn main() -> Result<(), Error> {
    let x: Tensor = Array::from_shape_vec((2, 4), vec![0.1f32, 0.2, 0.3, 0.4, -0.1, -0.2, -0.3, -0.4])
        .unwrap()
        .into_dyn();
    let y: Tensor = Array::from_shape_vec((2, 2), vec![1.0f32, 0.0, 0.0, 1.0])
        .unwrap()
        .into_dyn();

    let mut model = make_arch();
    model.compile(SGD::new(0.01, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());
    model.fit(&x, &y, 5)?;
    let before = model.predict(&x)?;

    // Save weights, then rebuild the identical stack and load into it.
    let path = "roundtrip_demo.bin";
    model.save_to_path(path)?;

    let mut restored = make_arch();
    restored.load_from_path(path)?;
    let after = restored.predict(&x)?;

    // The 2 prediction tensors must agree element-wise.
    let max_diff = (&after - &before)
        .mapv(f32::abs)
        .iter()
        .cloned()
        .fold(0.0f32, f32::max);
    println!("max abs difference after round-trip: {max_diff:e}");
    assert!(max_diff < 1e-6);

    std::fs::remove_file(path).unwrap();
    Ok(())
}
```

The round-trip is exact, and not approximate. postcard stores each `f32` losslessly. `load_from_path` writes the arrays straight into the layers, so the reloaded model runs the same computation, bit-for-bit. The `1e-6` tolerance in the example is defensive slack, and not a hedge against drift.

## 3.9.4. Normalization running statistics survive the round-trip

The inference path of `BatchNormalization` reads `moving_mean` and `moving_variance`. This example trains those statistics away from their defaults, saves the model, and reloads the weights into a *fresh, untrained* model. It then checks that eval-mode predictions still match. They do. The 2 arrays travel under their own paths, marked `NonTrainable`, next to the trainable `gamma` and `beta` of the same layer.

```rust
use ndarray::Array;
use rustyml::neural_network::Tensor;
use rustyml::neural_network::layers::regularization::normalization::batch_normalization::BatchNormalization;
use rustyml::neural_network::losses::MeanSquaredError;
use rustyml::neural_network::optimizers::SGD;
use rustyml::neural_network::Shape;
use rustyml::neural_network::sequential::{Sequential, SequentialBuilder};

fn make_arch() -> Sequential {
    SequentialBuilder::new()
        .add(BatchNormalization::new(0.9, 1e-5).unwrap())
        .build(&Shape::with_free_batch(&[4, 3]))
        .unwrap()
}

fn main() {
    let x: Tensor = Array::from_shape_vec(
        (4, 3),
        vec![0.5f32, -1.0, 2.0, 1.5, 0.2, -0.7, -1.2, 0.8, 1.1, 0.3, -0.4, 0.9],
    )
    .unwrap()
    .into_dyn();

    // Train so moving_mean / moving_variance move away from their initial values.
    let mut model = make_arch();
    model.compile(SGD::new(0.001, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());
    model.fit(&x, &x, 8).unwrap();
    let before = model.predict(&x).unwrap(); // eval mode uses the running stats

    let path = "batchnorm_demo.bin";
    model.save_to_path(path).unwrap();

    let mut restored = make_arch(); // fresh: running stats at their defaults
    restored.load_from_path(path).unwrap();
    let after = restored.predict(&x).unwrap();

    let max_diff = (&after - &before)
        .mapv(f32::abs)
        .iter()
        .cloned()
        .fold(0.0f32, f32::max);
    println!("running-stat round-trip max abs difference: {max_diff:e}");
    assert!(max_diff < 1e-6);

    std::fs::remove_file(path).unwrap();
}
```

If the file did not save the moving statistics, `after` would differ sharply from `before`. The defaults of the fresh model do not match the 8 epochs of accumulated batch statistics.

## 3.9.5. What is not saved, and why it bites

The optimizer, its accumulated state, the loss function, and the entire compile configuration stay behind. Loading gives weights on a blank model. The `optimizer` and `loss` fields are `None`, so 2 consequences follow.

First, the model needs another `compile` call before it can `fit` or train. Prediction works right away, because `predict` needs no optimizer. Second, a resumed training run after a load **restarts the optimizer from zero**. This point is easy to miss. The first-moment and second-moment estimates of [Adam](./3.4._Optimizers.md) reset to zero.

The momentum buffer of SGD resets too. The bias-correction timestep of Adam starts over at 1. For a few fine-tuning steps, this reset causes no harm. For a long training run split across a save and load, the first post-load steps take larger, less-damped updates than an uninterrupted run would take. The loss curve shows a temporary bump.

This bump is a persistence artifact, and not a data problem. To pause and resume long training without this discontinuity, keep the process alive. Do not pause it with a save to disk and a reload. RustyML has no API to serialize optimizer moments. The fit-time shuffle seed is also not saved. Set it again with [`set_seed`](../Chapter-07/7.1._Reproducibility_and_Random_Seeds.md) after a load, for a reproducible resumed shuffle.

## 3.9.6. Error variants on load

Every failure surfaces as `Error::Io(...)` (see [1.6. Error Handling](../Chapter-01/1.6._Error_Handling.md)). There are exactly 4 variants, and each one maps to a distinct cause of failure:

| Error | Cause |
| --- | --- |
| `IoError::Std` | The file could not be opened/read (missing path, permissions) or written |
| `IoError::UnsupportedModelFormat` | The file does not carry the magic tag and format version of this build. It is not a RustyML model, or a release with a different on-disk weight layout wrote it (see [3.9.8](#398-workflow-recipes)) |
| `IoError::Serialization` | The bytes are not valid postcard for the expected schema (corruption or truncation *after* a well-formed header) |
| `IoError::ModelStructureMismatch` | The model does not match the file. The causes are a wrong layer *count* or *type*, a wrong *build shape*, an unknown *name*, a disagreeing *kind*, or a disagreeing *shape* |

The load path checks the header first, so these 4 errors follow an order. A file that is not a model at all never reaches the postcard decoder. A file from an incompatible release never reaches the structural checks.

`ModelStructureMismatch` is the error a model hits most often while it is under development. The strict load runs 6 checks, and it runs them in this order. First the layer count, then the layer type of each position. Then the build shapes, where both sides carry them. Then the number of arrays of a layer, and the name of each one.

Then the kind of each array. Last the shape and the element count of each array. The whole first pass reads the model alone and writes nothing. A refusal therefore leaves the model exactly as it was. A model never holds the arrays of 1 file next to the arrays of another.

The message names the checkpoint path, or the layer position for a whole-layer disagreement. The build-shape check usually fires first, and it fires before it compares any array. Take a `Dense` model built for `(None, 4)`, loaded from a file that a model built for `(None, 5)` wrote. It fails with ``model structure mismatch: layer 0 (`Dense`) was built for input shape (None, 4), and the file records (None, 5)``. A merge layer names 1 shape per input on both sides of that message. Matching the error is straightforward:

```rust
use rustyml::error::{Error, IoError};
use rustyml::neural_network::Shape;
use rustyml::neural_network::layers::activation::linear::Linear;
use rustyml::neural_network::layers::dense::Dense;
use rustyml::neural_network::sequential::SequentialBuilder;

fn main() {
    // Save a 1-layer model.
    let saved = SequentialBuilder::new()
        .add(Dense::new(2, Linear::new()).unwrap())
        .build(&Shape::with_free_batch(&[2, 2]))
        .unwrap();
    let path = "mismatch_demo.bin";
    saved.save_to_path(path).unwrap();

    // Rebuild with the wrong layer count and try to load it.
    let mut wrong = SequentialBuilder::new()
        .add(Dense::new(2, Linear::new()).unwrap())
        .add(Dense::new(2, Linear::new()).unwrap())
        .build(&Shape::with_free_batch(&[2, 2]))
        .unwrap();

    match wrong.load_from_path(path) {
        Err(Error::Io(IoError::ModelStructureMismatch(msg))) => {
            println!("rejected as expected: {msg}");
        }
        Err(other) => panic!("unexpected error: {other:?}"),
        Ok(()) => panic!("load must not succeed on a structure mismatch"),
    }

    std::fs::remove_file(path).unwrap();
}
```

The type-name check compares strings. It catches common mistakes: a Conv layer where the file has a Dense layer, or a missing or extra layer. It does not catch a hyperparameter change that leaves the type name and weight shapes unchanged. 2 `Dense` layers with the same shapes but different activations load without complaint, because the activation is not on disk. This gap is the direct cost of storing metadata tags instead of a full architecture, and the reason the `make_arch` discipline matters.

**Strict is the default, and the type name is why.** A name and a shape together are a weaker key than a closed set of per-layer containers. `InstanceNormalization`, `GroupNormalization`, and a rank-2 `LayerNormalization` all expose `gamma [C]` and `beta [C]`, identically. Nothing but the per-layer type name tells the 3 apart. A lenient default would therefore load a file that is wrong for the model and apply every array of it.

The difference would surface later, as a wrong prediction. So the load refuses on any disagreement, and the lenient path is the separate `load_partial_from_path`, which a caller asks for by name. [3.9.9](#399-loading-part-of-a-file-on-purpose) covers it.

## 3.9.7. The postcard format: size, speed, portability

postcard is a compact, non-self-describing binary format. Non-self-describing means the stream carries no field names. It writes only the values, in declaration order. This design keeps the files small. It also ties each file to the exact struct and enum layout of the crate version that wrote it. File size is easy to predict.

Each parameter is an `f32`, 4 bytes. A small fixed overhead adds the array shape (varint-encoded) and 1 byte for the kind of each array. It also adds the build shapes and the short strings: 1 layer type name per layer, and 1 array name per array. The `Total params: N (N*4 B)` line that `Sequential::summary` prints is therefore a close upper estimate of the file size. A 100,000-parameter model takes about 400 KB. Serialization runs as a single linear pass with 1 buffered write, so I/O time, and not CPU time, dominates the cost.

The format is portable across machines. postcard defines its own byte order instead of a dump of native-endian memory. A checkpoint written on 1 architecture therefore deserializes correctly on another, whatever the endianness of the host. This portability needs no conversion step and no per-platform variant. The format is *not* portable across crate versions. That limit is the subject of the next section.

## 3.9.8. Workflow recipes

**Checkpoint the best model.** Train in short rounds. Score the model with `evaluate` after each round, and overwrite a single file whenever the score improves. `save_to_path` truncates the file, so it always holds the best weights found so far. The final in-memory model may have overfit past the best point, so discard it and use the reload instead. Score with `evaluate`, and not with the last entry of the `History` that `fit` returns.

A history entry is the loss measured *during* the epoch, on a forward pass taken before the weight update of that epoch. The last entry therefore describes weights the model no longer holds, and a rule based on it would checkpoint a round late. `evaluate` runs 1 inference-mode forward pass over the data and scores it with the compiled loss. It updates nothing: no gradients, no parameters, and no `BatchNormalization` running statistics. A selection rule built on `evaluate` therefore cannot change the training it measures.

```rust
use ndarray::Array;
use rustyml::error::Error;
use rustyml::neural_network::Tensor;
use rustyml::neural_network::layers::activation::linear::Linear;
use rustyml::neural_network::layers::dense::Dense;
use rustyml::neural_network::losses::MeanSquaredError;
use rustyml::neural_network::optimizers::SGD;
use rustyml::neural_network::Shape;
use rustyml::neural_network::sequential::{Sequential, SequentialBuilder};

fn make_arch() -> Sequential {
    SequentialBuilder::new()
        .add(Dense::new(3, Linear::new()).unwrap())
        .add(Dense::new(2, Linear::new()).unwrap())
        .build(&Shape::with_free_batch(&[2, 4]))
        .unwrap()
}

fn main() -> Result<(), Error> {
    let x: Tensor = Array::from_shape_vec((2, 4), vec![0.1f32, 0.2, 0.3, 0.4, -0.1, -0.2, -0.3, -0.4])
        .unwrap()
        .into_dyn();
    let y: Tensor = Array::from_shape_vec((2, 2), vec![1.0f32, 0.0, 0.0, 1.0])
        .unwrap()
        .into_dyn();

    let mut model = make_arch();
    model.compile(SGD::new(0.05, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());

    let path = "best.bin";
    let mut best = f32::INFINITY;
    for round in 0..10 {
        model.fit(&x, &y, 2)?;
        let val = model.evaluate(&x, &y)?;
        if val < best {
            best = val;
            model.save_to_path(path)?; // overwrites the previous checkpoint
            println!("round {round}: new best {val:.6}, checkpoint written");
        }
    }

    // best.bin holds the lowest-loss weights, not necessarily the final ones.
    let mut deployed = make_arch();
    deployed.load_from_path(path)?;
    // `evaluate` needs the compiled loss. It never touches the optimizer passed here.
    deployed.compile(SGD::new(0.05, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());
    assert_eq!(deployed.evaluate(&x, &y)?, best);

    std::fs::remove_file(path).unwrap();
    Ok(())
}
```

The final check uses exact equality, and not a tolerance. The reloaded weights are bit-for-bit the same as the saved ones, and `evaluate` is deterministic on a model with no dropout. The score of the restored checkpoint must therefore equal the score that caused RustyML to write it.

**Transfer weights between programs.** A training binary builds the architecture, trains the model, and calls `save_to_path`. A separate serving binary builds the *identical* architecture and calls `load_from_path`. Share the `make_arch` function through a common module or crate. The 2 programs then cannot disagree on the architecture, or on the input shape it builds for.

The type-name, count, and build-shape checks catch a drift, but only after a failed load. A shared constructor catches the same drift at compile time. The serving binary never needs to call `compile`, because it only calls `predict`.

**Manage versions across crate upgrades.** Every saved model opens with a magic tag and a format version, and `load_from_path` validates both before it decodes anything else. A checkpoint written by a release with a different weight layout therefore fails right away with `IoError::UnsupportedModelFormat`. Without this check, the load could parse the file into arrays that look correct but hold wrong values. The structural checks that run after the header compare layer counts, type names, and build shapes.

They compare the name, the kind, and the shape of every array. A stale file can satisfy every one of them by coincidence. A square convolution kernel, for example, keeps its extents when its axes are permuted. A `Dense` weight shape stays the same whatever tensor layout produced the input feeding it.

This guard depends on developer discipline. `MODEL_FORMAT_VERSION` is 3, and version 3 is the named checkpoint of [3.9.2](#392-the-named-checkpoint-1-address-per-array). Its build record holds 1 shape per input of a layer. A merge layer with 3 inputs therefore records 3 shapes, and a layer with 1 input records 1. Every earlier version stops loading, and the refusal names both version numbers. Re-save such a checkpoint from a model built under this release.

RustyML bumps the version on any change to the layout of a record. It also bumps on a change to the order of the fields of a structure, or to the meaning of a field. A new layer needs no bump. A new layer adds a type name and some array names, and both travel in the file as strings.

The load catches a change that gets a version bump. It does not catch a change that nobody bumped, because postcard is still non-self-describing and nothing else in the file is labeled. For a checkpoint that must reload weeks or versions later, pin the RustyML version in `Cargo.toml`. After a deliberate upgrade, run training again, or load under the old version and save again under the new one. Do not trust an old file against new code. The header makes a version failure loud and immediate instead of silent.

## 3.9.9. Loading part of a file on purpose

`load_partial_from_path` is the lenient load, and a caller asks for it by name. It writes an array under 3 conditions. The position holds the same layer type, the 2 sides agree on the build shapes, and the file holds the same name, kind, and shape. Nothing about the layer roster fails. It returns a `LoadReport` with 3 lists of checkpoint paths.

`applied` holds what took a value, and `missing` holds the paths of the model that got none. `unused` holds the paths of the file that reached no array. An array whose shape or kind disagrees is in `missing` and in `unused` at the same time. The model got no value for it, and the file value went nowhere. A position whose layer type or build shape differs puts every path of that layer in both lists. A name and a shape cannot tell 2 normalization layers apart.

The header is still checked. A file of another format version carries bytes that mean something else, so it is an error here as well.

```rust
use rustyml::error::Error;
use rustyml::neural_network::Shape;
use rustyml::neural_network::layers::activation::linear::Linear;
use rustyml::neural_network::layers::dense::Dense;
use rustyml::neural_network::sequential::SequentialBuilder;

fn main() -> Result<(), Error> {
    let saved = SequentialBuilder::new()
        .add(Dense::new(3, Linear::new()).unwrap())
        .add(Dense::new(2, Linear::new()).unwrap())
        .build(&Shape::with_free_batch(&[2, 4]))
        .unwrap();
    let path = "partial_demo.bin";
    saved.save_to_path(path)?;

    // The target keeps the first layer and widens the second one.
    let mut target = SequentialBuilder::new()
        .add(Dense::new(3, Linear::new()).unwrap())
        .add(Dense::new(5, Linear::new()).unwrap())
        .build(&Shape::with_free_batch(&[2, 4]))
        .unwrap();

    // The strict load refuses, and the message names the path that disagrees.
    let refusal = target.load_from_path(path).unwrap_err();
    println!("strict: {refusal}");

    // The lenient load takes what matches and reports the rest.
    let report = target.load_partial_from_path(path)?;
    println!("applied: {:?}", report.applied);
    println!("missing: {:?}", report.missing);
    println!("unused:  {:?}", report.unused);

    std::fs::remove_file(path).unwrap();
    Ok(())
}
```

```text
strict: model structure mismatch: `1.kernel` has shape [3, 5] in the model, and [3, 2] in the file
applied: ["0.kernel", "0.bias"]
missing: ["1.kernel", "1.bias"]
unused:  ["1.kernel", "1.bias"]
```

This is the transfer-learning path. Load a trained backbone into a model whose head is new or resized. Then read `missing` to confirm that only the head went unfilled. Read `unused` to confirm that the file gave up nothing worth keeping.

Treat a non-empty `missing` list as a result to check, and never as a warning to skip. The strict load stays the right choice everywhere else, a restart of a training run included. There the file and the model must agree completely.

For more detail on the persistence machinery, see [7.2. Model Persistence in Depth](../Chapter-07/7.2._Model_Persistence_in_Depth.md). That section covers the record layout of the file, the 2-pass load, and how the same postcard format serves the classical estimators.