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