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