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            revision_count: 0,
404        }
405    }
406
407    fn make_retrieved_fact(
408        category: &str,
409        key: &str,
410        value: &str,
411        retrieved_at: chrono::DateTime<Utc>,
412    ) -> crate::core::knowledge::KnowledgeFact {
413        let mut f = make_fact(category, key, value, 0.9);
414        f.last_retrieved = Some(retrieved_at);
415        f.retrieval_count = 1;
416        f
417    }
418
419    fn make_knowledge(
420        project_root: &str,
421        facts: Vec<crate::core::knowledge::KnowledgeFact>,
422    ) -> ProjectKnowledge {
423        ProjectKnowledge {
424            project_root: project_root.to_string(),
425            project_hash: "test-hash".to_string(),
426            facts,
427            patterns: Vec::new(),
428            history: Vec::new(),
429            updated_at: Utc::now(),
430            judged_pairs: Vec::new(),
431        }
432    }
433
434    fn make_graph(edges: Vec<KnowledgeEdge>) -> KnowledgeRelationGraph {
435        KnowledgeRelationGraph {
436            project_hash: "test-hash".to_string(),
437            edges,
438            updated_at: Utc::now(),
439        }
440    }
441
442    fn make_edge(from_cat: &str, from_key: &str, to_cat: &str, to_key: &str) -> KnowledgeEdge {
443        KnowledgeEdge {
444            from: KnowledgeNodeRef::new(from_cat, from_key),
445            to: KnowledgeNodeRef::new(to_cat, to_key),
446            kind: KnowledgeEdgeKind::RelatedTo,
447            created_at: Utc::now(),
448            last_seen: Some(Utc::now()),
449            count: 1,
450            source_session: "test".to_string(),
451            strength: 0.5,
452            decay_rate: 0.02,
453        }
454    }
455
456    #[test]
457    fn structural_repair_removes_orphaned_edges() {
458        let knowledge = make_knowledge(
459            "/tmp/test",
460            vec![
461                make_fact("arch", "db", "PostgreSQL", 0.9),
462                make_fact("arch", "cache", "Redis", 0.8),
463            ],
464        );
465
466        let mut graph = make_graph(vec![
467            make_edge("arch", "db", "arch", "cache"),
468            make_edge("arch", "db", "arch", "nonexistent"),
469            make_edge("gone", "missing", "arch", "db"),
470        ]);
471
472        let removed = step_structural_repair(&mut graph, &knowledge);
473        assert_eq!(removed, 2);
474        assert_eq!(graph.edges.len(), 1);
475        assert_eq!(graph.edges[0].from.key, "db");
476        assert_eq!(graph.edges[0].to.key, "cache");
477    }
478
479    #[test]
480    fn lateral_synthesis_connects_similar_facts() {
481        let knowledge = make_knowledge(
482            "/tmp/test",
483            vec![
484                make_fact(
485                    "arch",
486                    "db",
487                    "PostgreSQL database primary storage backend",
488                    0.9,
489                ),
490                make_fact("arch", "cache", "Redis cache for sessions", 0.8),
491                make_fact(
492                    "deploy",
493                    "db-host",
494                    "PostgreSQL database primary storage on AWS",
495                    0.7,
496                ),
497            ],
498        );
499
500        let mut graph = make_graph(Vec::new());
501        let added = step_lateral_synthesis(&knowledge, &mut graph);
502
503        assert!(
504            added >= 1,
505            "Should connect facts sharing vocabulary (PostgreSQL database primary storage)"
506        );
507        assert!(
508            graph.edges.iter().any(|e| {
509                (e.from.key == "db" && e.to.key == "db-host")
510                    || (e.from.key == "db-host" && e.to.key == "db")
511            }),
512            "Should have edge between db and db-host"
513        );
514    }
515
516    #[test]
517    fn contradiction_resolution_keeps_higher_quality() {
518        let mut f1 = make_fact("arch", "db", "PostgreSQL", 0.9);
519        f1.confirmation_count = 3;
520        let f2 = make_fact("arch", "db", "MySQL", 0.5);
521
522        let mut knowledge = make_knowledge("/tmp/test", vec![f1, f2]);
523        let resolved = step_contradiction_resolution(&mut knowledge);
524
525        assert_eq!(resolved, 1);
526        let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
527        assert_eq!(current.len(), 1);
528        assert_eq!(current[0].value, "PostgreSQL");
529    }
530
531    #[test]
532    fn hebbian_strengthen_co_retrieval() {
533        let now = Utc::now();
534        let knowledge = make_knowledge(
535            "/tmp/test",
536            vec![
537                make_retrieved_fact("arch", "db", "PostgreSQL", now),
538                make_retrieved_fact("arch", "cache", "Redis", now - Duration::minutes(30)),
539                make_retrieved_fact("arch", "queue", "Kafka", now - Duration::hours(5)),
540            ],
541        );
542
543        let mut graph = make_graph(Vec::new());
544        let strengthened = step_hebbian_strengthen(&knowledge, &mut graph);
545
546        assert!(
547            strengthened >= 1,
548            "Should strengthen co-retrieved facts within 1h window"
549        );
550        let has_db_cache = graph.edges.iter().any(|e| {
551            (e.from.key == "db" && e.to.key == "cache")
552                || (e.from.key == "cache" && e.to.key == "db")
553        });
554        assert!(has_db_cache, "db and cache were retrieved within 1h");
555    }
556
557    #[test]
558    fn decay_reduces_stale_edge_counts() {
559        let old = Utc::now() - Duration::days(45);
560        let mut graph = make_graph(vec![
561            {
562                let mut e = make_edge("arch", "db", "arch", "cache");
563                e.last_seen = Some(old);
564                e.count = 3;
565                e
566            },
567            {
568                let mut e = make_edge("arch", "old", "arch", "ancient");
569                e.last_seen = Some(old);
570                e.count = 1;
571                e
572            },
573        ]);
574
575        let policy = MemoryPolicy::default();
576        let mut knowledge = make_knowledge(
577            "/tmp/test",
578            vec![
579                make_fact("arch", "db", "PostgreSQL", 0.9),
580                make_fact("arch", "cache", "Redis", 0.8),
581            ],
582        );
583
584        step_decay(&mut knowledge, &mut graph, &policy);
585
586        assert_eq!(
587            graph.edges.len(),
588            1,
589            "Edge with count=1 and stale should be removed"
590        );
591        assert_eq!(
592            graph.edges[0].count, 2,
593            "Edge with count=3 should be decremented to 2"
594        );
595    }
596
597    #[test]
598    fn cognition_loop_runs_all_steps() {
599        let _lock = crate::core::data_dir::test_env_lock();
600        let tmp = tempfile::tempdir().expect("tempdir");
601        std::env::set_var(
602            "LEAN_CTX_DATA_DIR",
603            tmp.path().to_string_lossy().to_string(),
604        );
605
606        let project_root = tmp.path().join("proj");
607        std::fs::create_dir_all(&project_root).expect("mkdir");
608        let project_root_str = project_root.to_string_lossy().to_string();
609
610        let policy = MemoryPolicy::default();
611        let mut knowledge = ProjectKnowledge::load_or_create(&project_root_str);
612        knowledge.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
613        knowledge.remember("arch", "cache", "Redis", "s1", 0.8, &policy);
614        knowledge.remember("deploy", "host", "AWS", "s1", 0.7, &policy);
615        let _ = knowledge.save();
616
617        let report = run_cognition_loop(&project_root_str, 8);
618        assert_eq!(report.steps_run, 8);
619
620        std::env::remove_var("LEAN_CTX_DATA_DIR");
621    }
622}