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