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