Skip to main content

vtcode_core/tools/
pattern_detection.rs

1//! Pattern detection with sequence analysis and ML feature engineering.
2//!
3//! Analyzes tool call sequences to detect patterns, anomalies, and trends.
4//! Events are capped at `MAX_EVENTS` to prevent unbounded memory growth.
5//! Analysis is amortized: patterns are recomputed every `ANALYZE_INTERVAL` events.
6
7use hashbrown::HashMap;
8use serde_json::{Value, json};
9
10/// Maximum number of stored events before oldest are evicted.
11const MAX_EVENTS: usize = 500;
12/// Re-analyze patterns every N events to amortize cost.
13const ANALYZE_INTERVAL: usize = 10;
14
15/// A single tool call event.
16#[derive(Clone, Debug)]
17pub struct ToolEvent {
18    pub tool_name: String,
19    pub success: bool,
20    pub duration_ms: u64,
21    pub timestamp: std::time::Instant,
22}
23
24/// A detected pattern in tool call sequences.
25#[derive(Clone, Debug)]
26pub struct DetectedPattern {
27    pub name: String,
28    pub sequence: Vec<String>,
29    pub frequency: usize,
30    pub success_rate: f64,
31    pub avg_duration_ms: u64,
32    pub confidence: f64,
33}
34
35/// Pattern detector using sequence analysis.
36pub struct PatternDetector {
37    events: Vec<ToolEvent>,
38    patterns: HashMap<String, DetectedPattern>,
39    sequence_length: usize,
40    /// Counter tracking events since last full analysis.
41    events_since_analysis: usize,
42}
43
44impl PatternDetector {
45    /// Create new detector with sliding window size.
46    ///
47    /// A zero window is clamped to one: `slice::windows(0)` panics and the
48    /// occurrence count divides by the window size, so a degenerate caller
49    /// value would otherwise abort the analysis task.
50    pub fn new(sequence_length: usize) -> Self {
51        Self {
52            events: Vec::with_capacity(64),
53            patterns: HashMap::with_capacity(16),
54            sequence_length: sequence_length.max(1),
55            events_since_analysis: 0,
56        }
57    }
58
59    /// Add an event to the detector.
60    pub fn record_event(&mut self, event: ToolEvent) {
61        // Evict oldest events when capacity is reached.
62        if self.events.len() >= MAX_EVENTS {
63            let drain_count = MAX_EVENTS / 10; // Remove 10% at a time
64            self.events.drain(..drain_count);
65        }
66
67        self.events.push(event);
68        self.events_since_analysis += 1;
69
70        // Only re-analyze every ANALYZE_INTERVAL events to amortize cost.
71        if self.events_since_analysis >= ANALYZE_INTERVAL {
72            self.analyze();
73            self.events_since_analysis = 0;
74        }
75    }
76
77    /// Analyze events for patterns.
78    fn analyze(&mut self) {
79        if self.events.len() < self.sequence_length {
80            return;
81        }
82
83        let mut sequence_map: HashMap<Vec<&str>, Vec<&ToolEvent>> = HashMap::new();
84
85        // Slide window and extract sequences.
86        for window in self.events.windows(self.sequence_length) {
87            let seq: Vec<&str> = window.iter().map(|e| e.tool_name.as_str()).collect();
88
89            // Reserve or get the vector once, then push window events into it.
90            let entry = sequence_map.entry(seq.clone()).or_default();
91            for event in window {
92                entry.push(event);
93            }
94        }
95
96        // Extract patterns with metrics.
97        for (sequence, events) in sequence_map {
98            // Each occurrence pushes `sequence_length` events, so divide to get
99            // the actual occurrence count.
100            let frequency = events.len() / self.sequence_length;
101            if frequency >= 2 {
102                // Pattern appears at least twice.
103                let success_count = events.iter().filter(|e| e.success).count();
104                let success_rate = success_count as f64 / events.len() as f64;
105                let avg_duration = events.iter().map(|e| e.duration_ms).sum::<u64>() / events.len() as u64;
106
107                // Confidence: based on frequency and consistency.
108                let confidence = (success_rate * (frequency as f64 / 10.0).min(1.0)).min(1.0);
109
110                let sequence_vec = sequence.iter().map(|s| s.to_string()).collect::<Vec<String>>();
111                let pattern_name = format!("pattern_{:x}", hash_sequence(&sequence_vec));
112
113                self.patterns.insert(
114                    pattern_name.clone(),
115                    DetectedPattern {
116                        name: pattern_name,
117                        sequence: sequence_vec,
118                        frequency,
119                        success_rate,
120                        avg_duration_ms: avg_duration,
121                        confidence,
122                    },
123                );
124            }
125        }
126    }
127
128    /// Get detected patterns.
129    pub fn patterns(&self) -> Vec<DetectedPattern> {
130        let mut patterns: Vec<_> = self.patterns.values().cloned().collect();
131        patterns.sort_unstable_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
132        patterns
133    }
134
135    /// Extract normalized feature vector for ML.
136    ///
137    /// # Dimension key
138    ///
139    /// | Index | Name            | Meaning                              | Post-clamp range |
140    /// |-------|-----------------|--------------------------------------|------------------|
141    /// | 0     | `event_count`   | Number of recorded events            | `[0, ∞)`         |
142    /// | 1     | `success_rate`  | Fraction of successful events        | `[0, 1]`         |
143    /// | 2     | `avg_duration`  | Average execution duration (ms)      | `[0, 1]` (clamped) |
144    /// | 3     | `tool_diversity`| Number of unique tools used          | `[0, 1]` (clamped) |
145    /// | 4     | `pattern_density`| Ratio of detected patterns to events| `[0, 1]`         |
146    ///
147    /// **Note:** The values returned by this function have been clamped to `[0, 1]` (except
148    /// `event_count`). True min–max normalisation is not applied — callers that need
149    /// dynamic range scaling should wrap this function.
150    pub fn feature_vector(&self) -> Vec<f64> {
151        let mut features = Vec::with_capacity(5);
152
153        // Feature 1: Event count.
154        features.push(self.events.len() as f64);
155
156        // Feature 2: Success rate.
157        let success_rate = self.events.iter().filter(|e| e.success).count() as f64 / self.events.len().max(1) as f64;
158        features.push(success_rate);
159
160        // Feature 3: Average duration.
161        let avg_duration =
162            self.events.iter().map(|e| e.duration_ms).sum::<u64>() as f64 / self.events.len().max(1) as f64;
163        features.push(avg_duration);
164
165        // Feature 4: Tool diversity (unique tools).
166        let unique_tools = self
167            .events
168            .iter()
169            .map(|e| &e.tool_name)
170            .collect::<hashbrown::HashSet<_>>()
171            .len() as f64;
172        features.push(unique_tools);
173
174        // Feature 5: Pattern density (detected patterns / possible patterns).
175        let pattern_density = self.patterns.len() as f64 / self.events.len().max(1) as f64;
176        features.push(pattern_density);
177
178        // Clamp features to [0, 1] (feature 0 stays raw).
179        clamp_features(&features)
180    }
181
182    /// Clear all data.
183    pub fn reset(&mut self) {
184        self.events.clear();
185        self.patterns.clear();
186        self.events_since_analysis = 0;
187    }
188
189    /// Export patterns as JSON for analysis.
190    pub fn to_json(&self) -> Value {
191        json!({
192            "event_count": self.events.len(),
193            "pattern_count": self.patterns.len(),
194            "patterns": self.patterns()
195                .iter()
196                .map(|p| json!({
197                    "name": p.name,
198                    "sequence": p.sequence,
199                    "frequency": p.frequency,
200                    "success_rate": p.success_rate,
201                    "avg_duration_ms": p.avg_duration_ms,
202                    "confidence": p.confidence,
203                }))
204                .collect::<Vec<_>>(),
205            "feature_vector": self.feature_vector(),
206        })
207    }
208}
209
210/// Clamp features to `[0, 1]` (feature 0 / event_count stays raw).
211///
212/// This is *not* true normalisation — values above 1 are silently capped.
213/// Callers that need min–max scaling should apply it after this function.
214fn clamp_features(features: &[f64]) -> Vec<f64> {
215    features
216        .iter()
217        .enumerate()
218        .map(|(i, &f)| {
219            if i == 0 {
220                f // Keep event count as-is
221            } else {
222                f.clamp(0.0, 1.0)
223            }
224        })
225        .collect()
226}
227
228/// Quick hash for sequence.
229fn hash_sequence(seq: &[String]) -> u64 {
230    let mut hash: u64 = 0;
231    for s in seq {
232        for b in s.bytes() {
233            hash = hash.wrapping_mul(31).wrapping_add(b as u64);
234        }
235    }
236    hash
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use std::time::Instant;
243
244    #[test]
245    fn zero_window_is_clamped_to_one() {
246        // `windows(0)` panics and `events.len() / sequence_length` divides by the
247        // window size, so a degenerate window must not reach analysis.
248        let mut detector = PatternDetector::new(0);
249        let now = Instant::now();
250        for _ in 0..ANALYZE_INTERVAL {
251            detector.record_event(ToolEvent {
252                tool_name: "tool_a".into(),
253                success: true,
254                duration_ms: 10,
255                timestamp: now,
256            });
257        }
258
259        let patterns = detector.patterns();
260        assert!(patterns.iter().all(|p| p.sequence.len() == 1));
261    }
262
263    #[test]
264    fn test_pattern_detection() {
265        let mut detector = PatternDetector::new(2);
266
267        let now = Instant::now();
268
269        // Record a repeating pattern: (A, B) repeats enough times to trigger analysis
270        for _ in 0..6 {
271            detector.record_event(ToolEvent {
272                tool_name: "tool_a".into(),
273                success: true,
274                duration_ms: 100,
275                timestamp: now,
276            });
277            detector.record_event(ToolEvent {
278                tool_name: "tool_b".into(),
279                success: true,
280                duration_ms: 50,
281                timestamp: now,
282            });
283        }
284
285        let patterns = detector.patterns();
286        assert!(!patterns.is_empty());
287        assert!(patterns[0].sequence.len() == 2);
288    }
289
290    #[test]
291    fn test_feature_vector() {
292        let mut detector = PatternDetector::new(2);
293        let now = Instant::now();
294
295        for i in 0u64..5u64 {
296            detector.record_event(ToolEvent {
297                tool_name: format!("tool_{}", i % 2),
298                success: i % 2 == 0,
299                duration_ms: 50 + i * 10,
300                timestamp: now,
301            });
302        }
303
304        let features = detector.feature_vector();
305        assert_eq!(features.len(), 5);
306        assert!(features.iter().all(|f| *f >= 0.0));
307    }
308
309    #[test]
310    fn test_success_rate() {
311        let mut detector = PatternDetector::new(2);
312        let now = Instant::now();
313
314        detector.record_event(ToolEvent {
315            tool_name: "tool_a".into(),
316            success: true,
317            duration_ms: 100,
318            timestamp: now,
319        });
320        detector.record_event(ToolEvent {
321            tool_name: "tool_b".into(),
322            success: false,
323            duration_ms: 50,
324            timestamp: now,
325        });
326        detector.record_event(ToolEvent {
327            tool_name: "tool_a".into(),
328            success: true,
329            duration_ms: 100,
330            timestamp: now,
331        });
332
333        let features = detector.feature_vector();
334        // Feature 1 is success rate.
335        assert!(features[1] > 0.0 && features[1] < 1.0);
336    }
337}