Skip to main content

wm_dispatch/
composition.rs

1//! Tool Composition Discovery — analyzes dispatch sequences to find common patterns.
2//!
3//! When tools are frequently called in sequence (e.g. `memory.search` →
4//! `memory.create` → `memory.associate`), the composition discovery module
5//! identifies these patterns and surfaces them as reusable "tool chains".
6//!
7//! This enables:
8//! - Suggesting next tools based on recent dispatch history
9//! - Identifying common workflows for documentation or automation
10//! - Detecting co-usage patterns that could become composite tools
11//!
12//! # How it works
13//!
14//! The `CompositionTracker` records tool names in dispatch order. A sliding
15//! window of recent calls is maintained. Periodically, the window is scanned
16//! for recurring subsequences of length 2–5. Patterns that appear at least
17//! `min_frequency` times are promoted to `CompositionPattern` entries.
18
19#![forbid(unsafe_code)]
20
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23
24/// A discovered tool composition pattern (a frequent sequence of tool calls).
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct CompositionPattern {
27    /// The ordered sequence of tool names.
28    pub sequence: Vec<String>,
29    /// How many times this pattern was observed.
30    pub frequency: usize,
31    /// Average number of seconds between the first and last tool in the pattern.
32    pub avg_span_secs: f64,
33}
34
35impl CompositionPattern {
36    /// A display name for this pattern (tools joined by ` → `).
37    #[must_use]
38    pub fn display_name(&self) -> String {
39        self.sequence.join(" → ")
40    }
41
42    /// Pattern length (number of tools).
43    #[must_use]
44    pub fn len(&self) -> usize {
45        self.sequence.len()
46    }
47
48    /// Whether this pattern is empty.
49    #[must_use]
50    pub fn is_empty(&self) -> bool {
51        self.sequence.is_empty()
52    }
53}
54
55/// Configuration for composition discovery.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct CompositionConfig {
58    /// Maximum window size for recent tool calls (sliding window).
59    pub window_size: usize,
60    /// Minimum pattern length to detect (default: 2).
61    pub min_pattern_len: usize,
62    /// Maximum pattern length to detect (default: 5).
63    pub max_pattern_len: usize,
64    /// Minimum frequency for a pattern to be promoted (default: 3).
65    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
79/// Tracks tool dispatch sequences and discovers composition patterns.
80///
81/// Thread-safe via internal mutex (call recording is fast, analysis is
82/// done on demand).
83pub struct CompositionTracker {
84    config: CompositionConfig,
85    recent: std::sync::Mutex<Vec<String>>,
86    /// All observed patterns with frequencies (updated on `discover()`).
87    patterns: std::sync::Mutex<HashMap<Vec<String>, usize>>,
88}
89
90impl CompositionTracker {
91    /// Create a new composition tracker with the given config.
92    #[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    /// Create a tracker with default configuration.
102    #[must_use]
103    pub fn with_defaults() -> Self {
104        Self::new(CompositionConfig::default())
105    }
106
107    /// Record a tool dispatch.
108    ///
109    /// Call this after each tool is dispatched. The tool name is appended
110    /// to the sliding window of recent calls.
111    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    /// Discover composition patterns from the recent dispatch history.
122    ///
123    /// Scans the sliding window for all subsequences of length
124    /// `min_pattern_len`..=`max_pattern_len` and returns those that
125    /// appear at least `min_frequency` times.
126    ///
127    /// Also updates the internal pattern store.
128    #[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        // Release the `recent` lock before touching `patterns` to avoid
145        // holding two locks at once.
146        drop(recent);
147
148        // Filter by minimum frequency and sort by frequency (descending)
149        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, // We don't track timestamps yet
156            })
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        // Update internal store
166        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    /// Get the most frequent patterns (top N).
177    #[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    /// Suggest the next tool(s) based on the recent dispatch history.
184    ///
185    /// Given the last N tool calls, finds patterns that start with those
186    /// tools and returns the most likely continuation.
187    #[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            // Check if the pattern starts with the context
208            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    /// Get the current number of recorded dispatches in the window.
220    #[must_use]
221    pub fn window_count(&self) -> usize {
222        self.recent.lock().map_or(0, |r| r.len())
223    }
224
225    /// Clear all recorded dispatches and discovered patterns.
226    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        // Record: A B A B A B → pair "A B" appears 3 times, "B A" appears 2 times
265        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        // Record: A B C A B C → triple "A B C" appears 2 times
292        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, // high threshold
309        });
310
311        for tool in &["A", "B", "A", "B"] {
312            tracker.record(tool);
313        }
314
315        let patterns = tracker.discover();
316        // "A B" appears 2 times, "B A" appears 1 time — neither meets threshold of 5
317        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        // "A B" appears 3 times, "B C" appears 2 times
330        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        // Most frequent should be first
337        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        // Record: A B C A B C A B
350        for tool in &["A", "B", "C", "A", "B", "C", "A", "B"] {
351            tracker.record(tool);
352        }
353
354        // After "A B", the next tool should be "C"
355        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, // high threshold — no patterns will match
374        });
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"); // should evict "A"
395        assert_eq!(tracker.window_count(), 3);
396
397        let patterns = tracker.discover();
398        // Should find "B C", "C D" but not "A B"
399        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        // Simulate a realistic workflow: search → create → associate
475        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        // The full triple should be the most frequent
485        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        // The last recorded call is "memory.associate"; the recurring
508        // cycle is search → create → associate → search...
509        let suggestions = tracker.suggest_next(1);
510        assert_eq!(suggestions[0], "memory.search");
511
512        // The last two calls are "create → associate"; the cycle
513        // continues with "memory.search"
514        let suggestions = tracker.suggest_next(2);
515        assert_eq!(suggestions[0], "memory.search");
516
517        // After recording another "memory.search", the cycle continues
518        // with "memory.create"
519        tracker.record("memory.search");
520        let suggestions = tracker.suggest_next(1);
521        assert_eq!(suggestions[0], "memory.create");
522    }
523}