1#![forbid(unsafe_code)]
20
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct CompositionPattern {
27 pub sequence: Vec<String>,
29 pub frequency: usize,
31 pub avg_span_secs: f64,
33}
34
35impl CompositionPattern {
36 #[must_use]
38 pub fn display_name(&self) -> String {
39 self.sequence.join(" → ")
40 }
41
42 #[must_use]
44 pub fn len(&self) -> usize {
45 self.sequence.len()
46 }
47
48 #[must_use]
50 pub fn is_empty(&self) -> bool {
51 self.sequence.is_empty()
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct CompositionConfig {
58 pub window_size: usize,
60 pub min_pattern_len: usize,
62 pub max_pattern_len: usize,
64 pub min_frequency: usize,
66}
67
68impl Default for CompositionConfig {
69 fn default() -> Self {
70 Self {
71 window_size: 200,
72 min_pattern_len: 2,
73 max_pattern_len: 5,
74 min_frequency: 3,
75 }
76 }
77}
78
79pub struct CompositionTracker {
84 config: CompositionConfig,
85 recent: std::sync::Mutex<Vec<String>>,
86 patterns: std::sync::Mutex<HashMap<Vec<String>, usize>>,
88}
89
90impl CompositionTracker {
91 #[must_use]
93 pub fn new(config: CompositionConfig) -> Self {
94 Self {
95 config,
96 recent: std::sync::Mutex::new(Vec::new()),
97 patterns: std::sync::Mutex::new(HashMap::new()),
98 }
99 }
100
101 #[must_use]
103 pub fn with_defaults() -> Self {
104 Self::new(CompositionConfig::default())
105 }
106
107 pub fn record(&self, tool_name: &str) {
112 let Ok(mut recent) = self.recent.lock() else {
113 return;
114 };
115 recent.push(tool_name.to_string());
116 if recent.len() > self.config.window_size {
117 recent.remove(0);
118 }
119 }
120
121 #[must_use]
129 pub fn discover(&self) -> Vec<CompositionPattern> {
130 let Ok(recent) = self.recent.lock() else {
131 return Vec::new();
132 };
133 let mut counts: HashMap<Vec<String>, usize> = HashMap::new();
134
135 let max_len = self.config.max_pattern_len.min(recent.len());
136
137 for pattern_len in self.config.min_pattern_len..=max_len {
138 for i in 0..=recent.len().saturating_sub(pattern_len) {
139 let seq = &recent[i..i + pattern_len];
140 *counts.entry(seq.to_vec()).or_insert(0) += 1;
141 }
142 }
143
144 drop(recent);
147
148 let mut patterns: Vec<CompositionPattern> = counts
150 .into_iter()
151 .filter(|(_, freq)| *freq >= self.config.min_frequency)
152 .map(|(sequence, frequency)| CompositionPattern {
153 sequence,
154 frequency,
155 avg_span_secs: 0.0, })
157 .collect();
158
159 patterns.sort_by(|a, b| {
160 b.frequency
161 .cmp(&a.frequency)
162 .then_with(|| b.sequence.len().cmp(&a.sequence.len()))
163 });
164
165 if let Ok(mut store) = self.patterns.lock() {
167 store.clear();
168 for p in &patterns {
169 store.insert(p.sequence.clone(), p.frequency);
170 }
171 }
172
173 patterns
174 }
175
176 #[must_use]
178 pub fn top_patterns(&self, n: usize) -> Vec<CompositionPattern> {
179 let patterns = self.discover();
180 patterns.into_iter().take(n).collect()
181 }
182
183 #[must_use]
188 pub fn suggest_next(&self, last_n: usize) -> Vec<String> {
189 let context: Vec<String> = {
190 let Ok(recent) = self.recent.lock() else {
191 return Vec::new();
192 };
193 if recent.is_empty() {
194 return Vec::new();
195 }
196 let n = last_n.min(recent.len());
197 recent[recent.len() - n..].to_vec()
198 };
199
200 let patterns = self.discover();
201 let mut suggestions: HashMap<String, usize> = HashMap::new();
202
203 for pattern in &patterns {
204 if pattern.sequence.len() <= context.len() {
205 continue;
206 }
207 if &pattern.sequence[..context.len()] == context.as_slice() {
209 let next_tool = &pattern.sequence[context.len()];
210 *suggestions.entry(next_tool.clone()).or_insert(0) += pattern.frequency;
211 }
212 }
213
214 let mut sorted: Vec<(String, usize)> = suggestions.into_iter().collect();
215 sorted.sort_by_key(|x| std::cmp::Reverse(x.1));
216 sorted.into_iter().map(|(tool, _)| tool).collect()
217 }
218
219 #[must_use]
221 pub fn window_count(&self) -> usize {
222 self.recent.lock().map_or(0, |r| r.len())
223 }
224
225 pub fn clear(&self) {
227 if let Ok(mut recent) = self.recent.lock() {
228 recent.clear();
229 }
230 if let Ok(mut patterns) = self.patterns.lock() {
231 patterns.clear();
232 }
233 }
234}
235
236impl Default for CompositionTracker {
237 fn default() -> Self {
238 Self::with_defaults()
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn record_and_window_count() {
248 let tracker = CompositionTracker::with_defaults();
249 assert_eq!(tracker.window_count(), 0);
250 tracker.record("memory.search");
251 tracker.record("memory.create");
252 assert_eq!(tracker.window_count(), 2);
253 }
254
255 #[test]
256 fn discover_finds_pairs() {
257 let tracker = CompositionTracker::new(CompositionConfig {
258 window_size: 100,
259 min_pattern_len: 2,
260 max_pattern_len: 2,
261 min_frequency: 2,
262 });
263
264 for tool in &["A", "B", "A", "B", "A", "B"] {
266 tracker.record(tool);
267 }
268
269 let patterns = tracker.discover();
270 assert!(
271 patterns
272 .iter()
273 .any(|p| p.sequence == ["A".to_string(), "B".to_string()] && p.frequency == 3)
274 );
275 assert!(
276 patterns
277 .iter()
278 .any(|p| p.sequence == ["B".to_string(), "A".to_string()] && p.frequency == 2)
279 );
280 }
281
282 #[test]
283 fn discover_finds_triples() {
284 let tracker = CompositionTracker::new(CompositionConfig {
285 window_size: 100,
286 min_pattern_len: 3,
287 max_pattern_len: 3,
288 min_frequency: 2,
289 });
290
291 for tool in &["A", "B", "C", "A", "B", "C"] {
293 tracker.record(tool);
294 }
295
296 let patterns = tracker.discover();
297 assert!(patterns.iter().any(|p| p.sequence
298 == ["A".to_string(), "B".to_string(), "C".to_string()]
299 && p.frequency == 2));
300 }
301
302 #[test]
303 fn discover_filters_by_min_frequency() {
304 let tracker = CompositionTracker::new(CompositionConfig {
305 window_size: 100,
306 min_pattern_len: 2,
307 max_pattern_len: 2,
308 min_frequency: 5, });
310
311 for tool in &["A", "B", "A", "B"] {
312 tracker.record(tool);
313 }
314
315 let patterns = tracker.discover();
316 assert!(patterns.is_empty());
318 }
319
320 #[test]
321 fn discover_sorted_by_frequency_descending() {
322 let tracker = CompositionTracker::new(CompositionConfig {
323 window_size: 100,
324 min_pattern_len: 2,
325 max_pattern_len: 2,
326 min_frequency: 2,
327 });
328
329 for tool in &["A", "B", "A", "B", "A", "B", "C", "B", "C"] {
331 tracker.record(tool);
332 }
333
334 let patterns = tracker.discover();
335 assert!(!patterns.is_empty());
336 assert!(patterns[0].frequency >= patterns[1].frequency);
338 }
339
340 #[test]
341 fn suggest_next_finds_continuation() {
342 let tracker = CompositionTracker::new(CompositionConfig {
343 window_size: 100,
344 min_pattern_len: 2,
345 max_pattern_len: 3,
346 min_frequency: 2,
347 });
348
349 for tool in &["A", "B", "C", "A", "B", "C", "A", "B"] {
351 tracker.record(tool);
352 }
353
354 let suggestions = tracker.suggest_next(2);
356 assert!(!suggestions.is_empty());
357 assert_eq!(suggestions[0], "C");
358 }
359
360 #[test]
361 fn suggest_next_empty_when_no_history() {
362 let tracker = CompositionTracker::with_defaults();
363 let suggestions = tracker.suggest_next(3);
364 assert!(suggestions.is_empty());
365 }
366
367 #[test]
368 fn suggest_next_empty_when_no_patterns() {
369 let tracker = CompositionTracker::new(CompositionConfig {
370 window_size: 100,
371 min_pattern_len: 2,
372 max_pattern_len: 2,
373 min_frequency: 10, });
375
376 tracker.record("A");
377 tracker.record("B");
378 let suggestions = tracker.suggest_next(1);
379 assert!(suggestions.is_empty());
380 }
381
382 #[test]
383 fn window_size_evicts_old_entries() {
384 let tracker = CompositionTracker::new(CompositionConfig {
385 window_size: 3,
386 min_pattern_len: 2,
387 max_pattern_len: 2,
388 min_frequency: 1,
389 });
390
391 tracker.record("A");
392 tracker.record("B");
393 tracker.record("C");
394 tracker.record("D"); assert_eq!(tracker.window_count(), 3);
396
397 let patterns = tracker.discover();
398 assert!(
400 patterns
401 .iter()
402 .any(|p| p.sequence == ["C".to_string(), "D".to_string()])
403 );
404 assert!(
405 !patterns
406 .iter()
407 .any(|p| p.sequence == ["A".to_string(), "B".to_string()])
408 );
409 }
410
411 #[test]
412 fn clear_resets_everything() {
413 let tracker = CompositionTracker::with_defaults();
414 tracker.record("A");
415 tracker.record("B");
416 assert_eq!(tracker.window_count(), 2);
417
418 tracker.clear();
419 assert_eq!(tracker.window_count(), 0);
420
421 let patterns = tracker.discover();
422 assert!(patterns.is_empty());
423 }
424
425 #[test]
426 fn top_patterns_limits_results() {
427 let tracker = CompositionTracker::new(CompositionConfig {
428 window_size: 100,
429 min_pattern_len: 2,
430 max_pattern_len: 2,
431 min_frequency: 1,
432 });
433
434 for tool in &["A", "B", "C", "D", "A", "B"] {
435 tracker.record(tool);
436 }
437
438 let top = tracker.top_patterns(1);
439 assert_eq!(top.len(), 1);
440 }
441
442 #[test]
443 fn display_name_joins_with_arrow() {
444 let p = CompositionPattern {
445 sequence: vec!["memory.search".to_string(), "memory.create".to_string()],
446 frequency: 5,
447 avg_span_secs: 1.2,
448 };
449 assert_eq!(p.display_name(), "memory.search → memory.create");
450 }
451
452 #[test]
453 fn pattern_len_and_empty() {
454 let p = CompositionPattern {
455 sequence: vec!["A".to_string(), "B".to_string(), "C".to_string()],
456 frequency: 2,
457 avg_span_secs: 0.0,
458 };
459 assert_eq!(p.len(), 3);
460 assert!(!p.is_empty());
461
462 let empty = CompositionPattern {
463 sequence: vec![],
464 frequency: 0,
465 avg_span_secs: 0.0,
466 };
467 assert!(empty.is_empty());
468 }
469
470 #[test]
471 fn realistic_tool_sequence() {
472 let tracker = CompositionTracker::with_defaults();
473
474 for _ in 0..5 {
476 tracker.record("memory.search");
477 tracker.record("memory.create");
478 tracker.record("memory.associate");
479 }
480
481 let patterns = tracker.discover();
482 assert!(!patterns.is_empty());
483
484 let top = &patterns[0];
486 assert_eq!(
487 top.sequence,
488 vec![
489 "memory.search".to_string(),
490 "memory.create".to_string(),
491 "memory.associate".to_string()
492 ]
493 );
494 assert_eq!(top.frequency, 5);
495 }
496
497 #[test]
498 fn suggest_next_with_realistic_sequence() {
499 let tracker = CompositionTracker::with_defaults();
500
501 for _ in 0..5 {
502 tracker.record("memory.search");
503 tracker.record("memory.create");
504 tracker.record("memory.associate");
505 }
506
507 let suggestions = tracker.suggest_next(1);
510 assert_eq!(suggestions[0], "memory.search");
511
512 let suggestions = tracker.suggest_next(2);
515 assert_eq!(suggestions[0], "memory.search");
516
517 tracker.record("memory.search");
520 let suggestions = tracker.suggest_next(1);
521 assert_eq!(suggestions[0], "memory.create");
522 }
523}