Skip to main content

burn_store/
adapter.rs

1//! Module adapters for transforming tensors during save/load
2//!
3//! This module provides adapters for:
4//! - PyTorch/Burn format conversion (weight transposition, parameter renaming)
5//! - Mixed-precision storage (F32/F16 dtype casting via [`HalfPrecisionAdapter`])
6//! - Adapter chaining for composing multiple transformations
7
8use crate::bridge;
9
10use alloc::boxed::Box;
11use alloc::format;
12use alloc::string::String;
13use alloc::string::ToString;
14use alloc::vec;
15
16use burn_pack::Tensor as PackTensor;
17
18use burn_core::tensor::shape;
19use burn_core::tensor::{DType, TensorData};
20use hashbrown::HashSet;
21
22// Module type names as they appear in the container stack
23// These come from the Module derive macro which uses stringify! on the struct name
24// Format: "Struct:TypeName" for user-defined structs
25mod module_names {
26    // The actual string constants that match what the Module derive macro produces
27    pub const LINEAR: &str = "Struct:Linear";
28    pub const BATCH_NORM: &str = "Struct:BatchNorm";
29    pub const LAYER_NORM: &str = "Struct:LayerNorm";
30    pub const GROUP_NORM: &str = "Struct:GroupNorm";
31    pub const EMBEDDING: &str = "Struct:Embedding";
32    pub const CONV1D: &str = "Struct:Conv1d";
33    pub const CONV2D: &str = "Struct:Conv2d";
34    pub const CONV3D: &str = "Struct:Conv3d";
35    pub const CONV_TRANSPOSE1D: &str = "Struct:ConvTranspose1d";
36    pub const CONV_TRANSPOSE2D: &str = "Struct:ConvTranspose2d";
37    pub const CONV_TRANSPOSE3D: &str = "Struct:ConvTranspose3d";
38    pub const DEFORM_CONV2D: &str = "Struct:DeformConv2d";
39    pub const INSTANCE_NORM: &str = "Struct:InstanceNorm";
40    pub const RMS_NORM: &str = "Struct:RmsNorm";
41    pub const PRELU: &str = "Struct:PRelu";
42}
43
44/// Where in a module hierarchy a tensor was found.
45///
46/// The one thing a [`PackTensor`] cannot carry. Its contents are Burn module vocabulary
47/// (`"Struct:Linear"`, `"Vec"`, `"Enum:ConvType"`), produced by the `Module` derive macro,
48/// while `burn-pack` is a format crate with no notion of modules at all. Rather than push that
49/// vocabulary into the on-disk type, the traversal hands it to adapters alongside the tensor.
50///
51/// Borrowed rather than owned: both producers ([`Collector`](crate::Collector) and
52/// [`Applier`](crate::Applier)) keep this stack live as they walk the module, and pass it
53/// straight through.
54///
55/// Note this carries only the container stack, not the path. A tensor's parameter name comes
56/// from the tensor itself, because [`ChainAdapter`] threads each adapter's output into the
57/// next: an adapter that renames must be visible to the one after it, which a fixed context
58/// could not express.
59#[derive(Debug, Clone, Copy)]
60pub struct ModuleContext<'a> {
61    container_stack: &'a [String],
62}
63
64impl<'a> ModuleContext<'a> {
65    /// Wrap the container stack captured at a point in a module traversal.
66    ///
67    /// The stack runs outermost-first, one entry per level entered, e.g.
68    /// `["Struct:Model", "Vec", "Struct:Linear"]`. An empty stack means the tensor was not
69    /// reached through any user-defined module.
70    pub fn new(container_stack: &'a [String]) -> Self {
71        Self { container_stack }
72    }
73
74    /// The innermost user-defined module type, skipping collection wrappers.
75    ///
76    /// This is what adapters key on, because a parameter's meaning comes from the module that
77    /// declares it and not from the `Vec` or array that happens to hold that module.
78    ///
79    /// # Examples
80    /// - `Linear.weight` -> `Some("Struct:Linear")`
81    /// - `Vec<Linear>[0].weight` -> `Some("Struct:Linear")`
82    /// - a stack with no `Struct:`/`Enum:` entry at all -> `None`
83    pub fn module_type(&self) -> Option<&'a str> {
84        self.container_stack
85            .iter()
86            .rev()
87            .find(|ct| ct.starts_with("Struct:") || ct.starts_with("Enum:"))
88            .map(|s| s.as_str())
89    }
90}
91
92/// Trait for adapting tensors between different module formats
93pub trait ModuleAdapter: Send + Sync {
94    /// Adapt a tensor given where in the module hierarchy it was found.
95    ///
96    /// Takes the tensor by value so a pass-through costs nothing, and returns one whose data is
97    /// still unmaterialized: a transform is composed onto the byte source rather than run here.
98    ///
99    /// An adapter that changes `dtype` or `shape` must declare the resulting byte length, which
100    /// means going through [`bridge::map_data`](crate::bridge::map_data) rather than assigning
101    /// those fields: the writer reserves space from `byte_len` before any provider runs.
102    ///
103    /// `tensor.name` is the parameter's path in the module being traversed, not the key it was
104    /// stored under. An adapter that renames sets it, and the next adapter in a chain sees the
105    /// renamed value.
106    fn adapt(&self, tensor: PackTensor, ctx: ModuleContext<'_>) -> PackTensor;
107
108    /// Get alternative parameter name to try during matching
109    ///
110    /// When looking for a parameter in a module, this method provides an alternative
111    /// name to try if the direct name doesn't match. This enables matching parameters
112    /// with different naming conventions (e.g., PyTorch's "weight" vs Burn's "gamma").
113    ///
114    /// # Arguments
115    /// * `param_name` - The parameter name we're looking for
116    /// * `module_type` - The user-defined module the parameter sits in (e.g. "Struct:BatchNorm")
117    ///
118    /// # Returns
119    /// Alternative parameter name to try, or None if no alternative exists
120    fn get_alternative_param_name(&self, _param_name: &str, _module_type: &str) -> Option<String> {
121        None
122    }
123
124    /// Clone the adapter into a boxed trait object
125    fn clone_box(&self) -> Box<dyn ModuleAdapter>;
126
127    /// Chain adapters together, applying `self` first and then `next`.
128    ///
129    /// This is useful when multiple transformations are required when importing model weights
130    /// (e.g. PyTorch -> Burn layout conversion, then dtype casting, then custom remapping).
131    ///
132    /// The semantics follow a simple pipeline:
133    /// - `adapt`: `next.adapt(self.adapt(tensor, ctx), ctx)`
134    /// - `get_alternative_param_name`: try `self` first; if it returns an alternative name,
135    ///   try `next` with that name, otherwise return the first alternative name.
136    fn chain<A>(self, next: A) -> ChainAdapter
137    where
138        Self: Sized + 'static,
139        A: ModuleAdapter + 'static,
140    {
141        ChainAdapter::new(self, next)
142    }
143}
144
145impl Clone for Box<dyn ModuleAdapter> {
146    fn clone(&self) -> Self {
147        self.clone_box()
148    }
149}
150
151/// Adapter that applies two adapters in sequence.
152///
153/// This allows composing smaller adapters instead of creating one large monolithic adapter.
154#[derive(Clone)]
155pub struct ChainAdapter {
156    first: Box<dyn ModuleAdapter>,
157    second: Box<dyn ModuleAdapter>,
158}
159
160impl ChainAdapter {
161    /// Create a new adapter chain.
162    pub fn new<A, B>(first: A, second: B) -> Self
163    where
164        A: ModuleAdapter + 'static,
165        B: ModuleAdapter + 'static,
166    {
167        Self {
168            first: Box::new(first),
169            second: Box::new(second),
170        }
171    }
172}
173
174impl ModuleAdapter for ChainAdapter {
175    fn adapt(&self, tensor: PackTensor, ctx: ModuleContext<'_>) -> PackTensor {
176        self.second.adapt(self.first.adapt(tensor, ctx), ctx)
177    }
178
179    fn get_alternative_param_name(&self, param_name: &str, container_type: &str) -> Option<String> {
180        if let Some(name) = self
181            .first
182            .get_alternative_param_name(param_name, container_type)
183        {
184            self.second
185                .get_alternative_param_name(&name, container_type)
186                .or(Some(name))
187        } else {
188            self.second
189                .get_alternative_param_name(param_name, container_type)
190        }
191    }
192
193    fn clone_box(&self) -> Box<dyn ModuleAdapter> {
194        Box::new(self.clone())
195    }
196}
197
198/// Identity adapter that passes tensors through unchanged
199#[derive(Debug, Clone, Default)]
200pub struct IdentityAdapter;
201
202impl ModuleAdapter for IdentityAdapter {
203    fn adapt(&self, tensor: PackTensor, _ctx: ModuleContext<'_>) -> PackTensor {
204        tensor
205    }
206
207    fn clone_box(&self) -> Box<dyn ModuleAdapter> {
208        Box::new(self.clone())
209    }
210}
211
212/// Compose a dtype cast onto a tensor's data, leaving it deferred.
213fn cast(tensor: PackTensor, target: DType) -> PackTensor {
214    let (name, shape) = (tensor.name.clone(), tensor.shape.clone());
215
216    bridge::map_data(tensor, name, target, shape, move |data| {
217        data.convert_dtype(target)
218    })
219}
220
221/// The last segment of a tensor's name, which is the parameter's own name.
222fn param_name(tensor: &PackTensor) -> &str {
223    tensor.name.rsplit('.').next().unwrap_or("")
224}
225
226/// Replace the last segment of a tensor's name, leaving its data untouched.
227fn rename_param(mut tensor: PackTensor, new_name: &str) -> PackTensor {
228    let keep = tensor.name.rfind('.').map(|i| i + 1).unwrap_or(0);
229    tensor.name.truncate(keep);
230    tensor.name.push_str(new_name);
231    tensor
232}
233
234/// Returns the default set of module types that `HalfPrecisionAdapter` converts.
235///
236/// Includes: Linear, Embedding, all Conv variants, LayerNorm, GroupNorm,
237/// InstanceNorm, RmsNorm, PRelu.
238///
239/// Excludes BatchNorm by default because `running_var` underflows in F16.
240fn default_half_precision_modules() -> HashSet<String> {
241    let modules = [
242        module_names::LINEAR,
243        module_names::EMBEDDING,
244        module_names::CONV1D,
245        module_names::CONV2D,
246        module_names::CONV3D,
247        module_names::CONV_TRANSPOSE1D,
248        module_names::CONV_TRANSPOSE2D,
249        module_names::CONV_TRANSPOSE3D,
250        module_names::DEFORM_CONV2D,
251        module_names::LAYER_NORM,
252        module_names::GROUP_NORM,
253        module_names::INSTANCE_NORM,
254        module_names::RMS_NORM,
255        module_names::PRELU,
256    ];
257    modules.iter().map(|s| s.to_string()).collect()
258}
259
260/// Adapter for mixed-precision (F32/F16) model storage.
261///
262/// Auto-detects conversion direction from the tensor's dtype:
263/// - F32 source -> cast to F16 (typical for saving)
264/// - F16 source -> cast to F32 (typical for loading)
265/// - Other dtypes -> passed through unchanged
266///
267/// The same instance works for both `with_to_adapter` (save) and `with_from_adapter` (load).
268///
269/// By default, converts weights in: Linear, Embedding, Conv*, LayerNorm, GroupNorm,
270/// InstanceNorm, RmsNorm, PRelu. BatchNorm is excluded because `running_var` underflows in F16.
271///
272/// # Examples
273///
274/// Default usage (same adapter for save and load):
275/// ```rust
276/// # use burn_store::HalfPrecisionAdapter;
277/// let adapter = HalfPrecisionAdapter::new();
278/// // store.with_to_adapter(adapter.clone());  // F32 -> F16 on save
279/// // store.with_from_adapter(adapter);        // F16 -> F32 on load
280/// ```
281///
282/// Exclude a module type:
283/// ```rust
284/// # use burn_store::HalfPrecisionAdapter;
285/// let adapter = HalfPrecisionAdapter::new()
286///     .without_module("LayerNorm");
287/// ```
288///
289/// Add a custom module type:
290/// ```rust
291/// # use burn_store::HalfPrecisionAdapter;
292/// let adapter = HalfPrecisionAdapter::new()
293///     .with_module("CustomLayer");
294/// ```
295#[derive(Debug, Clone)]
296pub struct HalfPrecisionAdapter {
297    modules: HashSet<String>,
298}
299
300impl HalfPrecisionAdapter {
301    /// Create a new adapter with the default set of modules.
302    pub fn new() -> Self {
303        Self {
304            modules: default_half_precision_modules(),
305        }
306    }
307
308    /// Add a module type to convert. Accepts both short (`"MyLayer"`) and
309    /// qualified (`"Struct:MyLayer"`) forms.
310    ///
311    /// Note: short names are mapped to `"Struct:Name"`. If you have an Enum-based
312    /// module, use the qualified form `"Enum:MyModule"` explicitly.
313    pub fn with_module(mut self, module_type: impl Into<String>) -> Self {
314        let name = module_type.into();
315        if name.contains(':') {
316            self.modules.insert(name);
317        } else {
318            self.modules.insert(format!("Struct:{}", name));
319        }
320        self
321    }
322
323    /// Remove a module type from conversion. Accepts both short and qualified forms.
324    pub fn without_module(mut self, module_type: impl Into<String>) -> Self {
325        let name = module_type.into();
326        let key = if name.contains(':') {
327            name
328        } else {
329            format!("Struct:{}", name)
330        };
331        assert!(
332            self.modules.contains(&key),
333            "without_module called with '{}' which is not in the module set",
334            key
335        );
336        self.modules.remove(&key);
337        self
338    }
339
340    /// Check whether the tensor belongs to a module that should be converted.
341    fn should_convert(&self, ctx: ModuleContext<'_>) -> bool {
342        ctx.module_type()
343            .is_some_and(|mt| self.modules.contains(mt))
344    }
345}
346
347impl Default for HalfPrecisionAdapter {
348    fn default() -> Self {
349        Self::new()
350    }
351}
352
353impl ModuleAdapter for HalfPrecisionAdapter {
354    fn adapt(&self, tensor: PackTensor, ctx: ModuleContext<'_>) -> PackTensor {
355        // Determine target dtype from source: F32 -> F16, F16 -> F32, anything else -> skip
356        let target_dtype = match tensor.dtype {
357            DType::F32 => DType::F16,
358            DType::F16 => DType::F32,
359            _ => return tensor,
360        };
361
362        if !self.should_convert(ctx) {
363            return tensor;
364        }
365
366        cast(tensor, target_dtype)
367    }
368
369    fn clone_box(&self) -> Box<dyn ModuleAdapter> {
370        Box::new(self.clone())
371    }
372}
373
374/// Adapter that casts every float tensor to one target dtype.
375///
376/// Unlike [`HalfPrecisionAdapter`], which infers the direction from the
377/// source dtype (F32 <-> F16 only) and converts a fixed list of module types,
378/// this adapter is *target-driven*: every float tensor (F64, F32, Flex32,
379/// F16, BF16) is converted to the given dtype regardless of which module it
380/// belongs to. Non-float tensors (ints, bools, quantized) and tensors already
381/// in the target dtype pass through unchanged.
382///
383/// This is the right tool for loading a stock checkpoint into a module whose
384/// backend runs at a different float precision, e.g. BF16 safetensors into
385/// an F16 model. `HalfPrecisionAdapter` passes BF16 tensors through
386/// unconverted and skips custom module types entirely, so the loaded params
387/// silently keep the source dtype instead of the module's.
388///
389/// # Examples
390///
391/// Cast everything to the backend's float element on load:
392/// ```rust,ignore
393/// let adapter = FloatCastAdapter::to(<B::FloatElem as Element>::dtype());
394/// // store.with_from_adapter(adapter);
395/// ```
396///
397/// Chain after a framework adapter:
398/// ```rust
399/// # use burn_store::{FloatCastAdapter, ModuleAdapter, PyTorchToBurnAdapter};
400/// # use burn_core::tensor::DType;
401/// let adapter = PyTorchToBurnAdapter.chain(FloatCastAdapter::to(DType::F16));
402/// ```
403#[derive(Debug, Clone)]
404pub struct FloatCastAdapter {
405    /// The dtype every float tensor is converted to.
406    target: DType,
407}
408
409impl FloatCastAdapter {
410    /// Create an adapter that casts every float tensor to `target`.
411    ///
412    /// # Panics
413    ///
414    /// Panics if `target` is not a float dtype.
415    pub fn to(target: DType) -> Self {
416        assert!(
417            target.is_float(),
418            "FloatCastAdapter target must be a float dtype, got {target:?}"
419        );
420        Self { target }
421    }
422}
423
424impl ModuleAdapter for FloatCastAdapter {
425    fn adapt(&self, tensor: PackTensor, _ctx: ModuleContext<'_>) -> PackTensor {
426        if !tensor.dtype.is_float() || tensor.dtype == self.target {
427            return tensor;
428        }
429
430        cast(tensor, self.target)
431    }
432
433    fn clone_box(&self) -> Box<dyn ModuleAdapter> {
434        Box::new(self.clone())
435    }
436}
437
438/// Adapter for converting from PyTorch format to Burn format
439///
440/// Handles:
441/// - Linear layer weight transposition (PyTorch: [out, in] -> Burn: [in, out])
442/// - Normalization parameter renaming (weight -> gamma, bias -> beta)
443#[derive(Debug, Clone, Default)]
444pub struct PyTorchToBurnAdapter;
445
446impl ModuleAdapter for PyTorchToBurnAdapter {
447    fn adapt(&self, tensor: PackTensor, ctx: ModuleContext<'_>) -> PackTensor {
448        adapt_pytorch_tensor(tensor, ctx, PyTorchConversionDirection::PyTorchToBurn)
449    }
450
451    fn get_alternative_param_name(&self, param_name: &str, container_type: &str) -> Option<String> {
452        // For PyTorch->Burn: When looking for Burn names (gamma/beta), try PyTorch names (weight/bias)
453        if is_normalization_layer(container_type) {
454            burn_norm_param_to_pytorch(param_name).map(|s| s.to_string())
455        } else {
456            None
457        }
458    }
459
460    fn clone_box(&self) -> Box<dyn ModuleAdapter> {
461        Box::new(self.clone())
462    }
463}
464
465/// Adapter for converting from Burn format to PyTorch format
466///
467/// Handles:
468/// - Linear layer weight transposition (Burn: [in, out] -> PyTorch: [out, in])
469/// - Normalization parameter renaming (gamma -> weight, beta -> bias)
470#[derive(Debug, Clone, Default)]
471pub struct BurnToPyTorchAdapter;
472
473impl ModuleAdapter for BurnToPyTorchAdapter {
474    fn adapt(&self, tensor: PackTensor, ctx: ModuleContext<'_>) -> PackTensor {
475        adapt_pytorch_tensor(tensor, ctx, PyTorchConversionDirection::BurnToPyTorch)
476    }
477
478    fn get_alternative_param_name(&self, param_name: &str, container_type: &str) -> Option<String> {
479        // For Burn->PyTorch: When looking for PyTorch names (weight/bias), try Burn names (gamma/beta)
480        if is_normalization_layer(container_type) {
481            pytorch_norm_param_to_burn(param_name).map(|s| s.to_string())
482        } else {
483            None
484        }
485    }
486
487    fn clone_box(&self) -> Box<dyn ModuleAdapter> {
488        Box::new(self.clone())
489    }
490}
491
492/// Direction of PyTorch conversion for parameter naming
493#[derive(Debug, Clone, Copy)]
494enum PyTorchConversionDirection {
495    PyTorchToBurn,
496    BurnToPyTorch,
497}
498
499/// Check if container type is a normalization layer
500fn is_normalization_layer(container_type: &str) -> bool {
501    matches!(
502        container_type,
503        module_names::BATCH_NORM
504            | module_names::LAYER_NORM
505            | module_names::GROUP_NORM
506            | module_names::RMS_NORM
507    )
508}
509
510/// Map PyTorch normalization parameter name to Burn
511fn pytorch_norm_param_to_burn(param_name: &str) -> Option<&'static str> {
512    match param_name {
513        "weight" => Some("gamma"),
514        "bias" => Some("beta"),
515        _ => None,
516    }
517}
518
519/// Map Burn normalization parameter name to PyTorch
520fn burn_norm_param_to_pytorch(param_name: &str) -> Option<&'static str> {
521    match param_name {
522        "gamma" => Some("weight"),
523        "beta" => Some("bias"),
524        _ => None,
525    }
526}
527
528/// Core tensor adaptation logic for PyTorch format conversions
529fn adapt_pytorch_tensor(
530    tensor: PackTensor,
531    ctx: ModuleContext<'_>,
532    direction: PyTorchConversionDirection,
533) -> PackTensor {
534    // Get module type for matching (ignores Vec/Array wrappers)
535    let Some(module_type) = ctx.module_type() else {
536        return tensor; // No user-defined module found
537    };
538    let param = param_name(&tensor);
539
540    // Linear: transpose weight (bidirectional - same operation both ways)
541    if module_type == module_names::LINEAR && param == "weight" && tensor.shape.len() == 2 {
542        return transpose_2d_tensor(tensor);
543    }
544
545    // Normalization layers: rename parameters based on direction
546    if is_normalization_layer(module_type) {
547        let renamed = match direction {
548            PyTorchConversionDirection::PyTorchToBurn => pytorch_norm_param_to_burn(param),
549            PyTorchConversionDirection::BurnToPyTorch => burn_norm_param_to_pytorch(param),
550        };
551
552        if let Some(new_name) = renamed {
553            return rename_param(tensor, new_name);
554        }
555    }
556
557    tensor
558}
559
560/// Transpose a 2D tensor
561fn transpose_2d_tensor(tensor: PackTensor) -> PackTensor {
562    if tensor.shape.len() != 2 {
563        return tensor;
564    }
565
566    // Quantized data is packed values plus an inline scale tail, not a grid of equal-sized
567    // elements: `DType::size()` is 0 for a sub-byte value type, and a packed store's byte
568    // length is recomputed per line, so transposing one both scrambles the scales and
569    // contradicts the length declared below. Pass it through instead.
570    if matches!(tensor.dtype, DType::QFloat(_)) {
571        return tensor;
572    }
573
574    let (name, dtype) = (tensor.name.clone(), tensor.dtype);
575    let transposed_shape = shape![tensor.shape[1], tensor.shape[0]];
576
577    // Compose the transpose onto the byte source; it runs when the data is finally drawn.
578    bridge::map_data(tensor, name, dtype, transposed_shape, transpose_tensor_data)
579}
580
581/// Transpose tensor data (assumes 2D shape is already validated)
582fn transpose_tensor_data(data: TensorData) -> TensorData {
583    let shape = data.shape();
584    let rows = shape[0];
585    let cols = shape[1];
586    let transposed_shape = vec![cols, rows];
587
588    // Get the raw bytes and element size
589    let bytes = data.as_bytes();
590    let element_size = data.dtype().size();
591
592    // Create a new buffer for transposed data
593    let mut transposed_bytes = vec![0u8; bytes.len()];
594
595    // Transpose at the byte level - valid for fixed-width dtypes, which is why quantized
596    // data is turned away before it gets here
597    for i in 0..rows {
598        for j in 0..cols {
599            let src_idx = (i * cols + j) * element_size;
600            let dst_idx = (j * rows + i) * element_size;
601
602            // Copy the bytes for this element
603            transposed_bytes[dst_idx..dst_idx + element_size]
604                .copy_from_slice(&bytes[src_idx..src_idx + element_size]);
605        }
606    }
607
608    // Create new TensorData from transposed bytes
609    TensorData::from_bytes_vec(transposed_bytes, transposed_shape, data.dtype())
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use alloc::string::ToString;
616    use alloc::sync::Arc;
617    use alloc::vec::Vec;
618    use burn_core::tensor::{Bytes, DType, Shape, TensorData};
619    use core::sync::atomic::{AtomicUsize, Ordering};
620
621    #[test]
622    fn test_module_names_match_burn_nn() {
623        // If these types are renamed or moved in `burn-nn`, this test will fail to compile.
624        #[allow(unused_imports)]
625        use burn_nn::{
626            BatchNorm, Embedding, GroupNorm, InstanceNorm, LayerNorm, Linear, PRelu, RmsNorm,
627            conv::{
628                Conv1d, Conv2d, Conv3d, ConvTranspose1d, ConvTranspose2d, ConvTranspose3d,
629                DeformConv2d,
630            },
631        };
632
633        assert_eq!(module_names::LINEAR, "Struct:Linear");
634        assert_eq!(module_names::BATCH_NORM, "Struct:BatchNorm");
635        assert_eq!(module_names::LAYER_NORM, "Struct:LayerNorm");
636        assert_eq!(module_names::GROUP_NORM, "Struct:GroupNorm");
637        assert_eq!(module_names::EMBEDDING, "Struct:Embedding");
638        assert_eq!(module_names::CONV1D, "Struct:Conv1d");
639        assert_eq!(module_names::CONV2D, "Struct:Conv2d");
640        assert_eq!(module_names::CONV3D, "Struct:Conv3d");
641        assert_eq!(module_names::CONV_TRANSPOSE1D, "Struct:ConvTranspose1d");
642        assert_eq!(module_names::CONV_TRANSPOSE2D, "Struct:ConvTranspose2d");
643        assert_eq!(module_names::CONV_TRANSPOSE3D, "Struct:ConvTranspose3d");
644        assert_eq!(module_names::DEFORM_CONV2D, "Struct:DeformConv2d");
645        assert_eq!(module_names::INSTANCE_NORM, "Struct:InstanceNorm");
646        assert_eq!(module_names::RMS_NORM, "Struct:RmsNorm");
647        assert_eq!(module_names::PRELU, "Struct:PRelu");
648    }
649
650    /// A tensor holding `data`, standing in for one the collector produced.
651    fn tensor_with(name: &str, data: TensorData) -> PackTensor {
652        bridge::from_data(data, name.to_string(), None)
653    }
654
655    /// An F32 tensor of ones.
656    fn tensor(name: &str, shape: Shape) -> PackTensor {
657        let values = vec![1.0f32; shape.iter().product()];
658        tensor_with(name, TensorData::new(values, shape))
659    }
660
661    /// The container stack of a tensor found directly inside `container_type`.
662    fn containers(container_type: &str) -> Vec<String> {
663        vec![container_type.to_string()]
664    }
665
666    /// Adapt an F32 tensor of ones found directly inside `container_type`.
667    fn adapt_in(
668        adapter: &dyn ModuleAdapter,
669        name: &str,
670        shape: Shape,
671        container_type: &str,
672    ) -> PackTensor {
673        let containers = containers(container_type);
674        adapter.adapt(tensor(name, shape), ModuleContext::new(&containers))
675    }
676
677    /// Adapt a tensor reached outside any user-defined module.
678    fn adapt_without_module(adapter: &dyn ModuleAdapter, tensor: PackTensor) -> PackTensor {
679        adapter.adapt(tensor, ModuleContext::new(&[]))
680    }
681
682    #[test]
683    fn test_pytorch_to_burn_linear_weight() {
684        let adapter = PyTorchToBurnAdapter;
685
686        // Linear layer weight should be transposed
687        let adapted = adapt_in(&adapter, "fc.weight", shape![10, 5], module_names::LINEAR);
688        assert_eq!(adapted.shape, shape![5, 10]);
689
690        // Linear layer bias should not be transposed
691        let adapted = adapt_in(&adapter, "fc.bias", shape![10], module_names::LINEAR);
692        assert_eq!(adapted.shape, shape![10]);
693    }
694
695    #[test]
696    fn test_pytorch_to_burn_norm_params() {
697        let adapter = PyTorchToBurnAdapter;
698
699        // BatchNorm weight -> gamma
700        let adapted = adapt_in(
701            &adapter,
702            "norm.weight",
703            shape![10],
704            module_names::BATCH_NORM,
705        );
706        assert_eq!(adapted.name, "norm.gamma");
707
708        // BatchNorm bias -> beta
709        let adapted = adapt_in(&adapter, "norm.bias", shape![10], module_names::BATCH_NORM);
710        assert_eq!(adapted.name, "norm.beta");
711    }
712
713    #[test]
714    fn test_burn_to_pytorch_linear_weight() {
715        let adapter = BurnToPyTorchAdapter;
716
717        // Linear layer weight should be transposed
718        let adapted = adapt_in(&adapter, "fc.weight", shape![5, 10], module_names::LINEAR);
719        assert_eq!(adapted.shape, shape![10, 5]);
720    }
721
722    #[test]
723    fn test_burn_to_pytorch_norm_params() {
724        let adapter = BurnToPyTorchAdapter;
725
726        // BatchNorm gamma -> weight
727        let adapted = adapt_in(&adapter, "norm.gamma", shape![10], module_names::BATCH_NORM);
728        assert_eq!(adapted.name, "norm.weight");
729
730        // BatchNorm beta -> bias
731        let adapted = adapt_in(&adapter, "norm.beta", shape![10], module_names::BATCH_NORM);
732        assert_eq!(adapted.name, "norm.bias");
733    }
734
735    /// Renaming a parameter must leave the rest of the path alone, including a name with no
736    /// path at all (a top-level parameter) and one nested several levels deep.
737    #[test]
738    fn rename_keeps_the_enclosing_path() {
739        let adapter = PyTorchToBurnAdapter;
740
741        let adapted = adapt_in(
742            &adapter,
743            "encoder.layers.0.norm.weight",
744            shape![10],
745            module_names::LAYER_NORM,
746        );
747        assert_eq!(adapted.name, "encoder.layers.0.norm.gamma");
748
749        let adapted = adapt_in(&adapter, "weight", shape![10], module_names::LAYER_NORM);
750        assert_eq!(adapted.name, "gamma");
751    }
752
753    #[test]
754    fn test_transpose_different_dtypes() {
755        // Test that transpose works for different data types
756
757        // Test with F32
758        let f32_data = TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]);
759        let transposed = transpose_tensor_data(f32_data);
760        assert_eq!(*transposed.shape(), shape![3, 2]);
761        let values = transposed.try_to_vec::<f32>().unwrap();
762        assert_eq!(values, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
763
764        // Test with I32
765        let i32_data = TensorData::new(vec![1i32, 2, 3, 4, 5, 6], [2, 3]);
766        let transposed = transpose_tensor_data(i32_data);
767        assert_eq!(*transposed.shape(), shape![3, 2]);
768        let values = transposed.try_to_vec::<i32>().unwrap();
769        assert_eq!(values, vec![1, 4, 2, 5, 3, 6]);
770
771        // Test with F64
772        let f64_data = TensorData::new(vec![1.0f64, 2.0, 3.0, 4.0], [2, 2]);
773        let transposed = transpose_tensor_data(f64_data);
774        assert_eq!(*transposed.shape(), shape![2, 2]);
775        let values = transposed.try_to_vec::<f64>().unwrap();
776        assert_eq!(values, vec![1.0, 3.0, 2.0, 4.0]);
777    }
778
779    /// A transposed tensor must actually carry transposed bytes, not just a swapped shape.
780    /// `map_data` defers the transform, so nothing checks it until the data is drawn.
781    #[test]
782    fn transpose_moves_the_data_not_just_the_shape() {
783        let adapter = PyTorchToBurnAdapter;
784        let data = TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]);
785        let containers = containers(module_names::LINEAR);
786
787        let adapted = adapter.adapt(
788            tensor_with("fc.weight", data),
789            ModuleContext::new(&containers),
790        );
791
792        assert_eq!(adapted.shape, shape![3, 2]);
793        let values = bridge::to_data(&adapted)
794            .unwrap()
795            .try_into_vec::<f32>()
796            .unwrap();
797        assert_eq!(values, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
798    }
799
800    #[test]
801    fn test_no_container_info() {
802        let adapter = PyTorchToBurnAdapter;
803
804        // Without container info, no transformation occurs for linear layers
805        let adapted = adapt_without_module(&adapter, tensor("fc.weight", shape![10, 5]));
806        assert_eq!(adapted.shape, shape![10, 5]); // No transposition without container info
807
808        // Test a non-linear, non-norm parameter - should pass through unchanged
809        let adapted = adapt_without_module(&adapter, tensor("other.weight", shape![10, 5]));
810        assert_eq!(adapted.shape, shape![10, 5]); // No transposition
811    }
812
813    /// A `Vec<Linear>` still adapts as a Linear: the collection wrapper sits on top of the
814    /// module in the container stack, and `module_type` has to look past it.
815    #[test]
816    fn module_type_looks_past_collection_wrappers() {
817        let adapter = PyTorchToBurnAdapter;
818        let containers = vec![
819            "Struct:Model".to_string(),
820            "Vec".to_string(),
821            module_names::LINEAR.to_string(),
822        ];
823
824        let adapted = adapter.adapt(
825            tensor("layers.0.weight", shape![10, 5]),
826            ModuleContext::new(&containers),
827        );
828        assert_eq!(adapted.shape, shape![5, 10]);
829    }
830
831    #[derive(Clone)]
832    struct RenameParamAdapter {
833        from: &'static str,
834        to: &'static str,
835        called: Arc<AtomicUsize>,
836    }
837
838    impl ModuleAdapter for RenameParamAdapter {
839        fn adapt(&self, tensor: PackTensor, _ctx: ModuleContext<'_>) -> PackTensor {
840            self.called.fetch_add(1, Ordering::Relaxed);
841
842            if param_name(&tensor) != self.from {
843                return tensor;
844            }
845
846            rename_param(tensor, self.to)
847        }
848
849        fn get_alternative_param_name(
850            &self,
851            _param_name: &str,
852            _container_type: &str,
853        ) -> Option<String> {
854            None
855        }
856
857        fn clone_box(&self) -> Box<dyn ModuleAdapter> {
858            Box::new(self.clone())
859        }
860    }
861
862    #[derive(Clone)]
863    struct AltNameAdapter {
864        from: &'static str,
865        to: &'static str,
866        called: Arc<AtomicUsize>,
867    }
868
869    impl ModuleAdapter for AltNameAdapter {
870        fn adapt(&self, tensor: PackTensor, _ctx: ModuleContext<'_>) -> PackTensor {
871            tensor
872        }
873
874        fn get_alternative_param_name(
875            &self,
876            param_name: &str,
877            _container_type: &str,
878        ) -> Option<String> {
879            self.called.fetch_add(1, Ordering::Relaxed);
880            if param_name == self.from {
881                Some(self.to.to_string())
882            } else {
883                None
884            }
885        }
886
887        fn clone_box(&self) -> Box<dyn ModuleAdapter> {
888            Box::new(self.clone())
889        }
890    }
891
892    #[test]
893    fn test_chain_adapter_pipes_adapt() {
894        let called1 = Arc::new(AtomicUsize::new(0));
895        let called2 = Arc::new(AtomicUsize::new(0));
896
897        let a = RenameParamAdapter {
898            from: "weight",
899            to: "a",
900            called: called1.clone(),
901        };
902        let b = RenameParamAdapter {
903            from: "a",
904            to: "b",
905            called: called2.clone(),
906        };
907
908        let chain = a.chain(b);
909        let adapted = adapt_in(&chain, "fc.weight", shape![2, 2], module_names::LINEAR);
910
911        assert_eq!(adapted.name, "fc.b");
912        assert_eq!(called1.load(Ordering::Relaxed), 1);
913        assert_eq!(called2.load(Ordering::Relaxed), 1);
914    }
915
916    #[test]
917    fn test_chain_adapter_alternative_name_pipes_and_fallbacks() {
918        let called1 = Arc::new(AtomicUsize::new(0));
919        let called2 = Arc::new(AtomicUsize::new(0));
920
921        let a = AltNameAdapter {
922            from: "gamma",
923            to: "weight",
924            called: called1.clone(),
925        };
926        let b = AltNameAdapter {
927            from: "weight",
928            to: "scale",
929            called: called2.clone(),
930        };
931
932        let chain = a.chain(b);
933        let alt = chain.get_alternative_param_name("gamma", module_names::LAYER_NORM);
934        assert_eq!(alt.as_deref(), Some("scale"));
935        assert_eq!(called1.load(Ordering::Relaxed), 1);
936        assert_eq!(called2.load(Ordering::Relaxed), 1);
937
938        // If the second adapter doesn't have a mapping for the first alternative,
939        // fall back to the first alternative name.
940        let called1 = Arc::new(AtomicUsize::new(0));
941        let called2 = Arc::new(AtomicUsize::new(0));
942        let a = AltNameAdapter {
943            from: "gamma",
944            to: "weight",
945            called: called1.clone(),
946        };
947        let b = AltNameAdapter {
948            from: "something-else",
949            to: "unused",
950            called: called2.clone(),
951        };
952        let chain = a.chain(b);
953        let alt = chain.get_alternative_param_name("gamma", module_names::LAYER_NORM);
954        assert_eq!(alt.as_deref(), Some("weight"));
955        assert_eq!(called1.load(Ordering::Relaxed), 1);
956        assert_eq!(called2.load(Ordering::Relaxed), 1);
957
958        // If the first adapter doesn't provide an alternative, try the second with the original name.
959        let called1 = Arc::new(AtomicUsize::new(0));
960        let called2 = Arc::new(AtomicUsize::new(0));
961        let a = AltNameAdapter {
962            from: "something-else",
963            to: "unused",
964            called: called1.clone(),
965        };
966        let b = AltNameAdapter {
967            from: "gamma",
968            to: "weight",
969            called: called2.clone(),
970        };
971        let chain = a.chain(b);
972        let alt = chain.get_alternative_param_name("gamma", module_names::LAYER_NORM);
973        assert_eq!(alt.as_deref(), Some("weight"));
974        assert_eq!(called1.load(Ordering::Relaxed), 1);
975        assert_eq!(called2.load(Ordering::Relaxed), 1);
976
977        // clone_box must preserve behavior.
978        let boxed = chain.clone_box();
979        let alt = boxed.get_alternative_param_name("gamma", module_names::LAYER_NORM);
980        assert_eq!(alt.as_deref(), Some("weight"));
981    }
982
983    #[test]
984    fn test_half_precision_f32_to_f16() {
985        let adapter = HalfPrecisionAdapter::new();
986
987        let adapted = adapt_in(&adapter, "fc.weight", shape![2, 3], module_names::LINEAR);
988        assert_eq!(adapted.dtype, DType::F16);
989        assert_eq!(adapted.shape, shape![2, 3]);
990
991        let data = bridge::to_data(&adapted).unwrap();
992        assert_eq!(data.dtype(), DType::F16);
993    }
994
995    /// The cast has to be reflected in the declared byte length too, not only the dtype: the
996    /// writer reserves space from `byte_len` before the provider ever runs, so an F16 tensor
997    /// still claiming its F32 length would misplace every tensor written after it.
998    #[test]
999    fn test_half_precision_updates_byte_len() {
1000        let adapter = HalfPrecisionAdapter::new();
1001
1002        let adapted = adapt_in(&adapter, "fc.weight", shape![2, 3], module_names::LINEAR);
1003        assert_eq!(adapted.byte_len(), 6 * 2);
1004        assert_eq!(bridge::to_data(&adapted).unwrap().bytes().len(), 6 * 2);
1005    }
1006
1007    #[test]
1008    fn test_half_precision_f16_to_f32() {
1009        let adapter = HalfPrecisionAdapter::new();
1010
1011        // Create an F16 tensor
1012        let data = TensorData::new(vec![1.0f32; 6], shape![2, 3]).convert_dtype(DType::F16);
1013        let containers = containers(module_names::LINEAR);
1014
1015        let adapted = adapter.adapt(
1016            tensor_with("fc.weight", data),
1017            ModuleContext::new(&containers),
1018        );
1019        assert_eq!(adapted.dtype, DType::F32);
1020    }
1021
1022    #[test]
1023    fn test_half_precision_skips_batch_norm() {
1024        let adapter = HalfPrecisionAdapter::new();
1025
1026        // BatchNorm is excluded by default
1027        let adapted = adapt_in(
1028            &adapter,
1029            "norm.weight",
1030            shape![10],
1031            module_names::BATCH_NORM,
1032        );
1033        assert_eq!(adapted.dtype, DType::F32); // unchanged
1034    }
1035
1036    #[test]
1037    fn test_half_precision_converts_default_modules() {
1038        let adapter = HalfPrecisionAdapter::new();
1039
1040        for (name, shape, module) in [
1041            ("fc.weight", shape![2, 3], module_names::LINEAR),
1042            ("emb.weight", shape![100, 64], module_names::EMBEDDING),
1043            ("conv.weight", shape![3, 3, 3, 3], module_names::CONV2D),
1044            ("norm.gamma", shape![10], module_names::LAYER_NORM),
1045            ("gn.gamma", shape![10], module_names::GROUP_NORM),
1046            ("rms.weight", shape![10], module_names::RMS_NORM),
1047        ] {
1048            assert_eq!(
1049                adapt_in(&adapter, name, shape, module).dtype,
1050                DType::F16,
1051                "{module} should be converted"
1052            );
1053        }
1054    }
1055
1056    #[test]
1057    fn test_half_precision_without_module() {
1058        let adapter = HalfPrecisionAdapter::new().without_module("LayerNorm");
1059
1060        // LayerNorm removed from conversion set
1061        let adapted = adapt_in(&adapter, "norm.gamma", shape![10], module_names::LAYER_NORM);
1062        assert_eq!(adapted.dtype, DType::F32);
1063
1064        // Linear still converted
1065        let adapted = adapt_in(&adapter, "fc.weight", shape![2, 3], module_names::LINEAR);
1066        assert_eq!(adapted.dtype, DType::F16);
1067    }
1068
1069    #[test]
1070    fn test_half_precision_with_module() {
1071        let adapter = HalfPrecisionAdapter::new().with_module("CustomLayer");
1072
1073        // Custom module should now be converted
1074        let adapted = adapt_in(&adapter, "custom.weight", shape![5], "Struct:CustomLayer");
1075        assert_eq!(adapted.dtype, DType::F16);
1076    }
1077
1078    #[test]
1079    fn test_half_precision_with_qualified_name() {
1080        let adapter = HalfPrecisionAdapter::new().with_module("Struct:CustomLayer");
1081
1082        let adapted = adapt_in(&adapter, "custom.weight", shape![5], "Struct:CustomLayer");
1083        assert_eq!(adapted.dtype, DType::F16);
1084    }
1085
1086    #[test]
1087    fn test_half_precision_chain() {
1088        let adapter = PyTorchToBurnAdapter.chain(HalfPrecisionAdapter::new());
1089
1090        let adapted = adapt_in(&adapter, "fc.weight", shape![10, 5], module_names::LINEAR);
1091
1092        // Should be both transposed and cast
1093        assert_eq!(adapted.shape, shape![5, 10]);
1094        assert_eq!(adapted.dtype, DType::F16);
1095    }
1096
1097    #[test]
1098    fn test_half_precision_skips_no_container() {
1099        let adapter = HalfPrecisionAdapter::new();
1100
1101        // No module type info: skip
1102        let adapted = adapt_without_module(&adapter, tensor("fc.weight", shape![2, 3]));
1103        assert_eq!(adapted.dtype, DType::F32);
1104    }
1105
1106    #[test]
1107    fn test_half_precision_skips_non_float() {
1108        use burn_core::tensor::quantization::QuantScheme;
1109
1110        let adapter = HalfPrecisionAdapter::new();
1111
1112        // QFloat source: skip. Sized from the scheme so `byte_len` matches the bytes, the way
1113        // a real quantized tensor's would.
1114        let qfloat_dtype = DType::QFloat(QuantScheme::default());
1115        let shape = shape![2, 3];
1116        let bytes = Bytes::from_bytes_vec(vec![0u8; bridge::data_len(qfloat_dtype, &shape)]);
1117        let qfloat = PackTensor::new("fc.weight".to_string(), qfloat_dtype, shape, None, bytes);
1118
1119        let containers = containers(module_names::LINEAR);
1120        let adapted = adapter.adapt(qfloat, ModuleContext::new(&containers));
1121        assert_eq!(adapted.dtype, qfloat_dtype);
1122    }
1123
1124    #[test]
1125    fn test_half_precision_default_module_count() {
1126        let adapter = HalfPrecisionAdapter::new();
1127        // 14 modules: Linear, Embedding, Conv1d-3d, ConvTranspose1d-3d,
1128        // DeformConv2d, LayerNorm, GroupNorm, InstanceNorm, RmsNorm, PRelu
1129        assert_eq!(adapter.modules.len(), 14);
1130    }
1131
1132    #[test]
1133    fn test_half_precision_without_module_qualified() {
1134        let adapter = HalfPrecisionAdapter::new().without_module("Struct:LayerNorm");
1135
1136        let adapted = adapt_in(&adapter, "norm.gamma", shape![10], module_names::LAYER_NORM);
1137        assert_eq!(adapted.dtype, DType::F32);
1138    }
1139
1140    /// A tensor with a specific float dtype (values convertible exactly in F16/BF16 so
1141    /// round-trips can be checked precisely).
1142    fn float_tensor(dtype: DType) -> PackTensor {
1143        let values = vec![1.0f32, -2.0, 0.5, 4.0, -0.25, 8.0];
1144        let data = TensorData::new(values, shape![2, 3]).convert_dtype(dtype);
1145        tensor_with("fc.weight", data)
1146    }
1147
1148    #[test]
1149    fn test_float_cast_bf16_to_f16() {
1150        // The case HalfPrecisionAdapter cannot handle: BF16 sources.
1151        let adapter = FloatCastAdapter::to(DType::F16);
1152        let containers = containers(module_names::LINEAR);
1153
1154        let adapted = adapter.adapt(float_tensor(DType::BF16), ModuleContext::new(&containers));
1155        assert_eq!(adapted.dtype, DType::F16);
1156        assert_eq!(adapted.shape, shape![2, 3]);
1157        assert_eq!(adapted.name, "fc.weight");
1158
1159        let data = bridge::to_data(&adapted).unwrap();
1160        assert_eq!(data.dtype(), DType::F16);
1161        let values = data
1162            .convert_dtype(DType::F32)
1163            .try_into_vec::<f32>()
1164            .unwrap();
1165        assert_eq!(values, vec![1.0, -2.0, 0.5, 4.0, -0.25, 8.0]);
1166    }
1167
1168    #[test]
1169    fn test_float_cast_converts_any_module_type() {
1170        // No module-type allowlist: custom modules are converted too.
1171        let adapter = FloatCastAdapter::to(DType::F16);
1172        let containers = containers("Struct:CustomLayer");
1173        let adapted = adapter.adapt(float_tensor(DType::F32), ModuleContext::new(&containers));
1174        assert_eq!(adapted.dtype, DType::F16);
1175
1176        // Even with no container info at all.
1177        let adapted = adapt_without_module(&adapter, float_tensor(DType::F32));
1178        assert_eq!(adapted.dtype, DType::F16);
1179    }
1180
1181    #[test]
1182    fn test_float_cast_passthrough_on_target_dtype() {
1183        let adapter = FloatCastAdapter::to(DType::F16);
1184        let adapted = adapt_without_module(&adapter, float_tensor(DType::F16));
1185        assert_eq!(adapted.dtype, DType::F16);
1186    }
1187
1188    #[test]
1189    fn test_float_cast_skips_non_float() {
1190        let adapter = FloatCastAdapter::to(DType::F16);
1191        let containers = containers(module_names::EMBEDDING);
1192
1193        let data = TensorData::new(vec![1i64, 2, 3], shape![3]);
1194        let adapted = adapter.adapt(tensor_with("idx", data), ModuleContext::new(&containers));
1195        assert_eq!(adapted.dtype, DType::I64);
1196    }
1197
1198    /// A quantized weight is packed values plus an inline scale tail, not a grid of
1199    /// equal-sized elements: `DType::size()` is 0 for a sub-byte value type, so a byte-level
1200    /// transpose would copy nothing and hand back correctly-sized zeros. It has to pass
1201    /// through untouched.
1202    #[test]
1203    fn a_quantized_linear_weight_is_not_transposed() {
1204        use burn_core::tensor::quantization::{QuantScheme, QuantValue};
1205        use burn_core::tensor::{Device, Distribution, Tensor};
1206
1207        let device = Device::default();
1208        let quantized = Tensor::<2>::random(shape![3, 8], Distribution::Default, &device)
1209            .quantize_dynamic(&QuantScheme::default().with_value(QuantValue::Q4S));
1210
1211        let source = bridge::from_tensor(&quantized, "fc.weight".to_string(), None);
1212        let (shape, bytes) = (
1213            source.shape.clone(),
1214            bridge::to_data(&source).unwrap().as_bytes().to_vec(),
1215        );
1216
1217        let containers = containers(module_names::LINEAR);
1218        let adapted = BurnToPyTorchAdapter.adapt(source, ModuleContext::new(&containers));
1219
1220        assert_eq!(
1221            adapted.shape, shape,
1222            "a quantized weight must pass through unchanged"
1223        );
1224        assert_eq!(
1225            bridge::to_data(&adapted).unwrap().as_bytes().to_vec(),
1226            bytes
1227        );
1228    }
1229
1230    /// A stack of only collection wrappers has no user-defined module in it.
1231    #[test]
1232    fn module_type_is_none_without_a_user_defined_module() {
1233        let containers = vec!["Vec".to_string(), "Array".to_string()];
1234        assert_eq!(ModuleContext::new(&containers).module_type(), None);
1235    }
1236
1237    #[test]
1238    fn test_float_cast_chain_after_pytorch() {
1239        // Layout conversion then dtype cast, the checkpoint-loading shape.
1240        let adapter = PyTorchToBurnAdapter.chain(FloatCastAdapter::to(DType::F16));
1241
1242        let adapted = adapt_in(&adapter, "fc.weight", shape![10, 5], module_names::LINEAR);
1243        assert_eq!(adapted.shape, shape![5, 10]);
1244        assert_eq!(adapted.dtype, DType::F16);
1245    }
1246
1247    #[test]
1248    #[should_panic(expected = "must be a float dtype")]
1249    fn test_float_cast_rejects_non_float_target() {
1250        let _ = FloatCastAdapter::to(DType::I32);
1251    }
1252
1253    #[test]
1254    fn test_half_precision_with_module_batch_norm_opt_in() {
1255        let adapter = HalfPrecisionAdapter::new().with_module("BatchNorm");
1256
1257        let adapted = adapt_in(&adapter, "bn.weight", shape![10], module_names::BATCH_NORM);
1258        assert_eq!(adapted.dtype, DType::F16);
1259    }
1260}