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