lean_ctx/core/gotcha_tracker/
reflector.rs1use std::collections::{BTreeMap, BTreeSet};
14
15use super::GotchaStore;
16use crate::core::session::EntryKind;
17
18const PITFALL_MIN_SESSIONS: usize = 2;
21const STRATEGY_MIN_OCCURRENCES: u32 = 2;
23const MAX_INSIGHTS: usize = 12;
25
26#[derive(Debug, Clone, PartialEq)]
28pub struct ReflectionInsight {
29 pub kind: EntryKind,
30 pub content: String,
31 pub confidence: f32,
32}
33
34pub fn reflect(store: &GotchaStore) -> Vec<ReflectionInsight> {
38 let mut insights: Vec<ReflectionInsight> = Vec::new();
39
40 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 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 confidence: (0.5 + 0.1 * sessions.len() as f32).min(0.85),
86 });
87 }
88 }
89
90 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
101pub 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
121pub 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
190fn 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}