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