Skip to main content

lean_ctx/core/
cognition_loop.rs

1//! Hebbian-inspired Cognition Loop — periodic background reorganization of knowledge.
2//! Runs 9 steps: seed promote, structural repair, fidelity check, lateral synthesis,
3//! contradiction resolution, hebbian strengthen, decay, compact, observation synthesis.
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// Observation synthesis (#802/cognition): how many facts an entity needs before it
21// earns a summary, and the digest size caps that keep the value byte-stable.
22const SYNTHESIS_MAX_MEMBERS: usize = 6;
23const SYNTHESIS_VALUE_MAX: usize = 400;
24
25#[derive(Debug, Clone, Default)]
26pub struct CognitionLoopReport {
27    pub steps_run: u8,
28    pub facts_promoted: u32,
29    pub edges_repaired: u32,
30    pub edges_strengthened: u32,
31    pub facts_decayed: u32,
32    pub facts_archived: u32,
33    pub contradictions_resolved: u32,
34    pub lateral_connections: u32,
35    /// Facts whose confidence was lifted by the replay-consolidation pass (#3).
36    pub facts_consolidated: u32,
37    /// Low-value, mutually-similar facts collapsed into recoverable digests by
38    /// cluster compaction (#971) — this is what makes the live count *drop*.
39    pub facts_compacted: u32,
40    /// Per-entity observation summaries written/refreshed by synthesis (#802).
41    pub observations_synthesized: u32,
42    pub duration_ms: u64,
43}
44
45impl std::fmt::Display for CognitionLoopReport {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(
48            f,
49            "Cognition Loop ({} steps, {}ms): promoted={}, repaired={}, \
50             strengthened={}, decayed={}, archived={}, contradictions={}, lateral={}, \
51             consolidated={}, compacted={}, synthesized={}",
52            self.steps_run,
53            self.duration_ms,
54            self.facts_promoted,
55            self.edges_repaired,
56            self.edges_strengthened,
57            self.facts_decayed,
58            self.facts_archived,
59            self.contradictions_resolved,
60            self.lateral_connections,
61            self.facts_consolidated,
62            self.facts_compacted,
63            self.observations_synthesized,
64        )
65    }
66}
67
68pub fn run_cognition_loop(project_root: &str, max_steps: u8) -> CognitionLoopReport {
69    let start = std::time::Instant::now();
70    let mut report = CognitionLoopReport::default();
71
72    let config = crate::core::config::Config::load();
73    let Ok(policy) = config.memory_policy_effective() else {
74        return report;
75    };
76    let synth_min_cluster = config.autonomy.cognition_synthesis_min_cluster.max(1);
77
78    // Knowledge read-modify-write under the shared in-process + cross-process
79    // lock so this loop (also driven by the background cognition scheduler)
80    // never clobbers a concurrent foreground `remember`/`relate` write (issue
81    // #326). The relation graph is loaded and saved inside the same critical
82    // section; no step re-enters the knowledge lock, so this cannot deadlock.
83    let _ = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
84        let project_hash = knowledge.project_hash.clone();
85        let mut graph = KnowledgeRelationGraph::load_or_create(&project_hash);
86
87        if max_steps >= 1 {
88            report.facts_promoted = step_seed_promote(project_root, knowledge, &policy);
89            report.steps_run = 1;
90        }
91
92        if max_steps >= 2 {
93            report.edges_repaired = step_structural_repair(&mut graph, knowledge);
94            report.steps_run = 2;
95        }
96
97        // Step 3: Fidelity Check (structural only, no LLM)
98        if max_steps >= 3 {
99            report.steps_run = 3;
100        }
101
102        if max_steps >= 4 {
103            report.lateral_connections = step_lateral_synthesis(knowledge, &mut graph);
104            report.steps_run = 4;
105        }
106
107        if max_steps >= 5 {
108            report.contradictions_resolved = step_contradiction_resolution(knowledge);
109            report.steps_run = 5;
110        }
111
112        if max_steps >= 6 {
113            report.edges_strengthened = step_hebbian_strengthen(knowledge, &mut graph);
114            report.steps_run = 6;
115        }
116
117        if max_steps >= 7 {
118            report.facts_decayed = step_decay(knowledge, &mut graph, &policy);
119            report.steps_run = 7;
120        }
121
122        if max_steps >= 8 {
123            let lifecycle = knowledge.run_memory_lifecycle(&policy);
124            report.facts_archived = lifecycle.archived_count as u32;
125            // Step 8b (#3): complementary-learning-systems consolidation lifts the
126            // confidence of related, frequently-retrieved facts.
127            report.facts_consolidated = step_replay_consolidation(knowledge);
128            if report.facts_consolidated > 0 {
129                crate::core::introspect::tick("memory_consolidation");
130            }
131            // Step 8c (#971): collapse piles of low-value, mutually-similar facts
132            // into recoverable digests so the live count drops, not just churns at
133            // the cap. Runs after lifecycle so decayed confidences gate eligibility.
134            report.facts_compacted = knowledge.compact_low_value_clusters(&policy);
135            if report.facts_compacted > 0 {
136                crate::core::introspect::tick("memory_compaction");
137            }
138            report.steps_run = 8;
139        }
140
141        // Step 9 (#802): synthesize per-entity observation summaries from the now
142        // settled store (after lifecycle), so summaries reflect surviving facts.
143        if max_steps >= 9 {
144            report.observations_synthesized =
145                step_synthesize_observations(knowledge, &policy, synth_min_cluster);
146            report.steps_run = 9;
147        }
148
149        let _ = graph.save();
150    });
151
152    report.duration_ms = start.elapsed().as_millis() as u64;
153    report
154}
155
156/// Step 1: Promote recent session decisions/findings into project knowledge.
157///
158/// Loads the session for the *requested* project root (not cwd, #2362) and reuses
159/// the canonical session-import core ([`crate::core::consolidation_engine::import_session_into`])
160/// so budgets, fact keys and confidences match every other consolidation driver.
161/// Runs inside the cognition loop's existing knowledge lock, so it imports
162/// directly rather than re-entering the locked orchestrator.
163fn step_seed_promote(
164    project_root: &str,
165    knowledge: &mut ProjectKnowledge,
166    policy: &MemoryPolicy,
167) -> u32 {
168    let Some(session) =
169        crate::core::session::SessionState::load_latest_for_project_root(project_root)
170    else {
171        return 0;
172    };
173
174    let gotchas = promote_proven_gotchas(project_root, knowledge, &session.id, policy);
175
176    let opts = crate::core::consolidation_engine::ConsolidateOptions::scheduled(
177        crate::core::consolidation_engine::ConsolidationBudgets::default(),
178    );
179    let imported = crate::core::consolidation_engine::import_session_into(
180        knowledge, &session, &opts, policy, None,
181    );
182
183    gotchas + imported.total() as u32
184}
185
186/// Loop-weighting bridge (#980): promote *proven* gotchas — high confidence, seen
187/// across multiple sessions, and shown to prevent real errors — into durable
188/// project knowledge so `recall` surfaces them like any other fact. Read-only on
189/// the gotcha store (no nested write lock); `remember` upserts by key, so
190/// re-running the loop is idempotent. Capped so one noisy project cannot flood the
191/// knowledge store in a single pass.
192fn promote_proven_gotchas(
193    project_root: &str,
194    knowledge: &mut ProjectKnowledge,
195    session_id: &str,
196    policy: &MemoryPolicy,
197) -> u32 {
198    const MAX_PROMOTED: usize = 8;
199    let mut store = crate::core::gotcha_tracker::GotchaStore::load(project_root);
200    let promoted = store.check_promotions();
201    let mut count = 0u32;
202    for (_, trigger, resolution, confidence) in promoted.iter().take(MAX_PROMOTED) {
203        let key = crate::core::consolidation_engine::slug_key(trigger, 50);
204        let value = format!("{trigger} → {resolution}");
205        knowledge.remember("gotcha", &key, &value, session_id, *confidence, policy);
206        count += 1;
207    }
208    if count > 0 {
209        let _ = store.save(project_root);
210    }
211    count
212}
213
214/// Step 2: Remove edges whose endpoints no longer exist in the knowledge store.
215fn step_structural_repair(graph: &mut KnowledgeRelationGraph, knowledge: &ProjectKnowledge) -> u32 {
216    let fact_ids: HashSet<String> = knowledge
217        .facts
218        .iter()
219        .filter(|f| f.is_current())
220        .map(|f| format!("{}/{}", f.category, f.key))
221        .collect();
222
223    let before = graph.edges.len();
224    graph
225        .edges
226        .retain(|e| fact_ids.contains(&e.from.id()) && fact_ids.contains(&e.to.id()));
227    (before - graph.edges.len()) as u32
228}
229
230/// Step 4: Connect related facts that share vocabulary but lack an explicit edge.
231fn step_lateral_synthesis(knowledge: &ProjectKnowledge, graph: &mut KnowledgeRelationGraph) -> u32 {
232    let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
233
234    let existing_pairs: HashSet<(String, String)> = graph
235        .edges
236        .iter()
237        .map(|e| (e.from.id(), e.to.id()))
238        .collect();
239
240    let mut added = 0u32;
241
242    for (i, a) in current.iter().enumerate() {
243        if added >= LATERAL_MAX_NEW_EDGES as u32 {
244            break;
245        }
246        for b in &current[i + 1..] {
247            if added >= LATERAL_MAX_NEW_EDGES as u32 {
248                break;
249            }
250            let id_a = format!("{}/{}", a.category, a.key);
251            let id_b = format!("{}/{}", b.category, b.key);
252            if existing_pairs.contains(&(id_a.clone(), id_b.clone()))
253                || existing_pairs.contains(&(id_b.clone(), id_a.clone()))
254            {
255                continue;
256            }
257            let sim = crate::core::memory_consolidation::token_jaccard(&a.value, &b.value);
258            if sim >= LATERAL_SIM_THRESHOLD {
259                let from = KnowledgeNodeRef::new(&a.category, &a.key);
260                let to = KnowledgeNodeRef::new(&b.category, &b.key);
261                graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "cognition-loop");
262                added += 1;
263            }
264        }
265    }
266
267    added
268}
269
270/// Step 5: Resolve contradictions — same category+key, different values.
271/// Keeps the fact with higher quality_score, archives the other.
272fn step_contradiction_resolution(knowledge: &mut ProjectKnowledge) -> u32 {
273    let now = Utc::now();
274    let mut resolved = 0u32;
275
276    let mut seen: std::collections::HashMap<(String, String), usize> =
277        std::collections::HashMap::new();
278    let mut to_archive: Vec<usize> = Vec::new();
279
280    for (i, f) in knowledge.facts.iter().enumerate() {
281        if !f.is_current() {
282            continue;
283        }
284        let key = (f.category.clone(), f.key.clone());
285        if let Some(&prev_idx) = seen.get(&key) {
286            let prev = &knowledge.facts[prev_idx];
287            if prev.value != f.value {
288                if prev.quality_score() >= f.quality_score() {
289                    to_archive.push(i);
290                } else {
291                    to_archive.push(prev_idx);
292                    seen.insert(key, i);
293                }
294                resolved += 1;
295            }
296        } else {
297            seen.insert(key, i);
298        }
299    }
300
301    for &idx in &to_archive {
302        knowledge.facts[idx].valid_until = Some(now);
303    }
304
305    resolved
306}
307
308/// Step 6: Strengthen edges between facts co-retrieved in the same session window.
309fn step_hebbian_strengthen(
310    knowledge: &ProjectKnowledge,
311    graph: &mut KnowledgeRelationGraph,
312) -> u32 {
313    let retrieved: Vec<_> = knowledge
314        .facts
315        .iter()
316        .filter(|f| f.is_current() && f.last_retrieved.is_some())
317        .collect();
318
319    let window = Duration::hours(HEBBIAN_CO_RETRIEVAL_HOURS);
320    let mut strengthened = 0u32;
321
322    for (i, a) in retrieved.iter().enumerate() {
323        let Some(a_time) = a.last_retrieved else {
324            continue;
325        };
326        for b in &retrieved[i + 1..] {
327            let Some(b_time) = b.last_retrieved else {
328                continue;
329            };
330            let diff = (a_time - b_time).abs();
331            if diff <= window {
332                let from = KnowledgeNodeRef::new(&a.category, &a.key);
333                let to = KnowledgeNodeRef::new(&b.category, &b.key);
334                if !graph.strengthen_edge(&from, &to, 0.15) {
335                    graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "hebbian");
336                }
337                strengthened += 1;
338            }
339        }
340    }
341
342    strengthened
343}
344
345/// Step 7: Decay confidence on stale facts, and decay edge counts for unseen edges.
346fn step_decay(
347    knowledge: &mut ProjectKnowledge,
348    graph: &mut KnowledgeRelationGraph,
349    policy: &MemoryPolicy,
350) -> u32 {
351    let lifecycle_cfg = crate::core::memory_lifecycle::LifecycleConfig::from_policy(policy);
352    crate::core::memory_lifecycle::apply_confidence_decay(&mut knowledge.facts, &lifecycle_cfg);
353
354    let low_conf_count = knowledge
355        .facts
356        .iter()
357        .filter(|f| f.is_current() && f.confidence < 0.3)
358        .count() as u32;
359
360    graph.decay_all_edges(1.0);
361    graph.prune_weak_edges(0.05);
362
363    let stale_cutoff = Utc::now() - Duration::days(EDGE_STALE_DAYS);
364    graph.edges.retain_mut(|e| {
365        let last = e.last_seen.unwrap_or(e.created_at);
366        if last < stale_cutoff {
367            if e.count <= 1 {
368                return false;
369            }
370            e.count = e.count.saturating_sub(1);
371        }
372        true
373    });
374
375    low_conf_count
376}
377
378/// Step 8b (#3): replay consolidation over the knowledge facts. Maps facts into
379/// consolidation entries, runs the sleep-inspired NREM/REM/replay pass, then
380/// promotes the replay-boosted importance back onto fact confidence. Additive:
381/// merges and pruning are owned by the lifecycle step, so here we only *lift*
382/// the confidence of facts the replay pass found related-and-co-accessed —
383/// never lower or delete. Deterministic.
384fn step_replay_consolidation(knowledge: &mut ProjectKnowledge) -> u32 {
385    use crate::core::memory_consolidation::{KnowledgeEntry, consolidate};
386
387    let mut entries: Vec<KnowledgeEntry> = knowledge
388        .facts
389        .iter()
390        .filter(|f| f.is_current())
391        .map(|f| {
392            let last_access = f
393                .last_retrieved
394                .unwrap_or(f.last_confirmed)
395                .timestamp()
396                .max(0) as u64;
397            KnowledgeEntry {
398                key: format!("{}/{}", f.category, f.key),
399                content: f.value.clone(),
400                access_count: u64::from(f.retrieval_count),
401                last_access,
402                created_at: f.created_at.timestamp().max(0) as u64,
403                importance: f64::from(f.confidence),
404            }
405        })
406        .collect();
407    if entries.len() < 2 {
408        return 0;
409    }
410    consolidate(&mut entries);
411
412    let boosted: std::collections::HashMap<String, f64> =
413        entries.into_iter().map(|e| (e.key, e.importance)).collect();
414
415    let mut promoted = 0u32;
416    for f in knowledge.facts.iter_mut().filter(|f| f.is_current()) {
417        let id = format!("{}/{}", f.category, f.key);
418        if let Some(&imp) = boosted.get(&id) {
419            let new_conf = (imp as f32).min(1.0);
420            if new_conf > f.confidence + 0.001 {
421                f.confidence = new_conf;
422                promoted += 1;
423            }
424        }
425    }
426    promoted
427}
428
429/// Idle replay (#7): the sleep-inspired (sharp-wave-ripple) consolidation pass,
430/// run when the agent has been quiet rather than as part of the periodic loop.
431/// Reloads knowledge under the shared lock, replays the consolidation/promote
432/// step, and reports the facts whose confidence the replay lifted. Distinct from
433/// the in-loop step (#3) so idle-time "rest" consolidation is observable on its
434/// own via `introspect`. Deterministic; mutates the store, never tool output.
435pub fn run_idle_replay(project_root: &str) -> u32 {
436    let mut promoted = 0u32;
437    let _ = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
438        promoted = step_replay_consolidation(knowledge);
439    });
440    if promoted > 0 {
441        crate::core::introspect::tick("replay_consolidation");
442    }
443    promoted
444}
445
446/// Step 9 (#802/cognition): deterministically synthesize per-entity *observation*
447/// summaries from clusters of related raw facts — lean-ctx's take on Hindsight's
448/// observation network. Current facts (never synthesized observations) are grouped
449/// by an entity anchor (a file path referenced in the key/value, else the
450/// category); each cluster of `>= min_cluster` facts writes/refreshes one compact
451/// observation via [`ProjectKnowledge::remember`] (idempotent + versioned), so
452/// summaries never summarize summaries.
453///
454/// Deterministic: the value is a stable function of the source facts' content
455/// (sorted, capped, char-boundary-truncated — no timestamps/counters), so it never
456/// perturbs the prompt cache (#498). Runs in the background loop, never a hot path.
457fn step_synthesize_observations(
458    knowledge: &mut ProjectKnowledge,
459    policy: &MemoryPolicy,
460    min_cluster: usize,
461) -> u32 {
462    use std::collections::BTreeMap;
463
464    if min_cluster == 0 {
465        return 0;
466    }
467
468    // Cluster current facts by entity. Only *synthesized* observations are skipped
469    // (no recursion) — raw user findings (also `Observation` archetype) are valid
470    // input. BTreeMap → deterministic entity order.
471    let mut clusters: BTreeMap<String, Vec<(String, String, f32)>> = BTreeMap::new();
472    for f in knowledge.facts.iter().filter(|f| f.is_current()) {
473        if f.is_synthesized_observation() {
474            continue;
475        }
476        let entity = synthesis_entity_anchor(&f.category, &f.key, &f.value);
477        clusters.entry(entity).or_default().push((
478            f.category.clone(),
479            f.value.clone(),
480            f.confidence,
481        ));
482    }
483
484    let mut count = 0u32;
485    for (entity, mut members) in clusters {
486        if members.len() < min_cluster {
487            continue;
488        }
489        // Strongest evidence first, then lexical — deterministic.
490        members.sort_by(|a, b| {
491            b.2.partial_cmp(&a.2)
492                .unwrap_or(std::cmp::Ordering::Equal)
493                .then_with(|| a.1.cmp(&b.1))
494        });
495        members.truncate(SYNTHESIS_MAX_MEMBERS);
496
497        let summary = synthesize_observation_value(&entity, &members);
498        // Optional LLM refinement (opt-in via `llm.enabled`); deterministic fallback.
499        let summary = crate::core::llm_enhance::enhance_observation(&entity, &summary);
500        // An observation never out-confidences its own evidence: mean, capped.
501        let mean = members.iter().map(|m| m.2).sum::<f32>() / members.len() as f32;
502        knowledge.remember(
503            "observation",
504            &entity,
505            &summary,
506            crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE,
507            mean.min(0.9),
508            policy,
509        );
510        count += 1;
511    }
512
513    if count > 0 {
514        crate::core::introspect::tick("observation_synthesis");
515    }
516    count
517}
518
519/// The entity an observation summarizes: the first file path referenced in the
520/// fact key (findings key by `file:line`) or value, else the category. Deterministic.
521fn synthesis_entity_anchor(category: &str, key: &str, value: &str) -> String {
522    crate::core::content_chunk::extract_file_references(key)
523        .into_iter()
524        .next()
525        .or_else(|| {
526            crate::core::content_chunk::extract_file_references(value)
527                .into_iter()
528                .next()
529        })
530        .unwrap_or_else(|| category.to_string())
531}
532
533/// Compose a deterministic, structured digest of an entity's facts grouped by their
534/// source category. Char-boundary-truncated so the stored value is byte-stable.
535fn synthesize_observation_value(entity: &str, members: &[(String, String, f32)]) -> String {
536    use std::collections::BTreeMap;
537    let mut by_cat: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
538    for (cat, val, _) in members {
539        by_cat.entry(cat.as_str()).or_default().push(val.as_str());
540    }
541    let body = by_cat
542        .into_iter()
543        .map(|(cat, vals)| format!("{cat}: {}", vals.join("; ")))
544        .collect::<Vec<_>>()
545        .join(" | ");
546    format!(
547        "{entity} — {}",
548        truncate_on_char_boundary(&body, SYNTHESIS_VALUE_MAX)
549    )
550}
551
552/// Truncate to at most `max` bytes on a UTF-8 boundary, appending an ellipsis when
553/// it actually shortens. Deterministic; used to bound synthesized observation text.
554fn truncate_on_char_boundary(s: &str, max: usize) -> String {
555    if s.len() <= max {
556        return s.to_string();
557    }
558    let mut end = max;
559    while end > 0 && !s.is_char_boundary(end) {
560        end -= 1;
561    }
562    format!("{}…", &s[..end])
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::core::knowledge::KnowledgeArchetype;
569    use crate::core::knowledge_relations::KnowledgeEdge;
570    use crate::core::memory_boundary::FactPrivacy;
571
572    fn make_fact(
573        category: &str,
574        key: &str,
575        value: &str,
576        confidence: f32,
577    ) -> crate::core::knowledge::KnowledgeFact {
578        crate::core::knowledge::KnowledgeFact {
579            category: category.to_string(),
580            key: key.to_string(),
581            value: value.to_string(),
582            source_session: "test".to_string(),
583            confidence,
584            created_at: Utc::now(),
585            last_confirmed: Utc::now(),
586            retrieval_count: 0,
587            last_retrieved: None,
588            valid_from: Some(Utc::now()),
589            valid_until: None,
590            supersedes: None,
591            confirmation_count: 1,
592            feedback_up: 0,
593            feedback_down: 0,
594            last_feedback: None,
595            privacy: FactPrivacy::default(),
596            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
597            imported_from: None,
598            archetype: KnowledgeArchetype::default(),
599            fidelity: None,
600            revision_count: 0,
601        }
602    }
603
604    fn make_retrieved_fact(
605        category: &str,
606        key: &str,
607        value: &str,
608        retrieved_at: chrono::DateTime<Utc>,
609    ) -> crate::core::knowledge::KnowledgeFact {
610        let mut f = make_fact(category, key, value, 0.9);
611        f.last_retrieved = Some(retrieved_at);
612        f.retrieval_count = 1;
613        f
614    }
615
616    fn make_knowledge(
617        project_root: &str,
618        facts: Vec<crate::core::knowledge::KnowledgeFact>,
619    ) -> ProjectKnowledge {
620        ProjectKnowledge {
621            project_root: project_root.to_string(),
622            project_hash: "test-hash".to_string(),
623            facts,
624            patterns: Vec::new(),
625            history: Vec::new(),
626            updated_at: Utc::now(),
627            judged_pairs: Vec::new(),
628        }
629    }
630
631    fn make_graph(edges: Vec<KnowledgeEdge>) -> KnowledgeRelationGraph {
632        KnowledgeRelationGraph {
633            project_hash: "test-hash".to_string(),
634            edges,
635            updated_at: Utc::now(),
636        }
637    }
638
639    fn make_edge(from_cat: &str, from_key: &str, to_cat: &str, to_key: &str) -> KnowledgeEdge {
640        KnowledgeEdge {
641            from: KnowledgeNodeRef::new(from_cat, from_key),
642            to: KnowledgeNodeRef::new(to_cat, to_key),
643            kind: KnowledgeEdgeKind::RelatedTo,
644            created_at: Utc::now(),
645            last_seen: Some(Utc::now()),
646            count: 1,
647            source_session: "test".to_string(),
648            strength: 0.5,
649            decay_rate: 0.02,
650        }
651    }
652
653    #[test]
654    fn structural_repair_removes_orphaned_edges() {
655        let knowledge = make_knowledge(
656            "/tmp/test",
657            vec![
658                make_fact("arch", "db", "PostgreSQL", 0.9),
659                make_fact("arch", "cache", "Redis", 0.8),
660            ],
661        );
662
663        let mut graph = make_graph(vec![
664            make_edge("arch", "db", "arch", "cache"),
665            make_edge("arch", "db", "arch", "nonexistent"),
666            make_edge("gone", "missing", "arch", "db"),
667        ]);
668
669        let removed = step_structural_repair(&mut graph, &knowledge);
670        assert_eq!(removed, 2);
671        assert_eq!(graph.edges.len(), 1);
672        assert_eq!(graph.edges[0].from.key, "db");
673        assert_eq!(graph.edges[0].to.key, "cache");
674    }
675
676    #[test]
677    fn lateral_synthesis_connects_similar_facts() {
678        let knowledge = make_knowledge(
679            "/tmp/test",
680            vec![
681                make_fact(
682                    "arch",
683                    "db",
684                    "PostgreSQL database primary storage backend",
685                    0.9,
686                ),
687                make_fact("arch", "cache", "Redis cache for sessions", 0.8),
688                make_fact(
689                    "deploy",
690                    "db-host",
691                    "PostgreSQL database primary storage on AWS",
692                    0.7,
693                ),
694            ],
695        );
696
697        let mut graph = make_graph(Vec::new());
698        let added = step_lateral_synthesis(&knowledge, &mut graph);
699
700        assert!(
701            added >= 1,
702            "Should connect facts sharing vocabulary (PostgreSQL database primary storage)"
703        );
704        assert!(
705            graph.edges.iter().any(|e| {
706                (e.from.key == "db" && e.to.key == "db-host")
707                    || (e.from.key == "db-host" && e.to.key == "db")
708            }),
709            "Should have edge between db and db-host"
710        );
711    }
712
713    #[test]
714    fn contradiction_resolution_keeps_higher_quality() {
715        let mut f1 = make_fact("arch", "db", "PostgreSQL", 0.9);
716        f1.confirmation_count = 3;
717        let f2 = make_fact("arch", "db", "MySQL", 0.5);
718
719        let mut knowledge = make_knowledge("/tmp/test", vec![f1, f2]);
720        let resolved = step_contradiction_resolution(&mut knowledge);
721
722        assert_eq!(resolved, 1);
723        let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
724        assert_eq!(current.len(), 1);
725        assert_eq!(current[0].value, "PostgreSQL");
726    }
727
728    #[test]
729    fn hebbian_strengthen_co_retrieval() {
730        let now = Utc::now();
731        let knowledge = make_knowledge(
732            "/tmp/test",
733            vec![
734                make_retrieved_fact("arch", "db", "PostgreSQL", now),
735                make_retrieved_fact("arch", "cache", "Redis", now - Duration::minutes(30)),
736                make_retrieved_fact("arch", "queue", "Kafka", now - Duration::hours(5)),
737            ],
738        );
739
740        let mut graph = make_graph(Vec::new());
741        let strengthened = step_hebbian_strengthen(&knowledge, &mut graph);
742
743        assert!(
744            strengthened >= 1,
745            "Should strengthen co-retrieved facts within 1h window"
746        );
747        let has_db_cache = graph.edges.iter().any(|e| {
748            (e.from.key == "db" && e.to.key == "cache")
749                || (e.from.key == "cache" && e.to.key == "db")
750        });
751        assert!(has_db_cache, "db and cache were retrieved within 1h");
752    }
753
754    #[test]
755    fn decay_reduces_stale_edge_counts() {
756        let old = Utc::now() - Duration::days(45);
757        let mut graph = make_graph(vec![
758            {
759                let mut e = make_edge("arch", "db", "arch", "cache");
760                e.last_seen = Some(old);
761                e.count = 3;
762                e
763            },
764            {
765                let mut e = make_edge("arch", "old", "arch", "ancient");
766                e.last_seen = Some(old);
767                e.count = 1;
768                e
769            },
770        ]);
771
772        let policy = MemoryPolicy::default();
773        let mut knowledge = make_knowledge(
774            "/tmp/test",
775            vec![
776                make_fact("arch", "db", "PostgreSQL", 0.9),
777                make_fact("arch", "cache", "Redis", 0.8),
778            ],
779        );
780
781        step_decay(&mut knowledge, &mut graph, &policy);
782
783        assert_eq!(
784            graph.edges.len(),
785            1,
786            "Edge with count=1 and stale should be removed"
787        );
788        assert_eq!(
789            graph.edges[0].count, 2,
790            "Edge with count=3 should be decremented to 2"
791        );
792    }
793
794    #[test]
795    fn replay_consolidation_promotes_related_accessed_facts() {
796        // #3: related (jaccard in replay band) + frequently-retrieved facts get
797        // their confidence lifted by the replay-boost pass.
798        let mut f1 = make_fact(
799            "arch",
800            "db",
801            "uses postgres database for primary storage",
802            0.5,
803        );
804        f1.retrieval_count = 50;
805        f1.last_retrieved = Some(Utc::now());
806        let mut f2 = make_fact(
807            "arch",
808            "db2",
809            "uses postgres database for sessions cache",
810            0.5,
811        );
812        f2.retrieval_count = 50;
813        f2.last_retrieved = Some(Utc::now());
814
815        let mut knowledge = make_knowledge("/tmp/test", vec![f1, f2]);
816        let promoted = step_replay_consolidation(&mut knowledge);
817        assert!(
818            promoted >= 1,
819            "related, frequently-accessed facts should be promoted (#3)"
820        );
821        assert!(
822            knowledge.facts.iter().any(|f| f.confidence > 0.5),
823            "confidence must be lifted by replay boost"
824        );
825    }
826
827    #[test]
828    fn idle_replay_consolidates_from_disk() {
829        // #7: the idle replay pass loads knowledge under lock, consolidates the
830        // related/frequently-retrieved facts, and persists the lifted confidence.
831        let _lock = crate::core::data_dir::test_env_lock();
832        let tmp = tempfile::tempdir().expect("tempdir");
833        crate::test_env::set_var(
834            "LEAN_CTX_DATA_DIR",
835            tmp.path().to_string_lossy().to_string(),
836        );
837        let project_root = tmp.path().join("proj");
838        std::fs::create_dir_all(&project_root).expect("mkdir");
839        let root = project_root.to_string_lossy().to_string();
840
841        let policy = MemoryPolicy::default();
842        let mut knowledge = ProjectKnowledge::load_or_create(&root);
843        knowledge.remember(
844            "arch",
845            "db",
846            "uses postgres database for primary storage",
847            "s1",
848            0.5,
849            &policy,
850        );
851        knowledge.remember(
852            "arch",
853            "db2",
854            "uses postgres database for sessions cache",
855            "s1",
856            0.5,
857            &policy,
858        );
859        for f in &mut knowledge.facts {
860            f.retrieval_count = 50;
861            f.last_retrieved = Some(Utc::now());
862        }
863        let _ = knowledge.save();
864
865        let promoted = run_idle_replay(&root);
866        assert!(
867            promoted >= 1,
868            "idle replay should consolidate related facts (#7)"
869        );
870
871        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
872    }
873
874    #[test]
875    fn cognition_loop_runs_all_steps() {
876        let _lock = crate::core::data_dir::test_env_lock();
877        let tmp = tempfile::tempdir().expect("tempdir");
878        crate::test_env::set_var(
879            "LEAN_CTX_DATA_DIR",
880            tmp.path().to_string_lossy().to_string(),
881        );
882
883        let project_root = tmp.path().join("proj");
884        std::fs::create_dir_all(&project_root).expect("mkdir");
885        let project_root_str = project_root.to_string_lossy().to_string();
886
887        let policy = MemoryPolicy::default();
888        let mut knowledge = ProjectKnowledge::load_or_create(&project_root_str);
889        knowledge.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
890        knowledge.remember("arch", "cache", "Redis", "s1", 0.8, &policy);
891        knowledge.remember("deploy", "host", "AWS", "s1", 0.7, &policy);
892        let _ = knowledge.save();
893
894        let report = run_cognition_loop(&project_root_str, 8);
895        assert_eq!(report.steps_run, 8);
896
897        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
898    }
899
900    #[test]
901    fn promote_proven_gotchas_bridges_into_knowledge_and_is_idempotent() {
902        use crate::core::gotcha_tracker::{
903            Gotcha, GotchaCategory, GotchaSeverity, GotchaSource, GotchaStore,
904        };
905        let _iso = crate::core::data_dir::isolated_data_dir();
906        let dir = tempfile::tempdir().unwrap();
907        let root = dir.path().to_string_lossy().to_string();
908
909        // Seed a gotcha that clears every promotion threshold and persist it.
910        let mut store = GotchaStore::load(&root);
911        let mut g = Gotcha::new(
912            GotchaCategory::Build,
913            GotchaSeverity::Critical,
914            "cargo E0507 cannot move out of borrowed content",
915            "clone the value before the move",
916            GotchaSource::AgentReported {
917                session_id: "s1".into(),
918            },
919            "s1",
920        );
921        g.confidence = 0.95;
922        g.occurrences = 6;
923        g.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
924        g.prevented_count = 3;
925        store.gotchas.push(g);
926        store.save(&root).unwrap();
927
928        let policy = MemoryPolicy::default();
929        let mut knowledge = ProjectKnowledge::new(&root);
930
931        let n = promote_proven_gotchas(&root, &mut knowledge, "sess", &policy);
932        assert_eq!(n, 1, "the proven gotcha is promoted");
933        assert!(
934            knowledge
935                .facts
936                .iter()
937                .any(|f| f.category == "gotcha" && f.value.contains("clone the value")),
938            "a durable gotcha fact must appear in knowledge"
939        );
940
941        // Re-running upserts by key — it must not accumulate duplicates.
942        promote_proven_gotchas(&root, &mut knowledge, "sess", &policy);
943        let gotcha_facts = knowledge
944            .facts
945            .iter()
946            .filter(|f| f.category == "gotcha")
947            .count();
948        assert_eq!(gotcha_facts, 1, "promotion must be idempotent");
949    }
950
951    #[test]
952    fn synthesize_observation_value_is_deterministic() {
953        let members = vec![
954            ("finding".to_string(), "b issue".to_string(), 0.5f32),
955            ("finding".to_string(), "a issue".to_string(), 0.9f32),
956            ("gotcha".to_string(), "race".to_string(), 0.7f32),
957        ];
958        let v1 = synthesize_observation_value("src/x.rs", &members);
959        let v2 = synthesize_observation_value("src/x.rs", &members);
960        assert_eq!(v1, v2, "synthesis value must be deterministic");
961        assert!(v1.starts_with("src/x.rs — "));
962        // Grouped by source category in deterministic (BTreeMap) order.
963        let f = v1.find("finding:").expect("finding group");
964        let g = v1.find("gotcha:").expect("gotcha group");
965        assert!(f < g, "categories grouped in sorted order");
966    }
967
968    #[test]
969    fn synthesis_entity_anchor_resolves_file_then_category() {
970        assert_eq!(
971            synthesis_entity_anchor("finding", "src/auth.rs:42", "x"),
972            "src/auth.rs"
973        );
974        assert_eq!(
975            synthesis_entity_anchor("decision", "no-file", "see src/lib.rs here"),
976            "src/lib.rs"
977        );
978        assert_eq!(
979            synthesis_entity_anchor("decision", "plain-key", "no path at all"),
980            "decision"
981        );
982    }
983
984    #[test]
985    fn step_synthesizes_per_entity_observation_and_is_idempotent() {
986        let _lock = crate::core::data_dir::test_env_lock();
987        let tmp = tempfile::tempdir().expect("tempdir");
988        crate::test_env::set_var(
989            "LEAN_CTX_DATA_DIR",
990            tmp.path().to_string_lossy().to_string(),
991        );
992
993        let policy = MemoryPolicy::default();
994        let mut k = ProjectKnowledge::new("/tmp/test-synthesis");
995        // Three facts anchored to the same file → one entity cluster.
996        k.remember(
997            "finding",
998            "src/auth.rs:10",
999            "missing null check",
1000            "s1",
1001            0.8,
1002            &policy,
1003        );
1004        k.remember(
1005            "finding",
1006            "src/auth.rs:20",
1007            "token not validated",
1008            "s1",
1009            0.7,
1010            &policy,
1011        );
1012        k.remember(
1013            "gotcha",
1014            "src/auth.rs:30",
1015            "race on refresh",
1016            "s1",
1017            0.9,
1018            &policy,
1019        );
1020
1021        let made = step_synthesize_observations(&mut k, &policy, 3);
1022        assert_eq!(made, 1, "one observation for the clustered entity");
1023
1024        let obs: Vec<_> = k
1025            .facts
1026            .iter()
1027            .filter(|f| f.is_current() && f.is_synthesized_observation())
1028            .collect();
1029        assert_eq!(obs.len(), 1);
1030        assert_eq!(obs[0].key, "src/auth.rs");
1031        assert_eq!(obs[0].archetype, KnowledgeArchetype::Observation);
1032
1033        // Re-run with unchanged facts → same value → confirmation, not a duplicate.
1034        let again = step_synthesize_observations(&mut k, &policy, 3);
1035        assert_eq!(again, 1, "step still writes (confirms) the summary");
1036        let current = k
1037            .facts
1038            .iter()
1039            .filter(|f| f.is_current() && f.is_synthesized_observation())
1040            .count();
1041        assert_eq!(current, 1, "idempotent: no duplicate observation");
1042
1043        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1044    }
1045
1046    #[test]
1047    fn synthesis_excludes_synthesized_observations_from_input() {
1048        let policy = MemoryPolicy::default();
1049        let mut k = ProjectKnowledge::new("/tmp/test-no-recursion");
1050        // A pre-existing synthesized observation must never be re-summarized.
1051        k.remember(
1052            "observation",
1053            "src/x.rs",
1054            "src/x.rs — finding: a; b",
1055            crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE,
1056            0.6,
1057            &policy,
1058        );
1059        k.remember("finding", "src/y.rs:1", "issue one", "s1", 0.5, &policy);
1060        // Only one non-synthesized entity ("src/y.rs"), below the threshold → none.
1061        let made = step_synthesize_observations(&mut k, &policy, 3);
1062        assert_eq!(made, 0, "no entity reaches the cluster threshold");
1063    }
1064
1065    #[test]
1066    fn cognition_loop_step_9_synthesizes_observations() {
1067        let _lock = crate::core::data_dir::test_env_lock();
1068        let tmp = tempfile::tempdir().expect("tempdir");
1069        crate::test_env::set_var(
1070            "LEAN_CTX_DATA_DIR",
1071            tmp.path().to_string_lossy().to_string(),
1072        );
1073        let project_root = tmp.path().join("proj");
1074        std::fs::create_dir_all(&project_root).expect("mkdir");
1075        let root = project_root.to_string_lossy().to_string();
1076
1077        let policy = MemoryPolicy::default();
1078        let mut knowledge = ProjectKnowledge::load_or_create(&root);
1079        knowledge.remember(
1080            "finding",
1081            "src/api.rs:1",
1082            "no auth on route",
1083            "s1",
1084            0.8,
1085            &policy,
1086        );
1087        knowledge.remember(
1088            "finding",
1089            "src/api.rs:2",
1090            "missing rate limit",
1091            "s1",
1092            0.7,
1093            &policy,
1094        );
1095        knowledge.remember(
1096            "gotcha",
1097            "src/api.rs:3",
1098            "panics on empty body",
1099            "s1",
1100            0.9,
1101            &policy,
1102        );
1103        let _ = knowledge.save();
1104
1105        let report = run_cognition_loop(&root, 9);
1106        assert_eq!(report.steps_run, 9);
1107        assert!(
1108            report.observations_synthesized >= 1,
1109            "step 9 must synthesize at least one observation"
1110        );
1111
1112        let reloaded = ProjectKnowledge::load_or_create(&root);
1113        assert!(
1114            reloaded
1115                .facts
1116                .iter()
1117                .any(|f| f.is_current() && f.is_synthesized_observation()),
1118            "synthesized observation must persist"
1119        );
1120
1121        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1122    }
1123}