Skip to main content

mneme/
learn.rs

1//! Failure mining — aprende de sesiones fallidas para auto-corregir memorias.
2//!
3//! Inspirado por Headroom's `headroom learn` (mines failed agent sessions and
4//! writes corrections to `CLAUDE.md`/`AGENTS.md`).
5//!
6//! Proceso:
7//! 1. Detectar sesiones con outcomes `failure` o memorias con feedback negativo
8//! 2. Analizar patterns recurrentes (e.g. "missing_context", "outdated_advice")
9//! 3. Generar memorias correctivas via `mem_corrective` con la signatura
10//! 4. Persistir en `failure_patterns` + `corrective_memories` para audit
11
12use std::collections::HashMap;
13
14use chrono::Utc;
15use serde::{Deserialize, Serialize};
16
17use crate::store::db::Database;
18use crate::store::memory::{CreateMemoryInput, Importance, Memory, MemoryType, Scope};
19
20/// Outcome de una sesión: success o failure con razones.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22#[serde(rename_all = "snake_case")]
23pub enum SessionOutcome {
24    Success,
25    PartialSuccess,
26    Failure { reasons: Vec<String> },
27}
28
29/// Reporte de un análisis de failures.
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct FailureReport {
32    pub project: String,
33    pub sessions_analyzed: u32,
34    pub failed_sessions: u32,
35    pub not_useful_memories: u32,
36    pub patterns_found: u32,
37    pub corrective_memories_generated: u32,
38    pub patterns: Vec<FailurePattern>,
39}
40
41/// Pattern de failure detectado.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct FailurePattern {
44    pub id: Option<i64>,
45    pub pattern_key: String,
46    pub description: String,
47    pub frequency: u32,
48    pub confidence: f32,
49    pub corrective_memory_title: Option<String>,
50    pub corrective_memory_id: Option<String>,
51}
52
53/// Miner de failures.
54pub struct FailureMiner {
55    db: std::sync::Arc<Database>,
56}
57
58impl FailureMiner {
59    pub fn new(db: std::sync::Arc<Database>) -> Self {
60        Self { db }
61    }
62
63    /// Registra el outcome de una sesión.
64    pub fn record_session_outcome(
65        &self,
66        session_id: uuid::Uuid,
67        outcome: SessionOutcome,
68        affected_files: u32,
69        bugs_introduced: u32,
70        user_corrections: Option<&str>,
71    ) -> crate::error::Result<()> {
72        let conn = self.db.get_conn();
73        let conn = conn
74            .lock()
75            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
76
77        let outcome_str = match &outcome {
78            SessionOutcome::Success => "success",
79            SessionOutcome::PartialSuccess => "partial",
80            SessionOutcome::Failure { .. } => "failure",
81        };
82        let reasons_json = match &outcome {
83            SessionOutcome::Failure { reasons } => Some(serde_json::to_string(reasons)?),
84            _ => None,
85        };
86
87        conn.execute(
88            "UPDATE sessions SET outcome = ?1, failure_reasons = ?2,
89                               affected_files = ?3, bugs_introduced = ?4,
90                               user_corrections = ?5
91             WHERE id = ?6",
92            rusqlite::params![
93                outcome_str,
94                reasons_json,
95                affected_files as i64,
96                bugs_introduced as i64,
97                user_corrections,
98                session_id.to_string()
99            ],
100        )?;
101        Ok(())
102    }
103
104    /// Ejecuta el análisis completo: sesiones failure + feedback not_useful → patterns.
105    /// Genera memorias correctivas automáticamente para los patterns más frecuentes.
106    pub fn mine(&self, project: &str) -> crate::error::Result<FailureReport> {
107        tracing::info!(project = %project, "Starting failure mining");
108        let mut report = FailureReport {
109            project: project.to_string(),
110            ..Default::default()
111        };
112
113        // 1. Recopilar sesiones failure
114        let failed_sessions = self.find_failed_sessions(project)?;
115        report.sessions_analyzed = self.count_sessions(project)?;
116        report.failed_sessions = failed_sessions.len() as u32;
117
118        // 2. Recopilar memorias con feedback not_useful
119        let not_useful = self.find_not_useful_memories(project)?;
120        report.not_useful_memories = not_useful.len() as u32;
121
122        // 3. Detectar patterns
123        let mut pattern_signals: HashMap<String, PatternSignal> = HashMap::new();
124
125        for session in &failed_sessions {
126            // Analizar failure_reasons JSON
127            if let Some(ref reasons_json) = session.failure_reasons {
128                if let Ok(reasons) = serde_json::from_str::<Vec<String>>(reasons_json) {
129                    for r in reasons {
130                        pattern_signals
131                            .entry(r.clone())
132                            .or_insert_with(|| PatternSignal::new(&r))
133                            .sessions
134                            .push(session.id);
135                    }
136                }
137            }
138            // Si bugs_introduced > 0, signal "introduces_bugs"
139            if session.bugs_introduced > 0 {
140                pattern_signals
141                    .entry("introduces_bugs".to_string())
142                    .or_insert_with(|| PatternSignal::new("introduces_bugs"))
143                    .sessions
144                    .push(session.id);
145            }
146            // Si affected_files > 5 y no bugs, signal "scope_creep"
147            if session.affected_files > 5 && session.bugs_introduced == 0 {
148                pattern_signals
149                    .entry("scope_creep".to_string())
150                    .or_insert_with(|| PatternSignal::new("scope_creep"))
151                    .sessions
152                    .push(session.id);
153            }
154        }
155
156        // 4. Para memorias not_useful, clasificar por tipo
157        for mem in &not_useful {
158            let key = match mem.memory_type {
159                MemoryType::Decision => "outdated_decision",
160                MemoryType::Pattern => "outdated_pattern",
161                MemoryType::Convention => "outdated_convention",
162                MemoryType::Architecture => "outdated_architecture",
163                _ => "low_quality_memory",
164            };
165            pattern_signals
166                .entry(key.to_string())
167                .or_insert_with(|| PatternSignal::new(key))
168                .memories
169                .push(mem.id);
170        }
171
172        // 5. Generar o actualizar patterns en DB + corrective memories
173        for (key, signal) in pattern_signals {
174            let total = signal.sessions.len() + signal.memories.len();
175            if total < 1 {
176                continue;
177            }
178
179            let description = signal.description();
180            let corrective_title = signal.corrective_title();
181
182            // Save or update pattern
183            let pattern_id = self.upsert_pattern(project, &key, &description, total as u32)?;
184            report.patterns_found += 1;
185
186            // Generate a corrective memory (if not exists)
187            let corrective_id = self.generate_corrective_memory(
188                project,
189                &key,
190                &description,
191                &corrective_title,
192                total as u32,
193            )?;
194            if corrective_id.is_some() {
195                report.corrective_memories_generated += 1;
196            }
197
198            // Link corrective to pattern
199            if let (Some(pid), Some(cid)) = (pattern_id, corrective_id) {
200                let conn = self.db.get_conn();
201                let conn = conn
202                    .lock()
203                    .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
204                let _ = conn.execute(
205                    "UPDATE failure_patterns SET corrective_memory_id = ?1 WHERE id = ?2",
206                    rusqlite::params![cid.to_string(), pid],
207                );
208                let _ = conn.execute(
209                    "INSERT OR IGNORE INTO corrective_memories (project, failure_pattern_id, generated_memory_id, rationale, user_accepted)
210                     VALUES (?1, ?2, ?3, ?4, 0)",
211                    rusqlite::params![project, pid, cid.to_string(), description],
212                );
213            }
214
215            report.patterns.push(FailurePattern {
216                id: pattern_id,
217                pattern_key: key,
218                description,
219                frequency: total as u32,
220                confidence: signal.confidence(),
221                corrective_memory_title: corrective_id.map(|_| corrective_title),
222                corrective_memory_id: corrective_id.map(|u| u.to_string()),
223            });
224        }
225
226        Ok(report)
227    }
228
229    fn find_failed_sessions(&self, project: &str) -> crate::error::Result<Vec<SessionFailureInfo>> {
230        let conn = self.db.get_conn();
231        let conn = conn
232            .lock()
233            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
234        let mut stmt = conn.prepare(
235            "SELECT id, outcome, failure_reasons, affected_files, bugs_introduced
236             FROM sessions
237             WHERE project = ?1 AND outcome = 'failure' AND ended_at IS NOT NULL
238             ORDER BY started_at DESC LIMIT 100",
239        )?;
240        let rows = stmt.query_map(rusqlite::params![project], |row| {
241            Ok(SessionFailureInfo {
242                id: uuid::Uuid::parse_str(&row.get::<_, String>(0)?).map_err(|e| {
243                    rusqlite::Error::FromSqlConversionFailure(
244                        0,
245                        rusqlite::types::Type::Text,
246                        Box::new(e),
247                    )
248                })?,
249                failure_reasons: row.get(2)?,
250                affected_files: row.get::<_, i64>(3)? as u32,
251                bugs_introduced: row.get::<_, i64>(4)? as u32,
252            })
253        })?;
254        let mut sessions = Vec::new();
255        for r in rows {
256            sessions.push(r?);
257        }
258        Ok(sessions)
259    }
260
261    fn count_sessions(&self, project: &str) -> crate::error::Result<u32> {
262        let conn = self.db.get_conn();
263        let conn = conn
264            .lock()
265            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
266        let count: u32 = conn
267            .query_row(
268                "SELECT COUNT(*) FROM sessions WHERE project = ?1",
269                rusqlite::params![project],
270                |row| row.get(0),
271            )
272            .unwrap_or(0);
273        Ok(count)
274    }
275
276    fn find_not_useful_memories(&self, project: &str) -> crate::error::Result<Vec<Memory>> {
277        let conn = self.db.get_conn();
278        let conn = conn
279            .lock()
280            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
281        // Find memories with >= 2 negative feedbacks
282        let _stmt = conn.prepare(
283            "SELECT m.id, m.project, m.scope, m.title, m.content, m.what, m.why, m.context, m.learned,
284                    m.memory_type, m.importance, m.tags, m.topic_key, m.access_count, m.revision_count,
285                    m.duplicate_count, m.normalized_hash, m.created_at, m.updated_at, m.last_accessed_at, m.last_seen_at, m.deleted_at,
286                    m.deprecated_at, m.deprecated_reason, m.supersedes_id, m.context_inject_count, m.origin_peer,
287                    m.is_encrypted, m.encrypted_for
288             FROM memories m
289             JOIN memory_feedback f ON m.id = f.memory_id
290             WHERE m.project = ?1 AND m.deleted_at IS NULL AND f.is_useful = 0
291             GROUP BY m.id
292             HAVING COUNT(*) >= 2
293             LIMIT 100"
294        )?;
295        // Single efficient query: find memories with >= 2 negative feedbacks
296        let mut stmt = conn.prepare(
297            "SELECT id, title, content, memory_type
298             FROM memories
299             WHERE project = ?1 AND deleted_at IS NULL
300             AND id IN (
301                SELECT memory_id FROM memory_feedback WHERE is_useful = 0 GROUP BY memory_id HAVING COUNT(*) >= 2
302             )
303             LIMIT 100"
304        )?;
305        let rows = stmt.query_map(rusqlite::params![project], |row| {
306            Ok(NotUsefulRef {
307                id: uuid::Uuid::parse_str(&row.get::<_, String>(0)?).map_err(|e| {
308                    rusqlite::Error::FromSqlConversionFailure(
309                        0,
310                        rusqlite::types::Type::Text,
311                        Box::new(e),
312                    )
313                })?,
314                title: row.get(1)?,
315                content: row.get(2)?,
316                memory_type_str: row.get(3)?,
317            })
318        })?;
319        let mut result = Vec::new();
320        for r in rows {
321            let r = r?;
322            result.push(Memory {
323                id: r.id,
324                project: project.to_string(),
325                scope: Scope::Project,
326                title: r.title,
327                content: r.content,
328                what: None,
329                why: None,
330                context: None,
331                learned: None,
332                memory_type: std::str::FromStr::from_str(&r.memory_type_str)
333                    .unwrap_or(MemoryType::Note),
334                importance: Importance::Medium,
335                tags: Vec::new(),
336                topic_key: None,
337                access_count: 0,
338                revision_count: 0,
339                duplicate_count: 0,
340                normalized_hash: None,
341                created_at: chrono::DateTime::UNIX_EPOCH,
342                updated_at: chrono::DateTime::UNIX_EPOCH,
343                last_accessed_at: None,
344                last_seen_at: None,
345                deleted_at: None,
346                deprecated_at: None,
347                deprecated_reason: None,
348                supersedes_id: None,
349                context_inject_count: 0,
350                origin_peer: None,
351                is_encrypted: false,
352                encrypted_for: None,
353                valid_from: None,
354                valid_until: None,
355                provenance: None,
356            });
357        }
358        Ok(result)
359    }
360
361    fn upsert_pattern(
362        &self,
363        project: &str,
364        key: &str,
365        description: &str,
366        freq: u32,
367    ) -> crate::error::Result<Option<i64>> {
368        let conn = self.db.get_conn();
369        let conn = conn
370            .lock()
371            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
372
373        let now = Utc::now().to_rfc3339();
374
375        // Check if exists
376        let existing: Option<i64> = conn
377            .query_row(
378                "SELECT id FROM failure_patterns WHERE project = ?1 AND pattern_key = ?2",
379                rusqlite::params![project, key],
380                |row| row.get(0),
381            )
382            .ok();
383
384        if let Some(id) = existing {
385            conn.execute(
386                "UPDATE failure_patterns SET frequency = frequency + ?1, last_seen = ?2 WHERE id = ?3",
387                rusqlite::params![freq as i64, now, id],
388            )?;
389            Ok(Some(id))
390        } else {
391            conn.execute(
392                "INSERT INTO failure_patterns (project, pattern_key, description, frequency, first_seen, last_seen, confidence)
393                 VALUES (?1, ?2, ?3, ?4, ?5, ?5, 0.5)",
394                rusqlite::params![project, key, description, freq as i64, now],
395            )?;
396            Ok(conn.last_insert_rowid().into())
397        }
398    }
399
400    fn generate_corrective_memory(
401        &self,
402        project: &str,
403        pattern_key: &str,
404        description: &str,
405        corrective_title: &str,
406        _freq: u32,
407    ) -> crate::error::Result<Option<uuid::Uuid>> {
408        // Check if a corrective memory for this pattern already exists in this project.
409        // Scope the lock so it's released before store.save (which also needs the lock).
410        let already_exists: bool = {
411            let conn = self.db.get_conn();
412            let conn = conn
413                .lock()
414                .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
415            conn.query_row(
416                "SELECT id FROM memories
417                 WHERE project = ?1 AND deleted_at IS NULL
418                 AND provenance LIKE ?2",
419                rusqlite::params![project, format!("learn/{}/%", pattern_key)],
420                |row| row.get::<_, String>(0),
421            )
422            .is_ok()
423        };
424
425        if already_exists {
426            return Ok(None);
427        }
428
429        // Save new memory with provenance pointing to the pattern
430        let input = CreateMemoryInput {
431            project: project.to_string(),
432            scope: Some(Scope::Project),
433            title: corrective_title.to_string(),
434            content: format!(
435                "**Patrón detectado:** `{}`\n\n**Descripción:** {}\n\n**Recomendación:** Revisar memorias relacionadas con este patrón. Considerar marcar como obsoletas las que generen este tipo de error.",
436                pattern_key, description
437            ),
438            what: Some(format!("Pattern: {}", pattern_key)),
439            why: Some(description.to_string()),
440            context: None,
441            learned: Some("Auto-generado por FailureMiner".to_string()),
442            memory_type: MemoryType::Learning,
443            importance: Importance::High,
444            tags: vec!["learned".to_string(), "pattern".to_string(), pattern_key.to_string()],
445            topic_key: Some(format!("learn/{}", pattern_key)),
446            capture_prompt: None,
447            encrypt: false,
448            valid_from: None,
449            valid_until: None,
450            provenance: Some(format!("learn/{}/auto", pattern_key)),
451        };
452
453        let store = self.db.memories();
454        let memory = store.save(input, None, None)?;
455        Ok(Some(memory.id))
456    }
457}
458
459/// Referencia a una memoria con feedback negativo.
460struct NotUsefulRef {
461    id: uuid::Uuid,
462    title: String,
463    content: String,
464    memory_type_str: String,
465}
466
467/// Info de una sesión failure.
468struct SessionFailureInfo {
469    id: uuid::Uuid,
470    failure_reasons: Option<String>,
471    affected_files: u32,
472    bugs_introduced: u32,
473}
474
475/// Señal acumulada de un pattern de failure.
476struct PatternSignal {
477    key: String,
478    sessions: Vec<uuid::Uuid>,
479    memories: Vec<uuid::Uuid>,
480}
481
482impl PatternSignal {
483    fn new(key: &str) -> Self {
484        Self {
485            key: key.to_string(),
486            sessions: Vec::new(),
487            memories: Vec::new(),
488        }
489    }
490
491    fn total(&self) -> u32 {
492        (self.sessions.len() + self.memories.len()) as u32
493    }
494
495    fn confidence(&self) -> f32 {
496        let total = self.total();
497        // Higher frequency = higher confidence. Cap at 0.95.
498        (total as f32 * 0.15).min(0.95)
499    }
500
501    fn description(&self) -> String {
502        match self.key.as_str() {
503            "missing_context" => {
504                "El agente no tuvo suficiente contexto al recuperar memorias".to_string()
505            }
506            "outdated_decision" => {
507                "Decisiones arquitectónicas marcadas como outdated por feedback".to_string()
508            }
509            "outdated_pattern" => {
510                "Patrones de código obsoletos según feedback de usuarios".to_string()
511            }
512            "outdated_convention" => "Convenciones del proyecto que ya no se aplican".to_string(),
513            "outdated_architecture" => "Decisiones arquitectónicas que cambiaron".to_string(),
514            "low_quality_memory" => {
515                "Memorias con feedback negativo recurrente — revisar calidad".to_string()
516            }
517            "introduces_bugs" => "Sesiones donde el agente introdujo bugs".to_string(),
518            "scope_creep" => "Sesiones que tocaron muchos archivos sin introducir bugs".to_string(),
519            _ => format!("Pattern custom: {}", self.key),
520        }
521    }
522
523    fn corrective_title(&self) -> String {
524        format!("[Learn] {}", self.key)
525    }
526}
527
528/// Formatea un reporte como tabla markdown.
529pub fn format_failure_report(report: &FailureReport) -> String {
530    let mut out = String::new();
531    out.push_str(&format!("# Failure Mining Report: {}\n\n", report.project));
532    out.push_str("## Resumen\n\n");
533    out.push_str(&format!(
534        "- Sesiones analizadas: {}\n",
535        report.sessions_analyzed
536    ));
537    out.push_str(&format!(
538        "- Sesiones con failure: {}\n",
539        report.failed_sessions
540    ));
541    out.push_str(&format!(
542        "- Memorias con feedback negativo: {}\n",
543        report.not_useful_memories
544    ));
545    out.push_str(&format!(
546        "- Patterns detectados: {}\n",
547        report.patterns_found
548    ));
549    out.push_str(&format!(
550        "- Memorias correctivas generadas: {}\n\n",
551        report.corrective_memories_generated
552    ));
553    if report.patterns.is_empty() {
554        out.push_str("No se detectaron patterns de failure. ¡Todo bien!\n");
555    } else {
556        out.push_str("## Patterns detectados\n\n");
557        out.push_str("| Key | Frecuencia | Confianza | Descripción | Correctiva |\n");
558        out.push_str("|-----|------------|-----------|-------------|------------|\n");
559        for p in &report.patterns {
560            let title = p.corrective_memory_title.as_deref().unwrap_or("-");
561            out.push_str(&format!(
562                "| `{}` | {} | {:.2} | {} | {} |\n",
563                p.pattern_key,
564                p.frequency,
565                p.confidence,
566                truncate_str(&p.description, 40),
567                title
568            ));
569        }
570    }
571    out
572}
573
574fn truncate_str(s: &str, max: usize) -> String {
575    if s.chars().count() <= max {
576        s.to_string()
577    } else {
578        let t: String = s.chars().take(max.saturating_sub(1)).collect();
579        format!("{}…", t)
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::store::memory::{CreateMemoryInput, Importance, MemoryType, Scope};
587
588    fn make_db() -> std::sync::Arc<Database> {
589        let path =
590            std::path::PathBuf::from(format!("/tmp/mneme_learn_test_{}.db", uuid::Uuid::new_v4()));
591        std::sync::Arc::new(Database::open(&path).unwrap())
592    }
593
594    #[test]
595    fn test_pattern_signal_confidence() {
596        let mut sig = PatternSignal::new("test");
597        assert!((sig.confidence() - 0.0).abs() < 0.001);
598        for _ in 0..5 {
599            sig.sessions.push(uuid::Uuid::new_v4());
600        }
601        assert!(sig.confidence() > 0.5);
602    }
603
604    #[test]
605    fn test_pattern_signal_description_known() {
606        let sig = PatternSignal::new("missing_context");
607        assert!(sig.description().contains("contexto"));
608    }
609
610    #[test]
611    fn test_pattern_signal_description_unknown() {
612        let sig = PatternSignal::new("weird_thing");
613        assert!(sig.description().contains("weird_thing"));
614    }
615
616    #[test]
617    fn test_format_empty_report() {
618        let report = FailureReport::default();
619        let s = format_failure_report(&report);
620        assert!(s.contains("Failure Mining Report"));
621        assert!(s.contains("No se detectaron"));
622    }
623
624    #[test]
625    fn test_format_populated_report() {
626        let report = FailureReport {
627            project: "test-proj".to_string(),
628            sessions_analyzed: 10,
629            failed_sessions: 3,
630            not_useful_memories: 2,
631            patterns_found: 1,
632            corrective_memories_generated: 1,
633            patterns: vec![FailurePattern {
634                id: Some(1),
635                pattern_key: "introduces_bugs".to_string(),
636                description: "Sesiones donde el agente introdujo bugs".to_string(),
637                frequency: 3,
638                confidence: 0.45,
639                corrective_memory_title: Some("[Learn] introduces_bugs".to_string()),
640                corrective_memory_id: Some("uuid".to_string()),
641            }],
642        };
643        let s = format_failure_report(&report);
644        assert!(s.contains("introduces_bugs"));
645        assert!(s.contains("[Learn]"));
646    }
647
648    #[test]
649    fn test_mine_on_empty_project() {
650        let db = make_db();
651        let miner = FailureMiner::new(db);
652        let report = miner.mine("empty-project").unwrap();
653        assert_eq!(report.sessions_analyzed, 0);
654        assert_eq!(report.patterns_found, 0);
655    }
656
657    #[test]
658    fn test_record_session_outcome_and_mine() {
659        let db = make_db();
660        let _memories = db.memories();
661        let sessions = db.sessions();
662        let session = sessions.start("test-proj", Some("/tmp")).unwrap();
663
664        // Record a failure outcome
665        let miner = FailureMiner::new(db.clone());
666        miner
667            .record_session_outcome(
668                session.id,
669                SessionOutcome::Failure {
670                    reasons: vec![
671                        "missing_context".to_string(),
672                        "outdated_decision".to_string(),
673                    ],
674                },
675                3,
676                1,
677                Some("Fixed typo in function signature"),
678            )
679            .unwrap();
680        sessions.end(session.id, Some("test summary")).unwrap();
681
682        // Mine
683        let report = miner.mine("test-proj").unwrap();
684        assert_eq!(report.sessions_analyzed, 1);
685        assert_eq!(report.failed_sessions, 1);
686        assert!(report.patterns_found >= 2); // missing_context + outdated_decision
687    }
688}