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