Skip to main content

lean_ctx/core/knowledge/
mod.rs

1mod core;
2mod fact;
3mod format;
4mod import_export;
5mod persist;
6mod query;
7mod ranking;
8mod types;
9
10pub use import_export::{parse_import_data, ImportMerge, ImportResult, SimpleFactEntry};
11pub use ranking::{find_cross_key_similar, SimilarFact};
12pub use types::*;
13
14#[cfg(test)]
15mod tests {
16    use super::*;
17    use crate::core::memory_boundary::FactPrivacy;
18    use crate::core::memory_policy::MemoryPolicy;
19    use chrono::Utc;
20
21    fn default_policy() -> MemoryPolicy {
22        MemoryPolicy::default()
23    }
24
25    #[test]
26    fn remember_and_recall() {
27        let policy = default_policy();
28        let mut k = ProjectKnowledge::new("/tmp/test-project");
29        k.remember(
30            "architecture",
31            "auth",
32            "JWT RS256",
33            "session-1",
34            0.9,
35            &policy,
36        );
37        k.remember("api", "rate-limit", "100/min", "session-1", 0.8, &policy);
38
39        let results = k.recall("auth");
40        assert_eq!(results.len(), 1);
41        assert_eq!(results[0].value, "JWT RS256");
42
43        let results = k.recall("api rate");
44        assert_eq!(results.len(), 1);
45        assert_eq!(results[0].key, "rate-limit");
46    }
47
48    #[test]
49    fn facts_evict_down_to_cap_not_double() {
50        // Regression: remember() must keep the fact count at or below max_facts.
51        // Previously the lifecycle only fired above 2 * max_facts, so a store
52        // could silently grow to twice its configured budget before reclaiming.
53        let mut policy = default_policy();
54        policy.knowledge.max_facts = 5;
55        let mut k = ProjectKnowledge::new("/tmp/test-evict");
56        for i in 0..40 {
57            k.remember(
58                "finding",
59                &format!("key-{i}"),
60                &format!("value number {i}"),
61                "s1",
62                0.7,
63                &policy,
64            );
65        }
66        assert!(
67            k.facts.len() <= policy.knowledge.max_facts,
68            "expected <= {} facts after eviction, got {}",
69            policy.knowledge.max_facts,
70            k.facts.len()
71        );
72    }
73
74    #[test]
75    fn upsert_existing_fact() {
76        let policy = default_policy();
77        let mut k = ProjectKnowledge::new("/tmp/test");
78        k.remember("arch", "db", "PostgreSQL", "s1", 0.7, &policy);
79        k.remember(
80            "arch",
81            "db",
82            "PostgreSQL 16 with pgvector",
83            "s2",
84            0.95,
85            &policy,
86        );
87
88        let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
89        assert_eq!(current.len(), 1);
90        assert_eq!(current[0].value, "PostgreSQL 16 with pgvector");
91    }
92
93    #[test]
94    fn contradiction_detection() {
95        let policy = default_policy();
96        let mut k = ProjectKnowledge::new("/tmp/test");
97        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
98        k.facts[0].confirmation_count = 3;
99
100        let contradiction = k.check_contradiction("arch", "db", "MySQL", &policy);
101        assert!(contradiction.is_some());
102        let c = contradiction.unwrap();
103        assert_eq!(c.severity, ContradictionSeverity::High);
104    }
105
106    #[test]
107    fn temporal_validity() {
108        let policy = default_policy();
109        let mut k = ProjectKnowledge::new("/tmp/test");
110        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
111        k.facts[0].confirmation_count = 3;
112
113        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
114
115        let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
116        assert_eq!(current.len(), 1);
117        assert_eq!(current[0].value, "MySQL");
118
119        let all_db: Vec<_> = k.facts.iter().filter(|f| f.key == "db").collect();
120        assert_eq!(all_db.len(), 2);
121    }
122
123    #[test]
124    fn confirmation_count() {
125        let policy = default_policy();
126        let mut k = ProjectKnowledge::new("/tmp/test");
127        k.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
128        assert_eq!(k.facts[0].confirmation_count, 1);
129
130        k.remember("arch", "db", "PostgreSQL", "s2", 0.9, &policy);
131        assert_eq!(k.facts[0].confirmation_count, 2);
132    }
133
134    #[test]
135    fn remove_fact() {
136        let policy = default_policy();
137        let mut k = ProjectKnowledge::new("/tmp/test");
138        k.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
139        assert!(k.remove_fact("arch", "db"));
140        assert!(k.facts.is_empty());
141        assert!(!k.remove_fact("arch", "db"));
142    }
143
144    #[test]
145    fn list_rooms() {
146        let policy = default_policy();
147        let mut k = ProjectKnowledge::new("/tmp/test");
148        k.remember("architecture", "auth", "JWT", "s1", 0.9, &policy);
149        k.remember("architecture", "db", "PG", "s1", 0.9, &policy);
150        k.remember("deploy", "host", "AWS", "s1", 0.8, &policy);
151
152        let rooms = k.list_rooms();
153        assert_eq!(rooms.len(), 2);
154    }
155
156    #[test]
157    fn aaak_format() {
158        let policy = default_policy();
159        let mut k = ProjectKnowledge::new("/tmp/test");
160        k.remember("architecture", "auth", "JWT RS256", "s1", 0.95, &policy);
161        k.remember("architecture", "db", "PostgreSQL", "s1", 0.7, &policy);
162
163        let aaak = k.format_aaak();
164        assert!(aaak.contains("ARCHITECTURE:"));
165        assert!(aaak.contains("auth=JWT RS256"));
166    }
167
168    #[test]
169    fn consolidate_history() {
170        let policy = default_policy();
171        let mut k = ProjectKnowledge::new("/tmp/test");
172        k.consolidate(
173            "Migrated from REST to GraphQL",
174            vec!["s1".into(), "s2".into()],
175            &policy,
176        );
177        assert_eq!(k.history.len(), 1);
178        assert_eq!(k.history[0].from_sessions.len(), 2);
179    }
180
181    #[test]
182    fn format_summary_output() {
183        let policy = default_policy();
184        let mut k = ProjectKnowledge::new("/tmp/test");
185        k.remember("architecture", "auth", "JWT RS256", "s1", 0.9, &policy);
186        k.add_pattern(
187            "naming",
188            "snake_case for functions",
189            vec!["get_user()".into()],
190            "s1",
191            &policy,
192        );
193        let summary = k.format_summary();
194        assert!(summary.contains("PROJECT KNOWLEDGE:"));
195        assert!(summary.contains("auth: JWT RS256"));
196        assert!(summary.contains("PROJECT PATTERNS:"));
197    }
198
199    #[test]
200    fn temporal_recall_at_time() {
201        let policy = default_policy();
202        let mut k = ProjectKnowledge::new("/tmp/test");
203        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
204        k.facts[0].confirmation_count = 3;
205
206        let before_change = Utc::now();
207        std::thread::sleep(std::time::Duration::from_millis(10));
208
209        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
210
211        let results = k.recall_at_time("db", before_change);
212        assert_eq!(results.len(), 1);
213        assert_eq!(results[0].value, "PostgreSQL");
214
215        let results_now = k.recall_at_time("db", Utc::now());
216        assert_eq!(results_now.len(), 1);
217        assert_eq!(results_now[0].value, "MySQL");
218    }
219
220    #[test]
221    fn timeline_shows_history() {
222        let policy = default_policy();
223        let mut k = ProjectKnowledge::new("/tmp/test");
224        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
225        k.facts[0].confirmation_count = 3;
226        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
227
228        let timeline = k.timeline("arch");
229        assert_eq!(timeline.len(), 2);
230        assert!(!timeline[0].is_current());
231        assert!(timeline[1].is_current());
232    }
233
234    #[test]
235    fn wakeup_format() {
236        let policy = default_policy();
237        let mut k = ProjectKnowledge::new("/tmp/test");
238        k.remember("arch", "auth", "JWT", "s1", 0.95, &policy);
239        k.remember("arch", "db", "PG", "s1", 0.8, &policy);
240
241        let wakeup = k.format_wakeup();
242        assert!(wakeup.contains("FACTS:"));
243        assert!(wakeup.contains("arch/auth=JWT"));
244        assert!(wakeup.contains("arch/db=PG"));
245    }
246
247    #[test]
248    fn salience_prioritizes_decisions_over_findings_at_similar_confidence() {
249        let policy = default_policy();
250        let mut k = ProjectKnowledge::new("/tmp/test");
251        k.remember("finding", "f1", "some thing", "s1", 0.9, &policy);
252        k.remember("decision", "d1", "important", "s1", 0.85, &policy);
253
254        let wakeup = k.format_wakeup();
255        let items = wakeup
256            .strip_prefix("FACTS:")
257            .unwrap_or(&wakeup)
258            .split('|')
259            .collect::<Vec<_>>();
260        assert!(
261            items
262                .first()
263                .is_some_and(|s| s.contains("decision/d1=important")),
264            "expected decision first in wakeup: {wakeup}"
265        );
266    }
267
268    #[test]
269    fn low_confidence_contradiction() {
270        let policy = default_policy();
271        let mut k = ProjectKnowledge::new("/tmp/test");
272        k.remember("arch", "db", "PostgreSQL", "s1", 0.4, &policy);
273
274        let c = k.check_contradiction("arch", "db", "MySQL", &policy);
275        assert!(c.is_some());
276        assert_eq!(c.unwrap().severity, ContradictionSeverity::Low);
277    }
278
279    #[test]
280    fn no_contradiction_for_same_value() {
281        let policy = default_policy();
282        let mut k = ProjectKnowledge::new("/tmp/test");
283        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
284
285        let c = k.check_contradiction("arch", "db", "PostgreSQL", &policy);
286        assert!(c.is_none());
287    }
288
289    #[test]
290    fn no_contradiction_for_similar_values() {
291        let policy = default_policy();
292        let mut k = ProjectKnowledge::new("/tmp/test");
293        k.remember(
294            "arch",
295            "db",
296            "PostgreSQL 16 production database server",
297            "s1",
298            0.95,
299            &policy,
300        );
301
302        let c = k.check_contradiction(
303            "arch",
304            "db",
305            "PostgreSQL 16 production database server config",
306            &policy,
307        );
308        assert!(c.is_none());
309    }
310
311    #[test]
312    fn import_skip_existing() {
313        let policy = default_policy();
314        let mut k = ProjectKnowledge::new("/tmp/test");
315        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
316
317        let incoming = vec![KnowledgeFact {
318            category: "arch".into(),
319            key: "db".into(),
320            value: "MySQL".into(),
321            source_session: "import".into(),
322            confidence: 0.8,
323            created_at: Utc::now(),
324            last_confirmed: Utc::now(),
325            retrieval_count: 0,
326            last_retrieved: None,
327            valid_from: Some(Utc::now()),
328            valid_until: None,
329            supersedes: None,
330            confirmation_count: 1,
331            feedback_up: 0,
332            feedback_down: 0,
333            last_feedback: None,
334            privacy: FactPrivacy::default(),
335            imported_from: None,
336            archetype: KnowledgeArchetype::default(),
337            fidelity: None,
338            revision_count: 0,
339        }];
340
341        let result = k.import_facts(incoming, ImportMerge::SkipExisting, "imp-1", &policy);
342        assert_eq!(result.skipped, 1);
343        assert_eq!(result.added, 0);
344        assert_eq!(k.facts.iter().filter(|f| f.is_current()).count(), 1);
345    }
346
347    #[test]
348    fn import_replace_existing() {
349        let policy = default_policy();
350        let mut k = ProjectKnowledge::new("/tmp/test");
351        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
352
353        let incoming = vec![KnowledgeFact {
354            category: "arch".into(),
355            key: "db".into(),
356            value: "MySQL".into(),
357            source_session: "import".into(),
358            confidence: 0.8,
359            created_at: Utc::now(),
360            last_confirmed: Utc::now(),
361            retrieval_count: 0,
362            last_retrieved: None,
363            valid_from: Some(Utc::now()),
364            valid_until: None,
365            supersedes: None,
366            confirmation_count: 1,
367            feedback_up: 0,
368            feedback_down: 0,
369            last_feedback: None,
370            privacy: FactPrivacy::default(),
371            imported_from: None,
372            archetype: KnowledgeArchetype::default(),
373            fidelity: None,
374            revision_count: 0,
375        }];
376
377        let result = k.import_facts(incoming, ImportMerge::Replace, "imp-1", &policy);
378        assert_eq!(result.replaced, 1);
379        let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
380        assert_eq!(current.len(), 1);
381        assert_eq!(current[0].value, "MySQL");
382    }
383
384    #[test]
385    fn import_adds_new_facts() {
386        let policy = default_policy();
387        let mut k = ProjectKnowledge::new("/tmp/test");
388        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
389
390        let incoming = vec![KnowledgeFact {
391            category: "security".into(),
392            key: "auth".into(),
393            value: "JWT".into(),
394            source_session: "import".into(),
395            confidence: 0.9,
396            created_at: Utc::now(),
397            last_confirmed: Utc::now(),
398            retrieval_count: 0,
399            last_retrieved: None,
400            valid_from: Some(Utc::now()),
401            valid_until: None,
402            supersedes: None,
403            confirmation_count: 1,
404            feedback_up: 0,
405            feedback_down: 0,
406            last_feedback: None,
407            privacy: FactPrivacy::default(),
408            imported_from: None,
409            archetype: KnowledgeArchetype::default(),
410            fidelity: None,
411            revision_count: 0,
412        }];
413
414        let result = k.import_facts(incoming, ImportMerge::SkipExisting, "imp-1", &policy);
415        assert_eq!(result.added, 1);
416        assert_eq!(k.facts.iter().filter(|f| f.is_current()).count(), 2);
417    }
418
419    #[test]
420    fn parse_simple_json_array() {
421        let data = r#"[
422            {"category": "arch", "key": "db", "value": "PostgreSQL"},
423            {"category": "security", "key": "auth", "value": "JWT", "confidence": 0.9}
424        ]"#;
425        let facts = parse_import_data(data).unwrap();
426        assert_eq!(facts.len(), 2);
427        assert_eq!(facts[0].category, "arch");
428        assert_eq!(facts[1].confidence, 0.9);
429    }
430
431    #[test]
432    fn parse_jsonl_format() {
433        let data = "{\"category\":\"arch\",\"key\":\"db\",\"value\":\"PG\"}\n\
434                    {\"category\":\"security\",\"key\":\"auth\",\"value\":\"JWT\"}";
435        let facts = parse_import_data(data).unwrap();
436        assert_eq!(facts.len(), 2);
437    }
438
439    #[test]
440    fn export_simple_only_current() {
441        let policy = default_policy();
442        let mut k = ProjectKnowledge::new("/tmp/test");
443        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
444        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
445
446        let exported = k.export_simple();
447        assert_eq!(exported.len(), 1);
448        assert_eq!(exported[0].value, "MySQL");
449    }
450
451    #[test]
452    fn import_merge_parse() {
453        assert_eq!(ImportMerge::parse("replace"), Some(ImportMerge::Replace));
454        assert_eq!(ImportMerge::parse("append"), Some(ImportMerge::Append));
455        assert_eq!(
456            ImportMerge::parse("skip-existing"),
457            Some(ImportMerge::SkipExisting)
458        );
459        assert_eq!(
460            ImportMerge::parse("skip_existing"),
461            Some(ImportMerge::SkipExisting)
462        );
463        assert_eq!(ImportMerge::parse("skip"), Some(ImportMerge::SkipExisting));
464        assert!(ImportMerge::parse("invalid").is_none());
465    }
466
467    #[test]
468    fn revision_count_on_new_fact() {
469        let policy = default_policy();
470        let mut k = ProjectKnowledge::new("/tmp/test");
471        k.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
472        let cur = k.facts.iter().find(|f| f.is_current()).unwrap();
473        assert_eq!(cur.revision_count, 1);
474    }
475
476    #[test]
477    fn revision_count_increments_on_confirm() {
478        let policy = default_policy();
479        let mut k = ProjectKnowledge::new("/tmp/test");
480        k.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
481        k.remember("arch", "db", "PostgreSQL", "s2", 0.9, &policy);
482        k.remember("arch", "db", "PostgreSQL", "s3", 0.9, &policy);
483        let cur = k.facts.iter().find(|f| f.is_current()).unwrap();
484        assert_eq!(cur.revision_count, 3);
485        assert_eq!(cur.confirmation_count, 3);
486    }
487
488    #[test]
489    fn revision_count_carries_over_on_supersede() {
490        let policy = default_policy();
491        let mut k = ProjectKnowledge::new("/tmp/test");
492        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
493        k.remember("arch", "db", "PostgreSQL", "s2", 0.9, &policy);
494        assert_eq!(
495            k.facts
496                .iter()
497                .find(|f| f.is_current())
498                .unwrap()
499                .revision_count,
500            2
501        );
502        k.facts[0].confirmation_count = 3;
503        k.remember("arch", "db", "MySQL", "s3", 0.9, &policy);
504        let cur: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
505        assert_eq!(cur.len(), 1);
506        assert_eq!(cur[0].value, "MySQL");
507        assert_eq!(cur[0].revision_count, 3);
508        assert!(cur[0].supersedes.is_some());
509    }
510
511    #[test]
512    fn revision_count_default_zero_for_legacy() {
513        let json = r#"{
514            "category": "test", "key": "k", "value": "v",
515            "source_session": "s", "confidence": 0.8,
516            "created_at": "2024-01-01T00:00:00Z",
517            "last_confirmed": "2024-01-01T00:00:00Z"
518        }"#;
519        let fact: KnowledgeFact = serde_json::from_str(json).unwrap();
520        assert_eq!(fact.revision_count, 0);
521    }
522
523    #[test]
524    fn judged_pairs_default_empty_for_legacy() {
525        let json = r#"{
526            "project_root": "/test", "project_hash": "abc",
527            "facts": [], "patterns": [], "history": [],
528            "updated_at": "2024-01-01T00:00:00Z"
529        }"#;
530        let pk: ProjectKnowledge = serde_json::from_str(json).unwrap();
531        assert!(pk.judged_pairs.is_empty());
532    }
533
534    #[test]
535    fn cross_key_similar_finds_related_facts() {
536        let policy = default_policy();
537        let mut k = ProjectKnowledge::new("/tmp/test");
538        k.remember(
539            "architecture",
540            "auth",
541            "JWT RS256 token based authentication with Redis session store",
542            "s1",
543            0.9,
544            &policy,
545        );
546        k.remember(
547            "decision",
548            "session-model",
549            "JWT token authentication stored in Redis for session management",
550            "s1",
551            0.85,
552            &policy,
553        );
554        k.remember("deploy", "host", "AWS us-east-1", "s1", 0.8, &policy);
555
556        let similar = find_cross_key_similar(
557            "architecture",
558            "auth",
559            "JWT RS256 token based authentication with Redis session store",
560            &k.facts,
561            &k.judged_pairs,
562            3,
563        );
564        assert!(!similar.is_empty(), "should find session-model as similar");
565        assert_eq!(similar[0].category, "decision");
566        assert_eq!(similar[0].key, "session-model");
567        assert!(similar[0].similarity > 0.35);
568    }
569
570    #[test]
571    fn cross_key_similar_excludes_same_key() {
572        let policy = default_policy();
573        let mut k = ProjectKnowledge::new("/tmp/test");
574        k.remember("arch", "db", "PostgreSQL 16", "s1", 0.9, &policy);
575
576        let similar =
577            find_cross_key_similar("arch", "db", "PostgreSQL 16", &k.facts, &k.judged_pairs, 3);
578        assert!(similar.is_empty());
579    }
580
581    #[test]
582    fn cross_key_similar_excludes_judged_pairs() {
583        let policy = default_policy();
584        let mut k = ProjectKnowledge::new("/tmp/test");
585        k.remember(
586            "architecture",
587            "auth",
588            "JWT RS256 token based authentication with Redis",
589            "s1",
590            0.9,
591            &policy,
592        );
593        k.remember(
594            "decision",
595            "session-model",
596            "JWT token authentication stored in Redis",
597            "s1",
598            0.85,
599            &policy,
600        );
601
602        k.judged_pairs.push(JudgedPair {
603            key_a: "architecture/auth".into(),
604            key_b: "decision/session-model".into(),
605            verdict: "compatible".into(),
606            judged_at: Utc::now(),
607        });
608
609        let similar = find_cross_key_similar(
610            "architecture",
611            "auth",
612            "JWT RS256 token based authentication with Redis",
613            &k.facts,
614            &k.judged_pairs,
615            3,
616        );
617        assert!(similar.is_empty(), "judged pairs should be excluded");
618    }
619
620    #[test]
621    fn cross_key_similar_ignores_unrelated_facts() {
622        let policy = default_policy();
623        let mut k = ProjectKnowledge::new("/tmp/test");
624        k.remember(
625            "arch",
626            "db",
627            "PostgreSQL 16 with pgvector",
628            "s1",
629            0.9,
630            &policy,
631        );
632        k.remember("deploy", "host", "AWS us-east-1 region", "s1", 0.8, &policy);
633
634        let similar = find_cross_key_similar(
635            "arch",
636            "db",
637            "PostgreSQL 16 with pgvector",
638            &k.facts,
639            &k.judged_pairs,
640            3,
641        );
642        assert!(similar.is_empty(), "unrelated facts should not match");
643    }
644
645    #[test]
646    fn judge_supersedes_archives_target() {
647        let policy = default_policy();
648        let mut k = ProjectKnowledge::new("/tmp/test");
649        k.remember("architecture", "auth", "JWT RS256", "s1", 0.9, &policy);
650        k.remember("decision", "session", "JWT tokens", "s1", 0.85, &policy);
651
652        assert!(k.facts.iter().all(KnowledgeFact::is_current));
653
654        if let Some(tf) = k
655            .facts
656            .iter_mut()
657            .find(|f| f.category == "decision" && f.key == "session" && f.is_current())
658        {
659            tf.valid_until = Some(Utc::now());
660        }
661        k.judged_pairs.push(JudgedPair {
662            key_a: "architecture/auth".into(),
663            key_b: "decision/session".into(),
664            verdict: "supersedes".into(),
665            judged_at: Utc::now(),
666        });
667
668        let cur: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
669        assert_eq!(cur.len(), 1);
670        assert_eq!(cur[0].category, "architecture");
671    }
672}