Skip to main content

lean_ctx/core/knowledge/
mod.rs

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