1use std::collections::{HashMap, HashSet};
2
3#[derive(Debug, Clone)]
5#[non_exhaustive]
6pub struct RecurrenceConfig {
7 pub min_session_frequency: usize,
9 pub max_session_ratio: f64,
11 pub term_blocklist: HashSet<String>,
13}
14
15impl Default for RecurrenceConfig {
16 fn default() -> Self {
17 Self {
18 min_session_frequency: 2,
19 max_session_ratio: 0.8,
20 term_blocklist: HashSet::new(),
21 }
22 }
23}
24
25#[derive(Debug, Clone, PartialEq)]
27pub struct RecurrenceResult {
28 pub term: String,
30 pub session_frequency: usize,
32 pub recurrence_score: f64,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq)]
37struct FilteredRecurrenceMetrics {
38 session_frequency: usize,
39 recurrence_score: f64,
40}
41
42#[must_use]
47#[allow(
48 clippy::implicit_hasher,
49 clippy::cast_precision_loss,
50 reason = "Public API and recurrence score type are intentionally fixed by design"
51)]
52pub fn compute_recurrence(
53 session_term_maps: &[HashMap<String, usize>],
54 config: &RecurrenceConfig,
55) -> Vec<RecurrenceResult> {
56 if session_term_maps.is_empty() {
57 return Vec::new();
58 }
59
60 let total_sessions = session_term_maps.len();
61 let mut session_frequency: HashMap<String, usize> = HashMap::new();
62
63 for term_map in session_term_maps {
64 for term in term_map.keys() {
65 *session_frequency.entry(term.clone()).or_insert(0) += 1;
66 }
67 }
68
69 let mut filtered: HashMap<String, FilteredRecurrenceMetrics> = HashMap::new();
70 for (term, sf) in session_frequency {
71 if config.term_blocklist.contains(&term) {
72 continue;
73 }
74
75 if sf < config.min_session_frequency {
76 continue;
77 }
78
79 let recurrence_score = sf as f64 / total_sessions as f64;
80 if recurrence_score > config.max_session_ratio {
81 continue;
82 }
83
84 filtered.insert(
85 term,
86 FilteredRecurrenceMetrics {
87 session_frequency: sf,
88 recurrence_score,
89 },
90 );
91 }
92
93 let mut suppressed_unigrams: HashSet<String> = HashSet::new();
94 for term in filtered.keys() {
95 if term.contains(' ') {
96 for unigram in term.split_whitespace() {
97 suppressed_unigrams.insert(unigram.to_string());
98 }
99 }
100 }
101
102 for unigram in &suppressed_unigrams {
103 filtered.remove(unigram);
104 }
105
106 let mut results: Vec<RecurrenceResult> = filtered
107 .into_iter()
108 .map(|(term, metrics)| RecurrenceResult {
109 term,
110 session_frequency: metrics.session_frequency,
111 recurrence_score: metrics.recurrence_score,
112 })
113 .collect();
114 results.sort_by(|left, right| {
115 right
116 .recurrence_score
117 .total_cmp(&left.recurrence_score)
118 .then_with(|| left.term.cmp(&right.term))
119 });
120
121 results
122}
123
124#[cfg(test)]
125mod tests {
126 use std::collections::{HashMap, HashSet};
127
128 use super::{compute_recurrence, RecurrenceConfig};
129
130 fn term_map(terms: &[&str]) -> HashMap<String, usize> {
131 let mut map = HashMap::new();
132 for term in terms {
133 map.insert((*term).to_string(), 1);
134 }
135 map
136 }
137
138 fn default_config() -> RecurrenceConfig {
139 RecurrenceConfig::default()
140 }
141
142 #[test]
143 fn compute_recurrence_returns_empty_for_empty_input() {
144 let config = default_config();
145 let results = compute_recurrence(&[], &config);
146 assert!(results.is_empty());
147 }
148
149 #[test]
150 fn compute_recurrence_returns_empty_for_single_session_with_defaults() {
151 let sessions = vec![term_map(&["context", "forge", "context forge"])];
152 let config = default_config();
153
154 let results = compute_recurrence(&sessions, &config);
155 assert!(results.is_empty());
156 }
157
158 #[test]
159 fn compute_recurrence_counts_session_presence_not_raw_frequency() {
160 let mut session_a = HashMap::new();
161 session_a.insert("context".to_string(), 100);
162 session_a.insert("signal".to_string(), 1);
163
164 let mut session_b = HashMap::new();
165 session_b.insert("context".to_string(), 1);
166
167 let session_c = term_map(&["signal"]);
168
169 let sessions = vec![session_a, session_b, session_c];
170 let config = RecurrenceConfig {
171 min_session_frequency: 1,
172 max_session_ratio: 1.0,
173 term_blocklist: HashSet::new(),
174 };
175
176 let results = compute_recurrence(&sessions, &config);
177
178 let context = results
179 .iter()
180 .find(|result| result.term == "context")
181 .expect("context should be present");
182 let signal = results
183 .iter()
184 .find(|result| result.term == "signal")
185 .expect("signal should be present");
186
187 assert_eq!(context.session_frequency, 2);
188 assert_eq!(signal.session_frequency, 2);
189 assert!((context.recurrence_score - (2.0 / 3.0)).abs() < 1e-12);
190 }
191
192 #[test]
193 fn compute_recurrence_excludes_terms_below_low_cutoff() {
194 let sessions = vec![
195 term_map(&["alpha"]),
196 term_map(&["beta"]),
197 term_map(&["alpha"]),
198 ];
199 let config = default_config();
200
201 let results = compute_recurrence(&sessions, &config);
202 assert!(results.iter().all(|result| result.term != "beta"));
203 assert!(results.iter().any(|result| result.term == "alpha"));
204 }
205
206 #[test]
207 fn compute_recurrence_excludes_terms_above_high_cutoff() {
208 let sessions = vec![
209 term_map(&["context", "alpha"]),
210 term_map(&["context", "beta"]),
211 term_map(&["context", "gamma"]),
212 term_map(&["context", "delta"]),
213 term_map(&["context", "epsilon"]),
214 ];
215 let config = default_config();
216
217 let results = compute_recurrence(&sessions, &config);
218 assert!(results.iter().all(|result| result.term != "context"));
219 }
220
221 #[test]
222 fn compute_recurrence_includes_term_at_exact_high_cutoff() {
223 let sessions = vec![
224 term_map(&["threshold"]),
225 term_map(&["threshold"]),
226 term_map(&["threshold"]),
227 term_map(&["threshold", "other"]),
228 term_map(&["other"]),
229 ];
230 let config = default_config();
231
232 let results = compute_recurrence(&sessions, &config);
233 let threshold = results
234 .iter()
235 .find(|result| result.term == "threshold")
236 .expect("threshold should pass at exact 0.8");
237 assert_eq!(threshold.session_frequency, 4);
238 assert!((threshold.recurrence_score - 0.8).abs() < 1e-12);
239 }
240
241 #[test]
242 fn compute_recurrence_can_return_empty_when_band_pass_filters_everything() {
243 let sessions = vec![term_map(&["always"]), term_map(&["always"])];
244 let config = default_config();
245
246 let results = compute_recurrence(&sessions, &config);
247 assert!(results.is_empty());
248 }
249
250 #[test]
251 fn compute_recurrence_excludes_blocklisted_terms() {
252 let sessions = vec![
253 term_map(&["context", "signal"]),
254 term_map(&["context"]),
255 term_map(&["context"]),
256 ];
257 let config = RecurrenceConfig {
258 min_session_frequency: 1,
259 max_session_ratio: 1.0,
260 term_blocklist: HashSet::from(["context".to_string()]),
261 };
262
263 let results = compute_recurrence(&sessions, &config);
264 assert!(results.iter().all(|result| result.term != "context"));
265 }
266
267 #[test]
268 fn compute_recurrence_blocklisted_ngram_does_not_suppress_unigrams() {
269 let sessions = vec![
270 term_map(&["home", "devuser", "home devuser"]),
271 term_map(&["home", "devuser", "home devuser"]),
272 term_map(&["home", "devuser"]),
273 term_map(&["home", "devuser"]),
274 term_map(&["home"]),
275 ];
276
277 let config = RecurrenceConfig {
278 min_session_frequency: 1,
279 max_session_ratio: 1.0,
280 term_blocklist: HashSet::from(["home devuser".to_string()]),
281 };
282
283 let results = compute_recurrence(&sessions, &config);
284 assert!(results.iter().any(|result| result.term == "home"));
285 assert!(results.iter().any(|result| result.term == "devuser"));
286 assert!(results.iter().all(|result| result.term != "home devuser"));
287 }
288
289 #[test]
290 fn compute_recurrence_suppresses_unigrams_when_trigram_survives() {
291 let sessions = vec![
292 term_map(&["context", "forge", "hub", "context forge hub"]),
293 term_map(&["context", "forge", "hub", "context forge hub"]),
294 term_map(&["context", "forge", "hub", "context forge hub"]),
295 term_map(&["context", "forge", "hub"]),
296 term_map(&["other"]),
297 ];
298 let config = default_config();
299
300 let results = compute_recurrence(&sessions, &config);
301 assert!(results
302 .iter()
303 .any(|result| result.term == "context forge hub"));
304 assert!(results.iter().all(|result| result.term != "context"));
305 assert!(results.iter().all(|result| result.term != "forge"));
306 assert!(results.iter().all(|result| result.term != "hub"));
307 }
308
309 #[test]
310 fn compute_recurrence_keeps_bigrams_when_trigram_survives() {
311 let sessions = vec![
312 term_map(&["a", "b", "c", "a b", "b c", "a b c"]),
313 term_map(&["a", "b", "c", "a b", "b c", "a b c"]),
314 term_map(&["a", "b", "c", "a b", "b c", "a b c"]),
315 term_map(&["a", "b", "c", "a b", "b c"]),
316 term_map(&["other"]),
317 ];
318 let config = default_config();
319
320 let results = compute_recurrence(&sessions, &config);
321 assert!(results.iter().any(|result| result.term == "a b c"));
322 assert!(results.iter().any(|result| result.term == "a b"));
323 assert!(results.iter().any(|result| result.term == "b c"));
324 }
325
326 #[test]
327 fn compute_recurrence_bigram_suppresses_constituent_unigrams() {
328 let sessions = vec![
329 term_map(&["token", "model", "token model"]),
330 term_map(&["token", "model", "token model"]),
331 term_map(&["token", "model"]),
332 term_map(&["other"]),
333 ];
334 let config = default_config();
335 let results = compute_recurrence(&sessions, &config);
336 assert!(results.iter().any(|r| r.term == "token model"));
337 assert!(results.iter().all(|r| r.term != "token"));
338 assert!(results.iter().all(|r| r.term != "model"));
339 }
340
341 #[test]
342 fn compute_recurrence_honors_configurable_thresholds() {
343 let sessions = vec![
344 term_map(&["rare", "common"]),
345 term_map(&["common"]),
346 term_map(&["common"]),
347 term_map(&["common"]),
348 term_map(&["common"]),
349 ];
350
351 let config = RecurrenceConfig {
352 min_session_frequency: 1,
353 max_session_ratio: 1.0,
354 term_blocklist: HashSet::new(),
355 };
356
357 let results = compute_recurrence(&sessions, &config);
358 let rare = results
359 .iter()
360 .find(|result| result.term == "rare")
361 .expect("rare should be included with min_session_frequency=1");
362 assert_eq!(rare.session_frequency, 1);
363 assert!((rare.recurrence_score - 0.2).abs() < 1e-12);
364 }
365
366 #[test]
367 fn compute_recurrence_sorts_by_score_desc_then_term_asc() {
368 let sessions = vec![
369 term_map(&["banana", "apple", "carrot"]),
370 term_map(&["banana", "apple"]),
371 term_map(&["banana"]),
372 term_map(&["apple"]),
373 term_map(&["carrot"]),
374 ];
375
376 let config = RecurrenceConfig {
377 min_session_frequency: 1,
378 max_session_ratio: 1.0,
379 term_blocklist: HashSet::new(),
380 };
381
382 let results = compute_recurrence(&sessions, &config);
383 let terms: Vec<&str> = results.iter().map(|result| result.term.as_str()).collect();
384 assert_eq!(terms, vec!["apple", "banana", "carrot"]);
385 }
386}