Skip to main content

hydra_compiler/
detector.rs

1//! PatternDetector — scans action history for repeated sequences.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7/// Configuration for pattern detection
8#[derive(Debug, Clone)]
9pub struct DetectorConfig {
10    /// Minimum occurrences to consider a pattern
11    pub min_occurrences: u32,
12    /// Minimum success rate (0.0 - 1.0)
13    pub min_success_rate: f64,
14    /// Maximum age of entries to consider (days)
15    pub max_age_days: u32,
16}
17
18impl Default for DetectorConfig {
19    fn default() -> Self {
20        Self {
21            min_occurrences: 3,
22            min_success_rate: 0.9,
23            max_age_days: 7,
24        }
25    }
26}
27
28/// A recorded action for pattern analysis
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ActionRecord {
31    pub action: String,
32    pub tool: String,
33    pub params_hash: String,
34    pub success: bool,
35    pub timestamp: String,
36}
37
38/// A detected repeated pattern
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct DetectedPattern {
41    pub signature: String,
42    pub actions: Vec<String>,
43    pub tools: Vec<String>,
44    pub occurrences: u32,
45    pub success_rate: f64,
46    pub compilable: bool,
47}
48
49/// Detects repeated action sequences from execution history
50pub struct PatternDetector {
51    config: DetectorConfig,
52    /// action_signature → occurrences
53    signatures: parking_lot::Mutex<HashMap<String, PatternStats>>,
54}
55
56#[derive(Debug, Clone)]
57struct PatternStats {
58    actions: Vec<String>,
59    tools: Vec<String>,
60    total: u32,
61    successes: u32,
62    _first_seen: String,
63    last_seen: String,
64}
65
66impl PatternDetector {
67    pub fn new(config: DetectorConfig) -> Self {
68        Self {
69            config,
70            signatures: parking_lot::Mutex::new(HashMap::new()),
71        }
72    }
73
74    pub fn with_defaults() -> Self {
75        Self::new(DetectorConfig::default())
76    }
77
78    /// Record an action sequence execution
79    pub fn record(&self, signature: &str, actions: &[String], tools: &[String], success: bool) {
80        let mut sigs = self.signatures.lock();
81        let now = chrono::Utc::now().to_rfc3339();
82
83        let entry = sigs
84            .entry(signature.to_string())
85            .or_insert_with(|| PatternStats {
86                actions: actions.to_vec(),
87                tools: tools.to_vec(),
88                total: 0,
89                successes: 0,
90                _first_seen: now.clone(),
91                last_seen: now.clone(),
92            });
93
94        entry.total += 1;
95        if success {
96            entry.successes += 1;
97        }
98        entry.last_seen = now;
99    }
100
101    /// Detect patterns that meet the compilation threshold
102    pub fn detect(&self) -> Vec<DetectedPattern> {
103        let sigs = self.signatures.lock();
104        let mut patterns = Vec::new();
105
106        for (sig, stats) in sigs.iter() {
107            if stats.total < self.config.min_occurrences {
108                continue;
109            }
110
111            let success_rate = stats.successes as f64 / stats.total as f64;
112            if success_rate < self.config.min_success_rate {
113                continue;
114            }
115
116            patterns.push(DetectedPattern {
117                signature: sig.clone(),
118                actions: stats.actions.clone(),
119                tools: stats.tools.clone(),
120                occurrences: stats.total,
121                success_rate,
122                compilable: true,
123            });
124        }
125
126        // Sort by occurrences (most frequent first)
127        patterns.sort_by(|a, b| b.occurrences.cmp(&a.occurrences));
128        patterns
129    }
130
131    /// Number of tracked signatures
132    pub fn signature_count(&self) -> usize {
133        self.signatures.lock().len()
134    }
135
136    /// Clear all tracked patterns
137    pub fn clear(&self) {
138        self.signatures.lock().clear();
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_no_patterns_initially() {
148        let detector = PatternDetector::with_defaults();
149        assert!(detector.detect().is_empty());
150    }
151
152    #[test]
153    fn test_pattern_detection() {
154        let detector = PatternDetector::with_defaults();
155        let actions = vec!["git add".into(), "git commit".into(), "git push".into()];
156        let tools = vec!["git_add".into(), "git_commit".into(), "git_push".into()];
157
158        // Record 5 successful executions
159        for _ in 0..5 {
160            detector.record("git-push-flow", &actions, &tools, true);
161        }
162
163        let patterns = detector.detect();
164        assert_eq!(patterns.len(), 1);
165        assert_eq!(patterns[0].signature, "git-push-flow");
166        assert_eq!(patterns[0].occurrences, 5);
167        assert!((patterns[0].success_rate - 1.0).abs() < 0.01);
168        assert!(patterns[0].compilable);
169    }
170
171    #[test]
172    fn test_below_threshold() {
173        let detector = PatternDetector::with_defaults();
174        // Only 2 occurrences (threshold is 3)
175        detector.record("rare", &["a".into()], &["t".into()], true);
176        detector.record("rare", &["a".into()], &["t".into()], true);
177        assert!(detector.detect().is_empty());
178    }
179
180    #[test]
181    fn test_low_success_rate() {
182        let detector = PatternDetector::with_defaults();
183        // 3 occurrences but 33% success rate (threshold 90%)
184        detector.record("flaky", &["a".into()], &["t".into()], true);
185        detector.record("flaky", &["a".into()], &["t".into()], false);
186        detector.record("flaky", &["a".into()], &["t".into()], false);
187        assert!(detector.detect().is_empty());
188    }
189
190    #[test]
191    fn test_signature_count() {
192        let detector = PatternDetector::with_defaults();
193        detector.record("a", &["x".into()], &["t".into()], true);
194        detector.record("b", &["y".into()], &["t".into()], true);
195        assert_eq!(detector.signature_count(), 2);
196    }
197
198    #[test]
199    fn test_clear() {
200        let detector = PatternDetector::with_defaults();
201        detector.record("a", &["x".into()], &["t".into()], true);
202        detector.clear();
203        assert_eq!(detector.signature_count(), 0);
204    }
205
206    #[test]
207    fn test_detector_config_default() {
208        let config = DetectorConfig::default();
209        assert_eq!(config.min_occurrences, 3);
210        assert_eq!(config.min_success_rate, 0.9);
211        assert_eq!(config.max_age_days, 7);
212    }
213
214    #[test]
215    fn test_custom_config_lower_threshold() {
216        let config = DetectorConfig { min_occurrences: 1, min_success_rate: 0.5, max_age_days: 30 };
217        let detector = PatternDetector::new(config);
218        detector.record("once", &["a".into()], &["t".into()], true);
219        let patterns = detector.detect();
220        assert_eq!(patterns.len(), 1);
221    }
222
223    #[test]
224    fn test_multiple_patterns_sorted() {
225        let config = DetectorConfig { min_occurrences: 1, min_success_rate: 0.0, max_age_days: 30 };
226        let detector = PatternDetector::new(config);
227        detector.record("rare", &["a".into()], &["t".into()], true);
228        for _ in 0..5 {
229            detector.record("frequent", &["b".into()], &["t".into()], true);
230        }
231        let patterns = detector.detect();
232        assert_eq!(patterns[0].signature, "frequent"); // most frequent first
233    }
234
235    #[test]
236    fn test_detected_pattern_serde() {
237        let pattern = DetectedPattern {
238            signature: "sig".into(),
239            actions: vec!["a".into()],
240            tools: vec!["t".into()],
241            occurrences: 5,
242            success_rate: 1.0,
243            compilable: true,
244        };
245        let json = serde_json::to_string(&pattern).unwrap();
246        let restored: DetectedPattern = serde_json::from_str(&json).unwrap();
247        assert_eq!(restored.signature, "sig");
248        assert_eq!(restored.occurrences, 5);
249    }
250
251    #[test]
252    fn test_action_record_serde() {
253        let record = ActionRecord {
254            action: "deploy".into(),
255            tool: "deploy_tool".into(),
256            params_hash: "abc123".into(),
257            success: true,
258            timestamp: "2026-01-01".into(),
259        };
260        let json = serde_json::to_string(&record).unwrap();
261        let restored: ActionRecord = serde_json::from_str(&json).unwrap();
262        assert_eq!(restored.action, "deploy");
263    }
264}