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