Skip to main content

lean_ctx/core/knowledge/
mod.rs

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