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