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 Some(a_time) = a.last_retrieved else {
272 continue;
273 };
274 for b in &retrieved[i + 1..] {
275 let Some(b_time) = b.last_retrieved else {
276 continue;
277 };
278 let diff = (a_time - b_time).abs();
279 if diff <= window {
280 let from = KnowledgeNodeRef::new(&a.category, &a.key);
281 let to = KnowledgeNodeRef::new(&b.category, &b.key);
282 if !graph.strengthen_edge(&from, &to, 0.15) {
283 graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "hebbian");
284 }
285 strengthened += 1;
286 }
287 }
288 }
289
290 strengthened
291}
292
293fn step_decay(
295 knowledge: &mut ProjectKnowledge,
296 graph: &mut KnowledgeRelationGraph,
297 policy: &MemoryPolicy,
298) -> u32 {
299 let lifecycle_cfg = crate::core::memory_lifecycle::LifecycleConfig {
300 max_facts: policy.knowledge.max_facts,
301 decay_rate_per_day: policy.lifecycle.decay_rate,
302 low_confidence_threshold: policy.lifecycle.low_confidence_threshold,
303 stale_days: policy.lifecycle.stale_days,
304 consolidation_similarity: policy.lifecycle.similarity_threshold,
305 };
306 crate::core::memory_lifecycle::apply_confidence_decay(&mut knowledge.facts, &lifecycle_cfg);
307
308 let low_conf_count = knowledge
309 .facts
310 .iter()
311 .filter(|f| f.is_current() && f.confidence < 0.3)
312 .count() as u32;
313
314 graph.decay_all_edges(1.0);
315 graph.prune_weak_edges(0.05);
316
317 let stale_cutoff = Utc::now() - Duration::days(EDGE_STALE_DAYS);
318 graph.edges.retain_mut(|e| {
319 let last = e.last_seen.unwrap_or(e.created_at);
320 if last < stale_cutoff {
321 if e.count <= 1 {
322 return false;
323 }
324 e.count = e.count.saturating_sub(1);
325 }
326 true
327 });
328
329 low_conf_count
330}
331
332fn slug_key(s: &str, max: usize) -> String {
333 let mut out = String::new();
334 for ch in s.chars() {
335 if out.len() >= max {
336 break;
337 }
338 if ch.is_ascii_alphanumeric() {
339 out.push(ch.to_ascii_lowercase());
340 } else if (ch.is_whitespace() || ch == '-' || ch == '_')
341 && !out.ends_with('-')
342 && !out.is_empty()
343 {
344 out.push('-');
345 }
346 }
347 out.trim_matches('-').to_string()
348}
349
350fn finding_salience(summary: &str) -> u32 {
351 let s = summary.to_lowercase();
352 let mut score = 20u32;
353 let boosts = [
354 ("error", 25),
355 ("failed", 25),
356 ("panic", 30),
357 ("assert", 20),
358 ("forbidden", 25),
359 ("timeout", 20),
360 ("deadlock", 25),
361 ("security", 25),
362 ("vuln", 25),
363 ("e0", 15),
364 ];
365 for (pat, b) in boosts {
366 if s.contains(pat) {
367 score = score.saturating_add(b);
368 }
369 }
370 score
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use crate::core::knowledge::KnowledgeArchetype;
377 use crate::core::knowledge_relations::KnowledgeEdge;
378 use crate::core::memory_boundary::FactPrivacy;
379
380 fn make_fact(
381 category: &str,
382 key: &str,
383 value: &str,
384 confidence: f32,
385 ) -> crate::core::knowledge::KnowledgeFact {
386 crate::core::knowledge::KnowledgeFact {
387 category: category.to_string(),
388 key: key.to_string(),
389 value: value.to_string(),
390 source_session: "test".to_string(),
391 confidence,
392 created_at: Utc::now(),
393 last_confirmed: Utc::now(),
394 retrieval_count: 0,
395 last_retrieved: None,
396 valid_from: Some(Utc::now()),
397 valid_until: None,
398 supersedes: None,
399 confirmation_count: 1,
400 feedback_up: 0,
401 feedback_down: 0,
402 last_feedback: None,
403 privacy: FactPrivacy::default(),
404 imported_from: None,
405 archetype: KnowledgeArchetype::default(),
406 fidelity: None,
407 revision_count: 0,
408 }
409 }
410
411 fn make_retrieved_fact(
412 category: &str,
413 key: &str,
414 value: &str,
415 retrieved_at: chrono::DateTime<Utc>,
416 ) -> crate::core::knowledge::KnowledgeFact {
417 let mut f = make_fact(category, key, value, 0.9);
418 f.last_retrieved = Some(retrieved_at);
419 f.retrieval_count = 1;
420 f
421 }
422
423 fn make_knowledge(
424 project_root: &str,
425 facts: Vec<crate::core::knowledge::KnowledgeFact>,
426 ) -> ProjectKnowledge {
427 ProjectKnowledge {
428 project_root: project_root.to_string(),
429 project_hash: "test-hash".to_string(),
430 facts,
431 patterns: Vec::new(),
432 history: Vec::new(),
433 updated_at: Utc::now(),
434 judged_pairs: Vec::new(),
435 }
436 }
437
438 fn make_graph(edges: Vec<KnowledgeEdge>) -> KnowledgeRelationGraph {
439 KnowledgeRelationGraph {
440 project_hash: "test-hash".to_string(),
441 edges,
442 updated_at: Utc::now(),
443 }
444 }
445
446 fn make_edge(from_cat: &str, from_key: &str, to_cat: &str, to_key: &str) -> KnowledgeEdge {
447 KnowledgeEdge {
448 from: KnowledgeNodeRef::new(from_cat, from_key),
449 to: KnowledgeNodeRef::new(to_cat, to_key),
450 kind: KnowledgeEdgeKind::RelatedTo,
451 created_at: Utc::now(),
452 last_seen: Some(Utc::now()),
453 count: 1,
454 source_session: "test".to_string(),
455 strength: 0.5,
456 decay_rate: 0.02,
457 }
458 }
459
460 #[test]
461 fn structural_repair_removes_orphaned_edges() {
462 let knowledge = make_knowledge(
463 "/tmp/test",
464 vec![
465 make_fact("arch", "db", "PostgreSQL", 0.9),
466 make_fact("arch", "cache", "Redis", 0.8),
467 ],
468 );
469
470 let mut graph = make_graph(vec![
471 make_edge("arch", "db", "arch", "cache"),
472 make_edge("arch", "db", "arch", "nonexistent"),
473 make_edge("gone", "missing", "arch", "db"),
474 ]);
475
476 let removed = step_structural_repair(&mut graph, &knowledge);
477 assert_eq!(removed, 2);
478 assert_eq!(graph.edges.len(), 1);
479 assert_eq!(graph.edges[0].from.key, "db");
480 assert_eq!(graph.edges[0].to.key, "cache");
481 }
482
483 #[test]
484 fn lateral_synthesis_connects_similar_facts() {
485 let knowledge = make_knowledge(
486 "/tmp/test",
487 vec![
488 make_fact(
489 "arch",
490 "db",
491 "PostgreSQL database primary storage backend",
492 0.9,
493 ),
494 make_fact("arch", "cache", "Redis cache for sessions", 0.8),
495 make_fact(
496 "deploy",
497 "db-host",
498 "PostgreSQL database primary storage on AWS",
499 0.7,
500 ),
501 ],
502 );
503
504 let mut graph = make_graph(Vec::new());
505 let added = step_lateral_synthesis(&knowledge, &mut graph);
506
507 assert!(
508 added >= 1,
509 "Should connect facts sharing vocabulary (PostgreSQL database primary storage)"
510 );
511 assert!(
512 graph.edges.iter().any(|e| {
513 (e.from.key == "db" && e.to.key == "db-host")
514 || (e.from.key == "db-host" && e.to.key == "db")
515 }),
516 "Should have edge between db and db-host"
517 );
518 }
519
520 #[test]
521 fn contradiction_resolution_keeps_higher_quality() {
522 let mut f1 = make_fact("arch", "db", "PostgreSQL", 0.9);
523 f1.confirmation_count = 3;
524 let f2 = make_fact("arch", "db", "MySQL", 0.5);
525
526 let mut knowledge = make_knowledge("/tmp/test", vec![f1, f2]);
527 let resolved = step_contradiction_resolution(&mut knowledge);
528
529 assert_eq!(resolved, 1);
530 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
531 assert_eq!(current.len(), 1);
532 assert_eq!(current[0].value, "PostgreSQL");
533 }
534
535 #[test]
536 fn hebbian_strengthen_co_retrieval() {
537 let now = Utc::now();
538 let knowledge = make_knowledge(
539 "/tmp/test",
540 vec![
541 make_retrieved_fact("arch", "db", "PostgreSQL", now),
542 make_retrieved_fact("arch", "cache", "Redis", now - Duration::minutes(30)),
543 make_retrieved_fact("arch", "queue", "Kafka", now - Duration::hours(5)),
544 ],
545 );
546
547 let mut graph = make_graph(Vec::new());
548 let strengthened = step_hebbian_strengthen(&knowledge, &mut graph);
549
550 assert!(
551 strengthened >= 1,
552 "Should strengthen co-retrieved facts within 1h window"
553 );
554 let has_db_cache = graph.edges.iter().any(|e| {
555 (e.from.key == "db" && e.to.key == "cache")
556 || (e.from.key == "cache" && e.to.key == "db")
557 });
558 assert!(has_db_cache, "db and cache were retrieved within 1h");
559 }
560
561 #[test]
562 fn decay_reduces_stale_edge_counts() {
563 let old = Utc::now() - Duration::days(45);
564 let mut graph = make_graph(vec![
565 {
566 let mut e = make_edge("arch", "db", "arch", "cache");
567 e.last_seen = Some(old);
568 e.count = 3;
569 e
570 },
571 {
572 let mut e = make_edge("arch", "old", "arch", "ancient");
573 e.last_seen = Some(old);
574 e.count = 1;
575 e
576 },
577 ]);
578
579 let policy = MemoryPolicy::default();
580 let mut knowledge = make_knowledge(
581 "/tmp/test",
582 vec![
583 make_fact("arch", "db", "PostgreSQL", 0.9),
584 make_fact("arch", "cache", "Redis", 0.8),
585 ],
586 );
587
588 step_decay(&mut knowledge, &mut graph, &policy);
589
590 assert_eq!(
591 graph.edges.len(),
592 1,
593 "Edge with count=1 and stale should be removed"
594 );
595 assert_eq!(
596 graph.edges[0].count, 2,
597 "Edge with count=3 should be decremented to 2"
598 );
599 }
600
601 #[test]
602 fn cognition_loop_runs_all_steps() {
603 let _lock = crate::core::data_dir::test_env_lock();
604 let tmp = tempfile::tempdir().expect("tempdir");
605 std::env::set_var(
606 "LEAN_CTX_DATA_DIR",
607 tmp.path().to_string_lossy().to_string(),
608 );
609
610 let project_root = tmp.path().join("proj");
611 std::fs::create_dir_all(&project_root).expect("mkdir");
612 let project_root_str = project_root.to_string_lossy().to_string();
613
614 let policy = MemoryPolicy::default();
615 let mut knowledge = ProjectKnowledge::load_or_create(&project_root_str);
616 knowledge.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
617 knowledge.remember("arch", "cache", "Redis", "s1", 0.8, &policy);
618 knowledge.remember("deploy", "host", "AWS", "s1", 0.7, &policy);
619 let _ = knowledge.save();
620
621 let report = run_cognition_loop(&project_root_str, 8);
622 assert_eq!(report.steps_run, 8);
623
624 std::env::remove_var("LEAN_CTX_DATA_DIR");
625 }
626}