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