1use 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
20const 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 pub facts_consolidated: u32,
37 pub facts_compacted: u32,
40 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 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 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 report.facts_consolidated = step_replay_consolidation(knowledge);
128 if report.facts_consolidated > 0 {
129 crate::core::introspect::tick("memory_consolidation");
130 }
131 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 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
156fn step_seed_promote(
164 project_root: &str,
165 knowledge: &mut ProjectKnowledge,
166 policy: &MemoryPolicy,
167) -> u32 {
168 let Some(session) =
169 crate::core::session::SessionState::load_latest_for_project_root(project_root)
170 else {
171 return 0;
172 };
173
174 let gotchas = promote_proven_gotchas(project_root, knowledge, &session.id, policy);
175
176 let opts = crate::core::consolidation_engine::ConsolidateOptions::scheduled(
177 crate::core::consolidation_engine::ConsolidationBudgets::default(),
178 );
179 let imported = crate::core::consolidation_engine::import_session_into(
180 knowledge, &session, &opts, policy, None,
181 );
182
183 gotchas + imported.total() as u32
184}
185
186fn promote_proven_gotchas(
193 project_root: &str,
194 knowledge: &mut ProjectKnowledge,
195 session_id: &str,
196 policy: &MemoryPolicy,
197) -> u32 {
198 const MAX_PROMOTED: usize = 8;
199 let store = crate::core::gotcha_tracker::GotchaStore::load(project_root);
200 let mut count = 0u32;
201 for g in store.promotable().into_iter().take(MAX_PROMOTED) {
202 let key = crate::core::consolidation_engine::slug_key(&g.trigger, 50);
203 let value = format!("{} → {}", g.trigger, g.resolution);
204 knowledge.remember("gotcha", &key, &value, session_id, g.confidence, policy);
205 count += 1;
206 }
207 count
208}
209
210fn step_structural_repair(graph: &mut KnowledgeRelationGraph, knowledge: &ProjectKnowledge) -> u32 {
212 let fact_ids: HashSet<String> = knowledge
213 .facts
214 .iter()
215 .filter(|f| f.is_current())
216 .map(|f| format!("{}/{}", f.category, f.key))
217 .collect();
218
219 let before = graph.edges.len();
220 graph
221 .edges
222 .retain(|e| fact_ids.contains(&e.from.id()) && fact_ids.contains(&e.to.id()));
223 (before - graph.edges.len()) as u32
224}
225
226fn step_lateral_synthesis(knowledge: &ProjectKnowledge, graph: &mut KnowledgeRelationGraph) -> u32 {
228 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
229
230 let existing_pairs: HashSet<(String, String)> = graph
231 .edges
232 .iter()
233 .map(|e| (e.from.id(), e.to.id()))
234 .collect();
235
236 let mut added = 0u32;
237
238 for (i, a) in current.iter().enumerate() {
239 if added >= LATERAL_MAX_NEW_EDGES as u32 {
240 break;
241 }
242 for b in ¤t[i + 1..] {
243 if added >= LATERAL_MAX_NEW_EDGES as u32 {
244 break;
245 }
246 let id_a = format!("{}/{}", a.category, a.key);
247 let id_b = format!("{}/{}", b.category, b.key);
248 if existing_pairs.contains(&(id_a.clone(), id_b.clone()))
249 || existing_pairs.contains(&(id_b.clone(), id_a.clone()))
250 {
251 continue;
252 }
253 let sim = crate::core::memory_consolidation::token_jaccard(&a.value, &b.value);
254 if sim >= LATERAL_SIM_THRESHOLD {
255 let from = KnowledgeNodeRef::new(&a.category, &a.key);
256 let to = KnowledgeNodeRef::new(&b.category, &b.key);
257 graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "cognition-loop");
258 added += 1;
259 }
260 }
261 }
262
263 added
264}
265
266fn step_contradiction_resolution(knowledge: &mut ProjectKnowledge) -> u32 {
269 let now = Utc::now();
270 let mut resolved = 0u32;
271
272 let mut seen: std::collections::HashMap<(String, String), usize> =
273 std::collections::HashMap::new();
274 let mut to_archive: Vec<usize> = Vec::new();
275
276 for (i, f) in knowledge.facts.iter().enumerate() {
277 if !f.is_current() {
278 continue;
279 }
280 let key = (f.category.clone(), f.key.clone());
281 if let Some(&prev_idx) = seen.get(&key) {
282 let prev = &knowledge.facts[prev_idx];
283 if prev.value != f.value {
284 if prev.quality_score() >= f.quality_score() {
285 to_archive.push(i);
286 } else {
287 to_archive.push(prev_idx);
288 seen.insert(key, i);
289 }
290 resolved += 1;
291 }
292 } else {
293 seen.insert(key, i);
294 }
295 }
296
297 for &idx in &to_archive {
298 knowledge.facts[idx].valid_until = Some(now);
299 }
300
301 resolved
302}
303
304fn step_hebbian_strengthen(
306 knowledge: &ProjectKnowledge,
307 graph: &mut KnowledgeRelationGraph,
308) -> u32 {
309 let retrieved: Vec<_> = knowledge
310 .facts
311 .iter()
312 .filter(|f| f.is_current() && f.last_retrieved.is_some())
313 .collect();
314
315 let window = Duration::hours(HEBBIAN_CO_RETRIEVAL_HOURS);
316 let mut strengthened = 0u32;
317
318 for (i, a) in retrieved.iter().enumerate() {
319 let Some(a_time) = a.last_retrieved else {
320 continue;
321 };
322 for b in &retrieved[i + 1..] {
323 let Some(b_time) = b.last_retrieved else {
324 continue;
325 };
326 let diff = (a_time - b_time).abs();
327 if diff <= window {
328 let from = KnowledgeNodeRef::new(&a.category, &a.key);
329 let to = KnowledgeNodeRef::new(&b.category, &b.key);
330 if !graph.strengthen_edge(&from, &to, 0.15) {
331 graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "hebbian");
332 }
333 strengthened += 1;
334 }
335 }
336 }
337
338 strengthened
339}
340
341fn step_decay(
343 knowledge: &mut ProjectKnowledge,
344 graph: &mut KnowledgeRelationGraph,
345 policy: &MemoryPolicy,
346) -> u32 {
347 let lifecycle_cfg = crate::core::memory_lifecycle::LifecycleConfig::from_policy(policy);
348 crate::core::memory_lifecycle::apply_confidence_decay(&mut knowledge.facts, &lifecycle_cfg);
349
350 let low_conf_count = knowledge
351 .facts
352 .iter()
353 .filter(|f| f.is_current() && f.confidence < 0.3)
354 .count() as u32;
355
356 graph.decay_all_edges(1.0);
357 graph.prune_weak_edges(0.05);
358
359 let stale_cutoff = Utc::now() - Duration::days(EDGE_STALE_DAYS);
360 graph.edges.retain_mut(|e| {
361 let last = e.last_seen.unwrap_or(e.created_at);
362 if last < stale_cutoff {
363 if e.count <= 1 {
364 return false;
365 }
366 e.count = e.count.saturating_sub(1);
367 }
368 true
369 });
370
371 low_conf_count
372}
373
374fn step_replay_consolidation(knowledge: &mut ProjectKnowledge) -> u32 {
381 use crate::core::memory_consolidation::{KnowledgeEntry, consolidate};
382
383 let mut entries: Vec<KnowledgeEntry> = knowledge
384 .facts
385 .iter()
386 .filter(|f| f.is_current())
387 .map(|f| {
388 let last_access = f
389 .last_retrieved
390 .unwrap_or(f.last_confirmed)
391 .timestamp()
392 .max(0) as u64;
393 KnowledgeEntry {
394 key: format!("{}/{}", f.category, f.key),
395 content: f.value.clone(),
396 access_count: u64::from(f.retrieval_count),
397 last_access,
398 created_at: f.created_at.timestamp().max(0) as u64,
399 importance: f64::from(f.confidence),
400 }
401 })
402 .collect();
403 if entries.len() < 2 {
404 return 0;
405 }
406 consolidate(&mut entries);
407
408 let boosted: std::collections::HashMap<String, f64> =
409 entries.into_iter().map(|e| (e.key, e.importance)).collect();
410
411 let mut promoted = 0u32;
412 for f in knowledge.facts.iter_mut().filter(|f| f.is_current()) {
413 let id = format!("{}/{}", f.category, f.key);
414 if let Some(&imp) = boosted.get(&id) {
415 let new_conf = (imp as f32).min(1.0);
416 if new_conf > f.confidence + 0.001 {
417 f.confidence = new_conf;
418 promoted += 1;
419 }
420 }
421 }
422 promoted
423}
424
425pub fn run_idle_replay(project_root: &str) -> u32 {
432 let mut promoted = 0u32;
433 let _ = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
434 promoted = step_replay_consolidation(knowledge);
435 });
436 if promoted > 0 {
437 crate::core::introspect::tick("replay_consolidation");
438 }
439 promoted
440}
441
442fn step_synthesize_observations(
454 knowledge: &mut ProjectKnowledge,
455 policy: &MemoryPolicy,
456 min_cluster: usize,
457) -> u32 {
458 use std::collections::BTreeMap;
459
460 if min_cluster == 0 {
461 return 0;
462 }
463
464 let mut clusters: BTreeMap<String, Vec<(String, String, f32)>> = BTreeMap::new();
468 for f in knowledge.facts.iter().filter(|f| f.is_current()) {
469 if f.is_synthesized_observation() {
470 continue;
471 }
472 let entity = synthesis_entity_anchor(&f.category, &f.key, &f.value);
473 clusters.entry(entity).or_default().push((
474 f.category.clone(),
475 f.value.clone(),
476 f.confidence,
477 ));
478 }
479
480 let mut count = 0u32;
481 for (entity, mut members) in clusters {
482 if members.len() < min_cluster {
483 continue;
484 }
485 members.sort_by(|a, b| {
487 b.2.partial_cmp(&a.2)
488 .unwrap_or(std::cmp::Ordering::Equal)
489 .then_with(|| a.1.cmp(&b.1))
490 });
491 members.truncate(SYNTHESIS_MAX_MEMBERS);
492
493 let summary = synthesize_observation_value(&entity, &members);
494 let summary = crate::core::llm_enhance::enhance_observation(&entity, &summary);
496 let mean = members.iter().map(|m| m.2).sum::<f32>() / members.len() as f32;
498 knowledge.remember(
499 "observation",
500 &entity,
501 &summary,
502 crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE,
503 mean.min(0.9),
504 policy,
505 );
506 count += 1;
507 }
508
509 if count > 0 {
510 crate::core::introspect::tick("observation_synthesis");
511 }
512 count
513}
514
515fn synthesis_entity_anchor(category: &str, key: &str, value: &str) -> String {
518 crate::core::content_chunk::extract_file_references(key)
519 .into_iter()
520 .next()
521 .or_else(|| {
522 crate::core::content_chunk::extract_file_references(value)
523 .into_iter()
524 .next()
525 })
526 .unwrap_or_else(|| category.to_string())
527}
528
529fn synthesize_observation_value(entity: &str, members: &[(String, String, f32)]) -> String {
532 use std::collections::BTreeMap;
533 let mut by_cat: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
534 for (cat, val, _) in members {
535 by_cat.entry(cat.as_str()).or_default().push(val.as_str());
536 }
537 let body = by_cat
538 .into_iter()
539 .map(|(cat, vals)| format!("{cat}: {}", vals.join("; ")))
540 .collect::<Vec<_>>()
541 .join(" | ");
542 format!(
543 "{entity} — {}",
544 truncate_on_char_boundary(&body, SYNTHESIS_VALUE_MAX)
545 )
546}
547
548fn truncate_on_char_boundary(s: &str, max: usize) -> String {
551 if s.len() <= max {
552 return s.to_string();
553 }
554 let mut end = max;
555 while end > 0 && !s.is_char_boundary(end) {
556 end -= 1;
557 }
558 format!("{}…", &s[..end])
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use crate::core::knowledge::KnowledgeArchetype;
565 use crate::core::knowledge_relations::KnowledgeEdge;
566 use crate::core::memory_boundary::FactPrivacy;
567
568 fn make_fact(
569 category: &str,
570 key: &str,
571 value: &str,
572 confidence: f32,
573 ) -> crate::core::knowledge::KnowledgeFact {
574 crate::core::knowledge::KnowledgeFact {
575 category: category.to_string(),
576 key: key.to_string(),
577 value: value.to_string(),
578 source_session: "test".to_string(),
579 confidence,
580 created_at: Utc::now(),
581 last_confirmed: Utc::now(),
582 retrieval_count: 0,
583 last_retrieved: None,
584 valid_from: Some(Utc::now()),
585 valid_until: None,
586 supersedes: None,
587 confirmation_count: 1,
588 feedback_up: 0,
589 feedback_down: 0,
590 last_feedback: None,
591 privacy: FactPrivacy::default(),
592 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
593 imported_from: None,
594 archetype: KnowledgeArchetype::default(),
595 fidelity: None,
596 revision_count: 0,
597 }
598 }
599
600 fn make_retrieved_fact(
601 category: &str,
602 key: &str,
603 value: &str,
604 retrieved_at: chrono::DateTime<Utc>,
605 ) -> crate::core::knowledge::KnowledgeFact {
606 let mut f = make_fact(category, key, value, 0.9);
607 f.last_retrieved = Some(retrieved_at);
608 f.retrieval_count = 1;
609 f
610 }
611
612 fn make_knowledge(
613 project_root: &str,
614 facts: Vec<crate::core::knowledge::KnowledgeFact>,
615 ) -> ProjectKnowledge {
616 ProjectKnowledge {
617 project_root: project_root.to_string(),
618 project_hash: "test-hash".to_string(),
619 facts,
620 patterns: Vec::new(),
621 history: Vec::new(),
622 updated_at: Utc::now(),
623 judged_pairs: Vec::new(),
624 }
625 }
626
627 fn make_graph(edges: Vec<KnowledgeEdge>) -> KnowledgeRelationGraph {
628 KnowledgeRelationGraph {
629 project_hash: "test-hash".to_string(),
630 edges,
631 updated_at: Utc::now(),
632 }
633 }
634
635 fn make_edge(from_cat: &str, from_key: &str, to_cat: &str, to_key: &str) -> KnowledgeEdge {
636 KnowledgeEdge {
637 from: KnowledgeNodeRef::new(from_cat, from_key),
638 to: KnowledgeNodeRef::new(to_cat, to_key),
639 kind: KnowledgeEdgeKind::RelatedTo,
640 created_at: Utc::now(),
641 last_seen: Some(Utc::now()),
642 count: 1,
643 source_session: "test".to_string(),
644 strength: 0.5,
645 decay_rate: 0.02,
646 }
647 }
648
649 #[test]
650 fn structural_repair_removes_orphaned_edges() {
651 let knowledge = make_knowledge(
652 "/tmp/test",
653 vec![
654 make_fact("arch", "db", "PostgreSQL", 0.9),
655 make_fact("arch", "cache", "Redis", 0.8),
656 ],
657 );
658
659 let mut graph = make_graph(vec![
660 make_edge("arch", "db", "arch", "cache"),
661 make_edge("arch", "db", "arch", "nonexistent"),
662 make_edge("gone", "missing", "arch", "db"),
663 ]);
664
665 let removed = step_structural_repair(&mut graph, &knowledge);
666 assert_eq!(removed, 2);
667 assert_eq!(graph.edges.len(), 1);
668 assert_eq!(graph.edges[0].from.key, "db");
669 assert_eq!(graph.edges[0].to.key, "cache");
670 }
671
672 #[test]
673 fn lateral_synthesis_connects_similar_facts() {
674 let knowledge = make_knowledge(
675 "/tmp/test",
676 vec![
677 make_fact(
678 "arch",
679 "db",
680 "PostgreSQL database primary storage backend",
681 0.9,
682 ),
683 make_fact("arch", "cache", "Redis cache for sessions", 0.8),
684 make_fact(
685 "deploy",
686 "db-host",
687 "PostgreSQL database primary storage on AWS",
688 0.7,
689 ),
690 ],
691 );
692
693 let mut graph = make_graph(Vec::new());
694 let added = step_lateral_synthesis(&knowledge, &mut graph);
695
696 assert!(
697 added >= 1,
698 "Should connect facts sharing vocabulary (PostgreSQL database primary storage)"
699 );
700 assert!(
701 graph.edges.iter().any(|e| {
702 (e.from.key == "db" && e.to.key == "db-host")
703 || (e.from.key == "db-host" && e.to.key == "db")
704 }),
705 "Should have edge between db and db-host"
706 );
707 }
708
709 #[test]
710 fn contradiction_resolution_keeps_higher_quality() {
711 let mut f1 = make_fact("arch", "db", "PostgreSQL", 0.9);
712 f1.confirmation_count = 3;
713 let f2 = make_fact("arch", "db", "MySQL", 0.5);
714
715 let mut knowledge = make_knowledge("/tmp/test", vec![f1, f2]);
716 let resolved = step_contradiction_resolution(&mut knowledge);
717
718 assert_eq!(resolved, 1);
719 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
720 assert_eq!(current.len(), 1);
721 assert_eq!(current[0].value, "PostgreSQL");
722 }
723
724 #[test]
725 fn hebbian_strengthen_co_retrieval() {
726 let now = Utc::now();
727 let knowledge = make_knowledge(
728 "/tmp/test",
729 vec![
730 make_retrieved_fact("arch", "db", "PostgreSQL", now),
731 make_retrieved_fact("arch", "cache", "Redis", now - Duration::minutes(30)),
732 make_retrieved_fact("arch", "queue", "Kafka", now - Duration::hours(5)),
733 ],
734 );
735
736 let mut graph = make_graph(Vec::new());
737 let strengthened = step_hebbian_strengthen(&knowledge, &mut graph);
738
739 assert!(
740 strengthened >= 1,
741 "Should strengthen co-retrieved facts within 1h window"
742 );
743 let has_db_cache = graph.edges.iter().any(|e| {
744 (e.from.key == "db" && e.to.key == "cache")
745 || (e.from.key == "cache" && e.to.key == "db")
746 });
747 assert!(has_db_cache, "db and cache were retrieved within 1h");
748 }
749
750 #[test]
751 fn decay_reduces_stale_edge_counts() {
752 let old = Utc::now() - Duration::days(45);
753 let mut graph = make_graph(vec![
754 {
755 let mut e = make_edge("arch", "db", "arch", "cache");
756 e.last_seen = Some(old);
757 e.count = 3;
758 e
759 },
760 {
761 let mut e = make_edge("arch", "old", "arch", "ancient");
762 e.last_seen = Some(old);
763 e.count = 1;
764 e
765 },
766 ]);
767
768 let policy = MemoryPolicy::default();
769 let mut knowledge = make_knowledge(
770 "/tmp/test",
771 vec![
772 make_fact("arch", "db", "PostgreSQL", 0.9),
773 make_fact("arch", "cache", "Redis", 0.8),
774 ],
775 );
776
777 step_decay(&mut knowledge, &mut graph, &policy);
778
779 assert_eq!(
780 graph.edges.len(),
781 1,
782 "Edge with count=1 and stale should be removed"
783 );
784 assert_eq!(
785 graph.edges[0].count, 2,
786 "Edge with count=3 should be decremented to 2"
787 );
788 }
789
790 #[test]
791 fn replay_consolidation_promotes_related_accessed_facts() {
792 let mut f1 = make_fact(
795 "arch",
796 "db",
797 "uses postgres database for primary storage",
798 0.5,
799 );
800 f1.retrieval_count = 50;
801 f1.last_retrieved = Some(Utc::now());
802 let mut f2 = make_fact(
803 "arch",
804 "db2",
805 "uses postgres database for sessions cache",
806 0.5,
807 );
808 f2.retrieval_count = 50;
809 f2.last_retrieved = Some(Utc::now());
810
811 let mut knowledge = make_knowledge("/tmp/test", vec![f1, f2]);
812 let promoted = step_replay_consolidation(&mut knowledge);
813 assert!(
814 promoted >= 1,
815 "related, frequently-accessed facts should be promoted (#3)"
816 );
817 assert!(
818 knowledge.facts.iter().any(|f| f.confidence > 0.5),
819 "confidence must be lifted by replay boost"
820 );
821 }
822
823 #[test]
824 fn idle_replay_consolidates_from_disk() {
825 let _lock = crate::core::data_dir::test_env_lock();
828 let tmp = tempfile::tempdir().expect("tempdir");
829 crate::test_env::set_var(
830 "LEAN_CTX_DATA_DIR",
831 tmp.path().to_string_lossy().to_string(),
832 );
833 let project_root = tmp.path().join("proj");
834 std::fs::create_dir_all(&project_root).expect("mkdir");
835 let root = project_root.to_string_lossy().to_string();
836
837 let policy = MemoryPolicy::default();
838 let mut knowledge = ProjectKnowledge::load_or_create(&root);
839 knowledge.remember(
840 "arch",
841 "db",
842 "uses postgres database for primary storage",
843 "s1",
844 0.5,
845 &policy,
846 );
847 knowledge.remember(
848 "arch",
849 "db2",
850 "uses postgres database for sessions cache",
851 "s1",
852 0.5,
853 &policy,
854 );
855 for f in &mut knowledge.facts {
856 f.retrieval_count = 50;
857 f.last_retrieved = Some(Utc::now());
858 }
859 let _ = knowledge.save();
860
861 let promoted = run_idle_replay(&root);
862 assert!(
863 promoted >= 1,
864 "idle replay should consolidate related facts (#7)"
865 );
866
867 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
868 }
869
870 #[test]
871 fn cognition_loop_runs_all_steps() {
872 let _lock = crate::core::data_dir::test_env_lock();
873 let tmp = tempfile::tempdir().expect("tempdir");
874 crate::test_env::set_var(
875 "LEAN_CTX_DATA_DIR",
876 tmp.path().to_string_lossy().to_string(),
877 );
878
879 let project_root = tmp.path().join("proj");
880 std::fs::create_dir_all(&project_root).expect("mkdir");
881 let project_root_str = project_root.to_string_lossy().to_string();
882
883 let policy = MemoryPolicy::default();
884 let mut knowledge = ProjectKnowledge::load_or_create(&project_root_str);
885 knowledge.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
886 knowledge.remember("arch", "cache", "Redis", "s1", 0.8, &policy);
887 knowledge.remember("deploy", "host", "AWS", "s1", 0.7, &policy);
888 let _ = knowledge.save();
889
890 let report = run_cognition_loop(&project_root_str, 8);
891 assert_eq!(report.steps_run, 8);
892
893 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
894 }
895
896 #[test]
897 fn promote_proven_gotchas_bridges_into_knowledge_and_is_idempotent() {
898 use crate::core::gotcha_tracker::{
899 Gotcha, GotchaCategory, GotchaSeverity, GotchaSource, GotchaStore,
900 };
901 let _iso = crate::core::data_dir::isolated_data_dir();
902 let dir = tempfile::tempdir().unwrap();
903 let root = dir.path().to_string_lossy().to_string();
904
905 let mut store = GotchaStore::load(&root);
907 let mut g = Gotcha::new(
908 GotchaCategory::Build,
909 GotchaSeverity::Critical,
910 "cargo E0507 cannot move out of borrowed content",
911 "clone the value before the move",
912 GotchaSource::AgentReported {
913 session_id: "s1".into(),
914 },
915 "s1",
916 );
917 g.confidence = 0.95;
918 g.occurrences = 6;
919 g.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
920 g.prevented_count = 3;
921 store.gotchas.push(g);
922 store.save(&root).unwrap();
923
924 let policy = MemoryPolicy::default();
925 let mut knowledge = ProjectKnowledge::new(&root);
926
927 let n = promote_proven_gotchas(&root, &mut knowledge, "sess", &policy);
928 assert_eq!(n, 1, "the proven gotcha is promoted");
929 assert!(
930 knowledge
931 .facts
932 .iter()
933 .any(|f| f.category == "gotcha" && f.value.contains("clone the value")),
934 "a durable gotcha fact must appear in knowledge"
935 );
936
937 promote_proven_gotchas(&root, &mut knowledge, "sess", &policy);
939 let gotcha_facts = knowledge
940 .facts
941 .iter()
942 .filter(|f| f.category == "gotcha")
943 .count();
944 assert_eq!(gotcha_facts, 1, "promotion must be idempotent");
945 }
946
947 #[test]
948 fn synthesize_observation_value_is_deterministic() {
949 let members = vec![
950 ("finding".to_string(), "b issue".to_string(), 0.5f32),
951 ("finding".to_string(), "a issue".to_string(), 0.9f32),
952 ("gotcha".to_string(), "race".to_string(), 0.7f32),
953 ];
954 let v1 = synthesize_observation_value("src/x.rs", &members);
955 let v2 = synthesize_observation_value("src/x.rs", &members);
956 assert_eq!(v1, v2, "synthesis value must be deterministic");
957 assert!(v1.starts_with("src/x.rs — "));
958 let f = v1.find("finding:").expect("finding group");
960 let g = v1.find("gotcha:").expect("gotcha group");
961 assert!(f < g, "categories grouped in sorted order");
962 }
963
964 #[test]
965 fn synthesis_entity_anchor_resolves_file_then_category() {
966 assert_eq!(
967 synthesis_entity_anchor("finding", "src/auth.rs:42", "x"),
968 "src/auth.rs"
969 );
970 assert_eq!(
971 synthesis_entity_anchor("decision", "no-file", "see src/lib.rs here"),
972 "src/lib.rs"
973 );
974 assert_eq!(
975 synthesis_entity_anchor("decision", "plain-key", "no path at all"),
976 "decision"
977 );
978 }
979
980 #[test]
981 fn step_synthesizes_per_entity_observation_and_is_idempotent() {
982 let _lock = crate::core::data_dir::test_env_lock();
983 let tmp = tempfile::tempdir().expect("tempdir");
984 crate::test_env::set_var(
985 "LEAN_CTX_DATA_DIR",
986 tmp.path().to_string_lossy().to_string(),
987 );
988
989 let policy = MemoryPolicy::default();
990 let mut k = ProjectKnowledge::new("/tmp/test-synthesis");
991 k.remember(
993 "finding",
994 "src/auth.rs:10",
995 "missing null check",
996 "s1",
997 0.8,
998 &policy,
999 );
1000 k.remember(
1001 "finding",
1002 "src/auth.rs:20",
1003 "token not validated",
1004 "s1",
1005 0.7,
1006 &policy,
1007 );
1008 k.remember(
1009 "gotcha",
1010 "src/auth.rs:30",
1011 "race on refresh",
1012 "s1",
1013 0.9,
1014 &policy,
1015 );
1016
1017 let made = step_synthesize_observations(&mut k, &policy, 3);
1018 assert_eq!(made, 1, "one observation for the clustered entity");
1019
1020 let obs: Vec<_> = k
1021 .facts
1022 .iter()
1023 .filter(|f| f.is_current() && f.is_synthesized_observation())
1024 .collect();
1025 assert_eq!(obs.len(), 1);
1026 assert_eq!(obs[0].key, "src/auth.rs");
1027 assert_eq!(obs[0].archetype, KnowledgeArchetype::Observation);
1028
1029 let again = step_synthesize_observations(&mut k, &policy, 3);
1031 assert_eq!(again, 1, "step still writes (confirms) the summary");
1032 let current = k
1033 .facts
1034 .iter()
1035 .filter(|f| f.is_current() && f.is_synthesized_observation())
1036 .count();
1037 assert_eq!(current, 1, "idempotent: no duplicate observation");
1038
1039 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1040 }
1041
1042 #[test]
1043 fn synthesis_excludes_synthesized_observations_from_input() {
1044 let policy = MemoryPolicy::default();
1045 let mut k = ProjectKnowledge::new("/tmp/test-no-recursion");
1046 k.remember(
1048 "observation",
1049 "src/x.rs",
1050 "src/x.rs — finding: a; b",
1051 crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE,
1052 0.6,
1053 &policy,
1054 );
1055 k.remember("finding", "src/y.rs:1", "issue one", "s1", 0.5, &policy);
1056 let made = step_synthesize_observations(&mut k, &policy, 3);
1058 assert_eq!(made, 0, "no entity reaches the cluster threshold");
1059 }
1060
1061 #[test]
1062 fn cognition_loop_step_9_synthesizes_observations() {
1063 let _lock = crate::core::data_dir::test_env_lock();
1064 let tmp = tempfile::tempdir().expect("tempdir");
1065 crate::test_env::set_var(
1066 "LEAN_CTX_DATA_DIR",
1067 tmp.path().to_string_lossy().to_string(),
1068 );
1069 let project_root = tmp.path().join("proj");
1070 std::fs::create_dir_all(&project_root).expect("mkdir");
1071 let root = project_root.to_string_lossy().to_string();
1072
1073 let policy = MemoryPolicy::default();
1074 let mut knowledge = ProjectKnowledge::load_or_create(&root);
1075 knowledge.remember(
1076 "finding",
1077 "src/api.rs:1",
1078 "no auth on route",
1079 "s1",
1080 0.8,
1081 &policy,
1082 );
1083 knowledge.remember(
1084 "finding",
1085 "src/api.rs:2",
1086 "missing rate limit",
1087 "s1",
1088 0.7,
1089 &policy,
1090 );
1091 knowledge.remember(
1092 "gotcha",
1093 "src/api.rs:3",
1094 "panics on empty body",
1095 "s1",
1096 0.9,
1097 &policy,
1098 );
1099 let _ = knowledge.save();
1100
1101 let report = run_cognition_loop(&root, 9);
1102 assert_eq!(report.steps_run, 9);
1103 assert!(
1104 report.observations_synthesized >= 1,
1105 "step 9 must synthesize at least one observation"
1106 );
1107
1108 let reloaded = ProjectKnowledge::load_or_create(&root);
1109 assert!(
1110 reloaded
1111 .facts
1112 .iter()
1113 .any(|f| f.is_current() && f.is_synthesized_observation()),
1114 "synthesized observation must persist"
1115 );
1116
1117 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1118 }
1119}