Skip to main content

burn_store/
filter.rs

1use alloc::format;
2use alloc::string::String;
3use alloc::vec::Vec;
4use core::fmt;
5
6#[cfg(feature = "std")]
7use regex::Regex;
8
9/// A sophisticated path filter that supports multiple matching strategies.
10///
11/// The filter uses an OR logic - a path is included if it matches ANY of the configured criteria.
12/// This allows for flexible and powerful filtering configurations.
13///
14/// # Examples
15///
16/// ```rust,no_run
17/// # use burn_store::PathFilter;
18/// // Create a filter that matches encoder paths or any weight path
19/// let filter = PathFilter::new()
20///     .with_regex(r"^encoder\..*")
21///     .with_regex(r".*\.weight$")
22///     .with_full_path("special_tensor");
23///
24/// // Check if a path should be included
25/// if filter.matches("encoder.layer1.weight") {
26///     // This will match due to both regex patterns
27/// }
28/// ```
29#[derive(Debug, Clone, Default)]
30pub struct PathFilter {
31    /// Compiled regex patterns for matching paths
32    #[cfg(feature = "std")]
33    regex_patterns: Vec<Regex>,
34
35    /// Exact full paths to match
36    exact_paths: Vec<String>,
37
38    /// Predicate functions for custom matching logic based on path and container path
39    /// Note: These cannot be cloned, so we store them separately
40    predicates: Vec<fn(&str, &str) -> bool>,
41
42    /// If true, matches all paths (overrides other filters)
43    match_all: bool,
44}
45
46impl PathFilter {
47    /// Create a new empty filter (matches nothing by default)
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    /// Create a filter that matches all paths
53    pub fn all() -> Self {
54        Self {
55            match_all: true,
56            ..Default::default()
57        }
58    }
59
60    /// Create a filter that matches nothing
61    pub fn none() -> Self {
62        Self::default()
63    }
64
65    /// Add a regex pattern for matching paths
66    ///
67    /// # Panics
68    ///
69    /// Panics if `pattern` is not a valid regular expression.
70    #[cfg(feature = "std")]
71    pub fn with_regex<S: AsRef<str>>(mut self, pattern: S) -> Self {
72        let regex = Regex::new(pattern.as_ref()).expect("Invalid regex pattern");
73        self.regex_patterns.push(regex);
74        self
75    }
76
77    /// Add multiple regex patterns
78    ///
79    /// # Panics
80    ///
81    /// Panics if any pattern is not a valid regular expression.
82    #[cfg(feature = "std")]
83    pub fn with_regexes<I, S>(mut self, patterns: I) -> Self
84    where
85        I: IntoIterator<Item = S>,
86        S: AsRef<str>,
87    {
88        for pattern in patterns {
89            let regex = Regex::new(pattern.as_ref()).expect("Invalid regex pattern");
90            self.regex_patterns.push(regex);
91        }
92        self
93    }
94
95    /// Add an exact full path to match
96    pub fn with_full_path<S: Into<String>>(mut self, path: S) -> Self {
97        self.exact_paths.push(path.into());
98        self
99    }
100
101    /// Add multiple exact full paths
102    pub fn with_full_paths<I, S>(mut self, paths: I) -> Self
103    where
104        I: IntoIterator<Item = S>,
105        S: Into<String>,
106    {
107        self.exact_paths.extend(paths.into_iter().map(|p| p.into()));
108        self
109    }
110
111    /// Add a predicate function for custom matching based on path and container path
112    pub fn with_predicate(mut self, predicate: fn(&str, &str) -> bool) -> Self {
113        self.predicates.push(predicate);
114        self
115    }
116
117    /// Add multiple predicates
118    pub fn with_predicates<I>(mut self, predicates: I) -> Self
119    where
120        I: IntoIterator<Item = fn(&str, &str) -> bool>,
121    {
122        self.predicates.extend(predicates);
123        self
124    }
125
126    /// Set to match all paths
127    pub fn match_all(mut self) -> Self {
128        self.match_all = true;
129        self
130    }
131
132    /// Check if a path matches this filter (assumes empty container path for backward compatibility)
133    pub fn matches(&self, path: &str) -> bool {
134        self.matches_with_container_path_str(path, "")
135    }
136
137    /// Check if a path and container type match this filter (for backward compatibility)
138    pub fn matches_with_container(&self, path: &str, container_type: &str) -> bool {
139        // For backward compatibility, treat single container type as the full path
140        self.matches_with_container_path_str(path, container_type)
141    }
142
143    /// Check if a path and container path match this filter
144    pub fn matches_with_container_path(&self, path: &[String], container_stack: &[String]) -> bool {
145        let path_str = path.join(".");
146        let container_path = container_stack.join(".");
147        self.matches_with_container_path_str(&path_str, &container_path)
148    }
149
150    /// Check if a path and container path (dot-notated strings) match this filter
151    pub fn matches_with_container_path_str(&self, path: &str, container_path: &str) -> bool {
152        // If match_all is set, always return true
153        if self.match_all {
154            return true;
155        }
156
157        // If no filters are configured, match nothing
158        if self.is_empty() {
159            return false;
160        }
161
162        // Check exact path matches
163        if self.exact_paths.iter().any(|p| p == path) {
164            return true;
165        }
166
167        // Check regex patterns (on the path)
168        #[cfg(feature = "std")]
169        {
170            for regex in &self.regex_patterns {
171                if regex.is_match(path) {
172                    return true;
173                }
174            }
175        }
176
177        // Check predicates with container path
178        if self
179            .predicates
180            .iter()
181            .any(|pred| pred(path, container_path))
182        {
183            return true;
184        }
185
186        false
187    }
188
189    /// Check if the filter is empty (matches nothing)
190    pub fn is_empty(&self) -> bool {
191        if self.match_all {
192            return false;
193        }
194
195        #[cfg(feature = "std")]
196        let regex_empty = self.regex_patterns.is_empty();
197        #[cfg(not(feature = "std"))]
198        let regex_empty = true;
199
200        self.exact_paths.is_empty() && self.predicates.is_empty() && regex_empty
201    }
202
203    /// Get the number of filter criteria configured
204    pub fn criteria_count(&self) -> usize {
205        if self.match_all {
206            return 1;
207        }
208
209        #[allow(unused_mut)]
210        let mut count = self.exact_paths.len() + self.predicates.len();
211
212        #[cfg(feature = "std")]
213        {
214            count += self.regex_patterns.len();
215        }
216
217        count
218    }
219
220    /// Clear all regex patterns
221    #[cfg(feature = "std")]
222    pub fn clear_regex(&mut self) -> &mut Self {
223        self.regex_patterns.clear();
224        self
225    }
226
227    /// Clear all exact paths
228    pub fn clear_paths(&mut self) -> &mut Self {
229        self.exact_paths.clear();
230        self
231    }
232
233    /// Clear all predicates
234    pub fn clear_predicates(&mut self) -> &mut Self {
235        self.predicates.clear();
236        self
237    }
238
239    /// Clear all filters
240    pub fn clear(&mut self) -> &mut Self {
241        #[cfg(feature = "std")]
242        self.clear_regex();
243
244        self.clear_paths().clear_predicates();
245        self.match_all = false;
246        self
247    }
248
249    /// Create a filter from regex patterns only
250    ///
251    /// # Panics
252    ///
253    /// Panics if any pattern is not a valid regular expression.
254    #[cfg(feature = "std")]
255    pub fn from_regex_patterns<I, S>(patterns: I) -> Self
256    where
257        I: IntoIterator<Item = S>,
258        S: AsRef<str>,
259    {
260        Self::new().with_regexes(patterns)
261    }
262
263    /// Create a filter from exact paths only
264    pub fn from_paths<I, S>(paths: I) -> Self
265    where
266        I: IntoIterator<Item = S>,
267        S: Into<String>,
268    {
269        Self::new().with_full_paths(paths)
270    }
271
272    /// Create a filter from a single predicate
273    pub fn from_predicate(predicate: fn(&str, &str) -> bool) -> Self {
274        Self::new().with_predicate(predicate)
275    }
276
277    /// Combine with another filter using OR logic
278    pub fn or(mut self, other: Self) -> Self {
279        if self.match_all || other.match_all {
280            return Self::all();
281        }
282
283        #[cfg(feature = "std")]
284        {
285            self.regex_patterns.extend(other.regex_patterns);
286        }
287
288        self.exact_paths.extend(other.exact_paths);
289        self.predicates.extend(other.predicates);
290
291        self
292    }
293}
294
295impl fmt::Display for PathFilter {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        if self.match_all {
298            return write!(f, "PathFilter::all()");
299        }
300
301        if self.is_empty() {
302            return write!(f, "PathFilter::none()");
303        }
304
305        write!(f, "PathFilter[")?;
306
307        let mut parts = Vec::new();
308
309        #[cfg(feature = "std")]
310        if !self.regex_patterns.is_empty() {
311            parts.push(format!("regex: {:?}", self.regex_patterns));
312        }
313
314        if !self.exact_paths.is_empty() {
315            parts.push(format!("paths: {:?}", self.exact_paths));
316        }
317
318        if !self.predicates.is_empty() {
319            parts.push(format!("predicates: {}", self.predicates.len()));
320        }
321
322        write!(f, "{}]", parts.join(", "))
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn empty_filter() {
332        let filter = PathFilter::new();
333        assert!(filter.is_empty());
334        assert!(!filter.matches("encoder.weight"));
335        assert!(!filter.matches("decoder.bias"));
336    }
337
338    #[test]
339    fn match_all() {
340        let filter = PathFilter::all();
341        assert!(!filter.is_empty());
342        assert!(filter.matches("encoder.weight"));
343        assert!(filter.matches("decoder.bias"));
344        assert!(filter.matches("anything"));
345    }
346
347    #[test]
348    fn exact_paths() {
349        let filter = PathFilter::new()
350            .with_full_path("encoder.weight")
351            .with_full_path("decoder.bias");
352
353        assert!(filter.matches("encoder.weight"));
354        assert!(filter.matches("decoder.bias"));
355        assert!(!filter.matches("encoder.bias"));
356        assert!(!filter.matches("decoder.weight"));
357    }
358
359    #[test]
360    #[cfg(feature = "std")]
361    fn regex_patterns() {
362        let filter = PathFilter::new()
363            .with_regex(r"^encoder\..*")
364            .with_regex(r".*\.weight$");
365
366        assert!(filter.matches("encoder.layer1.bias"));
367        assert!(filter.matches("decoder.weight"));
368        assert!(filter.matches("encoder.weight"));
369        assert!(!filter.matches("decoder.bias"));
370    }
371
372    #[test]
373    #[cfg(feature = "std")]
374    #[should_panic(expected = "Invalid regex pattern")]
375    fn invalid_regex_panics() {
376        let _ = PathFilter::new().with_regex("[");
377    }
378
379    #[test]
380    #[cfg(feature = "std")]
381    #[should_panic(expected = "Invalid regex pattern")]
382    fn invalid_regex_in_collection_panics() {
383        let _ = PathFilter::new().with_regexes([r"^valid$", "["]);
384    }
385
386    #[test]
387    fn predicates() {
388        fn contains_norm(path: &str, _container_path: &str) -> bool {
389            path.contains("norm")
390        }
391
392        fn is_short(path: &str, _container_path: &str) -> bool {
393            path.len() < 10
394        }
395
396        let filter = PathFilter::new()
397            .with_predicate(contains_norm)
398            .with_predicate(is_short);
399
400        assert!(filter.matches("norm.weight"));
401        assert!(filter.matches("layer.norm.bias"));
402        assert!(filter.matches("bias"));
403        assert!(!filter.matches("encoder.decoder.weight.long.name"));
404    }
405
406    #[test]
407    fn combined_filters() {
408        let filter = PathFilter::new()
409            .with_full_path("special.tensor")
410            .with_predicate(|path, _container_path| path.contains("attention"));
411
412        #[cfg(feature = "std")]
413        let filter = filter.with_regex(r"^encoder\..*");
414
415        assert!(filter.matches("special.tensor"));
416        assert!(filter.matches("self_attention.query"));
417
418        #[cfg(feature = "std")]
419        assert!(filter.matches("encoder.anything"));
420
421        assert!(!filter.matches("decoder.weight"));
422    }
423
424    #[test]
425    fn or_combination() {
426        let encoder_filter = PathFilter::new().with_full_path("encoder.weight");
427        let decoder_filter = PathFilter::new().with_full_path("decoder.bias");
428
429        let combined = encoder_filter.or(decoder_filter);
430
431        assert!(combined.matches("encoder.weight"));
432        assert!(combined.matches("decoder.bias"));
433        assert!(!combined.matches("model.head.weight"));
434    }
435
436    #[test]
437    #[cfg(feature = "std")]
438    fn common_patterns() {
439        // Test encoder pattern
440        let encoder = PathFilter::new().with_regex(r"^encoder\..*");
441        assert!(encoder.matches("encoder.weight"));
442        assert!(!encoder.matches("decoder.weight"));
443
444        // Test weights-only pattern
445        let weights = PathFilter::new().with_regex(r".*\.weight$");
446        assert!(weights.matches("encoder.weight"));
447        assert!(weights.matches("decoder.weight"));
448        assert!(!weights.matches("encoder.bias"));
449
450        // Test layer-specific patterns
451        let layers = PathFilter::new()
452            .with_regex(r"(^|.*\.)layers\.0\.")
453            .with_regex(r"(^|.*\.)layers\.2\.")
454            .with_regex(r"(^|.*\.)layers\.4\.");
455        assert!(layers.matches("model.layers.0.weight"));
456        assert!(layers.matches("layers.2.bias"));
457        assert!(!layers.matches("layers.1.weight"));
458    }
459
460    #[test]
461    fn criteria_count() {
462        let filter = PathFilter::new()
463            .with_full_path("path1")
464            .with_full_path("path2")
465            .with_predicate(|_, _| true);
466
467        #[cfg(feature = "std")]
468        let filter = filter.with_regex(".*");
469
470        #[cfg(feature = "std")]
471        assert_eq!(filter.criteria_count(), 4);
472
473        #[cfg(not(feature = "std"))]
474        assert_eq!(filter.criteria_count(), 3);
475    }
476
477    #[test]
478    fn clear_operations() {
479        let mut filter = PathFilter::new().with_full_path("test");
480
481        filter.clear_paths();
482        assert!(!filter.matches("test"));
483
484        filter.clear();
485        assert!(filter.is_empty());
486    }
487
488    #[test]
489    fn container_predicates() {
490        // Filter that matches only Linear module weights
491        let linear_weights = PathFilter::new().with_predicate(|path, container_path| {
492            container_path.split('.').next_back() == Some("Linear") && path.ends_with(".weight")
493        });
494
495        assert!(linear_weights.matches_with_container("layer1.weight", "Linear"));
496        assert!(!linear_weights.matches_with_container("layer1.weight", "Conv2d"));
497        assert!(!linear_weights.matches_with_container("layer1.bias", "Linear"));
498
499        // Filter for specific container types
500        let conv_only = PathFilter::new().with_predicate(|_path, container_path| {
501            let last = container_path.split('.').next_back();
502            last == Some("Conv2d") || last == Some("ConvTranspose2d")
503        });
504
505        assert!(conv_only.matches_with_container("encoder.weight", "Conv2d"));
506        assert!(conv_only.matches_with_container("decoder.weight", "ConvTranspose2d"));
507        assert!(!conv_only.matches_with_container("fc.weight", "Linear"));
508
509        // Combine path and container predicates
510        let combined = PathFilter::new()
511            .with_predicate(|path, _container_path| path.starts_with("encoder."))
512            .with_predicate(|_path, container_path| {
513                container_path.split('.').next_back() == Some("BatchNorm2d")
514            });
515
516        // Should match either condition (OR logic)
517        assert!(combined.matches_with_container("encoder.layer1", "Linear"));
518        assert!(combined.matches_with_container("decoder.bn", "BatchNorm2d"));
519        assert!(!combined.matches_with_container("decoder.layer", "Linear"));
520    }
521
522    #[test]
523    fn container_predicate_with_regex() {
524        // Combine regex patterns with container predicates
525        #[cfg(feature = "std")]
526        {
527            let filter = PathFilter::new()
528                .with_regex(r"^encoder\..*")
529                .with_predicate(|path, container_path| {
530                    container_path.split('.').next_back() == Some("Linear")
531                        && path.contains(".bias")
532                });
533
534            // Matches due to regex
535            assert!(filter.matches_with_container("encoder.layer1.weight", "Conv2d"));
536            // Matches due to container predicate
537            assert!(filter.matches_with_container("decoder.fc.bias", "Linear"));
538            // Doesn't match either
539            assert!(!filter.matches_with_container("decoder.conv.weight", "Conv2d"));
540        }
541    }
542
543    #[test]
544    fn container_stack_predicates() {
545        // Filter using full container path - only tensors nested in a specific hierarchy
546        let nested_filter = PathFilter::new().with_predicate(|_path, container_path| {
547            // Check if tensor is nested within: Model -> TransformerBlock -> Linear
548            let parts: Vec<&str> = container_path.split('.').collect();
549            parts.len() >= 3
550                && parts[0] == "Model"
551                && parts[1] == "TransformerBlock"
552                && parts[2] == "Linear"
553        });
554
555        assert!(nested_filter.matches_with_container_path_str(
556            "encoder.weight",
557            "Model.TransformerBlock.Linear.Param"
558        ));
559        assert!(
560            !nested_filter
561                .matches_with_container_path_str("decoder.weight", "Model.Decoder.Linear.Param")
562        );
563        assert!(!nested_filter.matches_with_container_path_str(
564            "encoder.weight",
565            "Model.TransformerBlock.Conv2d.Param"
566        ));
567
568        // Filter that checks for specific depth in hierarchy
569        let depth_filter = PathFilter::new().with_predicate(|_path, container_path| {
570            let parts: Vec<&str> = container_path.split('.').collect();
571            parts.len() == 4 && parts.get(2) == Some(&"Linear")
572        });
573
574        assert!(depth_filter.matches_with_container_path_str(
575            "model.layer.weight",
576            "Model.TransformerBlock.Linear.Param"
577        ));
578        assert!(
579            !depth_filter
580                .matches_with_container_path_str("model.weight", "Model.TransformerBlock.Conv2d")
581        ); // Too shallow
582
583        // Filter that checks any Linear in the path (not just the last)
584        let any_linear = PathFilter::new()
585            .with_predicate(|_path, container_path| container_path.contains("Linear"));
586
587        assert!(
588            any_linear.matches_with_container_path_str(
589                "some.path",
590                "Model.TransformerBlock.Linear.Param"
591            )
592        );
593        assert!(
594            any_linear.matches_with_container_path_str("other.path", "Model.Decoder.Linear.Param")
595        );
596        assert!(
597            !any_linear.matches_with_container_path_str(
598                "conv.path",
599                "Model.TransformerBlock.Conv2d.Param"
600            )
601        );
602    }
603
604    #[test]
605    fn container_path_dot_notation() {
606        // Filter using dot-notated container path
607        let dot_filter = PathFilter::new().with_predicate(|_path, container_path| {
608            container_path.starts_with("Model.TransformerBlock")
609        });
610
611        // Test with matches_with_container_path
612        assert!(
613            dot_filter.matches_with_container_path_str("weight", "Model.TransformerBlock.Linear")
614        );
615        assert!(!dot_filter.matches_with_container_path_str("weight", "Model.Decoder.Linear"));
616
617        // Filter that checks for specific patterns in container path
618        let pattern_filter = PathFilter::new().with_predicate(|_path, container_path| {
619            // Match any path that has Linear after a block
620            container_path.contains("Block.Linear") || container_path.contains("Block.Conv")
621        });
622
623        assert!(
624            pattern_filter
625                .matches_with_container_path_str("weight", "Model.TransformerBlock.Linear")
626        );
627        assert!(pattern_filter.matches_with_container_path_str("weight", "Model.ResBlock.Conv2d"));
628        assert!(!pattern_filter.matches_with_container_path_str("weight", "Model.Linear.Param"));
629
630        // Filter combining path and container path patterns
631        let combined = PathFilter::new().with_predicate(|path, container_path| {
632            // Only weights in Linear layers that are inside blocks
633            path.ends_with(".weight")
634                && container_path.contains("Block")
635                && container_path.split('.').next_back() == Some("Linear")
636        });
637
638        assert!(
639            combined
640                .matches_with_container_path_str("layer.weight", "Model.TransformerBlock.Linear")
641        );
642        assert!(
643            !combined
644                .matches_with_container_path_str("layer.bias", "Model.TransformerBlock.Linear")
645        );
646        assert!(!combined.matches_with_container_path_str("layer.weight", "Model.Decoder.Linear"));
647    }
648}