Skip to main content

vtcode_core/tools/
improvement_algorithms.rs

1//! Production-grade algorithms for tool improvements.
2//!
3//! Provides: Jaro-Winkler string similarity, time-decay effectiveness scoring,
4//! pattern detection over tool execution history, and ML-ready feature vectors.
5
6use crate::utils::current_timestamp;
7use serde::{Deserialize, Serialize};
8use smallvec::SmallVec;
9
10// ── String similarity ─────────────────────────────────────────────────────────
11
12/// Jaro-Winkler string similarity in [0.0, 1.0].
13///
14/// Preferred over Levenshtein for short strings (tool arguments) because it
15/// rewards matching prefixes, which is common in tool argument patterns.
16/// Delegates to the battle-tested [`strsim`](https://docs.rs/strsim) implementation.
17pub fn jaro_winkler_similarity(s1: &str, s2: &str) -> f32 {
18    strsim::jaro_winkler(s1, s2) as f32
19}
20
21// ── Time-decay scoring ────────────────────────────────────────────────────────
22
23/// Time-decay effectiveness score.
24///
25/// Recent successes are weighted higher. Decay follows:
26/// `score × exp(−λ × age_hours)`, default λ = 0.1 per hour.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct TimeDecayedScore {
29    /// Base score (0.0–1.0).
30    pub base_score: f32,
31    /// Age in seconds.
32    pub age_seconds: u64,
33    /// Decay constant.
34    pub decay_lambda: f32,
35    /// Decayed score (0.0–1.0).
36    pub decayed_score: f32,
37}
38
39impl TimeDecayedScore {
40    /// Calculate a time-decayed score for `base_score` recorded at `timestamp`.
41    pub fn calculate(base_score: f32, timestamp: u64) -> Self {
42        const DEFAULT_LAMBDA: f32 = 0.1;
43        let now = current_timestamp();
44        let age_seconds = now.saturating_sub(timestamp);
45        let age_hours = age_seconds as f32 / 3600.0;
46        let decayed_score = (base_score * (-DEFAULT_LAMBDA * age_hours).exp()).clamp(0.0, 1.0);
47
48        Self {
49            base_score,
50            age_seconds,
51            decay_lambda: DEFAULT_LAMBDA,
52            decayed_score,
53        }
54    }
55
56    /// Return a copy with a custom decay constant applied.
57    pub fn with_decay(mut self, lambda: f32) -> Self {
58        let age_hours = self.age_seconds as f32 / 3600.0;
59        self.decayed_score = (self.base_score * (-lambda * age_hours).exp()).clamp(0.0, 1.0);
60        self.decay_lambda = lambda;
61        self
62    }
63}
64
65// ── Pattern detection ─────────────────────────────────────────────────────────
66
67/// Detected state in a tool-execution sequence.
68#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
69pub enum PatternState {
70    /// Single execution.
71    Single,
72    /// Two identical executions.
73    Duplicate,
74    /// Multiple identical executions (3+).
75    Loop,
76    /// Executions with slight variation (fuzzy match).
77    NearLoop,
78    /// Sequential quality improvement.
79    RefinementChain,
80    /// Multiple tools converging to similar quality.
81    Convergence,
82    /// Quality degrading over iterations.
83    Degradation,
84}
85
86/// A single tool-call record for pattern detection.
87///
88/// Shape: `(tool, args_hash, quality)` — named fields replace a bare tuple so
89/// the dimensional structure of the data is explicit in the type system.
90#[derive(Debug, Clone)]
91pub struct ToolCallRecord {
92    /// Tool name (e.g. `"grep"`, `"file_read"`).
93    pub tool: String,
94    /// Hash of the tool's arguments for fuzzy dedup.
95    pub args_hash: String,
96    /// Result quality in `[0, 1]`.
97    pub quality: f32,
98}
99
100/// Detect a pattern in a history of tool-call records.
101///
102/// Only the last `window_size` entries are examined.
103/// Returns [`PatternState::Single`] for an empty or single-entry history.
104///
105/// # Why a free function?
106/// The logic is stateless — `window_size` is the only parameter. A wrapper
107/// struct added no encapsulation and hurt discoverability (KISS).
108pub fn detect_pattern(history: &[ToolCallRecord], window_size: usize) -> PatternState {
109    if history.is_empty() {
110        return PatternState::Single;
111    }
112
113    // Work on the most recent `window_size` entries; borrow the slice directly
114    // (no intermediate Vec allocation).
115    let start = history.len().saturating_sub(window_size);
116    let recent = &history[start..];
117
118    if recent.len() < 2 {
119        return PatternState::Single;
120    }
121
122    let first = &recent[0];
123
124    // --- Exact duplicates ---
125    if recent.iter().all(|r| r.tool == first.tool && r.args_hash == first.args_hash) {
126        return if recent.len() >= 3 {
127            PatternState::Loop
128        } else {
129            PatternState::Duplicate
130        };
131    }
132
133    // --- Single combined scan -------------------------------------------------
134    // Collect qualities + same-tool flag + multi-tool flag in one pass.
135    let mut qualities = SmallVec::<[f32; 32]>::with_capacity(recent.len());
136    let mut same_tool = true;
137    let mut multi_tool = false;
138
139    for r in recent {
140        qualities.push(r.quality);
141        if r.tool != first.tool {
142            same_tool = false;
143            multi_tool = true;
144        }
145    }
146
147    // --- Quality trends (≥3 points) ---
148    if qualities.len() >= 3 {
149        if qualities.windows(2).all(|w| w[1] > w[0] + 0.05) {
150            return PatternState::RefinementChain;
151        }
152        if qualities.windows(2).all(|w| w[1] < w[0] - 0.05) {
153            return PatternState::Degradation;
154        }
155    }
156
157    // --- Near-loop: same tool, fuzzy args ---
158    // Use `.all()` directly — no intermediate Vec<f32> needed.
159    if same_tool
160        && recent.len() >= 3
161        && recent
162            .windows(2)
163            .all(|w| jaro_winkler_similarity(&w[0].args_hash, &w[1].args_hash) > 0.85)
164    {
165        return PatternState::NearLoop;
166    }
167
168    // --- Convergence: different tools, similar quality ---
169    if multi_tool {
170        let n = qualities.len() as f32;
171        let avg = qualities.iter().sum::<f32>() / n;
172        if qualities.iter().all(|&q| (q - avg).abs() < 0.1) {
173            return PatternState::Convergence;
174        }
175    }
176
177    PatternState::Single
178}
179
180// ── ML scoring ────────────────────────────────────────────────────────────────
181
182/// ML-ready scoring components for tool effectiveness.
183///
184/// Can be used as a feature vector for training models.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct MLScoreComponents {
187    /// Success rate (0–1).
188    pub success_rate: f32,
189    /// Average execution time (ms).
190    pub avg_execution_time: f32,
191    /// Result quality (0–1).
192    pub result_quality: f32,
193    /// Number of failure modes observed.
194    pub failure_count: usize,
195    /// Time since last use (hours).
196    pub age_hours: f32,
197    /// Usage frequency (calls per hour).
198    pub frequency: f32,
199    /// Confidence in measurement (0–1).
200    pub confidence: f32,
201}
202
203impl MLScoreComponents {
204    /// Combined ML score before time decay.
205    ///
206    /// Weights: success 40% + quality 30% + speed 15% + frequency 15%.
207    pub fn raw_score(&self) -> f32 {
208        (self.success_rate * 0.40)
209            + (self.result_quality * 0.30)
210            + ((10_000.0 - self.avg_execution_time).max(0.0) / 10_000.0 * 0.15)
211            + (self.frequency.min(1.0) * 0.15)
212    }
213
214    /// Apply confidence decay for older measurements (1-week half-life).
215    pub fn with_age_decay(mut self) -> Self {
216        // Older measurements are less reliable; decay confidence over time.
217        self.confidence = (self.confidence * (-self.age_hours / 168.0).exp()).max(0.1);
218        self
219    }
220
221    /// Return a 7-element feature vector, normalised for ML consumption.
222    ///
223    /// # Dimension key
224    ///
225    /// | Index | Name              | Meaning                          | Normalisation      |
226    /// |-------|-------------------|----------------------------------|--------------------|
227    /// | 0     | `success_rate`    | Fraction of successful calls     | `[0, 1]`           |
228    /// | 1     | `avg_exec_time`   | Mean execution time (ms)         | `/ 10_000`         |
229    /// | 2     | `result_quality`  | Mean quality of results          | `[0, 1]`           |
230    /// | 3     | `failure_count`   | Distinct failure modes observed  | `min(10) / 10`     |
231    /// | 4     | `age_hours`       | Time since last use (hours)      | `/ 168` (1 week)   |
232    /// | 5     | `frequency`       | Calls per hour                   | `[0, ∞)`           |
233    /// | 6     | `confidence`      | Measurement confidence           | `[0, 1]`           |
234    pub fn to_feature_vector(&self) -> [f32; 7] {
235        [
236            self.success_rate,
237            self.avg_execution_time / 10_000.0,
238            self.result_quality,
239            (self.failure_count as f32).min(10.0) / 10.0,
240            self.age_hours / 168.0,
241            self.frequency,
242            self.confidence,
243        ]
244    }
245}
246
247// ── Tests ─────────────────────────────────────────────────────────────────────
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_jaro_winkler_exact() {
255        assert!((jaro_winkler_similarity("hello", "hello") - 1.0).abs() < f32::EPSILON);
256    }
257
258    #[test]
259    fn test_jaro_winkler_partial() {
260        let sim = jaro_winkler_similarity("pattern", "pattern_file");
261        assert!(sim > 0.85 && sim < 1.0, "sim={sim}");
262    }
263
264    #[test]
265    fn test_jaro_winkler_prefix_boost() {
266        let with_prefix = jaro_winkler_similarity("test_one", "test_two");
267        let without = jaro_winkler_similarity("one_test", "two_test");
268        assert!(with_prefix > without, "prefix boost should be applied");
269    }
270
271    #[test]
272    fn test_time_decay_ordering() {
273        let now = current_timestamp();
274        let recent = TimeDecayedScore::calculate(0.9, now);
275        let old = TimeDecayedScore::calculate(0.9, now.saturating_sub(7 * 24 * 3600));
276        assert!(old.decayed_score < recent.decayed_score);
277    }
278
279    #[test]
280    fn test_detect_pattern_loop() {
281        let history = vec![
282            ToolCallRecord {
283                tool: "grep".into(),
284                args_hash: "pattern1".into(),
285                quality: 0.5,
286            },
287            ToolCallRecord {
288                tool: "grep".into(),
289                args_hash: "pattern1".into(),
290                quality: 0.5,
291            },
292            ToolCallRecord {
293                tool: "grep".into(),
294                args_hash: "pattern1".into(),
295                quality: 0.5,
296            },
297        ];
298        assert_eq!(detect_pattern(&history, 10), PatternState::Loop);
299    }
300
301    #[test]
302    fn test_detect_pattern_refinement() {
303        let history = vec![
304            ToolCallRecord {
305                tool: "grep".into(),
306                args_hash: "pat1".into(),
307                quality: 0.3,
308            },
309            ToolCallRecord {
310                tool: "grep".into(),
311                args_hash: "pat2".into(),
312                quality: 0.5,
313            },
314            ToolCallRecord {
315                tool: "grep".into(),
316                args_hash: "pat3".into(),
317                quality: 0.8,
318            },
319        ];
320        assert_eq!(detect_pattern(&history, 10), PatternState::RefinementChain);
321    }
322
323    #[test]
324    fn test_detect_pattern_near_loop_requires_three_entries() {
325        let two_entries = vec![
326            ToolCallRecord {
327                tool: "grep".into(),
328                args_hash: "pattern-one".into(),
329                quality: 0.4,
330            },
331            ToolCallRecord {
332                tool: "grep".into(),
333                args_hash: "pattern-two".into(),
334                quality: 0.45,
335            },
336        ];
337        assert_eq!(detect_pattern(&two_entries, 10), PatternState::Single);
338
339        let three_entries = vec![
340            ToolCallRecord {
341                tool: "grep".into(),
342                args_hash: "pattern-one".into(),
343                quality: 0.4,
344            },
345            ToolCallRecord {
346                tool: "grep".into(),
347                args_hash: "pattern-two".into(),
348                quality: 0.45,
349            },
350            ToolCallRecord {
351                tool: "grep".into(),
352                args_hash: "pattern-three".into(),
353                quality: 0.5,
354            },
355        ];
356        assert_eq!(detect_pattern(&three_entries, 10), PatternState::NearLoop);
357    }
358
359    #[test]
360    fn test_ml_raw_score() {
361        let c = MLScoreComponents {
362            success_rate: 0.9,
363            avg_execution_time: 100.0,
364            result_quality: 0.85,
365            failure_count: 1,
366            age_hours: 2.0,
367            frequency: 0.5,
368            confidence: 0.9,
369        };
370        let score = c.raw_score();
371        assert!(score > 0.7 && score < 1.0, "score={score}");
372    }
373
374    #[test]
375    fn test_ml_feature_vector_length() {
376        let c = MLScoreComponents {
377            success_rate: 0.9,
378            avg_execution_time: 100.0,
379            result_quality: 0.85,
380            failure_count: 1,
381            age_hours: 2.0,
382            frequency: 0.5,
383            confidence: 0.9,
384        };
385        assert_eq!(c.to_feature_vector().len(), 7);
386    }
387}