Skip to main content

burn_store/
collector.rs

1use alloc::boxed::Box;
2use alloc::string::{String, ToString};
3use alloc::vec::Vec;
4
5use burn_pack::Tensor as PackTensor;
6
7use burn_core::tensor::kind::Basic;
8use burn_core::tensor::{Bool, Int, Tensor};
9
10use crate::bridge;
11use crate::{ModuleAdapter, ModuleContext, PathFilter};
12use burn_core::module::{ModuleVisitor, Param, ParamId, Parameter};
13
14/// Collects a module's tensors without copying data.
15///
16/// This collector traverses a module hierarchy and produces [`burn_pack::Tensor`]s whose data
17/// is read back from the device only on demand, so a module can be walked for its structure
18/// and metadata alone.
19///
20/// # Examples
21///
22/// ## Collect all tensors
23/// ```rust,no_run
24/// # use burn_store::Collector;
25/// let collector = Collector::new(None, None, false);
26/// // Use with module.visit(&mut collector);
27/// let all_tensors = collector.tensors;
28/// ```
29///
30/// ## Filter with single pattern
31/// ```rust,no_run
32/// # use burn_store::{Collector, PathFilter};
33/// let filter = PathFilter::new().with_regex(r"^encoder\..*");
34/// let collector = Collector::new(Some(filter), None, false);
35/// // Use with module.visit(&mut collector);
36/// // Only collects tensors starting with "encoder."
37/// ```
38///
39/// ## Filter with multiple patterns (OR union)
40/// ```rust,no_run
41/// # use burn_store::{Collector, PathFilter};
42/// let filter = PathFilter::new()
43///     .with_regex(r"^encoder\..*")  // Match all encoder tensors
44///     .with_regex(r".*\.bias$");    // OR match any bias tensors
45/// let collector = Collector::new(Some(filter), None, false);
46/// // Use with module.visit(&mut collector);
47/// // Collects tensors matching ANY of the patterns
48/// ```
49pub struct Collector {
50    /// The collected tensors, already adapted.
51    pub tensors: Vec<PackTensor>,
52    path_stack: Vec<String>,
53    container_stack: Vec<String>,
54    filter: Option<PathFilter>,
55    adapter: Option<Box<dyn ModuleAdapter>>,
56    /// Skip enum variant names when building paths
57    /// When true, enum variant names are not included in tensor paths
58    skip_enum_variants: bool,
59}
60
61impl Default for Collector {
62    fn default() -> Self {
63        Self::new(None, None, false)
64    }
65}
66
67impl Collector {
68    /// Create a new collector with an optional filter and adapter.
69    ///
70    /// # Arguments
71    ///
72    /// * `filter` - An optional [`PathFilter`] to determine which tensors to collect.
73    ///   When `None`, all tensors are collected.
74    /// * `adapter` - Optional adapter to transform tensors based on container types.
75    ///   Applied to each tensor as it is collected.
76    /// * `skip_enum_variants` - Skip enum variant names when building paths.
77    ///   When true, paths will not include enum variant names (e.g., "feature.weight"
78    ///   instead of "feature.BaseConv.weight"). Useful when exporting to formats
79    ///   like PyTorch that don't use enum variants.
80    ///
81    /// # Examples
82    ///
83    /// ```rust,no_run
84    /// # use burn_store::{Collector, PathFilter};
85    /// // Collect all tensors without adapter
86    /// let collector = Collector::new(None, None, false);
87    ///
88    /// // Use PathFilter builder
89    /// let filter = PathFilter::new()
90    ///     .with_regex(r"^encoder\..*")
91    ///     .with_full_path("decoder.weight");
92    /// let collector = Collector::new(Some(filter), None, false);
93    ///
94    /// // Skip enum variants for PyTorch export
95    /// let collector = Collector::new(None, None, true);
96    /// ```
97    pub fn new(
98        filter: Option<PathFilter>,
99        adapter: Option<Box<dyn ModuleAdapter>>,
100        skip_enum_variants: bool,
101    ) -> Self {
102        Self {
103            tensors: Vec::new(),
104            path_stack: Vec::new(),
105            container_stack: Vec::new(),
106            filter,
107            adapter,
108            skip_enum_variants,
109        }
110    }
111
112    /// Return the collected tensors.
113    pub fn into_tensors(self) -> Vec<PackTensor> {
114        self.tensors
115    }
116
117    fn should_collect(&self, path: &[String], container_stack: &[String]) -> bool {
118        // If filter is present, use it; otherwise collect all
119        match &self.filter {
120            None => true,
121            Some(f) => f.matches_with_container_path(path, container_stack),
122        }
123    }
124
125    /// Collect a parameter reached at the collector's current path.
126    ///
127    /// One method covers float, int and bool parameters: [`Basic`] is what supplies `dtype`,
128    /// `shape` and `to_data` for all three kinds.
129    fn collect_param<const D: usize, K: Basic + 'static>(&mut self, param: &Param<Tensor<D, K>>)
130    where
131        Tensor<D, K>: Parameter,
132    {
133        if !self.should_collect(&self.path_stack, &self.container_stack) {
134            return;
135        }
136
137        // The `on_save` form is what the load side validates against and un-maps with
138        // `on_load`; saving `val()` breaks any param whose mapper changes the shape.
139        let tensor = param.transform_for_save().val();
140        let tensor = bridge::from_tensor(&tensor, self.path_stack.join("."), Some(param.id.val()));
141
142        let tensor = adapt(self.adapter.as_deref(), tensor, &self.container_stack);
143        self.tensors.push(tensor);
144    }
145
146    /// Collect a tensor reached at an explicit path rather than through the path stack.
147    fn collect_at<const D: usize, K: Basic + 'static>(
148        &mut self,
149        path: &[String],
150        id: ParamId,
151        tensor: &Tensor<D, K>,
152    ) {
153        // Path-based visits still use the current container stack for filtering.
154        if !self.should_collect(path, &self.container_stack) {
155            return;
156        }
157
158        let tensor = bridge::from_tensor(tensor, path.join("."), Some(id.val()));
159
160        let tensor = adapt(self.adapter.as_deref(), tensor, &self.container_stack);
161        self.tensors.push(tensor);
162    }
163}
164
165/// Adapt a tensor with the module position live on the traversal stack.
166///
167/// Adapting during the walk rather than at the end is what lets a collected tensor be a plain
168/// [`PackTensor`]: the stacks an adapter needs are in scope only here, so consuming them now
169/// means they never have to ride along on the tensor.
170///
171/// Free rather than a method so the borrow of the collector's container stack ends before its
172/// `tensors` field is borrowed mutably to push the result.
173fn adapt(
174    adapter: Option<&dyn ModuleAdapter>,
175    tensor: PackTensor,
176    containers: &[String],
177) -> PackTensor {
178    match adapter {
179        Some(adapter) => adapter.adapt(tensor, ModuleContext::new(containers)),
180        None => tensor,
181    }
182}
183
184impl ModuleVisitor for Collector {
185    fn enter_module(&mut self, name: &str, container_type: &str) {
186        // Always track the container type for proper filtering and module type detection
187        self.container_stack.push(container_type.to_string());
188
189        // Only add to path if it's not an enum variant (when skip_enum_variants is enabled)
190        // This ensures paths are built without enum variant names from the start
191        if !self.skip_enum_variants || !container_type.starts_with("Enum:") {
192            self.path_stack.push(name.to_string());
193        }
194    }
195
196    fn exit_module(&mut self, _name: &str, container_type: &str) {
197        self.container_stack.pop();
198
199        // Only pop from path if we added it (not an enum variant when skip_enum_variants is enabled)
200        if !self.skip_enum_variants || !container_type.starts_with("Enum:") {
201            self.path_stack.pop();
202        }
203    }
204
205    fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<D>>) {
206        self.collect_param(param);
207    }
208
209    fn visit_int<const D: usize>(&mut self, param: &Param<Tensor<D, Int>>) {
210        self.collect_param(param);
211    }
212
213    fn visit_bool<const D: usize>(&mut self, param: &Param<Tensor<D, Bool>>) {
214        self.collect_param(param);
215    }
216
217    fn visit_float_with_path<const D: usize>(
218        &mut self,
219        path: &[String],
220        id: ParamId,
221        tensor: &Tensor<D>,
222    ) {
223        self.collect_at(path, id, tensor);
224    }
225
226    fn visit_int_with_path<const D: usize>(
227        &mut self,
228        path: &[String],
229        id: ParamId,
230        tensor: &Tensor<D, Int>,
231    ) {
232        self.collect_at(path, id, tensor);
233    }
234
235    fn visit_bool_with_path<const D: usize>(
236        &mut self,
237        path: &[String],
238        id: ParamId,
239        tensor: &Tensor<D, Bool>,
240    ) {
241        self.collect_at(path, id, tensor);
242    }
243}
244
245#[cfg(all(test, feature = "std"))]
246mod tests {
247    use super::*;
248
249    use burn_core as burn;
250
251    use alloc::collections::BTreeMap;
252    use alloc::string::String;
253    use burn_core::module::{Module, Param};
254    use burn_core::tensor::{Device, shape};
255    use burn_nn::LinearConfig;
256
257    #[test]
258    fn collector_collects_a_tensor() {
259        let device = Default::default();
260        let tensor = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device);
261
262        let mut collector = Collector::new(None, None, false);
263        let id = ParamId::new();
264
265        // Collect a tensor
266        collector.visit_float_with_path(&["model".to_string(), "weight".to_string()], id, &tensor);
267
268        assert_eq!(collector.tensors.len(), 1);
269        assert_eq!(collector.tensors[0].name, "model.weight");
270
271        // Verify the tensor can be converted to data
272        let view = &collector.tensors[0];
273        let data = bridge::to_data(view).unwrap();
274        assert_eq!(*data.shape(), shape![2, 2]);
275    }
276
277    #[test]
278    fn root_level_parameters() {
279        use burn_core::module::ModuleVisitor;
280
281        let device = Default::default();
282
283        // Create root-level parameters (single-element path, not nested in modules)
284        let weight = Param::<Tensor<2>>::from_data([[1.0, 2.0], [3.0, 4.0]], &device);
285        let bias = Param::<Tensor<1>>::from_data([5.0, 6.0], &device);
286
287        let mut collector = Collector::new(None, None, false);
288
289        // Simulate module traversal for root-level parameters
290        // Enter "weight" path (as if we're visiting a field named "weight")
291        ModuleVisitor::enter_module(&mut collector, "weight", "");
292        ModuleVisitor::visit_float(&mut collector, &weight);
293        ModuleVisitor::exit_module(&mut collector, "weight", "");
294
295        // Enter "bias" path (as if we're visiting a field named "bias")
296        ModuleVisitor::enter_module(&mut collector, "bias", "");
297        ModuleVisitor::visit_float(&mut collector, &bias);
298        ModuleVisitor::exit_module(&mut collector, "bias", "");
299
300        // Verify both parameters were collected
301        assert_eq!(collector.tensors.len(), 2);
302
303        // Verify paths are correct (single-element paths)
304        assert_eq!(collector.tensors[0].name, "weight");
305        assert_eq!(collector.tensors[1].name, "bias");
306
307        // Verify data is correct
308        let weight_data = bridge::to_data(&collector.tensors[0])
309            .unwrap()
310            .try_into_vec::<f32>()
311            .unwrap();
312        let bias_data = bridge::to_data(&collector.tensors[1])
313            .unwrap()
314            .try_into_vec::<f32>()
315            .unwrap();
316
317        assert_eq!(weight_data, vec![1.0, 2.0, 3.0, 4.0]);
318        assert_eq!(bias_data, vec![5.0, 6.0]);
319    }
320
321    #[test]
322    #[cfg(target_has_atomic = "ptr")]
323    fn collector_with_filter() {
324        let device = Default::default();
325        let tensor = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device);
326
327        let filter = PathFilter::new().with_regex(r"^encoder\..*");
328        let mut collector = Collector::new(Some(filter), None, false);
329        let id = ParamId::new();
330
331        // This should be collected
332        collector.visit_float_with_path(
333            &["encoder".to_string(), "weight".to_string()],
334            id,
335            &tensor,
336        );
337        // This should NOT be collected
338        collector.visit_float_with_path(
339            &["decoder".to_string(), "weight".to_string()],
340            id,
341            &tensor,
342        );
343
344        assert_eq!(collector.tensors.len(), 1);
345        assert_eq!(collector.tensors[0].name, "encoder.weight");
346    }
347
348    #[test]
349    #[cfg(target_has_atomic = "ptr")]
350    fn collector_with_multiple_filters() {
351        let device = Default::default();
352        let tensor = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device);
353
354        // Multiple patterns - collect if matches ANY (OR union)
355        let filter = PathFilter::new()
356            .with_regex(r"^encoder\..*") // Match encoder.*
357            .with_regex(r".*\.bias$"); // Match *.bias
358        let mut collector = Collector::new(Some(filter), None, false);
359        let id = ParamId::new();
360
361        // These should be collected
362        collector.visit_float_with_path(
363            &["encoder".to_string(), "weight".to_string()],
364            id,
365            &tensor,
366        ); // matches first pattern
367        collector.visit_float_with_path(&["decoder".to_string(), "bias".to_string()], id, &tensor); // matches second pattern
368        collector.visit_float_with_path(&["encoder".to_string(), "bias".to_string()], id, &tensor); // matches both patterns
369
370        // This should NOT be collected
371        collector.visit_float_with_path(
372            &["decoder".to_string(), "weight".to_string()],
373            id,
374            &tensor,
375        ); // matches neither
376
377        assert_eq!(collector.tensors.len(), 3);
378        let paths: Vec<String> = collector.tensors.iter().map(|v| v.name.clone()).collect();
379        assert!(paths.contains(&"encoder.weight".to_string()));
380        assert!(paths.contains(&"decoder.bias".to_string()));
381        assert!(paths.contains(&"encoder.bias".to_string()));
382        assert!(!paths.contains(&"decoder.weight".to_string()));
383    }
384
385    #[test]
386    fn collector_with_predicate() {
387        let device = Default::default();
388        let tensor = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device);
389
390        // Use predicate function for filtering
391        fn filter_fn(path: &str, _container_path: &str) -> bool {
392            path.starts_with("encoder.") || path == "decoder.bias"
393        }
394        let filter = PathFilter::new().with_predicate(filter_fn);
395        let mut collector = Collector::new(Some(filter), None, false);
396        let id = ParamId::new();
397
398        // These should be collected
399        collector.visit_float_with_path(
400            &["encoder".to_string(), "weight".to_string()],
401            id,
402            &tensor,
403        );
404        collector.visit_float_with_path(&["encoder".to_string(), "bias".to_string()], id, &tensor);
405        collector.visit_float_with_path(&["decoder".to_string(), "bias".to_string()], id, &tensor);
406
407        // This should NOT be collected
408        collector.visit_float_with_path(
409            &["decoder".to_string(), "weight".to_string()],
410            id,
411            &tensor,
412        );
413        collector.visit_float_with_path(&["other".to_string(), "tensor".to_string()], id, &tensor);
414
415        assert_eq!(collector.tensors.len(), 3);
416        let paths: Vec<String> = collector.tensors.iter().map(|v| v.name.clone()).collect();
417        assert!(paths.contains(&"encoder.weight".to_string()));
418        assert!(paths.contains(&"encoder.bias".to_string()));
419        assert!(paths.contains(&"decoder.bias".to_string()));
420        assert!(!paths.contains(&"decoder.weight".to_string()));
421        assert!(!paths.contains(&"other.tensor".to_string()));
422    }
423
424    #[test]
425    fn collector_predicate_with_complex_logic() {
426        let device = Default::default();
427        let tensor = Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device);
428
429        // Complex predicate with multiple conditions
430        fn complex_filter(path: &str, _container_path: &str) -> bool {
431            let parts: Vec<&str> = path.split('.').collect();
432            if parts.len() != 3 {
433                return false;
434            }
435            // Only collect if it's layer1 or layer2, and it's a weight tensor
436            (parts[1] == "layer1" || parts[1] == "layer2") && parts[2] == "weight"
437        }
438        let filter = PathFilter::new().with_predicate(complex_filter);
439        let mut collector = Collector::new(Some(filter), None, false);
440        let id = ParamId::new();
441
442        // These should be collected
443        collector.visit_float_with_path(
444            &[
445                "model".to_string(),
446                "layer1".to_string(),
447                "weight".to_string(),
448            ],
449            id,
450            &tensor,
451        );
452        collector.visit_float_with_path(
453            &[
454                "model".to_string(),
455                "layer2".to_string(),
456                "weight".to_string(),
457            ],
458            id,
459            &tensor,
460        );
461
462        // These should NOT be collected
463        collector.visit_float_with_path(
464            &[
465                "model".to_string(),
466                "layer1".to_string(),
467                "bias".to_string(),
468            ],
469            id,
470            &tensor,
471        );
472        collector.visit_float_with_path(
473            &[
474                "model".to_string(),
475                "layer3".to_string(),
476                "weight".to_string(),
477            ],
478            id,
479            &tensor,
480        );
481        collector.visit_float_with_path(
482            &["encoder".to_string(), "weight".to_string()],
483            id,
484            &tensor,
485        ); // wrong structure
486
487        assert_eq!(collector.tensors.len(), 2);
488        let paths: Vec<String> = collector.tensors.iter().map(|v| v.name.clone()).collect();
489        assert!(paths.contains(&"model.layer1.weight".to_string()));
490        assert!(paths.contains(&"model.layer2.weight".to_string()));
491        assert!(!paths.contains(&"model.layer1.bias".to_string()));
492        assert!(!paths.contains(&"model.layer3.weight".to_string()));
493        assert!(!paths.contains(&"encoder.weight".to_string()));
494    }
495
496    // Test visitor that collects tensor paths
497    struct TensorPathCollector {
498        pub paths: BTreeMap<String, (ParamId, Vec<usize>)>,
499        path_stack: Vec<String>,
500    }
501
502    impl TensorPathCollector {
503        fn new() -> Self {
504            Self {
505                paths: BTreeMap::new(),
506                path_stack: Vec::new(),
507            }
508        }
509
510        fn current_path(&self) -> String {
511            self.path_stack.join(".")
512        }
513    }
514
515    impl ModuleVisitor for TensorPathCollector {
516        fn enter_module(&mut self, name: &str, _container_type: &str) {
517            self.path_stack.push(name.to_string());
518        }
519
520        fn exit_module(&mut self, _name: &str, _container_type: &str) {
521            self.path_stack.pop();
522        }
523
524        fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<D>>) {
525            let path = self.current_path();
526            if !path.is_empty() {
527                self.paths.insert(
528                    path,
529                    (param.id, param.transform_for_save().val().shape().to_vec()),
530                );
531            }
532        }
533
534        fn visit_int<const D: usize>(&mut self, param: &Param<Tensor<D, Int>>) {
535            let path = self.current_path();
536            if !path.is_empty() {
537                self.paths.insert(
538                    path,
539                    (param.id, param.transform_for_save().val().shape().to_vec()),
540                );
541            }
542        }
543
544        fn visit_bool<const D: usize>(&mut self, param: &Param<Tensor<D, Bool>>) {
545            let path = self.current_path();
546            if !path.is_empty() {
547                self.paths.insert(
548                    path,
549                    (param.id, param.transform_for_save().val().shape().to_vec()),
550                );
551            }
552        }
553    }
554
555    // Simple nested module for testing
556    #[derive(Module, Debug)]
557    struct InnerModule {
558        weight: Param<Tensor<2>>,
559        bias: Param<Tensor<1>>,
560    }
561
562    #[derive(Module, Debug)]
563    struct OuterModule {
564        layer1: InnerModule,
565        layer2: InnerModule,
566    }
567
568    impl InnerModule {
569        fn new(device: &Device) -> Self {
570            Self {
571                weight: Param::from_data([[1.0, 2.0], [3.0, 4.0]], device),
572                bias: Param::from_data([5.0, 6.0], device),
573            }
574        }
575    }
576
577    impl OuterModule {
578        fn new(device: &Device) -> Self {
579            Self {
580                layer1: InnerModule::new(device),
581                layer2: InnerModule::new(device),
582            }
583        }
584    }
585
586    #[test]
587    fn nested_module_path_tracking() {
588        let device = Default::default();
589        let module = OuterModule::new(&device);
590
591        let mut collector = TensorPathCollector::new();
592        module.visit(&mut collector);
593
594        let paths = collector.paths;
595
596        // Verify we have the expected paths
597        // Note: Param<Tensor> fields are themselves modules, so we get an extra level
598        assert!(paths.contains_key("layer1.weight"), "Missing layer1.weight");
599        assert!(paths.contains_key("layer1.bias"), "Missing layer1.bias");
600        assert!(paths.contains_key("layer2.weight"), "Missing layer2.weight");
601        assert!(paths.contains_key("layer2.bias"), "Missing layer2.bias");
602
603        // Verify the shapes are correct
604        assert_eq!(paths.get("layer1.weight").unwrap().1, vec![2, 2]);
605        assert_eq!(paths.get("layer1.bias").unwrap().1, vec![2]);
606        assert_eq!(paths.get("layer2.weight").unwrap().1, vec![2, 2]);
607        assert_eq!(paths.get("layer2.bias").unwrap().1, vec![2]);
608    }
609
610    #[test]
611    fn linear_module_paths() {
612        let device = Default::default();
613        let config = LinearConfig::new(10, 20).with_bias(true);
614        let linear = config.init(&device);
615
616        let mut collector = TensorPathCollector::new();
617        linear.visit(&mut collector);
618
619        let paths = collector.paths;
620
621        // Linear module has weight and optional bias
622        assert!(paths.contains_key("weight"));
623        assert!(paths.contains_key("bias"));
624
625        // Check dimensions
626        assert_eq!(paths.get("weight").unwrap().1, vec![10, 20]);
627        assert_eq!(paths.get("bias").unwrap().1, vec![20]);
628    }
629
630    // Deep nesting test structures (4+ levels)
631    #[derive(Module, Debug)]
632    struct Level4Module {
633        weight: Param<Tensor<2>>,
634        bias: Param<Tensor<1>>,
635    }
636
637    #[derive(Module, Debug)]
638    struct Level3Module {
639        layer: Level4Module,
640        extra: Level4Module,
641    }
642
643    #[derive(Module, Debug)]
644    struct Level2Module {
645        block1: Level3Module,
646        block2: Level3Module,
647    }
648
649    #[derive(Module, Debug)]
650    struct Level1Module {
651        encoder: Level2Module,
652        decoder: Level2Module,
653    }
654
655    #[derive(Module, Debug)]
656    struct DeepModel {
657        backbone: Level1Module,
658        head: Level4Module,
659    }
660
661    impl Level4Module {
662        fn new(device: &Device) -> Self {
663            Self {
664                weight: Param::from_data([[1.0, 2.0], [3.0, 4.0]], device),
665                bias: Param::from_data([5.0, 6.0], device),
666            }
667        }
668    }
669
670    impl Level3Module {
671        fn new(device: &Device) -> Self {
672            Self {
673                layer: Level4Module::new(device),
674                extra: Level4Module::new(device),
675            }
676        }
677    }
678
679    impl Level2Module {
680        fn new(device: &Device) -> Self {
681            Self {
682                block1: Level3Module::new(device),
683                block2: Level3Module::new(device),
684            }
685        }
686    }
687
688    impl Level1Module {
689        fn new(device: &Device) -> Self {
690            Self {
691                encoder: Level2Module::new(device),
692                decoder: Level2Module::new(device),
693            }
694        }
695    }
696
697    impl DeepModel {
698        fn new(device: &Device) -> Self {
699            Self {
700                backbone: Level1Module::new(device),
701                head: Level4Module::new(device),
702            }
703        }
704    }
705
706    #[test]
707    fn deep_module_path_tracking() {
708        let device = Default::default();
709        let model = DeepModel::new(&device);
710
711        let mut collector = Collector::new(None, None, false);
712        model.visit(&mut collector);
713
714        let views = collector.tensors;
715        let paths: Vec<String> = views.iter().map(|v| v.name.clone()).collect();
716
717        // Test 5-level deep paths
718        assert!(paths.contains(&"backbone.encoder.block1.layer.weight".to_string()));
719        assert!(paths.contains(&"backbone.encoder.block1.layer.bias".to_string()));
720        assert!(paths.contains(&"backbone.encoder.block1.extra.weight".to_string()));
721        assert!(paths.contains(&"backbone.encoder.block1.extra.bias".to_string()));
722
723        assert!(paths.contains(&"backbone.encoder.block2.layer.weight".to_string()));
724        assert!(paths.contains(&"backbone.encoder.block2.layer.bias".to_string()));
725        assert!(paths.contains(&"backbone.encoder.block2.extra.weight".to_string()));
726        assert!(paths.contains(&"backbone.encoder.block2.extra.bias".to_string()));
727
728        assert!(paths.contains(&"backbone.decoder.block1.layer.weight".to_string()));
729        assert!(paths.contains(&"backbone.decoder.block1.layer.bias".to_string()));
730        assert!(paths.contains(&"backbone.decoder.block1.extra.weight".to_string()));
731        assert!(paths.contains(&"backbone.decoder.block1.extra.bias".to_string()));
732
733        assert!(paths.contains(&"backbone.decoder.block2.layer.weight".to_string()));
734        assert!(paths.contains(&"backbone.decoder.block2.layer.bias".to_string()));
735        assert!(paths.contains(&"backbone.decoder.block2.extra.weight".to_string()));
736        assert!(paths.contains(&"backbone.decoder.block2.extra.bias".to_string()));
737
738        // Test 2-level paths
739        assert!(paths.contains(&"head.weight".to_string()));
740        assert!(paths.contains(&"head.bias".to_string()));
741
742        // Total should be 18 tensors (16 from backbone + 2 from head)
743        assert_eq!(views.len(), 18);
744
745        // Verify data can be materialized
746        let view = views
747            .iter()
748            .find(|v| v.name == "backbone.encoder.block1.layer.weight")
749            .unwrap();
750        let data = bridge::to_data(view).unwrap();
751        assert_eq!(*data.shape(), shape![2, 2]);
752    }
753
754    #[test]
755    fn deep_module_filtered_export() {
756        let device = Default::default();
757        let model = DeepModel::new(&device);
758
759        // Test filtering at different depths
760        #[cfg(target_has_atomic = "ptr")]
761        {
762            let filter = PathFilter::new().with_regex(r"^backbone\.encoder\..*");
763            let mut collector = Collector::new(Some(filter), None, false);
764            model.visit(&mut collector);
765            assert_eq!(collector.tensors.len(), 8); // Only encoder tensors
766        }
767
768        // Test filtering specific blocks
769        #[cfg(target_has_atomic = "ptr")]
770        {
771            let filter = PathFilter::new().with_regex(r".*\.block1\..*");
772            let mut collector = Collector::new(Some(filter), None, false);
773            model.visit(&mut collector);
774            assert_eq!(collector.tensors.len(), 8); // block1 in both encoder and decoder
775        }
776
777        // Test filtering by tensor type at any depth
778        #[cfg(target_has_atomic = "ptr")]
779        {
780            let filter = PathFilter::new().with_regex(r".*\.weight$");
781            let mut collector = Collector::new(Some(filter), None, false);
782            model.visit(&mut collector);
783            assert_eq!(collector.tensors.len(), 9); // All weight tensors
784        }
785
786        // Test complex multi-pattern filtering
787        #[cfg(target_has_atomic = "ptr")]
788        {
789            let filter = PathFilter::new()
790                .with_regex(r"^backbone\.encoder\.block1\..*") // All encoder.block1 tensors
791                .with_regex(r"^backbone\.decoder\..*\.bias$") // All decoder biases
792                .with_regex(r"^head\.weight$"); // Head weight only
793            let mut collector = Collector::new(Some(filter), None, false);
794            model.visit(&mut collector);
795
796            // Should have:
797            // - 4 from encoder.block1 (2 weights + 2 biases)
798            // - 4 decoder biases
799            // - 1 head weight
800            assert_eq!(collector.tensors.len(), 9);
801
802            let paths: Vec<String> = collector.tensors.iter().map(|v| v.name.clone()).collect();
803            assert!(paths.contains(&"backbone.encoder.block1.layer.weight".to_string()));
804            assert!(paths.contains(&"backbone.decoder.block1.layer.bias".to_string()));
805            assert!(paths.contains(&"head.weight".to_string()));
806            assert!(!paths.contains(&"head.bias".to_string())); // Not included
807        }
808    }
809
810    use crate::traits::ModuleSnapshot;
811    use burn_nn::{BatchNorm, BatchNormConfig, Linear};
812    use hashbrown::HashMap;
813    use std::sync::{Arc, Mutex};
814
815    // Test module with Option fields
816    #[derive(Module, Debug)]
817    struct OptionalFieldModule {
818        required: Param<Tensor<2>>,
819        optional: Option<Param<Tensor<1>>>,
820    }
821
822    impl OptionalFieldModule {
823        fn new_with_optional(device: &Device) -> Self {
824            Self {
825                required: Param::from_data([[1.0, 2.0], [3.0, 4.0]], device),
826                optional: Some(Param::from_data([5.0, 6.0], device)),
827            }
828        }
829
830        fn new_without_optional(device: &Device) -> Self {
831            Self {
832                required: Param::from_data([[1.0, 2.0], [3.0, 4.0]], device),
833                optional: None,
834            }
835        }
836    }
837
838    #[test]
839    fn optional_field_module_with_value() {
840        let device = Default::default();
841        let module = OptionalFieldModule::new_with_optional(&device);
842
843        let views: HashMap<String, PackTensor> = module
844            .collect(None, None, false)
845            .into_iter()
846            .map(|v| (v.name.clone(), v))
847            .collect();
848
849        assert_eq!(views.len(), 2);
850        assert!(views.contains_key("required"));
851        assert!(views.contains_key("optional"));
852    }
853
854    #[test]
855    fn optional_field_module_without_value() {
856        let device = Default::default();
857        let module = OptionalFieldModule::new_without_optional(&device);
858
859        let views: HashMap<String, PackTensor> = module
860            .collect(None, None, false)
861            .into_iter()
862            .map(|v| (v.name.clone(), v))
863            .collect();
864
865        assert_eq!(views.len(), 1);
866        assert!(views.contains_key("required"));
867        assert!(!views.contains_key("optional"));
868    }
869
870    // Test Vec of modules
871    #[derive(Module, Debug)]
872    struct VecModule {
873        layers: Vec<Linear>,
874    }
875
876    impl VecModule {
877        fn new(device: &Device, num_layers: usize) -> Self {
878            Self {
879                layers: (0..num_layers)
880                    .map(|_| LinearConfig::new(10, 10).init(device))
881                    .collect(),
882            }
883        }
884    }
885
886    // Test tuple of modules
887    #[derive(Module, Debug)]
888    struct TupleModule {
889        layers: (Linear, Linear, Linear),
890    }
891
892    impl TupleModule {
893        fn new(device: &Device) -> Self {
894            Self {
895                layers: (
896                    LinearConfig::new(10, 10).init(device),
897                    LinearConfig::new(10, 10).init(device),
898                    LinearConfig::new(10, 10).init(device),
899                ),
900            }
901        }
902    }
903
904    #[test]
905    fn vec_module_collect() {
906        let device = Default::default();
907        let module = VecModule::new(&device, 3);
908
909        let views: HashMap<String, PackTensor> = module
910            .collect(None, None, false)
911            .into_iter()
912            .map(|v| (v.name.clone(), v))
913            .collect();
914
915        // With the fix, all Vec items should now be properly indexed and visited
916        assert_eq!(views.len(), 6); // 3 layers × 2 tensors each = 6 tensors
917
918        // Check that all indexed paths exist
919        assert!(views.contains_key("layers.0.weight"));
920        assert!(views.contains_key("layers.0.bias"));
921        assert!(views.contains_key("layers.1.weight"));
922        assert!(views.contains_key("layers.1.bias"));
923        assert!(views.contains_key("layers.2.weight"));
924        assert!(views.contains_key("layers.2.bias"));
925    }
926
927    #[test]
928    fn tuple_module_collect() {
929        let device = Default::default();
930        let module = TupleModule::new(&device);
931
932        let tensors = module.collect(None, None, false);
933        assert_eq!(tensors.len(), 6);
934
935        let views: HashMap<String, PackTensor> =
936            tensors.into_iter().map(|v| (v.name.clone(), v)).collect();
937
938        assert_eq!(views.len(), 6);
939
940        assert!(views.contains_key("layers.0.weight"));
941        assert!(views.contains_key("layers.0.bias"));
942        assert!(views.contains_key("layers.1.weight"));
943        assert!(views.contains_key("layers.1.bias"));
944        assert!(views.contains_key("layers.2.weight"));
945        assert!(views.contains_key("layers.2.bias"));
946    }
947
948    // Test array of modules
949    #[derive(Module, Debug)]
950    struct ArrayModule {
951        layers: [Linear; 3],
952    }
953
954    impl ArrayModule {
955        fn new(device: &Device) -> Self {
956            Self {
957                layers: [
958                    LinearConfig::new(10, 10).init(device),
959                    LinearConfig::new(10, 10).init(device),
960                    LinearConfig::new(10, 10).init(device),
961                ],
962            }
963        }
964    }
965
966    #[test]
967    fn array_module_collect() {
968        let device = Default::default();
969        let module = ArrayModule::new(&device);
970
971        let views: HashMap<String, PackTensor> = module
972            .collect(None, None, false)
973            .into_iter()
974            .map(|v| (v.name.clone(), v))
975            .collect();
976
977        // All array items should be properly indexed
978        assert_eq!(views.len(), 6); // 3 layers × 2 tensors each = 6 tensors
979
980        // Check indexed paths
981        for i in 0..3 {
982            assert!(views.contains_key(&format!("layers.{}.weight", i)));
983            assert!(views.contains_key(&format!("layers.{}.bias", i)));
984        }
985    }
986
987    // Test enum modules
988    #[derive(Module, Debug)]
989    enum EnumModule {
990        LayerA(Linear),
991        LayerB(Linear),
992        LayerC(Linear),
993    }
994
995    #[test]
996    fn enum_module_collect() {
997        let device = Default::default();
998
999        // Test variant A
1000        let module_a = EnumModule::LayerA(LinearConfig::new(10, 20).init(&device));
1001        let views_a: HashMap<String, PackTensor> = module_a
1002            .collect(None, None, false)
1003            .into_iter()
1004            .map(|v| (v.name.clone(), v))
1005            .collect();
1006
1007        // Should have the variant name in the path
1008        assert_eq!(views_a.len(), 2);
1009        assert!(views_a.contains_key("LayerA.weight"));
1010        assert!(views_a.contains_key("LayerA.bias"));
1011
1012        // Test variant B
1013        let module_b = EnumModule::LayerB(LinearConfig::new(10, 20).init(&device));
1014        let views_b: HashMap<String, PackTensor> = module_b
1015            .collect(None, None, false)
1016            .into_iter()
1017            .map(|v| (v.name.clone(), v))
1018            .collect();
1019
1020        assert_eq!(views_b.len(), 2);
1021        assert!(views_b.contains_key("LayerB.weight"));
1022        assert!(views_b.contains_key("LayerB.bias"));
1023    }
1024    /// A tensor's path paired with the module type it was seen in.
1025    type Sighting = (String, Option<String>);
1026
1027    /// Records the module context each tensor was seen in, and changes nothing.
1028    ///
1029    /// The container stack is consumed during the walk rather than kept on the collected
1030    /// tensor, so this is how the traversal's view of it is observed. Asserting through a real
1031    /// adapter's dtype output would instead couple these tests to that adapter's (documented as
1032    /// changeable) module allowlist.
1033    #[derive(Clone, Default)]
1034    struct RecordingAdapter {
1035        seen: Arc<Mutex<Vec<Sighting>>>,
1036    }
1037
1038    impl RecordingAdapter {
1039        /// The sightings recorded so far, sorted by path.
1040        fn seen(&self) -> Vec<Sighting> {
1041            let mut seen = self.seen.lock().unwrap().clone();
1042            seen.sort();
1043            seen
1044        }
1045    }
1046
1047    impl ModuleAdapter for RecordingAdapter {
1048        fn adapt(&self, tensor: PackTensor, ctx: ModuleContext<'_>) -> PackTensor {
1049            self.seen.lock().unwrap().push((
1050                tensor.name.clone(),
1051                ctx.module_type().map(|t| t.to_string()),
1052            ));
1053            tensor
1054        }
1055
1056        fn clone_box(&self) -> Box<dyn ModuleAdapter> {
1057            Box::new(self.clone())
1058        }
1059    }
1060
1061    /// The module type a sighting is expected to carry.
1062    fn module(module: &str) -> Option<String> {
1063        Some(module.to_string())
1064    }
1065
1066    // Container type tracking tests
1067    #[test]
1068    fn linear_container_type() {
1069        let device = Default::default();
1070
1071        #[derive(Module, Debug)]
1072        struct ModelWithLinear {
1073            linear: Linear,
1074            norm: BatchNorm,
1075        }
1076
1077        impl ModelWithLinear {
1078            fn new(device: &Device) -> Self {
1079                Self {
1080                    linear: LinearConfig::new(10, 20).init(device),
1081                    norm: BatchNormConfig::new(20).init(device),
1082                }
1083            }
1084        }
1085
1086        let model = ModelWithLinear::new(&device);
1087        let adapter = RecordingAdapter::default();
1088        model.collect(None, Some(Box::new(adapter.clone())), false);
1089
1090        assert_eq!(
1091            adapter.seen(),
1092            vec![
1093                ("linear.bias".to_string(), module("Struct:Linear")),
1094                ("linear.weight".to_string(), module("Struct:Linear")),
1095                ("norm.beta".to_string(), module("Struct:BatchNorm")),
1096                ("norm.gamma".to_string(), module("Struct:BatchNorm")),
1097                ("norm.running_mean".to_string(), module("Struct:BatchNorm")),
1098                ("norm.running_var".to_string(), module("Struct:BatchNorm")),
1099            ]
1100        );
1101    }
1102
1103    #[test]
1104    fn complex_model_container_types() {
1105        let device = Default::default();
1106
1107        #[derive(Module, Debug)]
1108        struct ComplexModel {
1109            linear_layers: [Linear; 2],
1110            vec_layers: Vec<Linear>,
1111            single_linear: Linear,
1112        }
1113
1114        impl ComplexModel {
1115            fn new(device: &Device) -> Self {
1116                Self {
1117                    linear_layers: [
1118                        LinearConfig::new(100, 50).init(device),
1119                        LinearConfig::new(50, 10).init(device),
1120                    ],
1121                    vec_layers: vec![
1122                        LinearConfig::new(10, 10).init(device),
1123                        LinearConfig::new(10, 10).init(device),
1124                    ],
1125                    single_linear: LinearConfig::new(10, 1).init(device),
1126                }
1127            }
1128        }
1129
1130        let model = ComplexModel::new(&device);
1131        let adapter = RecordingAdapter::default();
1132        model.collect(None, Some(Box::new(adapter.clone())), false);
1133
1134        // Should have 10 tensors total
1135        let seen = adapter.seen();
1136        assert_eq!(seen.len(), 10);
1137
1138        // Every one is inside a Linear, including those reached through an array or a Vec: the
1139        // collection wrapper sits above the module on the container stack, and looking past it
1140        // is exactly what `ModuleContext::module_type` does.
1141        for (name, module_type) in seen {
1142            assert_eq!(
1143                module_type,
1144                module("Struct:Linear"),
1145                "'{name}' should have been seen as a Linear parameter"
1146            );
1147        }
1148    }
1149
1150    #[test]
1151    fn collect_with_container_filter() {
1152        let device = Default::default();
1153
1154        #[derive(Module, Debug)]
1155        struct FilterTestModel {
1156            layers: Vec<Linear>,
1157            norm: BatchNorm,
1158        }
1159
1160        impl FilterTestModel {
1161            fn new(device: &Device) -> Self {
1162                Self {
1163                    layers: vec![
1164                        LinearConfig::new(10, 10).init(device),
1165                        LinearConfig::new(10, 10).init(device),
1166                    ],
1167                    norm: BatchNormConfig::new(10).init(device),
1168                }
1169            }
1170        }
1171
1172        let model = FilterTestModel::new(&device);
1173
1174        // Filter to only collect tensors from Linear modules
1175        let filter = PathFilter::new().with_predicate(|_path, container_path| {
1176            container_path.split('.').next_back() == Some("Struct:Linear")
1177        });
1178
1179        let linear_views: Vec<PackTensor> = model.collect(Some(filter), None, false);
1180
1181        // Only the Linear tensors, not the BatchNorm ones the model also has.
1182        let mut paths: Vec<String> = linear_views.iter().map(|v| v.name.clone()).collect();
1183        paths.sort();
1184        assert_eq!(
1185            paths,
1186            vec![
1187                "layers.0.bias",
1188                "layers.0.weight",
1189                "layers.1.bias",
1190                "layers.1.weight"
1191            ]
1192        );
1193    }
1194}