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