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 revision_count: 0,
404 }
405 }
406
407 fn make_retrieved_fact(
408 category: &str,
409 key: &str,
410 value: &str,
411 retrieved_at: chrono::DateTime<Utc>,
412 ) -> crate::core::knowledge::KnowledgeFact {
413 let mut f = make_fact(category, key, value, 0.9);
414 f.last_retrieved = Some(retrieved_at);
415 f.retrieval_count = 1;
416 f
417 }
418
419 fn make_knowledge(
420 project_root: &str,
421 facts: Vec<crate::core::knowledge::KnowledgeFact>,
422 ) -> ProjectKnowledge {
423 ProjectKnowledge {
424 project_root: project_root.to_string(),
425 project_hash: "test-hash".to_string(),
426 facts,
427 patterns: Vec::new(),
428 history: Vec::new(),
429 updated_at: Utc::now(),
430 judged_pairs: Vec::new(),
431 }
432 }
433
434 fn make_graph(edges: Vec<KnowledgeEdge>) -> KnowledgeRelationGraph {
435 KnowledgeRelationGraph {
436 project_hash: "test-hash".to_string(),
437 edges,
438 updated_at: Utc::now(),
439 }
440 }
441
442 fn make_edge(from_cat: &str, from_key: &str, to_cat: &str, to_key: &str) -> KnowledgeEdge {
443 KnowledgeEdge {
444 from: KnowledgeNodeRef::new(from_cat, from_key),
445 to: KnowledgeNodeRef::new(to_cat, to_key),
446 kind: KnowledgeEdgeKind::RelatedTo,
447 created_at: Utc::now(),
448 last_seen: Some(Utc::now()),
449 count: 1,
450 source_session: "test".to_string(),
451 strength: 0.5,
452 decay_rate: 0.02,
453 }
454 }
455
456 #[test]
457 fn structural_repair_removes_orphaned_edges() {
458 let knowledge = make_knowledge(
459 "/tmp/test",
460 vec![
461 make_fact("arch", "db", "PostgreSQL", 0.9),
462 make_fact("arch", "cache", "Redis", 0.8),
463 ],
464 );
465
466 let mut graph = make_graph(vec![
467 make_edge("arch", "db", "arch", "cache"),
468 make_edge("arch", "db", "arch", "nonexistent"),
469 make_edge("gone", "missing", "arch", "db"),
470 ]);
471
472 let removed = step_structural_repair(&mut graph, &knowledge);
473 assert_eq!(removed, 2);
474 assert_eq!(graph.edges.len(), 1);
475 assert_eq!(graph.edges[0].from.key, "db");
476 assert_eq!(graph.edges[0].to.key, "cache");
477 }
478
479 #[test]
480 fn lateral_synthesis_connects_similar_facts() {
481 let knowledge = make_knowledge(
482 "/tmp/test",
483 vec![
484 make_fact(
485 "arch",
486 "db",
487 "PostgreSQL database primary storage backend",
488 0.9,
489 ),
490 make_fact("arch", "cache", "Redis cache for sessions", 0.8),
491 make_fact(
492 "deploy",
493 "db-host",
494 "PostgreSQL database primary storage on AWS",
495 0.7,
496 ),
497 ],
498 );
499
500 let mut graph = make_graph(Vec::new());
501 let added = step_lateral_synthesis(&knowledge, &mut graph);
502
503 assert!(
504 added >= 1,
505 "Should connect facts sharing vocabulary (PostgreSQL database primary storage)"
506 );
507 assert!(
508 graph.edges.iter().any(|e| {
509 (e.from.key == "db" && e.to.key == "db-host")
510 || (e.from.key == "db-host" && e.to.key == "db")
511 }),
512 "Should have edge between db and db-host"
513 );
514 }
515
516 #[test]
517 fn contradiction_resolution_keeps_higher_quality() {
518 let mut f1 = make_fact("arch", "db", "PostgreSQL", 0.9);
519 f1.confirmation_count = 3;
520 let f2 = make_fact("arch", "db", "MySQL", 0.5);
521
522 let mut knowledge = make_knowledge("/tmp/test", vec![f1, f2]);
523 let resolved = step_contradiction_resolution(&mut knowledge);
524
525 assert_eq!(resolved, 1);
526 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
527 assert_eq!(current.len(), 1);
528 assert_eq!(current[0].value, "PostgreSQL");
529 }
530
531 #[test]
532 fn hebbian_strengthen_co_retrieval() {
533 let now = Utc::now();
534 let knowledge = make_knowledge(
535 "/tmp/test",
536 vec![
537 make_retrieved_fact("arch", "db", "PostgreSQL", now),
538 make_retrieved_fact("arch", "cache", "Redis", now - Duration::minutes(30)),
539 make_retrieved_fact("arch", "queue", "Kafka", now - Duration::hours(5)),
540 ],
541 );
542
543 let mut graph = make_graph(Vec::new());
544 let strengthened = step_hebbian_strengthen(&knowledge, &mut graph);
545
546 assert!(
547 strengthened >= 1,
548 "Should strengthen co-retrieved facts within 1h window"
549 );
550 let has_db_cache = graph.edges.iter().any(|e| {
551 (e.from.key == "db" && e.to.key == "cache")
552 || (e.from.key == "cache" && e.to.key == "db")
553 });
554 assert!(has_db_cache, "db and cache were retrieved within 1h");
555 }
556
557 #[test]
558 fn decay_reduces_stale_edge_counts() {
559 let old = Utc::now() - Duration::days(45);
560 let mut graph = make_graph(vec![
561 {
562 let mut e = make_edge("arch", "db", "arch", "cache");
563 e.last_seen = Some(old);
564 e.count = 3;
565 e
566 },
567 {
568 let mut e = make_edge("arch", "old", "arch", "ancient");
569 e.last_seen = Some(old);
570 e.count = 1;
571 e
572 },
573 ]);
574
575 let policy = MemoryPolicy::default();
576 let mut knowledge = make_knowledge(
577 "/tmp/test",
578 vec![
579 make_fact("arch", "db", "PostgreSQL", 0.9),
580 make_fact("arch", "cache", "Redis", 0.8),
581 ],
582 );
583
584 step_decay(&mut knowledge, &mut graph, &policy);
585
586 assert_eq!(
587 graph.edges.len(),
588 1,
589 "Edge with count=1 and stale should be removed"
590 );
591 assert_eq!(
592 graph.edges[0].count, 2,
593 "Edge with count=3 should be decremented to 2"
594 );
595 }
596
597 #[test]
598 fn cognition_loop_runs_all_steps() {
599 let _lock = crate::core::data_dir::test_env_lock();
600 let tmp = tempfile::tempdir().expect("tempdir");
601 std::env::set_var(
602 "LEAN_CTX_DATA_DIR",
603 tmp.path().to_string_lossy().to_string(),
604 );
605
606 let project_root = tmp.path().join("proj");
607 std::fs::create_dir_all(&project_root).expect("mkdir");
608 let project_root_str = project_root.to_string_lossy().to_string();
609
610 let policy = MemoryPolicy::default();
611 let mut knowledge = ProjectKnowledge::load_or_create(&project_root_str);
612 knowledge.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
613 knowledge.remember("arch", "cache", "Redis", "s1", 0.8, &policy);
614 knowledge.remember("deploy", "host", "AWS", "s1", 0.7, &policy);
615 let _ = knowledge.save();
616
617 let report = run_cognition_loop(&project_root_str, 8);
618 assert_eq!(report.steps_run, 8);
619
620 std::env::remove_var("LEAN_CTX_DATA_DIR");
621 }
622}