Skip to main content

vtcode_core/tools/
pattern_engine.rs

1//! Advanced pattern detection with sequence analysis
2//!
3//! Tracks execution sequences, detects behavioral patterns,
4//! predicts user intent based on action history.
5
6use crate::tools::improvement_algorithms::jaro_winkler_similarity;
7use hashbrown::HashMap;
8use parking_lot::RwLock;
9use smallvec::SmallVec;
10use std::collections::VecDeque;
11use std::sync::Arc;
12
13/// Single execution event.
14#[derive(Clone, Debug)]
15pub struct ExecutionEvent {
16    pub tool_name: String,
17    pub arguments: String,
18    pub success: bool,
19    pub quality_score: f32, // 0.0-1.0
20    pub duration_ms: u64,
21    pub timestamp: u64,
22}
23
24/// Detected pattern in execution sequence.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum DetectedPattern {
27    /// Tool called once.
28    Single,
29    /// Same tool, same args, 2x.
30    ExactRepeat,
31    /// Same tool, same args, 3+ times (likely stuck).
32    Loop,
33    /// Same tool, similar args (fuzzy match >0.85).
34    NearLoop,
35    /// Increasing quality across iterations.
36    Refinement,
37    /// Decreasing quality (user frustrated).
38    Degradation,
39    /// Different tools with consistent results.
40    Convergence,
41    /// Tool switching, exploring alternatives.
42    Exploration,
43}
44
45/// Pattern detection engine with state tracking.
46pub struct PatternEngine {
47    max_history: usize,
48    sequence_window: usize,
49    events: Arc<RwLock<VecDeque<ExecutionEvent>>>,
50}
51
52impl PatternEngine {
53    /// Create a new pattern engine.
54    pub fn new(max_history: usize, sequence_window: usize) -> Self {
55        Self {
56            max_history,
57            sequence_window,
58            events: Arc::new(RwLock::new(VecDeque::with_capacity(max_history))),
59        }
60    }
61
62    /// Record an execution event.
63    pub fn record(&self, event: ExecutionEvent) {
64        let mut events = self.events.write();
65
66        if events.len() >= self.max_history {
67            events.pop_front();
68        }
69
70        events.push_back(event);
71    }
72
73    /// Detect overall pattern in execution history.
74    pub fn detect_pattern(&self) -> DetectedPattern {
75        let events = self.events.read();
76
77        let len = events.len();
78        if len < 2 {
79            return DetectedPattern::Single;
80        }
81
82        // Use SmallVec to avoid heap allocation for the recent window.
83        // sequence_window is typically 20.
84        let mut recent = SmallVec::<[&ExecutionEvent; 32]>::new();
85        recent.extend(events.iter().rev().take(self.sequence_window));
86
87        detect_in_sequence(&recent)
88    }
89
90    /// Predict the user's next likely tool based on predecessor frequency.
91    pub fn predict_next_tool(&self) -> Option<String> {
92        let events = self.events.read();
93
94        let len = events.len();
95        if len < 2 {
96            return None;
97        }
98
99        // We only need the tool names for prediction.
100        let mut recent_tools = SmallVec::<[&str; 32]>::new();
101        recent_tools.extend(events.iter().rev().take(self.sequence_window).map(|e| e.tool_name.as_str()));
102
103        if recent_tools.is_empty() {
104            return None;
105        }
106
107        let last_tool = recent_tools[0];
108        let mut predecessors = HashMap::<&str, usize>::new();
109
110        for w in recent_tools.windows(2) {
111            if w[0] == last_tool {
112                *predecessors.entry(w[1]).or_default() += 1;
113            }
114        }
115
116        predecessors
117            .into_iter()
118            .max_by_key(|(_, count)| *count)
119            .map(|(tool, _)| tool.to_owned())
120    }
121
122    /// Compute an execution summary in a single pass over the event history.
123    ///
124    /// Multiple separate `.iter()` passes each pay the traversal cost again, which
125    /// prevents cache-friendly pipelining (turbopuffer "zero-cost" lesson). One
126    /// combined loop is clearer and faster.
127    pub fn summary(&self) -> ExecutionSummary {
128        let events = self.events.read();
129        if events.is_empty() {
130            return ExecutionSummary::default();
131        }
132
133        let total = events.len();
134        let mut successful = 0usize;
135        let mut quality_sum = 0.0f32;
136        let mut duration_sum = 0u64;
137        // `HashSet` capacity = total is an upper bound; most runs use ≤ a handful of tools.
138        let mut unique_tools: hashbrown::HashSet<&str> = hashbrown::HashSet::with_capacity(total.min(16));
139
140        for e in events.iter() {
141            if e.success {
142                successful += 1;
143            }
144            quality_sum += e.quality_score;
145            duration_sum += e.duration_ms;
146            unique_tools.insert(e.tool_name.as_str());
147        }
148
149        ExecutionSummary {
150            total_executions: total,
151            successful_executions: successful,
152            success_rate: successful as f32 / total as f32,
153            average_quality: quality_sum / total as f32,
154            average_duration_ms: duration_sum / total as u64,
155            unique_tools: unique_tools.len(),
156            current_pattern: self.detect_pattern_with_guard(&events),
157        }
158    }
159
160    /// Internal helper to detect patterns given a read guard.
161    fn detect_pattern_with_guard(&self, events: &VecDeque<ExecutionEvent>) -> DetectedPattern {
162        let len = events.len();
163        if len < 2 {
164            return DetectedPattern::Single;
165        }
166
167        let mut recent = SmallVec::<[&ExecutionEvent; 32]>::new();
168        recent.extend(events.iter().rev().take(self.sequence_window));
169
170        detect_in_sequence(&recent)
171    }
172}
173
174/// Core pattern classification over a reversed-chronological event slice.
175///
176/// Extracted from `PatternEngine` so it can be tested without the `Arc<RwLock<…>>`
177/// wrapper and called without `&self` (it uses no engine state).
178///
179/// `events[0]` is the **newest** event.
180fn detect_in_sequence(events: &[&ExecutionEvent]) -> DetectedPattern {
181    let len = events.len();
182    if len == 0 {
183        return DetectedPattern::Single;
184    }
185
186    let first = events[0];
187
188    // --- Single combined scan: exact repeats, qualities, tool variety ---
189    //
190    // We combine all metrics into one traversal to maximize cache locality and
191    // enable LLM/SIMD optimization (fused iteration pattern).
192    let mut qualities = SmallVec::<[f32; 32]>::with_capacity(len);
193    let mut same_tool = true;
194    let mut same_args = true;
195
196    for e in events {
197        qualities.push(e.quality_score);
198        if e.tool_name != first.tool_name {
199            same_tool = false;
200        }
201        if e.arguments != first.arguments {
202            same_args = false;
203        }
204    }
205
206    // 1. Check for exact repeats/loops first (strictest pattern)
207    if same_tool && same_args {
208        return match len {
209            1 | 2 => DetectedPattern::ExactRepeat,
210            _ => DetectedPattern::Loop,
211        };
212    }
213
214    // 2. Trend analysis (needs ≥3 points)
215    if len >= 3 {
216        // events[0] is newest → qualities[0] is the latest score.
217        // Fusing the window check to optimize trend detection.
218        let is_refinement = qualities.windows(2).all(|w| w[0] > w[1] + 0.05);
219        if is_refinement {
220            return DetectedPattern::Refinement;
221        }
222
223        let is_degradation = qualities.windows(2).all(|w| w[0] < w[1] - 0.05);
224        if is_degradation {
225            return DetectedPattern::Degradation;
226        }
227    }
228
229    // 3. Near-loop check (same tool, similar args)
230    if same_tool
231        && len >= 3
232        && events
233            .windows(2)
234            .all(|w| jaro_winkler_similarity(&w[0].arguments, &w[1].arguments) > 0.85)
235    {
236        return DetectedPattern::NearLoop;
237    }
238
239    // 4. Multi-tool analysis (Exploration/Convergence)
240    if !same_tool && len >= 3 {
241        // Single-pass mean + mean-absolute-deviation over qualities.
242        let n = len as f32;
243        let avg = qualities.iter().sum::<f32>() / n;
244        let mad = qualities.iter().map(|q| (q - avg).abs()).sum::<f32>() / n;
245
246        if mad < 0.15 {
247            return DetectedPattern::Convergence;
248        }
249        return DetectedPattern::Exploration;
250    }
251
252    if !same_tool {
253        return DetectedPattern::Exploration;
254    }
255
256    DetectedPattern::Single
257}
258
259/// Execution summary statistics.
260#[derive(Clone, Debug)]
261pub struct ExecutionSummary {
262    pub total_executions: usize,
263    pub successful_executions: usize,
264    pub success_rate: f32,
265    pub average_quality: f32,
266    pub average_duration_ms: u64,
267    pub unique_tools: usize,
268    pub current_pattern: DetectedPattern,
269}
270
271impl Default for ExecutionSummary {
272    fn default() -> Self {
273        Self {
274            total_executions: 0,
275            successful_executions: 0,
276            success_rate: 0.0,
277            average_quality: 0.0,
278            average_duration_ms: 0,
279            unique_tools: 0,
280            current_pattern: DetectedPattern::Single,
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    fn make_event(tool: &str, args: &str, quality: f32) -> ExecutionEvent {
290        ExecutionEvent {
291            tool_name: tool.to_owned(),
292            arguments: args.to_owned(),
293            success: true,
294            quality_score: quality,
295            duration_ms: 100,
296            timestamp: 0,
297        }
298    }
299
300    #[test]
301    fn test_pattern_single() {
302        let engine = PatternEngine::new(100, 10);
303        engine.record(make_event("grep", "pattern:test", 0.8));
304        assert_eq!(engine.detect_pattern(), DetectedPattern::Single);
305    }
306
307    #[test]
308    fn test_pattern_loop() {
309        let engine = PatternEngine::new(100, 10);
310        for _ in 0..3 {
311            engine.record(make_event("grep", "pattern:test", 0.5));
312        }
313        assert_eq!(engine.detect_pattern(), DetectedPattern::Loop);
314    }
315
316    #[test]
317    fn test_pattern_refinement() {
318        let engine = PatternEngine::new(100, 10);
319        for (i, &quality) in [0.3f32, 0.5, 0.8].iter().enumerate() {
320            engine.record(ExecutionEvent {
321                tool_name: "grep".to_owned(),
322                arguments: format!("pattern:{i}"),
323                success: true,
324                quality_score: quality,
325                duration_ms: 100,
326                timestamp: i as u64,
327            });
328        }
329        assert_eq!(engine.detect_pattern(), DetectedPattern::Refinement);
330    }
331
332    #[test]
333    fn test_pattern_degradation() {
334        let engine = PatternEngine::new(100, 10);
335        for (i, &quality) in [0.9f32, 0.6, 0.2].iter().enumerate() {
336            engine.record(ExecutionEvent {
337                tool_name: "grep".to_owned(),
338                arguments: format!("pattern:{i}"),
339                success: true,
340                quality_score: quality,
341                duration_ms: 100,
342                timestamp: i as u64,
343            });
344        }
345        assert_eq!(engine.detect_pattern(), DetectedPattern::Degradation);
346    }
347
348    #[test]
349    fn test_predict_next_tool() {
350        let engine = PatternEngine::new(100, 10);
351        for (i, &tool) in ["grep", "read", "grep", "read"].iter().enumerate() {
352            engine.record(ExecutionEvent {
353                tool_name: tool.to_owned(),
354                arguments: "arg".to_owned(),
355                success: true,
356                quality_score: 0.8,
357                duration_ms: 100,
358                timestamp: i as u64,
359            });
360        }
361        assert_eq!(engine.predict_next_tool(), Some("grep".to_owned()));
362    }
363
364    #[test]
365    fn test_execution_summary() {
366        let engine = PatternEngine::new(100, 10);
367        for i in 0..5u64 {
368            engine.record(ExecutionEvent {
369                tool_name: "grep".to_owned(),
370                arguments: "arg".to_owned(),
371                success: i != 2,
372                quality_score: 0.8,
373                duration_ms: 100,
374                timestamp: i,
375            });
376        }
377
378        let summary = engine.summary();
379        assert_eq!(summary.total_executions, 5);
380        assert_eq!(summary.successful_executions, 4);
381        assert!((summary.success_rate - 0.8).abs() < f32::EPSILON);
382        assert_eq!(summary.unique_tools, 1);
383    }
384}