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
20#[derive(Debug, Clone, Default)]
21pub struct CognitionLoopReport {
22 pub steps_run: u8,
23 pub facts_promoted: u32,
24 pub edges_repaired: u32,
25 pub edges_strengthened: u32,
26 pub facts_decayed: u32,
27 pub facts_archived: u32,
28 pub contradictions_resolved: u32,
29 pub lateral_connections: u32,
30 pub duration_ms: u64,
31}
32
33impl std::fmt::Display for CognitionLoopReport {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 write!(
36 f,
37 "Cognition Loop ({} steps, {}ms): promoted={}, repaired={}, \
38 strengthened={}, decayed={}, archived={}, contradictions={}, lateral={}",
39 self.steps_run,
40 self.duration_ms,
41 self.facts_promoted,
42 self.edges_repaired,
43 self.edges_strengthened,
44 self.facts_decayed,
45 self.facts_archived,
46 self.contradictions_resolved,
47 self.lateral_connections,
48 )
49 }
50}
51
52pub fn run_cognition_loop(project_root: &str, max_steps: u8) -> CognitionLoopReport {
53 let start = std::time::Instant::now();
54 let mut report = CognitionLoopReport::default();
55
56 let Ok(policy) = crate::core::config::Config::load().memory_policy_effective() else {
57 return report;
58 };
59
60 let _ = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
66 let project_hash = knowledge.project_hash.clone();
67 let mut graph = KnowledgeRelationGraph::load_or_create(&project_hash);
68
69 if max_steps >= 1 {
70 report.facts_promoted = step_seed_promote(project_root, knowledge, &policy);
71 report.steps_run = 1;
72 }
73
74 if max_steps >= 2 {
75 report.edges_repaired = step_structural_repair(&mut graph, knowledge);
76 report.steps_run = 2;
77 }
78
79 if max_steps >= 3 {
81 report.steps_run = 3;
82 }
83
84 if max_steps >= 4 {
85 report.lateral_connections = step_lateral_synthesis(knowledge, &mut graph);
86 report.steps_run = 4;
87 }
88
89 if max_steps >= 5 {
90 report.contradictions_resolved = step_contradiction_resolution(knowledge);
91 report.steps_run = 5;
92 }
93
94 if max_steps >= 6 {
95 report.edges_strengthened = step_hebbian_strengthen(knowledge, &mut graph);
96 report.steps_run = 6;
97 }
98
99 if max_steps >= 7 {
100 report.facts_decayed = step_decay(knowledge, &mut graph, &policy);
101 report.steps_run = 7;
102 }
103
104 if max_steps >= 8 {
105 let lifecycle = knowledge.run_memory_lifecycle(&policy);
106 report.facts_archived = lifecycle.archived_count as u32;
107 report.steps_run = 8;
108 }
109
110 let _ = graph.save();
111 });
112
113 report.duration_ms = start.elapsed().as_millis() as u64;
114 report
115}
116
117fn step_seed_promote(
119 _project_root: &str,
120 knowledge: &mut ProjectKnowledge,
121 policy: &MemoryPolicy,
122) -> u32 {
123 let Some(session) = crate::core::session::SessionState::load_latest() else {
124 return 0;
125 };
126
127 let mut count = 0u32;
128 let max_decisions = 5usize;
129 let max_findings = 8usize;
130
131 let mut decisions = session.decisions.clone();
132 decisions.sort_by_key(|d| std::cmp::Reverse(d.timestamp));
133 decisions.truncate(max_decisions);
134 for d in &decisions {
135 let key = slug_key(&d.summary, 50);
136 knowledge.remember("decision", &key, &d.summary, &session.id, 0.9, policy);
137 count += 1;
138 }
139
140 let mut findings = session.findings.clone();
141 findings.sort_by_key(|f| std::cmp::Reverse(f.timestamp));
142 let mut kept = 0usize;
143 for f in &findings {
144 if kept >= max_findings {
145 break;
146 }
147 if finding_salience(&f.summary) < 45 {
148 continue;
149 }
150 let key = if let Some(ref file) = f.file {
151 if let Some(line) = f.line {
152 format!("{file}:{line}")
153 } else {
154 file.clone()
155 }
156 } else {
157 format!("finding-{}", slug_key(&f.summary, 36))
158 };
159 knowledge.remember("finding", &key, &f.summary, &session.id, 0.75, policy);
160 count += 1;
161 kept += 1;
162 }
163
164 count
165}
166
167fn step_structural_repair(graph: &mut KnowledgeRelationGraph, knowledge: &ProjectKnowledge) -> u32 {
169 let fact_ids: HashSet<String> = knowledge
170 .facts
171 .iter()
172 .filter(|f| f.is_current())
173 .map(|f| format!("{}/{}", f.category, f.key))
174 .collect();
175
176 let before = graph.edges.len();
177 graph
178 .edges
179 .retain(|e| fact_ids.contains(&e.from.id()) && fact_ids.contains(&e.to.id()));
180 (before - graph.edges.len()) as u32
181}
182
183fn step_lateral_synthesis(knowledge: &ProjectKnowledge, graph: &mut KnowledgeRelationGraph) -> u32 {
185 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
186
187 let existing_pairs: HashSet<(String, String)> = graph
188 .edges
189 .iter()
190 .map(|e| (e.from.id(), e.to.id()))
191 .collect();
192
193 let mut added = 0u32;
194
195 for (i, a) in current.iter().enumerate() {
196 if added >= LATERAL_MAX_NEW_EDGES as u32 {
197 break;
198 }
199 for b in ¤t[i + 1..] {
200 if added >= LATERAL_MAX_NEW_EDGES as u32 {
201 break;
202 }
203 let id_a = format!("{}/{}", a.category, a.key);
204 let id_b = format!("{}/{}", b.category, b.key);
205 if existing_pairs.contains(&(id_a.clone(), id_b.clone()))
206 || existing_pairs.contains(&(id_b.clone(), id_a.clone()))
207 {
208 continue;
209 }
210 let sim = crate::core::memory_consolidation::token_jaccard(&a.value, &b.value);
211 if sim >= LATERAL_SIM_THRESHOLD {
212 let from = KnowledgeNodeRef::new(&a.category, &a.key);
213 let to = KnowledgeNodeRef::new(&b.category, &b.key);
214 graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "cognition-loop");
215 added += 1;
216 }
217 }
218 }
219
220 added
221}
222
223fn step_contradiction_resolution(knowledge: &mut ProjectKnowledge) -> u32 {
226 let now = Utc::now();
227 let mut resolved = 0u32;
228
229 let mut seen: std::collections::HashMap<(String, String), usize> =
230 std::collections::HashMap::new();
231 let mut to_archive: Vec<usize> = Vec::new();
232
233 for (i, f) in knowledge.facts.iter().enumerate() {
234 if !f.is_current() {
235 continue;
236 }
237 let key = (f.category.clone(), f.key.clone());
238 if let Some(&prev_idx) = seen.get(&key) {
239 let prev = &knowledge.facts[prev_idx];
240 if prev.value != f.value {
241 if prev.quality_score() >= f.quality_score() {
242 to_archive.push(i);
243 } else {
244 to_archive.push(prev_idx);
245 seen.insert(key, i);
246 }
247 resolved += 1;
248 }
249 } else {
250 seen.insert(key, i);
251 }
252 }
253
254 for &idx in &to_archive {
255 knowledge.facts[idx].valid_until = Some(now);
256 }
257
258 resolved
259}
260
261fn step_hebbian_strengthen(
263 knowledge: &ProjectKnowledge,
264 graph: &mut KnowledgeRelationGraph,
265) -> u32 {
266 let retrieved: Vec<_> = knowledge
267 .facts
268 .iter()
269 .filter(|f| f.is_current() && f.last_retrieved.is_some())
270 .collect();
271
272 let window = Duration::hours(HEBBIAN_CO_RETRIEVAL_HOURS);
273 let mut strengthened = 0u32;
274
275 for (i, a) in retrieved.iter().enumerate() {
276 let Some(a_time) = a.last_retrieved else {
277 continue;
278 };
279 for b in &retrieved[i + 1..] {
280 let Some(b_time) = b.last_retrieved else {
281 continue;
282 };
283 let diff = (a_time - b_time).abs();
284 if diff <= window {
285 let from = KnowledgeNodeRef::new(&a.category, &a.key);
286 let to = KnowledgeNodeRef::new(&b.category, &b.key);
287 if !graph.strengthen_edge(&from, &to, 0.15) {
288 graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "hebbian");
289 }
290 strengthened += 1;
291 }
292 }
293 }
294
295 strengthened
296}
297
298fn step_decay(
300 knowledge: &mut ProjectKnowledge,
301 graph: &mut KnowledgeRelationGraph,
302 policy: &MemoryPolicy,
303) -> u32 {
304 let lifecycle_cfg = crate::core::memory_lifecycle::LifecycleConfig {
305 max_facts: policy.knowledge.max_facts,
306 decay_rate_per_day: policy.lifecycle.decay_rate,
307 low_confidence_threshold: policy.lifecycle.low_confidence_threshold,
308 stale_days: policy.lifecycle.stale_days,
309 consolidation_similarity: policy.lifecycle.similarity_threshold,
310 };
311 crate::core::memory_lifecycle::apply_confidence_decay(&mut knowledge.facts, &lifecycle_cfg);
312
313 let low_conf_count = knowledge
314 .facts
315 .iter()
316 .filter(|f| f.is_current() && f.confidence < 0.3)
317 .count() as u32;
318
319 graph.decay_all_edges(1.0);
320 graph.prune_weak_edges(0.05);
321
322 let stale_cutoff = Utc::now() - Duration::days(EDGE_STALE_DAYS);
323 graph.edges.retain_mut(|e| {
324 let last = e.last_seen.unwrap_or(e.created_at);
325 if last < stale_cutoff {
326 if e.count <= 1 {
327 return false;
328 }
329 e.count = e.count.saturating_sub(1);
330 }
331 true
332 });
333
334 low_conf_count
335}
336
337fn slug_key(s: &str, max: usize) -> String {
338 let mut out = String::new();
339 for ch in s.chars() {
340 if out.len() >= max {
341 break;
342 }
343 if ch.is_ascii_alphanumeric() {
344 out.push(ch.to_ascii_lowercase());
345 } else if (ch.is_whitespace() || ch == '-' || ch == '_')
346 && !out.ends_with('-')
347 && !out.is_empty()
348 {
349 out.push('-');
350 }
351 }
352 out.trim_matches('-').to_string()
353}
354
355fn finding_salience(summary: &str) -> u32 {
356 let s = summary.to_lowercase();
357 let mut score = 20u32;
358 let boosts = [
359 ("error", 25),
360 ("failed", 25),
361 ("panic", 30),
362 ("assert", 20),
363 ("forbidden", 25),
364 ("timeout", 20),
365 ("deadlock", 25),
366 ("security", 25),
367 ("vuln", 25),
368 ("e0", 15),
369 ];
370 for (pat, b) in boosts {
371 if s.contains(pat) {
372 score = score.saturating_add(b);
373 }
374 }
375 score
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use crate::core::knowledge::KnowledgeArchetype;
382 use crate::core::knowledge_relations::KnowledgeEdge;
383 use crate::core::memory_boundary::FactPrivacy;
384
385 fn make_fact(
386 category: &str,
387 key: &str,
388 value: &str,
389 confidence: f32,
390 ) -> crate::core::knowledge::KnowledgeFact {
391 crate::core::knowledge::KnowledgeFact {
392 category: category.to_string(),
393 key: key.to_string(),
394 value: value.to_string(),
395 source_session: "test".to_string(),
396 confidence,
397 created_at: Utc::now(),
398 last_confirmed: Utc::now(),
399 retrieval_count: 0,
400 last_retrieved: None,
401 valid_from: Some(Utc::now()),
402 valid_until: None,
403 supersedes: None,
404 confirmation_count: 1,
405 feedback_up: 0,
406 feedback_down: 0,
407 last_feedback: None,
408 privacy: FactPrivacy::default(),
409 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
410 imported_from: None,
411 archetype: KnowledgeArchetype::default(),
412 fidelity: None,
413 revision_count: 0,
414 }
415 }
416
417 fn make_retrieved_fact(
418 category: &str,
419 key: &str,
420 value: &str,
421 retrieved_at: chrono::DateTime<Utc>,
422 ) -> crate::core::knowledge::KnowledgeFact {
423 let mut f = make_fact(category, key, value, 0.9);
424 f.last_retrieved = Some(retrieved_at);
425 f.retrieval_count = 1;
426 f
427 }
428
429 fn make_knowledge(
430 project_root: &str,
431 facts: Vec<crate::core::knowledge::KnowledgeFact>,
432 ) -> ProjectKnowledge {
433 ProjectKnowledge {
434 project_root: project_root.to_string(),
435 project_hash: "test-hash".to_string(),
436 facts,
437 patterns: Vec::new(),
438 history: Vec::new(),
439 updated_at: Utc::now(),
440 judged_pairs: Vec::new(),
441 }
442 }
443
444 fn make_graph(edges: Vec<KnowledgeEdge>) -> KnowledgeRelationGraph {
445 KnowledgeRelationGraph {
446 project_hash: "test-hash".to_string(),
447 edges,
448 updated_at: Utc::now(),
449 }
450 }
451
452 fn make_edge(from_cat: &str, from_key: &str, to_cat: &str, to_key: &str) -> KnowledgeEdge {
453 KnowledgeEdge {
454 from: KnowledgeNodeRef::new(from_cat, from_key),
455 to: KnowledgeNodeRef::new(to_cat, to_key),
456 kind: KnowledgeEdgeKind::RelatedTo,
457 created_at: Utc::now(),
458 last_seen: Some(Utc::now()),
459 count: 1,
460 source_session: "test".to_string(),
461 strength: 0.5,
462 decay_rate: 0.02,
463 }
464 }
465
466 #[test]
467 fn structural_repair_removes_orphaned_edges() {
468 let knowledge = make_knowledge(
469 "/tmp/test",
470 vec![
471 make_fact("arch", "db", "PostgreSQL", 0.9),
472 make_fact("arch", "cache", "Redis", 0.8),
473 ],
474 );
475
476 let mut graph = make_graph(vec![
477 make_edge("arch", "db", "arch", "cache"),
478 make_edge("arch", "db", "arch", "nonexistent"),
479 make_edge("gone", "missing", "arch", "db"),
480 ]);
481
482 let removed = step_structural_repair(&mut graph, &knowledge);
483 assert_eq!(removed, 2);
484 assert_eq!(graph.edges.len(), 1);
485 assert_eq!(graph.edges[0].from.key, "db");
486 assert_eq!(graph.edges[0].to.key, "cache");
487 }
488
489 #[test]
490 fn lateral_synthesis_connects_similar_facts() {
491 let knowledge = make_knowledge(
492 "/tmp/test",
493 vec![
494 make_fact(
495 "arch",
496 "db",
497 "PostgreSQL database primary storage backend",
498 0.9,
499 ),
500 make_fact("arch", "cache", "Redis cache for sessions", 0.8),
501 make_fact(
502 "deploy",
503 "db-host",
504 "PostgreSQL database primary storage on AWS",
505 0.7,
506 ),
507 ],
508 );
509
510 let mut graph = make_graph(Vec::new());
511 let added = step_lateral_synthesis(&knowledge, &mut graph);
512
513 assert!(
514 added >= 1,
515 "Should connect facts sharing vocabulary (PostgreSQL database primary storage)"
516 );
517 assert!(
518 graph.edges.iter().any(|e| {
519 (e.from.key == "db" && e.to.key == "db-host")
520 || (e.from.key == "db-host" && e.to.key == "db")
521 }),
522 "Should have edge between db and db-host"
523 );
524 }
525
526 #[test]
527 fn contradiction_resolution_keeps_higher_quality() {
528 let mut f1 = make_fact("arch", "db", "PostgreSQL", 0.9);
529 f1.confirmation_count = 3;
530 let f2 = make_fact("arch", "db", "MySQL", 0.5);
531
532 let mut knowledge = make_knowledge("/tmp/test", vec![f1, f2]);
533 let resolved = step_contradiction_resolution(&mut knowledge);
534
535 assert_eq!(resolved, 1);
536 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
537 assert_eq!(current.len(), 1);
538 assert_eq!(current[0].value, "PostgreSQL");
539 }
540
541 #[test]
542 fn hebbian_strengthen_co_retrieval() {
543 let now = Utc::now();
544 let knowledge = make_knowledge(
545 "/tmp/test",
546 vec![
547 make_retrieved_fact("arch", "db", "PostgreSQL", now),
548 make_retrieved_fact("arch", "cache", "Redis", now - Duration::minutes(30)),
549 make_retrieved_fact("arch", "queue", "Kafka", now - Duration::hours(5)),
550 ],
551 );
552
553 let mut graph = make_graph(Vec::new());
554 let strengthened = step_hebbian_strengthen(&knowledge, &mut graph);
555
556 assert!(
557 strengthened >= 1,
558 "Should strengthen co-retrieved facts within 1h window"
559 );
560 let has_db_cache = graph.edges.iter().any(|e| {
561 (e.from.key == "db" && e.to.key == "cache")
562 || (e.from.key == "cache" && e.to.key == "db")
563 });
564 assert!(has_db_cache, "db and cache were retrieved within 1h");
565 }
566
567 #[test]
568 fn decay_reduces_stale_edge_counts() {
569 let old = Utc::now() - Duration::days(45);
570 let mut graph = make_graph(vec![
571 {
572 let mut e = make_edge("arch", "db", "arch", "cache");
573 e.last_seen = Some(old);
574 e.count = 3;
575 e
576 },
577 {
578 let mut e = make_edge("arch", "old", "arch", "ancient");
579 e.last_seen = Some(old);
580 e.count = 1;
581 e
582 },
583 ]);
584
585 let policy = MemoryPolicy::default();
586 let mut knowledge = make_knowledge(
587 "/tmp/test",
588 vec![
589 make_fact("arch", "db", "PostgreSQL", 0.9),
590 make_fact("arch", "cache", "Redis", 0.8),
591 ],
592 );
593
594 step_decay(&mut knowledge, &mut graph, &policy);
595
596 assert_eq!(
597 graph.edges.len(),
598 1,
599 "Edge with count=1 and stale should be removed"
600 );
601 assert_eq!(
602 graph.edges[0].count, 2,
603 "Edge with count=3 should be decremented to 2"
604 );
605 }
606
607 #[test]
608 fn cognition_loop_runs_all_steps() {
609 let _lock = crate::core::data_dir::test_env_lock();
610 let tmp = tempfile::tempdir().expect("tempdir");
611 std::env::set_var(
612 "LEAN_CTX_DATA_DIR",
613 tmp.path().to_string_lossy().to_string(),
614 );
615
616 let project_root = tmp.path().join("proj");
617 std::fs::create_dir_all(&project_root).expect("mkdir");
618 let project_root_str = project_root.to_string_lossy().to_string();
619
620 let policy = MemoryPolicy::default();
621 let mut knowledge = ProjectKnowledge::load_or_create(&project_root_str);
622 knowledge.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
623 knowledge.remember("arch", "cache", "Redis", "s1", 0.8, &policy);
624 knowledge.remember("deploy", "host", "AWS", "s1", 0.7, &policy);
625 let _ = knowledge.save();
626
627 let report = run_cognition_loop(&project_root_str, 8);
628 assert_eq!(report.steps_run, 8);
629
630 std::env::remove_var("LEAN_CTX_DATA_DIR");
631 }
632}