Skip to main content

pulsedb/search/
filter.rs

1//! Search filtering for experience queries.
2//!
3//! [`SearchFilter`] provides a composable way to filter experiences across
4//! different query types (recent, similarity, context candidates). Filters
5//! are applied as post-filters after the primary retrieval (timestamp scan
6//! or HNSW search).
7
8use crate::experience::{Experience, ExperienceType};
9use crate::types::Timestamp;
10
11/// Filter criteria for experience search operations.
12///
13/// Used by `get_recent_experiences_filtered()` and `search_similar_filtered()`.
14/// Fields set to `None` are not filtered on. The `exclude_archived` field
15/// defaults to `true` since archived experiences are rarely wanted in queries.
16///
17/// # Example
18///
19/// ```rust
20/// use pulsedb::SearchFilter;
21///
22/// // Filter to only "Solution" experiences with importance >= 0.5
23/// let filter = SearchFilter {
24///     min_importance: Some(0.5),
25///     experience_types: Some(vec![pulsedb::ExperienceType::Solution {
26///         problem_ref: None,
27///         approach: String::new(),
28///         worked: true,
29///     }]),
30///     ..SearchFilter::default()
31/// };
32/// ```
33#[derive(Clone, Debug)]
34pub struct SearchFilter {
35    /// Only include experiences with at least one matching domain tag.
36    ///
37    /// `None` means no domain filtering. An empty `Some(vec![])` matches nothing.
38    pub domains: Option<Vec<String>>,
39
40    /// Only include experiences of these types.
41    ///
42    /// Matching is done on the type discriminant (tag), not the associated data.
43    /// For example, any `Solution { .. }` matches if `Solution` is in the list.
44    pub experience_types: Option<Vec<ExperienceType>>,
45
46    /// Only include experiences with importance >= this threshold.
47    pub min_importance: Option<f32>,
48
49    /// Only include experiences with confidence >= this threshold.
50    pub min_confidence: Option<f32>,
51
52    /// Only include experiences created at or after this timestamp.
53    pub since: Option<Timestamp>,
54
55    /// Whether to exclude archived experiences (default: `true`).
56    pub exclude_archived: bool,
57}
58
59impl Default for SearchFilter {
60    fn default() -> Self {
61        Self {
62            domains: None,
63            experience_types: None,
64            min_importance: None,
65            min_confidence: None,
66            since: None,
67            exclude_archived: true,
68        }
69    }
70}
71
72impl SearchFilter {
73    /// Returns `true` if the given experience passes all filter criteria.
74    pub fn matches(&self, experience: &Experience) -> bool {
75        // Check archived status
76        if self.exclude_archived && experience.archived {
77            return false;
78        }
79
80        // Check domain overlap
81        if let Some(ref domains) = self.domains {
82            let has_match = experience
83                .domain
84                .iter()
85                .any(|d| domains.iter().any(|f| f == d));
86            if !has_match {
87                return false;
88            }
89        }
90
91        // Check experience type (compare by discriminant tag, not associated data)
92        if let Some(ref types) = self.experience_types {
93            let exp_tag = experience.experience_type.type_tag();
94            let has_match = types.iter().any(|t| t.type_tag() == exp_tag);
95            if !has_match {
96                return false;
97            }
98        }
99
100        // Check importance threshold
101        if let Some(min) = self.min_importance {
102            if experience.importance < min {
103                return false;
104            }
105        }
106
107        // Check confidence threshold
108        if let Some(min) = self.min_confidence {
109            if experience.confidence < min {
110                return false;
111            }
112        }
113
114        // Check timestamp
115        if let Some(since) = self.since {
116            if experience.timestamp < since {
117                return false;
118            }
119        }
120
121        true
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::types::{AgentId, CollectiveId, ExperienceId};
129
130    /// Helper to create a minimal test experience.
131    fn test_experience() -> Experience {
132        Experience {
133            id: ExperienceId::new(),
134            collective_id: CollectiveId::new(),
135            content: "test content".to_string(),
136            embedding: vec![0.1; 384],
137            experience_type: ExperienceType::Fact {
138                statement: "test".to_string(),
139                source: String::new(),
140            },
141            importance: 0.5,
142            confidence: 0.8,
143            applications: 0,
144            domain: vec!["rust".to_string(), "testing".to_string()],
145            related_files: vec![],
146            source_agent: AgentId::new("agent-1"),
147            source_task: None,
148            timestamp: Timestamp::now(),
149            archived: false,
150        }
151    }
152
153    #[test]
154    fn test_default_filter_excludes_archived() {
155        let filter = SearchFilter::default();
156        assert!(filter.exclude_archived);
157
158        let mut exp = test_experience();
159        assert!(filter.matches(&exp));
160
161        exp.archived = true;
162        assert!(!filter.matches(&exp));
163    }
164
165    #[test]
166    fn test_default_filter_matches_all_non_archived() {
167        let filter = SearchFilter::default();
168        let exp = test_experience();
169        assert!(filter.matches(&exp));
170    }
171
172    #[test]
173    fn test_domain_filter() {
174        let filter = SearchFilter {
175            domains: Some(vec!["rust".to_string()]),
176            ..SearchFilter::default()
177        };
178
179        let exp = test_experience(); // has domain: ["rust", "testing"]
180        assert!(filter.matches(&exp));
181
182        let filter_no_match = SearchFilter {
183            domains: Some(vec!["python".to_string()]),
184            ..SearchFilter::default()
185        };
186        assert!(!filter_no_match.matches(&exp));
187    }
188
189    #[test]
190    fn test_experience_type_filter() {
191        let filter = SearchFilter {
192            experience_types: Some(vec![ExperienceType::Fact {
193                statement: String::new(),
194                source: String::new(),
195            }]),
196            ..SearchFilter::default()
197        };
198
199        let exp = test_experience(); // Fact type
200        assert!(filter.matches(&exp));
201
202        let filter_no_match = SearchFilter {
203            experience_types: Some(vec![ExperienceType::Generic { category: None }]),
204            ..SearchFilter::default()
205        };
206        assert!(!filter_no_match.matches(&exp));
207    }
208
209    #[test]
210    fn test_importance_filter() {
211        let filter = SearchFilter {
212            min_importance: Some(0.7),
213            ..SearchFilter::default()
214        };
215
216        let mut exp = test_experience(); // importance: 0.5
217        assert!(!filter.matches(&exp));
218
219        exp.importance = 0.8;
220        assert!(filter.matches(&exp));
221    }
222
223    #[test]
224    fn test_confidence_filter() {
225        let filter = SearchFilter {
226            min_confidence: Some(0.9),
227            ..SearchFilter::default()
228        };
229
230        let exp = test_experience(); // confidence: 0.8
231        assert!(!filter.matches(&exp));
232    }
233
234    #[test]
235    fn test_since_filter() {
236        let before = Timestamp::now();
237        let exp = test_experience();
238
239        // Filter for experiences after "before" — exp was created after, should match
240        let filter = SearchFilter {
241            since: Some(before),
242            ..SearchFilter::default()
243        };
244        assert!(filter.matches(&exp));
245    }
246
247    #[test]
248    fn test_combined_filters() {
249        let filter = SearchFilter {
250            domains: Some(vec!["rust".to_string()]),
251            min_importance: Some(0.3),
252            min_confidence: Some(0.5),
253            exclude_archived: true,
254            ..SearchFilter::default()
255        };
256
257        let exp = test_experience(); // domain: ["rust", "testing"], importance: 0.5, confidence: 0.8
258        assert!(filter.matches(&exp));
259    }
260}