Skip to main content

lean_ctx/core/gotcha_tracker/
model.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::hash_map::DefaultHasher;
4use std::hash::{Hash, Hasher};
5
6pub(super) const MAX_GOTCHAS: usize = 100;
7pub(super) const MAX_SESSION_LOGS: usize = 20;
8pub(super) const MAX_PENDING: usize = 10;
9pub(super) const PENDING_TIMEOUT_SECS: i64 = 900; // 15 minutes
10pub(super) const DECAY_ARCHIVE_THRESHOLD: f32 = 0.15;
11const PROMOTION_CONFIDENCE: f32 = 0.9;
12const PROMOTION_OCCURRENCES: u32 = 5;
13const PROMOTION_SESSIONS: usize = 3;
14const PROMOTION_PREVENTED: u32 = 2;
15
16// ---------------------------------------------------------------------------
17// Core types
18// ---------------------------------------------------------------------------
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub enum GotchaCategory {
22    Build,
23    Test,
24    Config,
25    Runtime,
26    Dependency,
27    Platform,
28    Convention,
29    Security,
30}
31
32impl GotchaCategory {
33    pub fn from_str_loose(s: &str) -> Self {
34        match s.to_lowercase().as_str() {
35            "build" | "compile" => Self::Build,
36            "test" => Self::Test,
37            "config" | "configuration" => Self::Config,
38            "runtime" => Self::Runtime,
39            "dependency" | "dep" | "deps" => Self::Dependency,
40            "platform" | "os" => Self::Platform,
41            "security" | "sec" => Self::Security,
42            _ => Self::Convention,
43        }
44    }
45
46    pub fn short_label(&self) -> &'static str {
47        match self {
48            Self::Build => "build",
49            Self::Test => "test",
50            Self::Config => "config",
51            Self::Runtime => "runtime",
52            Self::Dependency => "dep",
53            Self::Platform => "platform",
54            Self::Convention => "conv",
55            Self::Security => "sec",
56        }
57    }
58}
59
60impl std::fmt::Display for GotchaCategory {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.write_str(self.short_label())
63    }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67pub enum GotchaSeverity {
68    Critical,
69    Warning,
70    Info,
71}
72
73impl GotchaSeverity {
74    pub fn multiplier(&self) -> f32 {
75        match self {
76            Self::Critical => 1.5,
77            Self::Warning => 1.0,
78            Self::Info => 0.7,
79        }
80    }
81
82    pub fn prefix(&self) -> &'static str {
83        match self {
84            Self::Critical | Self::Warning => "!",
85            Self::Info => "",
86        }
87    }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub enum GotchaSource {
92    AutoDetected { command: String, exit_code: i32 },
93    AgentReported { session_id: String },
94    CrossSessionCorrelated { sessions: Vec<String> },
95    Promoted { from_knowledge_key: String },
96}
97
98impl GotchaSource {
99    pub fn decay_rate(&self) -> f32 {
100        match self {
101            Self::Promoted { .. } => 0.01,
102            Self::AgentReported { .. } => 0.02,
103            Self::CrossSessionCorrelated { .. } => 0.03,
104            Self::AutoDetected { .. } => 0.05,
105        }
106    }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ProvenanceRef {
111    pub kind: String,
112    pub url: Option<String>,
113    pub commit_hash: Option<String>,
114    pub tool_call_id: Option<String>,
115    pub session_id: Option<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Gotcha {
120    pub id: String,
121    pub category: GotchaCategory,
122    pub severity: GotchaSeverity,
123    pub trigger: String,
124    pub resolution: String,
125    pub file_patterns: Vec<String>,
126    pub occurrences: u32,
127    pub session_ids: Vec<String>,
128    pub first_seen: DateTime<Utc>,
129    pub last_seen: DateTime<Utc>,
130    pub confidence: f32,
131    pub source: GotchaSource,
132    pub prevented_count: u32,
133    pub tags: Vec<String>,
134    #[serde(default)]
135    pub provenance: Vec<ProvenanceRef>,
136    #[serde(default)]
137    pub expires_at: Option<DateTime<Utc>>,
138    #[serde(default)]
139    pub decay_rate_override: Option<f32>,
140}
141
142impl Gotcha {
143    pub fn new(
144        category: GotchaCategory,
145        severity: GotchaSeverity,
146        trigger: &str,
147        resolution: &str,
148        source: GotchaSource,
149        session_id: &str,
150    ) -> Self {
151        let now = Utc::now();
152        let confidence = match &source {
153            GotchaSource::AgentReported { .. } => 0.9,
154            GotchaSource::CrossSessionCorrelated { .. } => 0.85,
155            GotchaSource::AutoDetected { .. } => 0.6,
156            GotchaSource::Promoted { .. } => 0.95,
157        };
158        Self {
159            id: gotcha_id(trigger, &category),
160            category,
161            severity,
162            trigger: trigger.to_string(),
163            resolution: resolution.to_string(),
164            file_patterns: Vec::new(),
165            occurrences: 1,
166            session_ids: vec![session_id.to_string()],
167            first_seen: now,
168            last_seen: now,
169            confidence,
170            source,
171            prevented_count: 0,
172            tags: Vec::new(),
173            provenance: Vec::new(),
174            expires_at: None,
175            decay_rate_override: None,
176        }
177    }
178
179    pub fn merge_with(&mut self, other: &Gotcha) {
180        self.occurrences += other.occurrences;
181        for sid in &other.session_ids {
182            if !self.session_ids.contains(sid) {
183                self.session_ids.push(sid.clone());
184            }
185        }
186        for fp in &other.file_patterns {
187            if !self.file_patterns.contains(fp) {
188                self.file_patterns.push(fp.clone());
189            }
190        }
191        if other.last_seen > self.last_seen {
192            self.last_seen = other.last_seen;
193            self.resolution.clone_from(&other.resolution);
194        }
195        self.confidence = self.confidence.max(other.confidence);
196    }
197
198    pub fn is_promotable(&self) -> bool {
199        self.confidence >= PROMOTION_CONFIDENCE
200            && self.occurrences >= PROMOTION_OCCURRENCES
201            && self.session_ids.len() >= PROMOTION_SESSIONS
202            && self.prevented_count >= PROMOTION_PREVENTED
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Pending errors (in-memory, not persisted)
208// ---------------------------------------------------------------------------
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct PendingError {
212    pub error_signature: String,
213    pub category: GotchaCategory,
214    pub severity: GotchaSeverity,
215    pub command: String,
216    pub exit_code: i32,
217    pub files_at_error: Vec<String>,
218    pub timestamp: DateTime<Utc>,
219    pub raw_snippet: String,
220    pub session_id: String,
221}
222
223impl PendingError {
224    pub fn is_expired(&self) -> bool {
225        (Utc::now() - self.timestamp).num_seconds() > PENDING_TIMEOUT_SECS
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Session error log (for cross-session correlation)
231// ---------------------------------------------------------------------------
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct SessionErrorLog {
235    pub session_id: String,
236    pub timestamp: DateTime<Utc>,
237    pub errors: Vec<ErrorEntry>,
238    pub fixes: Vec<FixEntry>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ErrorEntry {
243    pub signature: String,
244    pub command: String,
245    pub timestamp: DateTime<Utc>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct FixEntry {
250    pub error_signature: String,
251    pub resolution: String,
252    pub files_changed: Vec<String>,
253    pub timestamp: DateTime<Utc>,
254}
255
256// ---------------------------------------------------------------------------
257// Stats
258// ---------------------------------------------------------------------------
259
260#[derive(Debug, Clone, Serialize, Deserialize, Default)]
261pub struct GotchaStats {
262    pub total_errors_detected: u64,
263    pub total_fixes_correlated: u64,
264    pub total_prevented: u64,
265    pub gotchas_promoted: u64,
266    pub gotchas_decayed: u64,
267}
268
269// ---------------------------------------------------------------------------
270// GotchaStore
271// ---------------------------------------------------------------------------
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct GotchaStore {
275    pub project_hash: String,
276    pub gotchas: Vec<Gotcha>,
277    #[serde(default)]
278    pub error_log: Vec<SessionErrorLog>,
279    #[serde(default)]
280    pub stats: GotchaStats,
281    pub updated_at: DateTime<Utc>,
282
283    #[serde(skip)]
284    pub pending_errors: Vec<PendingError>,
285}
286
287impl GotchaStore {
288    pub fn new(project_hash: &str) -> Self {
289        Self {
290            project_hash: project_hash.to_string(),
291            gotchas: Vec::new(),
292            error_log: Vec::new(),
293            stats: GotchaStats::default(),
294            updated_at: Utc::now(),
295            pending_errors: Vec::new(),
296        }
297    }
298
299    pub fn clear(&mut self) {
300        self.gotchas.clear();
301        self.pending_errors.clear();
302        self.updated_at = Utc::now();
303    }
304}
305
306// ---------------------------------------------------------------------------
307// Helpers
308// ---------------------------------------------------------------------------
309
310pub(super) fn gotcha_id(trigger: &str, category: &GotchaCategory) -> String {
311    let mut hasher = DefaultHasher::new();
312    trigger.hash(&mut hasher);
313    category.short_label().hash(&mut hasher);
314    format!("{:016x}", hasher.finish())
315}