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 a_time = a.last_retrieved.unwrap();
272        for b in &retrieved[i + 1..] {
273            let b_time = b.last_retrieved.unwrap();
274            let diff = (a_time - b_time).abs();
275            if diff <= window {
276                let from = KnowledgeNodeRef::new(&a.category, &a.key);
277                let to = KnowledgeNodeRef::new(&b.category, &b.key);
278                if !graph.strengthen_edge(&from, &to, 0.15) {
279                    graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "hebbian");
280                }
281                strengthened += 1;
282            }
283        }
284    }
285
286    strengthened
287}
288
289/// Step 7: Decay confidence on stale facts, and decay edge counts for unseen edges.
290fn step_decay(
291    knowledge: &mut ProjectKnowledge,
292    graph: &mut KnowledgeRelationGraph,
293    policy: &MemoryPolicy,
294) -> u32 {
295    let lifecycle_cfg = crate::core::memory_lifecycle::LifecycleConfig {
296        max_facts: policy.knowledge.max_facts,
297        decay_rate_per_day: policy.lifecycle.decay_rate,
298        low_confidence_threshold: policy.lifecycle.low_confidence_threshold,
299        stale_days: policy.lifecycle.stale_days,
300        consolidation_similarity: policy.lifecycle.similarity_threshold,
301    };
302    crate::core::memory_lifecycle::apply_confidence_decay(&mut knowledge.facts, &lifecycle_cfg);
303
304    let low_conf_count = knowledge
305        .facts
306        .iter()
307        .filter(|f| f.is_current() && f.confidence < 0.3)
308        .count() as u32;
309
310    graph.decay_all_edges(1.0);
311    graph.prune_weak_edges(0.05);
312
313    let stale_cutoff = Utc::now() - Duration::days(EDGE_STALE_DAYS);
314    graph.edges.retain_mut(|e| {
315        let last = e.last_seen.unwrap_or(e.created_at);
316        if last < stale_cutoff {
317            if e.count <= 1 {
318                return false;
319            }
320            e.count = e.count.saturating_sub(1);
321        }
322        true
323    });
324
325    low_conf_count
326}
327
328fn slug_key(s: &str, max: usize) -> String {
329    let mut out = String::new();
330    for ch in s.chars() {
331        if out.len() >= max {
332            break;
333        }
334        if ch.is_ascii_alphanumeric() {
335            out.push(ch.to_ascii_lowercase());
336        } else if (ch.is_whitespace() || ch == '-' || ch == '_')
337            && !out.ends_with('-')
338            && !out.is_empty()
339        {
340            out.push('-');
341        }
342    }
343    out.trim_matches('-').to_string()
344}
345
346fn finding_salience(summary: &str) -> u32 {
347    let s = summary.to_lowercase();
348    let mut score = 20u32;
349    let boosts = [
350        ("error", 25),
351        ("failed", 25),
352        ("panic", 30),
353        ("assert", 20),
354        ("forbidden", 25),
355        ("timeout", 20),
356        ("deadlock", 25),
357        ("security", 25),
358        ("vuln", 25),
359        ("e0", 15),
360    ];
361    for (pat, b) in boosts {
362        if s.contains(pat) {
363            score = score.saturating_add(b);
364        }
365    }
366    score
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::core::knowledge::KnowledgeArchetype;
373    use crate::core::knowledge_relations::KnowledgeEdge;
374    use crate::core::memory_boundary::FactPrivacy;
375
376    fn make_fact(
377        category: &str,
378        key: &str,
379        value: &str,
380        confidence: f32,
381    ) -> crate::core::knowledge::KnowledgeFact {
382        crate::core::knowledge::KnowledgeFact {
383            category: category.to_string(),
384            key: key.to_string(),
385            value: value.to_string(),
386            source_session: "test".to_string(),
387            confidence,
388            created_at: Utc::now(),
389            last_confirmed: Utc::now(),
390            retrieval_count: 0,
391            last_retrieved: None,
392            valid_from: Some(Utc::now()),
393            valid_until: None,
394            supersedes: None,
395            confirmation_count: 1,
396            feedback_up: 0,
397            feedback_down: 0,
398            last_feedback: None,
399            privacy: FactPrivacy::default(),
400            imported_from: None,
401            archetype: KnowledgeArchetype::default(),
402            fidelity: None,
403        }
404    }
405
406    fn make_retrieved_fact(
407        category: &str,
408        key: &str,
409        value: &str,
410        retrieved_at: chrono::DateTime<Utc>,
411    ) -> crate::core::knowledge::KnowledgeFact {
412        let mut f = make_fact(category, key, value, 0.9);
413        f.last_retrieved = Some(retrieved_at);
414        f.retrieval_count = 1;
415        f
416    }
417
418    fn make_knowledge(
419        project_root: &str,
420        facts: Vec<crate::core::knowledge::KnowledgeFact>,
421    ) -> ProjectKnowledge {
422        ProjectKnowledge {
423            project_root: project_root.to_string(),
424            project_hash: "test-hash".to_string(),
425            facts,
426            patterns: Vec::new(),
427            history: Vec::new(),
428            updated_at: Utc::now(),
429        }
430    }
431
432    fn make_graph(edges: Vec<KnowledgeEdge>) -> KnowledgeRelationGraph {
433        KnowledgeRelationGraph {
434            project_hash: "test-hash".to_string(),
435            edges,
436            updated_at: Utc::now(),
437        }
438    }
439
440    fn make_edge(from_cat: &str, from_key: &str, to_cat: &str, to_key: &str) -> KnowledgeEdge {
441        KnowledgeEdge {
442            from: KnowledgeNodeRef::new(from_cat, from_key),
443            to: KnowledgeNodeRef::new(to_cat, to_key),
444            kind: KnowledgeEdgeKind::RelatedTo,
445            created_at: Utc::now(),
446            last_seen: Some(Utc::now()),
447            count: 1,
448            source_session: "test".to_string(),
449            strength: 0.5,
450            decay_rate: 0.02,
451        }
452    }
453
454    #[test]
455    fn structural_repair_removes_orphaned_edges() {
456        let knowledge = make_knowledge(
457            "/tmp/test",
458            vec![
459                make_fact("arch", "db", "PostgreSQL", 0.9),
460                make_fact("arch", "cache", "Redis", 0.8),
461            ],
462        );
463
464        let mut graph = make_graph(vec![
465            make_edge("arch", "db", "arch", "cache"),
466            make_edge("arch", "db", "arch", "nonexistent"),
467            make_edge("gone", "missing", "arch", "db"),
468        ]);
469
470        let removed = step_structural_repair(&mut graph, &knowledge);
471        assert_eq!(removed, 2);
472        assert_eq!(graph.edges.len(), 1);
473        assert_eq!(graph.edges[0].from.key, "db");
474        assert_eq!(graph.edges[0].to.key, "cache");
475    }
476
477    #[test]
478    fn lateral_synthesis_connects_similar_facts() {
479        let knowledge = make_knowledge(
480            "/tmp/test",
481            vec![
482                make_fact(
483                    "arch",
484                    "db",
485                    "PostgreSQL database primary storage backend",
486                    0.9,
487                ),
488                make_fact("arch", "cache", "Redis cache for sessions", 0.8),
489                make_fact(
490                    "deploy",
491                    "db-host",
492                    "PostgreSQL database primary storage on AWS",
493                    0.7,
494                ),
495            ],
496        );
497
498        let mut graph = make_graph(Vec::new());
499        let added = step_lateral_synthesis(&knowledge, &mut graph);
500
501        assert!(
502            added >= 1,
503            "Should connect facts sharing vocabulary (PostgreSQL database primary storage)"
504        );
505        assert!(
506            graph.edges.iter().any(|e| {
507                (e.from.key == "db" && e.to.key == "db-host")
508                    || (e.from.key == "db-host" && e.to.key == "db")
509            }),
510            "Should have edge between db and db-host"
511        );
512    }
513
514    #[test]
515    fn contradiction_resolution_keeps_higher_quality() {
516        let mut f1 = make_fact("arch", "db", "PostgreSQL", 0.9);
517        f1.confirmation_count = 3;
518        let f2 = make_fact("arch", "db", "MySQL", 0.5);
519
520        let mut knowledge = make_knowledge("/tmp/test", vec![f1, f2]);
521        let resolved = step_contradiction_resolution(&mut knowledge);
522
523        assert_eq!(resolved, 1);
524        let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
525        assert_eq!(current.len(), 1);
526        assert_eq!(current[0].value, "PostgreSQL");
527    }
528
529    #[test]
530    fn hebbian_strengthen_co_retrieval() {
531        let now = Utc::now();
532        let knowledge = make_knowledge(
533            "/tmp/test",
534            vec![
535                make_retrieved_fact("arch", "db", "PostgreSQL", now),
536                make_retrieved_fact("arch", "cache", "Redis", now - Duration::minutes(30)),
537                make_retrieved_fact("arch", "queue", "Kafka", now - Duration::hours(5)),
538            ],
539        );
540
541        let mut graph = make_graph(Vec::new());
542        let strengthened = step_hebbian_strengthen(&knowledge, &mut graph);
543
544        assert!(
545            strengthened >= 1,
546            "Should strengthen co-retrieved facts within 1h window"
547        );
548        let has_db_cache = graph.edges.iter().any(|e| {
549            (e.from.key == "db" && e.to.key == "cache")
550                || (e.from.key == "cache" && e.to.key == "db")
551        });
552        assert!(has_db_cache, "db and cache were retrieved within 1h");
553    }
554
555    #[test]
556    fn decay_reduces_stale_edge_counts() {
557        let old = Utc::now() - Duration::days(45);
558        let mut graph = make_graph(vec![
559            {
560                let mut e = make_edge("arch", "db", "arch", "cache");
561                e.last_seen = Some(old);
562                e.count = 3;
563                e
564            },
565            {
566                let mut e = make_edge("arch", "old", "arch", "ancient");
567                e.last_seen = Some(old);
568                e.count = 1;
569                e
570            },
571        ]);
572
573        let policy = MemoryPolicy::default();
574        let mut knowledge = make_knowledge(
575            "/tmp/test",
576            vec![
577                make_fact("arch", "db", "PostgreSQL", 0.9),
578                make_fact("arch", "cache", "Redis", 0.8),
579            ],
580        );
581
582        step_decay(&mut knowledge, &mut graph, &policy);
583
584        assert_eq!(
585            graph.edges.len(),
586            1,
587            "Edge with count=1 and stale should be removed"
588        );
589        assert_eq!(
590            graph.edges[0].count, 2,
591            "Edge with count=3 should be decremented to 2"
592        );
593    }
594
595    #[test]
596    fn cognition_loop_runs_all_steps() {
597        let _lock = crate::core::data_dir::test_env_lock();
598        let tmp = tempfile::tempdir().expect("tempdir");
599        std::env::set_var(
600            "LEAN_CTX_DATA_DIR",
601            tmp.path().to_string_lossy().to_string(),
602        );
603
604        let project_root = tmp.path().join("proj");
605        std::fs::create_dir_all(&project_root).expect("mkdir");
606        let project_root_str = project_root.to_string_lossy().to_string();
607
608        let policy = MemoryPolicy::default();
609        let mut knowledge = ProjectKnowledge::load_or_create(&project_root_str);
610        knowledge.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
611        knowledge.remember("arch", "cache", "Redis", "s1", 0.8, &policy);
612        knowledge.remember("deploy", "host", "AWS", "s1", 0.7, &policy);
613        let _ = knowledge.save();
614
615        let report = run_cognition_loop(&project_root_str, 8);
616        assert_eq!(report.steps_run, 8);
617
618        std::env::remove_var("LEAN_CTX_DATA_DIR");
619    }
620}