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