rlevo_evolution/param_reshaper.rs
1//! Bridge between a Burn [`Module`] and a flat parameter vector.
2//!
3//! Weight-only neuroevolution evolves a `Tensor<B, 2>` population of shape
4//! `(pop_size, num_params)` and must, per population member, splat one flat
5//! parameter row back into a concrete network to score it. The
6//! [`ParamReshaper`] trait captures that bidirectional bridge:
7//!
8//! - [`flatten`](ParamReshaper::flatten) walks a module's float leaves in a
9//! deterministic order and concatenates them into a 1-D tensor.
10//! - [`unflatten`](ParamReshaper::unflatten) clones a template module and
11//! replaces each float leaf with the matching slice of a flat tensor, in the
12//! *same* order. The reconstructed leaves are **views into the flat tensor's
13//! storage**, not copies — see [`unflatten`](ParamReshaper::unflatten)'s
14//! `# Aliasing` note.
15//!
16//! [`ModuleReshaper`] is the concrete implementation. It relies on the fact
17//! that Burn's `#[derive(Module)]` generates `visit`/`map` traversals that
18//! visit fields in declaration order, recursively — so `flatten` (a
19//! [`ModuleVisitor`]) and `unflatten` (a [`ModuleMapper`]) agree leaf-for-leaf.
20//!
21//! # Non-trainable module state
22//!
23//! Burn's `visit`/`map` traversal touches every float leaf reachable through
24//! the `Module` tree, **including** non-`Param` running statistics such as a
25//! [`burn::nn::BatchNorm`] layer's running mean/variance (they are wrapped in a
26//! `RunningState`, which is itself a `Module` that forwards to `visit_float` /
27//! `map_float`). The proof-of-concept in this module's test submodule verifies this
28//! empirically. The practical consequence: if an evolved network contains
29//! `BatchNorm`, its running statistics are flattened, perturbed by evolution,
30//! and re-splatted like any weight. For fixed-topology MLP policies (the v1
31//! target) this is moot — there are no running buffers. Callers that evolve
32//! batch-normalized networks should reset running statistics after
33//! [`unflatten`](ParamReshaper::unflatten).
34//!
35//! # Gradient isolation
36//!
37//! This module is generic over `B: Backend`, **not** `AutodiffBackend`.
38//! Tensors produced by [`unflatten`](ParamReshaper::unflatten) do not require
39//! gradients. Callers holding an autodiff module call `.valid()` before
40//! constructing a [`ModuleReshaper`], so the constraint is enforced at the
41//! type level rather than by convention.
42
43use std::marker::PhantomData;
44
45use burn::module::{Module, ModuleMapper, ModuleVisitor, Param};
46use burn::tensor::{Tensor, backend::Backend};
47
48/// Bridges a Burn [`Module`] and a flat `Tensor<B, 1>` parameter vector.
49///
50/// Lives entirely in `rlevo-evolution` and depends only on `burn` — no
51/// `rlevo-core` coupling.
52///
53/// # Invariants
54///
55/// - [`flatten`](Self::flatten) and [`unflatten`](Self::unflatten) must visit
56/// float leaves in the *same* deterministic order, so that
57/// `unflatten(flatten(m))` reconstructs `m` leaf-for-leaf.
58/// - [`num_params`](Self::num_params) equals the total element count produced
59/// by [`flatten`](Self::flatten).
60///
61/// Implementors are `Send + Sync` so a single reshaper can be shared across
62/// parallel fitness evaluations.
63pub trait ParamReshaper<B: Backend>: Send + Sync {
64 /// The Burn module type this reshaper flattens and reconstructs.
65 type Module: Module<B>;
66
67 /// Total number of trainable float parameters (the flat-vector length).
68 fn num_params(&self) -> usize;
69
70 /// Flatten all float `Param` leaves of `module` into a 1-D tensor.
71 ///
72 /// The returned tensor is moved onto `device` and has length
73 /// [`num_params`](Self::num_params). Leaf visitation order is
74 /// deterministic and matches [`unflatten`](Self::unflatten).
75 ///
76 /// # Panics
77 ///
78 /// Panics if `module` has no float leaves (the underlying tensor
79 /// concatenation requires at least one part).
80 fn flatten(&self, module: &Self::Module, device: &B::Device) -> Tensor<B, 1>;
81
82 /// Clone the template module and replace its float leaves with slices of
83 /// `flat`, in the same order as [`flatten`](Self::flatten).
84 ///
85 /// # Aliasing
86 ///
87 /// The returned module's leaves are **views into `flat`'s storage** (Burn
88 /// `Tensor::clone` is a refcount bump; `slice`/`reshape` are view ops). This
89 /// is allocation-free and safe for the forward-only scoring this bridge
90 /// exists for. Do **not** mutate `flat` or the returned module's weights in
91 /// place while the other is live — they share backing storage. The output
92 /// module inherits `flat`'s device.
93 ///
94 /// # Panics
95 ///
96 /// Panics if `flat.dims()[0] != self.num_params()`.
97 fn unflatten(&self, flat: Tensor<B, 1>) -> Self::Module;
98}
99
100/// A [`ParamReshaper`] backed by a cloned template module.
101///
102/// Construction clones the supplied module once and counts its float leaves.
103/// Each [`unflatten`](ParamReshaper::unflatten) call clones that template and
104/// maps the flat buffer into the clone's leaves; each
105/// [`flatten`](ParamReshaper::flatten) call visits a module's leaves and
106/// concatenates them.
107///
108/// # Single-source width convention
109///
110/// A reshaper *is* the genome-width source of truth. Build **one** reshaper for
111/// the width, then hand `reshaper.clone()` (this type is [`Clone`]) to the
112/// fitness adapter — the strategy and its evaluator then agree on
113/// [`num_params`](ParamReshaper::num_params) *by construction*. Prefer this over
114/// building two `ModuleReshaper::new(template.clone())` instances whose widths
115/// match only by convention: a silent divergence surfaces late as the
116/// documented width-mismatch panic in
117/// [`unflatten`](ParamReshaper::unflatten) / `evaluate_batch`. Where a
118/// [`WeightOnly`](crate::algorithms::neuroevolution::WeightOnly) wrapper already
119/// owns a reshaper, clone *its* via `strategy.reshaper().clone()`.
120///
121/// # Example
122///
123/// See the [`module_eval_fn`](crate::module_eval_fn) tests for a runnable
124/// end-to-end example of `flatten`/`unflatten` in the weight-only pipeline.
125pub struct ModuleReshaper<B: Backend, M: Module<B>> {
126 template: M,
127 num_params: usize,
128 // `fn() -> B` keeps the marker `Send + Sync` for any `B` and encodes that
129 // `B` is produced, never consumed — mirroring the crate's other markers.
130 _backend: PhantomData<fn() -> B>,
131}
132
133impl<B: Backend, M: Module<B>> std::fmt::Debug for ModuleReshaper<B, M> {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.debug_struct("ModuleReshaper")
136 .field("num_params", &self.num_params)
137 .finish_non_exhaustive()
138 }
139}
140
141// Hand-written, not `#[derive(Clone)]`: the derive would emit a spurious
142// `where B: Clone` bound, but `B: Backend` does not imply `B: Clone` and the
143// only `B` this struct stores is `PhantomData<fn() -> B>` (which is `Clone` for
144// any `B`). Bound instead on what is actually cloned — `template`, via `M`'s
145// `Module: Clone` supertrait — and copy the `num_params` scalar. This is the
146// enabling half of the single-source pattern documented on `ModuleReshaper`.
147impl<B: Backend, M: Module<B>> Clone for ModuleReshaper<B, M> {
148 fn clone(&self) -> Self {
149 Self {
150 template: self.template.clone(),
151 num_params: self.num_params,
152 _backend: PhantomData,
153 }
154 }
155}
156
157impl<B: Backend, M: Module<B>> ModuleReshaper<B, M> {
158 /// Build a reshaper from a template module.
159 ///
160 /// The template is cloned and retained; its float-leaf count is computed
161 /// once and cached as [`num_params`](ParamReshaper::num_params).
162 #[must_use]
163 pub fn new(template: M) -> Self {
164 let mut counter = CountVisitor { count: 0 };
165 template.visit(&mut counter);
166 Self {
167 template,
168 num_params: counter.count,
169 _backend: PhantomData,
170 }
171 }
172
173 /// Borrow the retained template module.
174 #[must_use]
175 pub fn template(&self) -> &M {
176 &self.template
177 }
178
179 /// Number of float parameters (flat-vector length).
180 ///
181 /// Inherent mirror of [`ParamReshaper::num_params`] so callers can read the
182 /// width without the `M: Sync` bound the trait requires.
183 #[must_use]
184 pub fn num_params(&self) -> usize {
185 self.num_params
186 }
187}
188
189impl<B, M> ParamReshaper<B> for ModuleReshaper<B, M>
190where
191 B: Backend,
192 // `Sync` is required by the `ParamReshaper` supertrait so the reshaper can
193 // be shared across parallel evaluations; Burn modules built from
194 // `Param<Tensor>` leaves satisfy it.
195 M: Module<B> + Sync,
196{
197 type Module = M;
198
199 fn num_params(&self) -> usize {
200 self.num_params
201 }
202
203 fn flatten(&self, module: &M, device: &B::Device) -> Tensor<B, 1> {
204 let mut visitor: FlattenVisitor<B> = FlattenVisitor { parts: Vec::new() };
205 module.visit(&mut visitor);
206 assert!(
207 !visitor.parts.is_empty(),
208 "module has no float parameters to flatten"
209 );
210 Tensor::cat(visitor.parts, 0).to_device(device)
211 }
212
213 fn unflatten(&self, flat: Tensor<B, 1>) -> M {
214 let len = flat.dims()[0];
215 assert_eq!(
216 len, self.num_params,
217 "flat length {len} does not match num_params {}",
218 self.num_params
219 );
220 let mut mapper: SlicingMapper<B> = SlicingMapper { flat, cursor: 0 };
221 self.template.clone().map(&mut mapper)
222 }
223}
224
225/// Counts the total number of float-leaf elements in a module.
226struct CountVisitor {
227 count: usize,
228}
229
230impl<B: Backend> ModuleVisitor<B> for CountVisitor {
231 fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<B, D>>) {
232 self.count += param.dims().iter().product::<usize>();
233 }
234}
235
236/// Collects each float leaf, reshaped to 1-D, in visitation order.
237struct FlattenVisitor<B: Backend> {
238 parts: Vec<Tensor<B, 1>>,
239}
240
241impl<B: Backend> ModuleVisitor<B> for FlattenVisitor<B> {
242 fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<B, D>>) {
243 let value: Tensor<B, D> = param.val();
244 let n: usize = value.dims().iter().product();
245 self.parts.push(value.reshape([n]));
246 }
247}
248
249/// Replaces each float leaf with the next `n` elements of `flat`, reshaped to
250/// the leaf's original shape, advancing a cursor in visitation order.
251struct SlicingMapper<B: Backend> {
252 flat: Tensor<B, 1>,
253 cursor: usize,
254}
255
256impl<B: Backend> ModuleMapper<B> for SlicingMapper<B> {
257 fn map_float<const D: usize>(&mut self, param: Param<Tensor<B, D>>) -> Param<Tensor<B, D>> {
258 let dims: [usize; D] = param.dims();
259 let n: usize = dims.iter().product();
260 let start = self.cursor;
261 self.cursor += n;
262 let flat = self.flat.clone();
263 // `Param::map` preserves the parameter id and any load/save mapper while
264 // swapping the inner tensor; the new tensor does not require grad
265 // (gradient isolation — see module docs).
266 param.map(move |_old| {
267 #[allow(clippy::single_range_in_vec_init)]
268 let slice = flat.slice([start..start + n]);
269 slice.reshape(dims)
270 })
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use burn::backend::Flex;
278 use burn::nn::{
279 BatchNorm, BatchNormConfig, Linear, LinearConfig, Relu,
280 conv::{Conv2d, Conv2dConfig},
281 };
282 use burn::tensor::TensorData;
283
284 type TestBackend = Flex;
285
286 /// 2-layer MLP: `Linear(3 -> 4) -> ReLU -> Linear(4 -> 2)`.
287 ///
288 /// Float-leaf count: `3*4 + 4` (l1 weight + bias) `+ 4*2 + 2`
289 /// (l2 weight + bias) = `26`.
290 #[derive(Module, Debug)]
291 struct TestMlp<B: Backend> {
292 l1: Linear<B>,
293 act: Relu,
294 l2: Linear<B>,
295 }
296
297 impl<B: Backend> TestMlp<B> {
298 fn new(device: &B::Device) -> Self {
299 Self {
300 l1: LinearConfig::new(3, 4).init(device),
301 act: Relu::new(),
302 l2: LinearConfig::new(4, 2).init(device),
303 }
304 }
305 }
306
307 fn approx_eq(a: &Tensor<TestBackend, 1>, b: &Tensor<TestBackend, 1>) {
308 let av = a
309 .to_data()
310 .into_vec::<f32>()
311 .expect("genome host-read of a tensor this test just built");
312 let bv = b
313 .to_data()
314 .into_vec::<f32>()
315 .expect("genome host-read of a tensor this test just built");
316 assert_eq!(av.len(), bv.len(), "length mismatch");
317 for (x, y) in av.iter().zip(bv.iter()) {
318 approx::assert_relative_eq!(x, y, epsilon = 1e-6);
319 }
320 }
321
322 #[test]
323 fn test_module_reshaper_num_params_matches_expected() {
324 let device = Default::default();
325 let mlp = TestMlp::<TestBackend>::new(&device);
326 let reshaper = ModuleReshaper::new(mlp);
327 assert_eq!(reshaper.num_params(), 26);
328 }
329
330 /// `flatten` panics when the module has no float leaves — locks the
331 /// documented `# Panics` contract. `Relu` is a `Module` with zero
332 /// parameters, so its visitor collects no parts.
333 #[test]
334 #[should_panic(expected = "module has no float parameters to flatten")]
335 fn test_module_reshaper_flatten_panics_on_empty_module() {
336 let device = Default::default();
337 let reshaper = ModuleReshaper::<TestBackend, Relu>::new(Relu::new());
338 let _ = reshaper.flatten(&Relu::new(), &device);
339 }
340
341 /// `unflatten` panics when the flat length differs from `num_params` —
342 /// locks the documented `# Panics` contract.
343 #[test]
344 #[should_panic(expected = "flat length")]
345 fn test_module_reshaper_unflatten_panics_on_length_mismatch() {
346 let device = Default::default();
347 let reshaper = ModuleReshaper::new(TestMlp::<TestBackend>::new(&device));
348 let wrong =
349 Tensor::<TestBackend, 1>::from_data(TensorData::new(vec![0f32; 10], [10]), &device);
350 let _ = reshaper.unflatten(wrong);
351 }
352
353 /// AC #2: `unflatten(flatten(m)) ≈ m`. We compare via re-flatten, which is
354 /// element-wise injective over the deterministic leaf order, so equality of
355 /// the flat vectors is equivalent to equality of the modules' float leaves.
356 #[test]
357 fn test_module_reshaper_round_trip_mlp() {
358 let device = Default::default();
359 let mlp = TestMlp::<TestBackend>::new(&device);
360 let reshaper = ModuleReshaper::new(mlp.clone());
361
362 let flat = reshaper.flatten(&mlp, &device);
363 assert_eq!(flat.dims(), [26]);
364
365 let restored = reshaper.unflatten(flat.clone());
366 let flat2 = reshaper.flatten(&restored, &device);
367 approx_eq(&flat, &flat2);
368 }
369
370 /// Property test catching leaf-ordering bugs: a known flat buffer survives
371 /// `unflatten -> flatten` unchanged.
372 #[test]
373 fn test_module_reshaper_round_trip_arbitrary_flat() {
374 let device = Default::default();
375 let mlp = TestMlp::<TestBackend>::new(&device);
376 let reshaper = ModuleReshaper::new(mlp);
377
378 #[allow(clippy::cast_precision_loss)]
379 let values: Vec<f32> = (0..26).map(|i| i as f32 * 0.1 - 1.3).collect();
380 let flat = Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [26]), &device);
381
382 let module = reshaper.unflatten(flat.clone());
383 let flat2 = reshaper.flatten(&module, &device);
384 approx_eq(&flat, &flat2);
385 }
386
387 /// Confirms whether Burn's traversal touches non-trainable `BatchNorm`
388 /// running statistics. Empirically yes — `RunningState` is a `Module` and
389 /// forwards to `visit_float` / `map_float`. A `BatchNorm` over `d` features
390 /// therefore exposes `4*d` float leaves: `gamma`, `beta`, `running_mean`,
391 /// `running_var`.
392 #[test]
393 fn test_module_reshaper_batchnorm_running_stats_traversed() {
394 let device = Default::default();
395 let d = 5;
396 let bn: BatchNorm<TestBackend> = BatchNormConfig::new(d).init(&device);
397 let reshaper = ModuleReshaper::new(bn.clone());
398 // 4 * d if running stats are traversed; 2 * d if only gamma/beta are.
399 assert_eq!(
400 reshaper.num_params(),
401 4 * d,
402 "expected BatchNorm running stats to be traversed as float leaves"
403 );
404 // And the round-trip must still hold over all traversed leaves.
405 let flat = reshaper.flatten(&bn, &device);
406 let restored = reshaper.unflatten(flat.clone());
407 approx_eq(&flat, &reshaper.flatten(&restored, &device));
408 }
409
410 /// A non-trivial module with a conv layer also round-trips, confirming the
411 /// reshaper is not MLP-specific.
412 #[test]
413 fn test_module_reshaper_round_trip_conv() {
414 let device = Default::default();
415 let conv: Conv2d<TestBackend> = Conv2dConfig::new([2, 3], [3, 3]).init(&device);
416 let reshaper = ModuleReshaper::new(conv.clone());
417 let flat = reshaper.flatten(&conv, &device);
418 let restored = reshaper.unflatten(flat.clone());
419 approx_eq(&flat, &reshaper.flatten(&restored, &device));
420 }
421
422 // --- Bounded-NAS enum-derive probe ---------------------------------------
423 //
424 // Question: does Burn 0.21 `#[derive(Module)]` work on a Rust *enum* whose
425 // arms hold heterogeneous concrete `Module` variants? The bounded-NAS
426 // design (closure-erased `VariantEvaluator` registry) does NOT depend on
427 // the answer; this probe records the finding (it compiles) for reference.
428 //
429 // If `#[derive(Module)]` below fails to compile, the whole crate fails to
430 // build and this probe never runs — a build failure IS the negative result.
431
432 /// Minimal one-hidden-layer MLP variant for the enum-derive probe.
433 #[derive(Module, Debug)]
434 struct TestSmallMlp<B: Backend> {
435 l1: Linear<B>,
436 l2: Linear<B>,
437 }
438
439 impl<B: Backend> TestSmallMlp<B> {
440 fn new(device: &B::Device) -> Self {
441 Self {
442 l1: LinearConfig::new(2, 4).init(device),
443 l2: LinearConfig::new(4, 1).init(device),
444 }
445 }
446 }
447
448 /// Minimal two-hidden-layer MLP variant for the enum-derive probe.
449 #[derive(Module, Debug)]
450 struct TestLargeMlp<B: Backend> {
451 l1: Linear<B>,
452 l2: Linear<B>,
453 l3: Linear<B>,
454 }
455
456 impl<B: Backend> TestLargeMlp<B> {
457 fn new(device: &B::Device) -> Self {
458 Self {
459 l1: LinearConfig::new(2, 8).init(device),
460 l2: LinearConfig::new(8, 4).init(device),
461 l3: LinearConfig::new(4, 1).init(device),
462 }
463 }
464 }
465
466 /// Two-arm enum with heterogeneous `Module` variants. The backend generic
467 /// must be named literally `B` for `#[derive(Module)]` to succeed.
468 // This is a derive-capability probe, not a runtime data structure — the
469 // size disparity between arms is irrelevant here.
470 #[allow(clippy::large_enum_variant)]
471 #[derive(Module, Debug)]
472 enum TestArch<B: Backend> {
473 Shallow(TestSmallMlp<B>),
474 Deep(TestLargeMlp<B>),
475 }
476
477 /// Probe: confirm `#[derive(Module)]` on a heterogeneous-arm enum compiles
478 /// and that the enum can be visited as a `Module` (flattened) — i.e. the
479 /// derive emits real `visit`/`map` traversals, not just a stub.
480 #[test]
481 fn test_module_reshaper_enum_derive_compiles() {
482 let device = Default::default();
483
484 let shallow = TestArch::<TestBackend>::Shallow(TestSmallMlp::new(&device));
485 let deep = TestArch::<TestBackend>::Deep(TestLargeMlp::new(&device));
486
487 // TestSmallMlp: 2*4 + 4 + 4*1 + 1 = 17 ; TestLargeMlp: 2*8+8 + 8*4+4 + 4*1+1 = 65.
488 let shallow_reshaper = ModuleReshaper::new(shallow);
489 let deep_reshaper = ModuleReshaper::new(deep);
490
491 println!(
492 "burn_enum_derive_probe: #[derive(Module)] on enum COMPILES; \
493 enum is a Module. Shallow arm flattens to {} params, \
494 Deep arm flattens to {} params.",
495 shallow_reshaper.num_params(),
496 deep_reshaper.num_params(),
497 );
498
499 // The enum derive visits the active arm's leaves.
500 assert_eq!(shallow_reshaper.num_params(), 17);
501 assert_eq!(deep_reshaper.num_params(), 65);
502 }
503}