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