Skip to main content

lean_ctx/core/
cognition_loop.rs

1//! Hebbian-inspired Cognition Loop — periodic background reorganization of knowledge.
2//! Runs 8 steps: seed promote, structural repair, fidelity check, lateral synthesis,
3//! contradiction resolution, hebbian strengthen, decay, compact.
4
5use std::collections::HashSet;
6
7use chrono::{Duration, Utc};
8
9use crate::core::knowledge::ProjectKnowledge;
10use crate::core::knowledge_relations::{
11    KnowledgeEdgeKind, KnowledgeNodeRef, KnowledgeRelationGraph,
12};
13use crate::core::memory_policy::MemoryPolicy;
14
15const LATERAL_SIM_THRESHOLD: f64 = 0.3;
16const LATERAL_MAX_NEW_EDGES: usize = 20;
17const HEBBIAN_CO_RETRIEVAL_HOURS: i64 = 1;
18const EDGE_STALE_DAYS: i64 = 30;
19
20#[derive(Debug, Clone, Default)]
21pub struct CognitionLoopReport {
22    pub steps_run: u8,
23    pub facts_promoted: u32,
24    pub edges_repaired: u32,
25    pub edges_strengthened: u32,
26    pub facts_decayed: u32,
27    pub facts_archived: u32,
28    pub contradictions_resolved: u32,
29    pub lateral_connections: u32,
30    pub duration_ms: u64,
31}
32
33impl std::fmt::Display for CognitionLoopReport {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        write!(
36            f,
37            "Cognition Loop ({} steps, {}ms): promoted={}, repaired={}, \
38             strengthened={}, decayed={}, archived={}, contradictions={}, lateral={}",
39            self.steps_run,
40            self.duration_ms,
41            self.facts_promoted,
42            self.edges_repaired,
43            self.edges_strengthened,
44            self.facts_decayed,
45            self.facts_archived,
46            self.contradictions_resolved,
47            self.lateral_connections,
48        )
49    }
50}
51
52pub fn run_cognition_loop(project_root: &str, max_steps: u8) -> CognitionLoopReport {
53    let start = std::time::Instant::now();
54    let mut report = CognitionLoopReport::default();
55
56    let Ok(policy) = crate::core::config::Config::load().memory_policy_effective() else {
57        return report;
58    };
59
60    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
61    let project_hash = knowledge.project_hash.clone();
62    let mut graph = KnowledgeRelationGraph::load_or_create(&project_hash);
63
64    if max_steps >= 1 {
65        report.facts_promoted = step_seed_promote(project_root, &mut knowledge, &policy);
66        report.steps_run = 1;
67    }
68
69    if max_steps >= 2 {
70        report.edges_repaired = step_structural_repair(&mut graph, &knowledge);
71        report.steps_run = 2;
72    }
73
74    // Step 3: Fidelity Check (structural only, no LLM)
75    if max_steps >= 3 {
76        report.steps_run = 3;
77    }
78
79    if max_steps >= 4 {
80        report.lateral_connections = step_lateral_synthesis(&knowledge, &mut graph);
81        report.steps_run = 4;
82    }
83
84    if max_steps >= 5 {
85        report.contradictions_resolved = step_contradiction_resolution(&mut knowledge);
86        report.steps_run = 5;
87    }
88
89    if max_steps >= 6 {
90        report.edges_strengthened = step_hebbian_strengthen(&knowledge, &mut graph);
91        report.steps_run = 6;
92    }
93
94    if max_steps >= 7 {
95        report.facts_decayed = step_decay(&mut knowledge, &mut graph, &policy);
96        report.steps_run = 7;
97    }
98
99    if max_steps >= 8 {
100        let lifecycle = knowledge.run_memory_lifecycle(&policy);
101        report.facts_archived = lifecycle.archived_count as u32;
102        report.steps_run = 8;
103    }
104
105    let _ = knowledge.save();
106    let _ = graph.save();
107
108    report.duration_ms = start.elapsed().as_millis() as u64;
109    report
110}
111
112/// Step 1: Promote recent session decisions/findings into project knowledge.
113fn step_seed_promote(
114    _project_root: &str,
115    knowledge: &mut ProjectKnowledge,
116    policy: &MemoryPolicy,
117) -> u32 {
118    let Some(session) = crate::core::session::SessionState::load_latest() else {
119        return 0;
120    };
121
122    let mut count = 0u32;
123    let max_decisions = 5usize;
124    let max_findings = 8usize;
125
126    let mut decisions = session.decisions.clone();
127    decisions.sort_by_key(|d| std::cmp::Reverse(d.timestamp));
128    decisions.truncate(max_decisions);
129    for d in &decisions {
130        let key = slug_key(&d.summary, 50);
131        knowledge.remember("decision", &key, &d.summary, &session.id, 0.9, policy);
132        count += 1;
133    }
134
135    let mut findings = session.findings.clone();
136    findings.sort_by_key(|f| std::cmp::Reverse(f.timestamp));
137    let mut kept = 0usize;
138    for f in &findings {
139        if kept >= max_findings {
140            break;
141        }
142        if finding_salience(&f.summary) < 45 {
143            continue;
144        }
145        let key = if let Some(ref file) = f.file {
146            if let Some(line) = f.line {
147                format!("{file}:{line}")
148            } else {
149                file.clone()
150            }
151        } else {
152            format!("finding-{}", slug_key(&f.summary, 36))
153        };
154        knowledge.remember("finding", &key, &f.summary, &session.id, 0.75, policy);
155        count += 1;
156        kept += 1;
157    }
158
159    count
160}
161
162/// Step 2: Remove edges whose endpoints no longer exist in the knowledge store.
163fn step_structural_repair(graph: &mut KnowledgeRelationGraph, knowledge: &ProjectKnowledge) -> u32 {
164    let fact_ids: HashSet<String> = knowledge
165        .facts
166        .iter()
167        .filter(|f| f.is_current())
168        .map(|f| format!("{}/{}", f.category, f.key))
169        .collect();
170
171    let before = graph.edges.len();
172    graph
173        .edges
174        .retain(|e| fact_ids.contains(&e.from.id()) && fact_ids.contains(&e.to.id()));
175    (before - graph.edges.len()) as u32
176}
177
178/// Step 4: Connect related facts that share vocabulary but lack an explicit edge.
179fn step_lateral_synthesis(knowledge: &ProjectKnowledge, graph: &mut KnowledgeRelationGraph) -> u32 {
180    let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
181
182    let existing_pairs: HashSet<(String, String)> = graph
183        .edges
184        .iter()
185        .map(|e| (e.from.id(), e.to.id()))
186        .collect();
187
188    let mut added = 0u32;
189
190    for (i, a) in current.iter().enumerate() {
191        if added >= LATERAL_MAX_NEW_EDGES as u32 {
192            break;
193        }
194        for b in &current[i + 1..] {
195            if added >= LATERAL_MAX_NEW_EDGES as u32 {
196                break;
197            }
198            let id_a = format!("{}/{}", a.category, a.key);
199            let id_b = format!("{}/{}", b.category, b.key);
200            if existing_pairs.contains(&(id_a.clone(), id_b.clone()))
201                || existing_pairs.contains(&(id_b.clone(), id_a.clone()))
202            {
203                continue;
204            }
205            let sim = crate::core::memory_consolidation::token_jaccard(&a.value, &b.value);
206            if sim >= LATERAL_SIM_THRESHOLD {
207                let from = KnowledgeNodeRef::new(&a.category, &a.key);
208                let to = KnowledgeNodeRef::new(&b.category, &b.key);
209                graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "cognition-loop");
210                added += 1;
211            }
212        }
213    }
214
215    added
216}
217
218/// Step 5: Resolve contradictions — same category+key, different values.
219/// Keeps the fact with higher quality_score, archives the other.
220fn step_contradiction_resolution(knowledge: &mut ProjectKnowledge) -> u32 {
221    let now = Utc::now();
222    let mut resolved = 0u32;
223
224    let mut seen: std::collections::HashMap<(String, String), usize> =
225        std::collections::HashMap::new();
226    let mut to_archive: Vec<usize> = Vec::new();
227
228    for (i, f) in knowledge.facts.iter().enumerate() {
229        if !f.is_current() {
230            continue;
231        }
232        let key = (f.category.clone(), f.key.clone());
233        if let Some(&prev_idx) = seen.get(&key) {
234            let prev = &knowledge.facts[prev_idx];
235            if prev.value != f.value {
236                if prev.quality_score() >= f.quality_score() {
237                    to_archive.push(i);
238                } else {
239                    to_archive.push(prev_idx);
240                    seen.insert(key, i);
241                }
242                resolved += 1;
243            }
244        } else {
245            seen.insert(key, i);
246        }
247    }
248
249    for &idx in &to_archive {
250        knowledge.facts[idx].valid_until = Some(now);
251    }
252
253    resolved
254}
255
256/// Step 6: Strengthen edges between facts co-retrieved in the same session window.
257fn step_hebbian_strengthen(
258    knowledge: &ProjectKnowledge,
259    graph: &mut KnowledgeRelationGraph,
260) -> u32 {
261    let retrieved: Vec<_> = knowledge
262        .facts
263        .iter()
264        .filter(|f| f.is_current() && f.last_retrieved.is_some())
265        .collect();
266
267    let window = Duration::hours(HEBBIAN_CO_RETRIEVAL_HOURS);
268    let mut strengthened = 0u32;
269
270    for (i, a) in retrieved.iter().enumerate() {
271        let Some(a_time) = a.last_retrieved else {
272            continue;
273        };
274        for b in &retrieved[i + 1..] {
275            let Some(b_time) = b.last_retrieved else {
276                continue;
277            };
278            let diff = (a_time - b_time).abs();
279            if diff <= window {
280                let from = KnowledgeNodeRef::new(&a.category, &a.key);
281                let to = KnowledgeNodeRef::new(&b.category, &b.key);
282                if !graph.strengthen_edge(&from, &to, 0.15) {
283                    graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "hebbian");
284                }
285                strengthened += 1;
286            }
287        }
288    }
289
290    strengthened
291}
292
293/// Step 7: Decay confidence on stale facts, and decay edge counts for unseen edges.
294fn step_decay(
295    knowledge: &mut ProjectKnowledge,
296    graph: &mut KnowledgeRelationGraph,
297    policy: &MemoryPolicy,
298) -> u32 {
299    let lifecycle_cfg = crate::core::memory_lifecycle::LifecycleConfig {
300        max_facts: policy.knowledge.max_facts,
301        decay_rate_per_day: policy.lifecycle.decay_rate,
302        low_confidence_threshold: policy.lifecycle.low_confidence_threshold,
303        stale_days: policy.lifecycle.stale_days,
304        consolidation_similarity: policy.lifecycle.similarity_threshold,
305    };
306    crate::core::memory_lifecycle::apply_confidence_decay(&mut knowledge.facts, &lifecycle_cfg);
307
308    let low_conf_count = knowledge
309        .facts
310        .iter()
311        .filter(|f| f.is_current() && f.confidence < 0.3)
312        .count() as u32;
313
314    graph.decay_all_edges(1.0);
315    graph.prune_weak_edges(0.05);
316
317    let stale_cutoff = Utc::now() - Duration::days(EDGE_STALE_DAYS);
318    graph.edges.retain_mut(|e| {
319        let last = e.last_seen.unwrap_or(e.created_at);
320        if last < stale_cutoff {
321            if e.count <= 1 {
322                return false;
323            }
324            e.count = e.count.saturating_sub(1);
325        }
326        true
327    });
328
329    low_conf_count
330}
331
332fn slug_key(s: &str, max: usize) -> String {
333    let mut out = String::new();
334    for ch in s.chars() {
335        if out.len() >= max {
336            break;
337        }
338        if ch.is_ascii_alphanumeric() {
339            out.push(ch.to_ascii_lowercase());
340        } else if (ch.is_whitespace() || ch == '-' || ch == '_')
341            && !out.ends_with('-')
342            && !out.is_empty()
343        {
344            out.push('-');
345        }
346    }
347    out.trim_matches('-').to_string()
348}
349
350fn finding_salience(summary: &str) -> u32 {
351    let s = summary.to_lowercase();
352    let mut score = 20u32;
353    let boosts = [
354        ("error", 25),
355        ("failed", 25),
356        ("panic", 30),
357        ("assert", 20),
358        ("forbidden", 25),
359        ("timeout", 20),
360        ("deadlock", 25),
361        ("security", 25),
362        ("vuln", 25),
363        ("e0", 15),
364    ];
365    for (pat, b) in boosts {
366        if s.contains(pat) {
367            score = score.saturating_add(b);
368        }
369    }
370    score
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::core::knowledge::KnowledgeArchetype;
377    use crate::core::knowledge_relations::KnowledgeEdge;
378    use crate::core::memory_boundary::FactPrivacy;
379
380    fn make_fact(
381        category: &str,
382        key: &str,
383        value: &str,
384        confidence: f32,
385    ) -> crate::core::knowledge::KnowledgeFact {
386        crate::core::knowledge::KnowledgeFact {
387            category: category.to_string(),
388            key: key.to_string(),
389            value: value.to_string(),
390            source_session: "test".to_string(),
391            confidence,
392            created_at: Utc::now(),
393            last_confirmed: Utc::now(),
394            retrieval_count: 0,
395            last_retrieved: None,
396            valid_from: Some(Utc::now()),
397            valid_until: None,
398            supersedes: None,
399            confirmation_count: 1,
400            feedback_up: 0,
401            feedback_down: 0,
402            last_feedback: None,
403            privacy: FactPrivacy::default(),
404            imported_from: None,
405            archetype: KnowledgeArchetype::default(),
406            fidelity: None,
407            revision_count: 0,
408        }
409    }
410
411    fn make_retrieved_fact(
412        category: &str,
413        key: &str,
414        value: &str,
415        retrieved_at: chrono::DateTime<Utc>,
416    ) -> crate::core::knowledge::KnowledgeFact {
417        let mut f = make_fact(category, key, value, 0.9);
418        f.last_retrieved = Some(retrieved_at);
419        f.retrieval_count = 1;
420        f
421    }
422
423    fn make_knowledge(
424        project_root: &str,
425        facts: Vec<crate::core::knowledge::KnowledgeFact>,
426    ) -> ProjectKnowledge {
427        ProjectKnowledge {
428            project_root: project_root.to_string(),
429            project_hash: "test-hash".to_string(),
430            facts,
431            patterns: Vec::new(),
432            history: Vec::new(),
433            updated_at: Utc::now(),
434            judged_pairs: Vec::new(),
435        }
436    }
437
438    fn make_graph(edges: Vec<KnowledgeEdge>) -> KnowledgeRelationGraph {
439        KnowledgeRelationGraph {
440            project_hash: "test-hash".to_string(),
441            edges,
442            updated_at: Utc::now(),
443        }
444    }
445
446    fn make_edge(from_cat: &str, from_key: &str, to_cat: &str, to_key: &str) -> KnowledgeEdge {
447        KnowledgeEdge {
448            from: KnowledgeNodeRef::new(from_cat, from_key),
449            to: KnowledgeNodeRef::new(to_cat, to_key),
450            kind: KnowledgeEdgeKind::RelatedTo,
451            created_at: Utc::now(),
452            last_seen: Some(Utc::now()),
453            count: 1,
454            source_session: "test".to_string(),
455            strength: 0.5,
456            decay_rate: 0.02,
457        }
458    }
459
460    #[test]
461    fn structural_repair_removes_orphaned_edges() {
462        let knowledge = make_knowledge(
463            "/tmp/test",
464            vec![
465                make_fact("arch", "db", "PostgreSQL", 0.9),
466                make_fact("arch", "cache", "Redis", 0.8),
467            ],
468        );
469
470        let mut graph = make_graph(vec![
471            make_edge("arch", "db", "arch", "cache"),
472            make_edge("arch", "db", "arch", "nonexistent"),
473            make_edge("gone", "missing", "arch", "db"),
474        ]);
475
476        let removed = step_structural_repair(&mut graph, &knowledge);
477        assert_eq!(removed, 2);
478        assert_eq!(graph.edges.len(), 1);
479        assert_eq!(graph.edges[0].from.key, "db");
480        assert_eq!(graph.edges[0].to.key, "cache");
481    }
482
483    #[test]
484    fn lateral_synthesis_connects_similar_facts() {
485        let knowledge = make_knowledge(
486            "/tmp/test",
487            vec![
488                make_fact(
489                    "arch",
490                    "db",
491                    "PostgreSQL database primary storage backend",
492                    0.9,
493                ),
494                make_fact("arch", "cache", "Redis cache for sessions", 0.8),
495                make_fact(
496                    "deploy",
497                    "db-host",
498                    "PostgreSQL database primary storage on AWS",
499                    0.7,
500                ),
501            ],
502        );
503
504        let mut graph = make_graph(Vec::new());
505        let added = step_lateral_synthesis(&knowledge, &mut graph);
506
507        assert!(
508            added >= 1,
509            "Should connect facts sharing vocabulary (PostgreSQL database primary storage)"
510        );
511        assert!(
512            graph.edges.iter().any(|e| {
513                (e.from.key == "db" && e.to.key == "db-host")
514                    || (e.from.key == "db-host" && e.to.key == "db")
515            }),
516            "Should have edge between db and db-host"
517        );
518    }
519
520    #[test]
521    fn contradiction_resolution_keeps_higher_quality() {
522        let mut f1 = make_fact("arch", "db", "PostgreSQL", 0.9);
523        f1.confirmation_count = 3;
524        let f2 = make_fact("arch", "db", "MySQL", 0.5);
525
526        let mut knowledge = make_knowledge("/tmp/test", vec![f1, f2]);
527        let resolved = step_contradiction_resolution(&mut knowledge);
528
529        assert_eq!(resolved, 1);
530        let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
531        assert_eq!(current.len(), 1);
532        assert_eq!(current[0].value, "PostgreSQL");
533    }
534
535    #[test]
536    fn hebbian_strengthen_co_retrieval() {
537        let now = Utc::now();
538        let knowledge = make_knowledge(
539            "/tmp/test",
540            vec![
541                make_retrieved_fact("arch", "db", "PostgreSQL", now),
542                make_retrieved_fact("arch", "cache", "Redis", now - Duration::minutes(30)),
543                make_retrieved_fact("arch", "queue", "Kafka", now - Duration::hours(5)),
544            ],
545        );
546
547        let mut graph = make_graph(Vec::new());
548        let strengthened = step_hebbian_strengthen(&knowledge, &mut graph);
549
550        assert!(
551            strengthened >= 1,
552            "Should strengthen co-retrieved facts within 1h window"
553        );
554        let has_db_cache = graph.edges.iter().any(|e| {
555            (e.from.key == "db" && e.to.key == "cache")
556                || (e.from.key == "cache" && e.to.key == "db")
557        });
558        assert!(has_db_cache, "db and cache were retrieved within 1h");
559    }
560
561    #[test]
562    fn decay_reduces_stale_edge_counts() {
563        let old = Utc::now() - Duration::days(45);
564        let mut graph = make_graph(vec![
565            {
566                let mut e = make_edge("arch", "db", "arch", "cache");
567                e.last_seen = Some(old);
568                e.count = 3;
569                e
570            },
571            {
572                let mut e = make_edge("arch", "old", "arch", "ancient");
573                e.last_seen = Some(old);
574                e.count = 1;
575                e
576            },
577        ]);
578
579        let policy = MemoryPolicy::default();
580        let mut knowledge = make_knowledge(
581            "/tmp/test",
582            vec![
583                make_fact("arch", "db", "PostgreSQL", 0.9),
584                make_fact("arch", "cache", "Redis", 0.8),
585            ],
586        );
587
588        step_decay(&mut knowledge, &mut graph, &policy);
589
590        assert_eq!(
591            graph.edges.len(),
592            1,
593            "Edge with count=1 and stale should be removed"
594        );
595        assert_eq!(
596            graph.edges[0].count, 2,
597            "Edge with count=3 should be decremented to 2"
598        );
599    }
600
601    #[test]
602    fn cognition_loop_runs_all_steps() {
603        let _lock = crate::core::data_dir::test_env_lock();
604        let tmp = tempfile::tempdir().expect("tempdir");
605        std::env::set_var(
606            "LEAN_CTX_DATA_DIR",
607            tmp.path().to_string_lossy().to_string(),
608        );
609
610        let project_root = tmp.path().join("proj");
611        std::fs::create_dir_all(&project_root).expect("mkdir");
612        let project_root_str = project_root.to_string_lossy().to_string();
613
614        let policy = MemoryPolicy::default();
615        let mut knowledge = ProjectKnowledge::load_or_create(&project_root_str);
616        knowledge.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
617        knowledge.remember("arch", "cache", "Redis", "s1", 0.8, &policy);
618        knowledge.remember("deploy", "host", "AWS", "s1", 0.7, &policy);
619        let _ = knowledge.save();
620
621        let report = run_cognition_loop(&project_root_str, 8);
622        assert_eq!(report.steps_run, 8);
623
624        std::env::remove_var("LEAN_CTX_DATA_DIR");
625    }
626}