Skip to main content

lean_ctx/core/gotcha_tracker/
reflector.rs

1//! Deterministic reflection over the gotcha trace (ACE Reflector analog).
2//!
3//! ACE (Agentic Context Engineering) turns raw execution traces into curated,
4//! reusable skillbook entries. lean-ctx now records a structured trace of real
5//! shell outcomes — the gotcha `error_log` plus correlated `gotchas`, populated
6//! by [`record_shell_outcome`](super::record_shell_outcome). This module
7//! distills that trace into [`ReflectionInsight`]s with deterministic,
8//! rule-based passes (no LLM), which the session [`Playbook`] folds in as
9//! Strategy / Pitfall deltas at checkpoint time.
10//!
11//! [`Playbook`]: crate::core::session::Playbook
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use super::GotchaStore;
16use crate::core::session::EntryKind;
17
18/// Distinct sessions an unresolved error must span to count as a recurring
19/// pitfall worth surfacing — one-off failures are noise.
20const PITFALL_MIN_SESSIONS: usize = 2;
21/// Occurrences a correlated fix needs before it is a proven, reusable strategy.
22const STRATEGY_MIN_OCCURRENCES: u32 = 2;
23/// Bound on emitted insights; the playbook caps anyway, so keep reflection cheap.
24const MAX_INSIGHTS: usize = 12;
25
26/// A distilled, reusable insight derived from the gotcha trace.
27#[derive(Debug, Clone, PartialEq)]
28pub struct ReflectionInsight {
29    pub kind: EntryKind,
30    pub content: String,
31    pub confidence: f32,
32}
33
34/// Distill the gotcha trace into reusable insights. Pure and deterministic:
35/// an identical store always yields byte-identical, identically-ordered output
36/// (no timestamps, no map iteration order — `BTree*` keep it stable).
37pub fn reflect(store: &GotchaStore) -> Vec<ReflectionInsight> {
38    let mut insights: Vec<ReflectionInsight> = Vec::new();
39
40    // Pass 1 — proven strategies: a gotcha whose fix recurred is reusable.
41    for g in &store.gotchas {
42        if g.occurrences >= STRATEGY_MIN_OCCURRENCES && !g.resolution.trim().is_empty() {
43            let trigger = crate::core::sanitize::neutralize_metadata(&g.trigger);
44            let resolution = crate::core::sanitize::neutralize_metadata(&g.resolution);
45            insights.push(ReflectionInsight {
46                kind: EntryKind::Strategy,
47                content: format!(
48                    "When `{}`: {}",
49                    short(&trigger, 80),
50                    short(&resolution, 100)
51                ),
52                confidence: g.confidence,
53            });
54        }
55    }
56
57    // Pass 2 — recurring unresolved pitfalls: error signatures seen across
58    // multiple sessions with no recorded fix anywhere in the log.
59    let fixed: BTreeSet<&str> = store
60        .error_log
61        .iter()
62        .flat_map(|l| l.fixes.iter().map(|f| f.error_signature.as_str()))
63        .collect();
64
65    let mut sig_sessions: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
66    for log in &store.error_log {
67        for e in &log.errors {
68            sig_sessions
69                .entry(e.signature.as_str())
70                .or_default()
71                .insert(log.session_id.as_str());
72        }
73    }
74    for (sig, sessions) in &sig_sessions {
75        if sessions.len() >= PITFALL_MIN_SESSIONS && !fixed.contains(*sig) {
76            let clean = crate::core::sanitize::neutralize_metadata(sig);
77            insights.push(ReflectionInsight {
78                kind: EntryKind::Pitfall,
79                content: format!(
80                    "Recurring unresolved error across {} sessions: {}",
81                    sessions.len(),
82                    short(&clean, 120)
83                ),
84                // Reach raises salience but stays below a proven fix's confidence.
85                confidence: (0.5 + 0.1 * sessions.len() as f32).min(0.85),
86            });
87        }
88    }
89
90    // Deterministic order: strongest first, ties broken by content.
91    insights.sort_by(|a, b| {
92        b.confidence
93            .partial_cmp(&a.confidence)
94            .unwrap_or(std::cmp::Ordering::Equal)
95            .then_with(|| a.content.cmp(&b.content))
96    });
97    insights.truncate(MAX_INSIGHTS);
98    insights
99}
100
101/// Fold reflection insights into a session playbook as deltas. Near-duplicates
102/// confirm the existing entry (the playbook's grow-and-refine invariant), so
103/// repeated checkpoints reinforce rather than bloat. Returns `(added, confirmed)`.
104pub fn fold_into_playbook(
105    insights: &[ReflectionInsight],
106    playbook: &mut crate::core::session::Playbook,
107    turn: u32,
108) -> (usize, usize) {
109    use crate::core::session::DeltaOutcome;
110
111    let (mut added, mut confirmed) = (0usize, 0usize);
112    for insight in insights {
113        match playbook.add_delta(insight.kind, &insight.content, turn) {
114            DeltaOutcome::Added(_) => added += 1,
115            DeltaOutcome::Confirmed(_) => confirmed += 1,
116        }
117    }
118    (added, confirmed)
119}
120
121/// Human-readable **Learning Ledger** — what lean-ctx has actually learned from
122/// real shell outcomes: proven error→fix strategies, recurring pitfalls, and the
123/// counts behind them. Honest counts only — no fabricated token-savings figures,
124/// since "repeat errors avoided" has no measured per-incident token cost.
125pub fn format_ledger(store: &GotchaStore) -> String {
126    let insights = reflect(store);
127    let s = &store.stats;
128
129    let mut out = String::from("LEARNING LEDGER — what lean-ctx learned from real runs\n");
130    out.push_str(&format!(
131        "  Errors observed:        {}\n",
132        s.total_errors_detected
133    ));
134    out.push_str(&format!(
135        "  Fixes correlated:       {}\n",
136        s.total_fixes_correlated
137    ));
138    out.push_str(&format!(
139        "  Repeat errors avoided:  {}\n",
140        s.total_prevented
141    ));
142    out.push_str(&format!(
143        "  Promoted to knowledge:  {}\n",
144        s.gotchas_promoted
145    ));
146    out.push_str(&format!(
147        "  Active gotchas:         {}\n",
148        store.gotchas.len()
149    ));
150
151    let strategies: Vec<&ReflectionInsight> = insights
152        .iter()
153        .filter(|i| i.kind == EntryKind::Strategy)
154        .collect();
155    let pitfalls: Vec<&ReflectionInsight> = insights
156        .iter()
157        .filter(|i| i.kind == EntryKind::Pitfall)
158        .collect();
159
160    if insights.is_empty() {
161        out.push_str(
162            "\nNo distilled insights yet — run builds/tests through lean-ctx and they accrue here.\n",
163        );
164        return out;
165    }
166
167    if !strategies.is_empty() {
168        out.push_str(&format!("\nProven strategies ({}):\n", strategies.len()));
169        for i in &strategies {
170            out.push_str(&format!(
171                "  • {} ({:.0}%)\n",
172                i.content,
173                i.confidence * 100.0
174            ));
175        }
176    }
177    if !pitfalls.is_empty() {
178        out.push_str(&format!("\nRecurring pitfalls ({}):\n", pitfalls.len()));
179        for i in &pitfalls {
180            out.push_str(&format!(
181                "  ! {} ({:.0}%)\n",
182                i.content,
183                i.confidence * 100.0
184            ));
185        }
186    }
187    out
188}
189
190/// Char-boundary-safe truncation with an ellipsis (keeps multibyte signatures
191/// from panicking on a byte slice).
192fn short(s: &str, max: usize) -> String {
193    let s = s.trim();
194    if s.chars().count() <= max {
195        return s.to_string();
196    }
197    let cut: String = s.chars().take(max.saturating_sub(1)).collect();
198    format!("{cut}…")
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::core::gotcha_tracker::{
205        ErrorEntry, FixEntry, Gotcha, GotchaCategory, GotchaSeverity, GotchaSource, SessionErrorLog,
206    };
207    use crate::core::session::Playbook;
208    use chrono::Utc;
209
210    fn gotcha(trigger: &str, resolution: &str, occurrences: u32) -> Gotcha {
211        let mut g = Gotcha::new(
212            GotchaCategory::Build,
213            GotchaSeverity::Critical,
214            trigger,
215            resolution,
216            GotchaSource::AutoDetected {
217                command: "cargo build".into(),
218                exit_code: 1,
219            },
220            "s1",
221        );
222        g.occurrences = occurrences;
223        g
224    }
225
226    fn error_log(session: &str, sig: &str) -> SessionErrorLog {
227        SessionErrorLog {
228            session_id: session.to_string(),
229            timestamp: Utc::now(),
230            errors: vec![ErrorEntry {
231                signature: sig.to_string(),
232                command: "cargo test".into(),
233                timestamp: Utc::now(),
234            }],
235            fixes: Vec::new(),
236        }
237    }
238
239    #[test]
240    fn reflects_proven_fix_as_strategy() {
241        let mut store = GotchaStore::new("h");
242        store
243            .gotchas
244            .push(gotcha("error E0507", "use clone() on the field", 3));
245        let insights = reflect(&store);
246        assert!(
247            insights
248                .iter()
249                .any(|i| i.kind == EntryKind::Strategy && i.content.contains("use clone()")),
250            "a recurring fix must surface as a Strategy: {insights:?}"
251        );
252    }
253
254    #[test]
255    fn skips_one_off_fix() {
256        let mut store = GotchaStore::new("h");
257        store.gotchas.push(gotcha("error E0507", "use clone()", 1));
258        assert!(
259            reflect(&store).is_empty(),
260            "a single occurrence is not yet a proven strategy"
261        );
262    }
263
264    #[test]
265    fn reflects_recurring_unresolved_error_as_pitfall() {
266        let mut store = GotchaStore::new("h");
267        store.error_log.push(error_log("s1", "flaky link error"));
268        store.error_log.push(error_log("s2", "flaky link error"));
269        let insights = reflect(&store);
270        assert!(
271            insights
272                .iter()
273                .any(|i| i.kind == EntryKind::Pitfall && i.content.contains("flaky link error")),
274            "an unresolved error across sessions must surface as a Pitfall: {insights:?}"
275        );
276    }
277
278    #[test]
279    fn resolved_error_is_not_a_pitfall() {
280        let mut store = GotchaStore::new("h");
281        store.error_log.push(error_log("s1", "fixed error"));
282        let mut s2 = error_log("s2", "fixed error");
283        s2.fixes.push(FixEntry {
284            error_signature: "fixed error".into(),
285            resolution: "added the missing import".into(),
286            files_changed: vec!["src/lib.rs".into()],
287            timestamp: Utc::now(),
288        });
289        store.error_log.push(s2);
290        assert!(
291            !reflect(&store).iter().any(|i| i.kind == EntryKind::Pitfall),
292            "an error with a recorded fix must not be flagged unresolved"
293        );
294    }
295
296    #[test]
297    fn single_session_error_is_not_a_pitfall() {
298        let mut store = GotchaStore::new("h");
299        store.error_log.push(error_log("s1", "one-off error"));
300        assert!(!reflect(&store).iter().any(|i| i.kind == EntryKind::Pitfall));
301    }
302
303    #[test]
304    fn output_is_deterministic() {
305        let mut store = GotchaStore::new("h");
306        store.gotchas.push(gotcha("err A", "fix A", 4));
307        store.gotchas.push(gotcha("err B", "fix B", 2));
308        store.error_log.push(error_log("s1", "recurring C"));
309        store.error_log.push(error_log("s2", "recurring C"));
310        assert_eq!(reflect(&store), reflect(&store));
311    }
312
313    #[test]
314    fn ledger_reports_counts_and_insights() {
315        let mut store = GotchaStore::new("h");
316        store.stats.total_errors_detected = 5;
317        store.stats.total_fixes_correlated = 2;
318        store.stats.total_prevented = 1;
319        store
320            .gotchas
321            .push(gotcha("error E0507", "use clone() on the field", 3));
322
323        let ledger = format_ledger(&store);
324        assert!(ledger.contains("LEARNING LEDGER"));
325        assert!(ledger.contains("Errors observed:        5"));
326        assert!(ledger.contains("Repeat errors avoided:  1"));
327        assert!(ledger.contains("Proven strategies (1)"));
328        assert!(ledger.contains("use clone()"));
329    }
330
331    #[test]
332    fn ledger_handles_empty_store() {
333        let store = GotchaStore::new("h");
334        let ledger = format_ledger(&store);
335        assert!(ledger.contains("LEARNING LEDGER"));
336        assert!(ledger.contains("No distilled insights yet"));
337    }
338
339    #[test]
340    fn fold_into_playbook_confirms_on_repeat() {
341        let insights = vec![ReflectionInsight {
342            kind: EntryKind::Strategy,
343            content: "When `cargo build` fails with E0507: clone the field".into(),
344            confidence: 0.9,
345        }];
346        let mut pb = Playbook::default();
347        assert_eq!(fold_into_playbook(&insights, &mut pb, 1), (1, 0));
348        assert_eq!(
349            fold_into_playbook(&insights, &mut pb, 2),
350            (0, 1),
351            "a second fold confirms, never duplicates"
352        );
353    }
354}