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