Skip to main content

lean_ctx/core/knowledge/
mod.rs

1pub mod agents_md;
2pub mod anchors;
3pub mod chunking;
4mod core;
5mod fact;
6mod format;
7pub mod hooks;
8mod import_export;
9pub mod import_tag;
10pub mod journal;
11pub mod local_store;
12pub mod maintenance;
13pub mod okf;
14mod persist;
15pub mod protection;
16mod query;
17mod ranking;
18pub mod retrieval;
19pub mod snapshot;
20pub mod source_view;
21pub mod store;
22pub mod supersession;
23mod types;
24
25pub use format::fact_served;
26pub use import_export::{ImportMerge, ImportResult, SimpleFactEntry, parse_import_data};
27pub use persist::is_machine_derived;
28pub use persist::knowledge_dir_of;
29pub use query::KnowledgeQuery;
30pub use ranking::fact_version_id_v1;
31pub use ranking::sort_fact_for_output;
32pub use ranking::{SimilarFact, find_cross_key_similar, string_similarity};
33pub use snapshot::KnowledgeSnapshot;
34pub use types::*;
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39    use crate::core::memory_boundary::FactPrivacy;
40    use crate::core::memory_policy::MemoryPolicy;
41    use chrono::Utc;
42
43    fn default_policy() -> MemoryPolicy {
44        MemoryPolicy::default()
45    }
46
47    fn current_count(k: &ProjectKnowledge) -> usize {
48        k.facts.iter().filter(|f| f.is_current()).count()
49    }
50
51    // === Write-time admission (#970) ===
52
53    #[test]
54    fn admission_merges_same_category_near_duplicate_without_growing() {
55        let policy = default_policy(); // enabled, merge 0.9, floor 0
56        let k_root = tempfile::tempdir().unwrap();
57        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
58
59        let r1 = k.remember_admitted(
60            "architecture",
61            "db-primary",
62            "use postgres for the primary datastore",
63            "s1",
64            0.8,
65            &policy,
66        );
67        assert!(matches!(r1, AdmissionResult::Stored(_)));
68        assert_eq!(current_count(&k), 1);
69
70        // Identical word set under a *different* key → merge, not insert.
71        let r2 = k.remember_admitted(
72            "architecture",
73            "db-store",
74            "use postgres for the primary datastore",
75            "s2",
76            0.9,
77            &policy,
78        );
79        match r2 {
80            AdmissionResult::Merged {
81                category,
82                key,
83                confirmations,
84                ..
85            } => {
86                assert_eq!(category, "architecture");
87                assert_eq!(key, "db-primary", "merges into the earliest match");
88                assert_eq!(confirmations, 2);
89            }
90            other => panic!("expected Merged, got {other:?}"),
91        }
92        assert_eq!(current_count(&k), 1, "merge must not grow the store");
93        let f = k
94            .facts
95            .iter()
96            .find(|f| f.key == "db-primary" && f.is_current())
97            .unwrap();
98        assert_eq!(f.confirmation_count, 2);
99    }
100
101    #[test]
102    fn admission_rejects_below_salience_floor() {
103        let mut policy = default_policy();
104        policy.admission.min_salience = 100; // plain text (base 20) is below it
105
106        let k_root = tempfile::tempdir().unwrap();
107
108        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
109        let r = k.remember_admitted("misc", "note", "a quiet plain note", "s1", 0.8, &policy);
110        assert!(matches!(r, AdmissionResult::RejectedLowSalience { .. }));
111        assert_eq!(current_count(&k), 0, "rejected fact is never stored");
112
113        // High-signal content clears the floor (security+error+failed+timeout boosts).
114        let r2 = k.remember_admitted(
115            "misc",
116            "bug",
117            "security error: auth failed with timeout",
118            "s1",
119            0.8,
120            &policy,
121        );
122        assert!(matches!(r2, AdmissionResult::Stored(_)));
123        assert_eq!(current_count(&k), 1);
124    }
125
126    #[test]
127    fn admission_preserves_contradiction_supersede_on_same_key() {
128        let policy = default_policy();
129        let k_root = tempfile::tempdir().unwrap();
130        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
131
132        let r1 = k.remember_admitted("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
133        assert!(matches!(r1, AdmissionResult::Stored(None)));
134
135        // Same (category,key), conflicting value → must supersede, never merge/reject.
136        let r2 = k.remember_admitted("arch", "db", "MySQL", "s2", 0.9, &policy);
137        assert!(
138            matches!(r2, AdmissionResult::Stored(Some(_))),
139            "same-key conflict must surface a contradiction, got {r2:?}"
140        );
141        let cur = k
142            .facts
143            .iter()
144            .find(|f| f.category == "arch" && f.key == "db" && f.is_current())
145            .unwrap();
146        assert_eq!(cur.value, "MySQL");
147    }
148
149    #[test]
150    fn admission_disabled_inserts_like_legacy() {
151        let mut policy = default_policy();
152        policy.admission.enabled = false;
153
154        let k_root = tempfile::tempdir().unwrap();
155
156        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
157        k.remember_admitted(
158            "architecture",
159            "a",
160            "use postgres for the primary datastore",
161            "s1",
162            0.8,
163            &policy,
164        );
165        let r = k.remember_admitted(
166            "architecture",
167            "b",
168            "use postgres for the primary datastore",
169            "s2",
170            0.9,
171            &policy,
172        );
173        assert!(
174            matches!(r, AdmissionResult::Stored(_)),
175            "disabled admission never merges"
176        );
177        assert_eq!(current_count(&k), 2, "both distinct keys stored");
178    }
179
180    #[test]
181    fn admission_does_not_merge_across_categories() {
182        let policy = default_policy();
183        let k_root = tempfile::tempdir().unwrap();
184        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
185
186        k.remember_admitted(
187            "architecture",
188            "db",
189            "use postgres for the primary datastore",
190            "s1",
191            0.8,
192            &policy,
193        );
194        // Same value, *different category* → categories carry meaning, so no merge.
195        let r = k.remember_admitted(
196            "decision",
197            "db",
198            "use postgres for the primary datastore",
199            "s2",
200            0.9,
201            &policy,
202        );
203        assert!(matches!(r, AdmissionResult::Stored(_)));
204        assert_eq!(current_count(&k), 2);
205    }
206
207    #[test]
208    fn remember_and_recall() {
209        let policy = default_policy();
210        let k_root = tempfile::tempdir().unwrap();
211        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
212        k.remember(
213            "architecture",
214            "auth",
215            "JWT RS256",
216            "session-1",
217            0.9,
218            &policy,
219        );
220        k.remember("api", "rate-limit", "100/min", "session-1", 0.8, &policy);
221
222        let results = k.recall("auth");
223        assert_eq!(results.len(), 1);
224        assert_eq!(results[0].value, "JWT RS256");
225
226        let results = k.recall("api rate");
227        assert_eq!(results.len(), 1);
228        assert_eq!(results[0].key, "rate-limit");
229    }
230
231    /// E2E pt8: an explicit query is split like the index, so hyphenated and
232    /// upper-case words recall their facts on every recall path.
233    #[test]
234    fn recall_splits_hyphenated_upper_case_queries_like_the_index() {
235        let policy = default_policy();
236        let k_root = tempfile::tempdir().unwrap();
237        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
238        k.remember(
239            "decision",
240            "local-read-docs",
241            "Local-Read docs answer unavailable on a broken vault map",
242            "s1",
243            0.8,
244            &policy,
245        );
246        for query in ["Local-Read", "LOCAL-READ unavailable", "local_read/Docs"] {
247            assert_eq!(k.recall(query).len(), 1, "{query}");
248            assert_eq!(k.recall_at_time(query, Utc::now()).len(), 1, "{query}");
249            assert_eq!(k.recall_for_output(query, 5).0.len(), 1, "{query}");
250        }
251        assert!(k.recall("-- / .").is_empty());
252    }
253
254    #[test]
255    fn recall_for_task_matches_content_words_only() {
256        let policy = default_policy();
257        let k_root = tempfile::tempdir().unwrap();
258        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
259        k.remember(
260            "finding",
261            "deploy",
262            "Review the deploy order",
263            "s1",
264            0.8,
265            &policy,
266        );
267        k.remember("api", "rate-limit", "100/min", "s1", 0.8, &policy);
268
269        // #1832: "review" is framing; the explicit query still matches it.
270        assert!(
271            k.recall_for_task("Review the rate limit.")
272                .iter()
273                .all(|f| f.key != "deploy")
274        );
275        assert_eq!(k.recall("review").len(), 1);
276
277        // Punctuation and underscores split like the index splits facts.
278        let hits = k.recall_for_task("Check rate_limit handling.");
279        assert_eq!(hits.len(), 1);
280        assert_eq!(hits[0].key, "rate-limit");
281
282        assert!(k.recall_for_task("Please review and fix this.").is_empty());
283    }
284
285    #[test]
286    fn remember_infers_archetype_from_category() {
287        let policy = default_policy();
288        let k_root = tempfile::tempdir().unwrap();
289        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
290        k.remember("architecture", "db", "PostgreSQL", "s1", 0.9, &policy);
291        k.remember("decision", "lang", "Rust", "s1", 0.9, &policy);
292        k.remember("known_bugs", "src/a.rs:1", "panics", "s1", 0.9, &policy);
293        k.remember("data_model", "user", "id,email", "s1", 0.9, &policy);
294        k.remember("misc", "note", "whatever", "s1", 0.9, &policy);
295
296        let arch = |cat: &str| {
297            k.facts
298                .iter()
299                .find(|f| f.category == cat && f.is_current())
300                .map(|f| f.archetype.clone())
301                .expect("fact present")
302        };
303        assert_eq!(arch("architecture"), KnowledgeArchetype::Architecture);
304        assert_eq!(arch("decision"), KnowledgeArchetype::Decision);
305        assert_eq!(arch("known_bugs"), KnowledgeArchetype::Gotcha);
306        assert_eq!(arch("data_model"), KnowledgeArchetype::Architecture);
307        assert_eq!(arch("misc"), KnowledgeArchetype::Fact);
308    }
309
310    #[test]
311    fn recall_surfaces_synthesized_observation_first() {
312        let policy = default_policy();
313        let k_root = tempfile::tempdir().unwrap();
314        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
315        // A raw decision and a synthesized observation, equally relevant to "jwt".
316        k.remember(
317            "decision",
318            "auth-choice",
319            "auth module uses jwt tokens",
320            "s1",
321            0.6,
322            &policy,
323        );
324        k.remember(
325            "observation",
326            "src/auth.rs",
327            "auth module summary jwt tokens",
328            crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE,
329            0.6,
330            &policy,
331        );
332
333        let (out, total) = k.recall_for_output("jwt", 10);
334        assert!(total >= 2, "both facts match the query");
335        assert!(
336            out[0].is_synthesized_observation(),
337            "synthesized observation ranks above an equally relevant raw fact"
338        );
339        assert_eq!(out[0].key, "src/auth.rs");
340    }
341
342    #[test]
343    fn recall_exact_key_hit_outranks_observation() {
344        // #802 keeps the observation tier *balanced*: an exact key hit (+1.0) still
345        // outranks a synthesized summary (+0.4), so a stale summary can never bury a
346        // precisely-queried raw fact. This is the guarantee the reference docs make.
347        // `remember` admits each fact through the process-global gateway config and
348        // data dirs; without the env lock a parallel test switching them made the
349        // admission withhold "jwt" and this ranking assert on the wrong fact (#53).
350        let _iso = crate::core::data_dir::isolated_data_dir();
351        let policy = default_policy();
352        let k_root = tempfile::tempdir().unwrap();
353        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
354        k.remember(
355            "decision",
356            "jwt",
357            "auth module uses jwt tokens",
358            "s1",
359            0.6,
360            &policy,
361        );
362        k.remember(
363            "observation",
364            "src/auth.rs",
365            "auth module summary jwt tokens",
366            crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE,
367            0.6,
368            &policy,
369        );
370
371        let (out, _total) = k.recall_for_output("jwt", 10);
372        assert_eq!(out[0].key, "jwt", "exact key hit leads the ranking");
373        assert_eq!(out[0].category, "decision");
374        assert!(
375            out.iter()
376                .any(crate::core::knowledge::KnowledgeFact::is_synthesized_observation),
377            "the observation is still recalled, just below the exact hit"
378        );
379    }
380
381    #[test]
382    fn writes_never_evict_facts_by_count() {
383        let _iso = crate::core::data_dir::isolated_data_dir();
384        let root = tempfile::tempdir().unwrap();
385        // Owner decision 24.10.: knowledge has no limits. A store over
386        // `max_facts` keeps every fact, whether remembered or imported; only
387        // the quality lifecycle archives, and writes do not run it.
388        let mut policy = default_policy();
389        policy.knowledge.max_facts = Some(5);
390        let mut k = ProjectKnowledge::new(root.path().to_str().unwrap());
391        for i in 0..40 {
392            k.remember(
393                "finding",
394                &format!("key-{i}"),
395                &format!("value number {i}"),
396                "s1",
397                0.7,
398                &policy,
399            );
400        }
401        assert_eq!(k.facts.len(), 40);
402
403        let other = tempfile::tempdir().unwrap();
404        let mut imported = ProjectKnowledge::new(other.path().to_str().unwrap());
405        imported.import_facts(k.facts.clone(), ImportMerge::Append, "s2");
406        assert_eq!(imported.facts.len(), 40);
407
408        let report = k.run_memory_lifecycle(&policy).expect("lifecycle");
409        assert_eq!(report.archived_count, 0);
410        assert_eq!(k.facts.len(), 40, "healthy facts stay over max_facts");
411    }
412
413    /// Loads reuse the parsed store while its file is unchanged and parse
414    /// again after any write: a rewrite through the store, and an edit in
415    /// place that keeps the inode.
416    #[test]
417    fn a_cached_store_follows_every_write() {
418        let _iso = crate::core::data_dir::isolated_data_dir();
419        let root = tempfile::tempdir().unwrap();
420        let root = root.path().to_str().unwrap();
421        let policy = default_policy();
422        ProjectKnowledge::mutate_locked(root, |k| {
423            k.remember("decision", "first", "first value", "s1", 0.9, &policy);
424        })
425        .unwrap();
426        let keys = |k: &ProjectKnowledge| {
427            let mut keys: Vec<String> = k.facts.iter().map(|f| f.key.clone()).collect();
428            keys.sort();
429            keys
430        };
431        assert_eq!(keys(&ProjectKnowledge::load(root).unwrap()), ["first"]);
432        assert_eq!(keys(&ProjectKnowledge::load(root).unwrap()), ["first"]);
433
434        ProjectKnowledge::mutate_locked(root, |k| {
435            k.remember("decision", "second", "second value", "s1", 0.9, &policy);
436        })
437        .unwrap();
438        assert_eq!(
439            keys(&ProjectKnowledge::load(root).unwrap()),
440            ["first", "second"]
441        );
442
443        let path = crate::core::data_dir::lean_ctx_data_dir()
444            .unwrap()
445            .join("knowledge")
446            .join(super::ranking::hash_project_root(root))
447            .join("knowledge.json");
448        let text = std::fs::read_to_string(&path).unwrap();
449        std::fs::write(
450            &path,
451            text.replace("second value", "edited in place, longer"),
452        )
453        .unwrap();
454        let loaded = ProjectKnowledge::load(root).unwrap();
455        assert!(
456            loaded
457                .facts
458                .iter()
459                .any(|f| f.value == "edited in place, longer"),
460            "an in-place edit is seen"
461        );
462    }
463
464    /// 24.10.: a store of 7 317 facts (8.4 MB) was over the 8 MiB
465    /// protected-content cap, so its checked read failed and every
466    /// `remember` and auto-capture after it was refused. A store far over
467    /// the cap and 10 000 facts must stay writable and recallable.
468    #[test]
469    fn a_store_over_eight_mib_stays_writable_and_recallable() {
470        let _iso = crate::core::data_dir::isolated_data_dir();
471        let root = tempfile::tempdir().unwrap();
472        let root = root.path().to_str().unwrap();
473        let policy = default_policy();
474        let mut k = ProjectKnowledge::new(root);
475        k.remember("finding", "seed", "seed value", "s1", 0.7, &policy);
476        let template = k.facts[0].clone();
477        let filler = "x".repeat(800);
478        for i in 0..12_000 {
479            let mut fact = template.clone();
480            fact.key = format!("bulk-{i}");
481            fact.value = format!("bulk fact {i} {filler}");
482            k.facts.push(fact);
483        }
484        k.rebuild_index();
485        let size = serde_json::to_string_pretty(&k).unwrap().len();
486        assert!(
487            size > crate::core::policy::content::MAX_PROTECTED_CONTENT_BYTES,
488            "{size}"
489        );
490        k.save().expect("a large store is saved");
491
492        let started = std::time::Instant::now();
493        let (stored, ()) = ProjectKnowledge::mutate_locked(root, |k| {
494            k.remember("decision", "needle", "the needle fact", "s2", 0.9, &policy);
495        })
496        .expect("remember into a store over the cap");
497        assert_eq!(stored.facts.len(), 12_002);
498        let loaded = ProjectKnowledge::load(root).expect("load");
499        assert!(loaded.recall("needle").iter().any(|f| f.key == "needle"));
500        let elapsed = started.elapsed();
501        // The wall-clock half is opt-in (`LEAN_CTX_PERF_GATE=1`, CI's quiet,
502        // serialized perf step): on a loaded runner the same remember + recall
503        // took 35.8 s (mr-quick job 21704) without any regression. The
504        // functional assertions above run always.
505        assert!(
506            std::env::var("LEAN_CTX_PERF_GATE").as_deref() != Ok("1")
507                || elapsed < std::time::Duration::from_secs(30),
508            "remember + recall took {elapsed:?}"
509        );
510    }
511
512    #[test]
513    fn upsert_existing_fact() {
514        let policy = default_policy();
515        let k_root = tempfile::tempdir().unwrap();
516        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
517        k.remember("arch", "db", "PostgreSQL", "s1", 0.7, &policy);
518        k.remember(
519            "arch",
520            "db",
521            "PostgreSQL 16 with pgvector",
522            "s2",
523            0.95,
524            &policy,
525        );
526
527        let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
528        assert_eq!(current.len(), 1);
529        assert_eq!(current[0].value, "PostgreSQL 16 with pgvector");
530    }
531
532    #[test]
533    fn contradiction_detection() {
534        let policy = default_policy();
535        let k_root = tempfile::tempdir().unwrap();
536        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
537        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
538        k.facts[0].confirmation_count = 3;
539
540        let contradiction = k.check_contradiction("arch", "db", "MySQL", &policy);
541        assert!(contradiction.is_some());
542        let c = contradiction.unwrap();
543        assert_eq!(c.severity, ContradictionSeverity::High);
544    }
545
546    #[test]
547    fn temporal_validity() {
548        let policy = default_policy();
549        let k_root = tempfile::tempdir().unwrap();
550        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
551        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
552        k.facts[0].confirmation_count = 3;
553
554        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
555
556        let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
557        assert_eq!(current.len(), 1);
558        assert_eq!(current[0].value, "MySQL");
559
560        let all_db: Vec<_> = k.facts.iter().filter(|f| f.key == "db").collect();
561        assert_eq!(all_db.len(), 2);
562    }
563
564    #[test]
565    fn confirmation_count() {
566        let policy = default_policy();
567        let k_root = tempfile::tempdir().unwrap();
568        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
569        k.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
570        assert_eq!(k.facts[0].confirmation_count, 1);
571
572        k.remember("arch", "db", "PostgreSQL", "s2", 0.9, &policy);
573        assert_eq!(k.facts[0].confirmation_count, 2);
574    }
575
576    #[test]
577    fn remove_fact() {
578        let policy = default_policy();
579        let k_root = tempfile::tempdir().unwrap();
580        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
581        k.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
582        assert!(k.remove_fact("arch", "db"));
583        assert!(k.facts.is_empty());
584        assert!(!k.remove_fact("arch", "db"));
585    }
586
587    #[test]
588    fn list_rooms() {
589        let policy = default_policy();
590        let k_root = tempfile::tempdir().unwrap();
591        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
592        k.remember("architecture", "auth", "JWT", "s1", 0.9, &policy);
593        k.remember("architecture", "db", "PG", "s1", 0.9, &policy);
594        k.remember("deploy", "host", "AWS", "s1", 0.8, &policy);
595
596        let rooms = k.list_rooms();
597        assert_eq!(rooms.len(), 2);
598    }
599
600    #[test]
601    fn aaak_format() {
602        let policy = default_policy();
603        let k_root = tempfile::tempdir().unwrap();
604        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
605        k.remember("architecture", "auth", "JWT RS256", "s1", 0.95, &policy);
606        k.remember("architecture", "db", "PostgreSQL", "s1", 0.7, &policy);
607
608        let aaak = k.format_aaak();
609        assert!(aaak.contains("ARCHITECTURE:"));
610        assert!(aaak.contains("auth=JWT RS256"));
611    }
612
613    #[test]
614    fn consolidate_history() {
615        let policy = default_policy();
616        let k_root = tempfile::tempdir().unwrap();
617        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
618        k.consolidate(
619            "Migrated from REST to GraphQL",
620            vec!["s1".into(), "s2".into()],
621            &policy,
622        )
623        .expect("consolidation succeeds");
624        assert_eq!(k.history.len(), 1);
625        assert_eq!(k.history[0].from_sessions.len(), 2);
626    }
627
628    #[test]
629    fn format_summary_output() {
630        let policy = default_policy();
631        let k_root = tempfile::tempdir().unwrap();
632        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
633        k.remember("architecture", "auth", "JWT RS256", "s1", 0.9, &policy);
634        k.add_pattern(
635            "naming",
636            "snake_case for functions",
637            vec!["get_user()".into()],
638            "s1",
639            &policy,
640        );
641        let summary = k.format_summary();
642        assert!(summary.contains("PROJECT KNOWLEDGE:"));
643        assert!(summary.contains("auth: JWT RS256"));
644        assert!(summary.contains("PROJECT PATTERNS:"));
645    }
646
647    #[test]
648    fn temporal_recall_at_time() {
649        let policy = default_policy();
650        let k_root = tempfile::tempdir().unwrap();
651        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
652        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
653        k.facts[0].confirmation_count = 3;
654
655        let before_change = Utc::now();
656        std::thread::sleep(std::time::Duration::from_millis(10));
657
658        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
659
660        let results = k.recall_at_time("db", before_change);
661        assert_eq!(results.len(), 1);
662        assert_eq!(results[0].value, "PostgreSQL");
663
664        let results_now = k.recall_at_time("db", Utc::now());
665        assert_eq!(results_now.len(), 1);
666        assert_eq!(results_now[0].value, "MySQL");
667    }
668
669    #[test]
670    fn timeline_shows_history() {
671        let policy = default_policy();
672        let k_root = tempfile::tempdir().unwrap();
673        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
674        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
675        k.facts[0].confirmation_count = 3;
676        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
677
678        let timeline = k.timeline("arch");
679        assert_eq!(timeline.len(), 2);
680        assert!(!timeline[0].is_current());
681        assert!(timeline[1].is_current());
682    }
683
684    #[test]
685    fn wakeup_format() {
686        let policy = default_policy();
687        let k_root = tempfile::tempdir().unwrap();
688        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
689        k.remember("arch", "auth", "JWT", "s1", 0.95, &policy);
690        k.remember("arch", "db", "PG", "s1", 0.8, &policy);
691
692        // Theta-gamma chunked format (#543): same-category facts share one
693        // `[topic]` header that amortizes the category prefix.
694        let wakeup = k.format_wakeup();
695        assert!(wakeup.contains("FACTS:"));
696        assert!(wakeup.contains("[arch]"));
697        assert!(wakeup.contains("auth=JWT"));
698        assert!(wakeup.contains("db=PG"));
699    }
700
701    #[test]
702    fn salience_prioritizes_decisions_over_findings_at_similar_confidence() {
703        let policy = default_policy();
704        let k_root = tempfile::tempdir().unwrap();
705        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
706        k.remember("finding", "f1", "some thing", "s1", 0.9, &policy);
707        k.remember("decision", "d1", "important", "s1", 0.85, &policy);
708
709        // Chunked format (#543): the founding fact of the first chunk is the
710        // most salient one, so the decision chunk must render first.
711        let wakeup = k.format_wakeup();
712        let d = wakeup.find("d1=important").expect("decision in wakeup");
713        let f = wakeup.find("f1=some thing").expect("finding in wakeup");
714        assert!(
715            d < f,
716            "expected decision before finding in wakeup: {wakeup}"
717        );
718    }
719
720    #[test]
721    fn low_confidence_contradiction() {
722        let policy = default_policy();
723        let k_root = tempfile::tempdir().unwrap();
724        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
725        k.remember("arch", "db", "PostgreSQL", "s1", 0.4, &policy);
726
727        let c = k.check_contradiction("arch", "db", "MySQL", &policy);
728        assert!(c.is_some());
729        assert_eq!(c.unwrap().severity, ContradictionSeverity::Low);
730    }
731
732    #[test]
733    fn no_contradiction_for_same_value() {
734        let policy = default_policy();
735        let k_root = tempfile::tempdir().unwrap();
736        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
737        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
738
739        let c = k.check_contradiction("arch", "db", "PostgreSQL", &policy);
740        assert!(c.is_none());
741    }
742
743    #[test]
744    fn no_contradiction_for_similar_values() {
745        let policy = default_policy();
746        let k_root = tempfile::tempdir().unwrap();
747        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
748        k.remember(
749            "arch",
750            "db",
751            "PostgreSQL 16 production database server",
752            "s1",
753            0.95,
754            &policy,
755        );
756
757        let c = k.check_contradiction(
758            "arch",
759            "db",
760            "PostgreSQL 16 production database server config",
761            &policy,
762        );
763        assert!(c.is_none());
764    }
765
766    #[test]
767    fn import_skip_existing() {
768        let policy = default_policy();
769        let k_root = tempfile::tempdir().unwrap();
770        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
771        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
772
773        let incoming = vec![KnowledgeFact {
774            anchors: Vec::new(),
775            review: None,
776            capture: Default::default(),
777            origin: crate::core::knowledge::FactOrigin::Local,
778            category: "arch".into(),
779            key: "db".into(),
780            value: "MySQL".into(),
781            source_session: "import".into(),
782            confidence: 0.8,
783            created_at: Utc::now(),
784            last_confirmed: Utc::now(),
785            retrieval_count: 0,
786            last_retrieved: None,
787            valid_from: Some(Utc::now()),
788            valid_until: None,
789            supersedes: None,
790            confirmation_count: 1,
791            feedback_up: 0,
792            feedback_down: 0,
793            last_feedback: None,
794            privacy: FactPrivacy::default(),
795            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
796            imported_from: None,
797            archetype: KnowledgeArchetype::default(),
798            fidelity: None,
799            revision_count: 0,
800            status: None,
801            validity: None,
802        }];
803
804        let result = k.import_facts(incoming, ImportMerge::SkipExisting, "imp-1");
805        assert_eq!(result.skipped, 1);
806        assert_eq!(result.added, 0);
807        assert_eq!(k.facts.iter().filter(|f| f.is_current()).count(), 1);
808    }
809
810    #[test]
811    fn import_replace_existing() {
812        let policy = default_policy();
813        let k_root = tempfile::tempdir().unwrap();
814        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
815        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
816
817        let incoming = vec![KnowledgeFact {
818            anchors: Vec::new(),
819            review: None,
820            capture: Default::default(),
821            origin: crate::core::knowledge::FactOrigin::Local,
822            category: "arch".into(),
823            key: "db".into(),
824            value: "MySQL".into(),
825            source_session: "import".into(),
826            confidence: 0.8,
827            created_at: Utc::now(),
828            last_confirmed: Utc::now(),
829            retrieval_count: 0,
830            last_retrieved: None,
831            valid_from: Some(Utc::now()),
832            valid_until: None,
833            supersedes: None,
834            confirmation_count: 1,
835            feedback_up: 0,
836            feedback_down: 0,
837            last_feedback: None,
838            privacy: FactPrivacy::default(),
839            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
840            imported_from: None,
841            archetype: KnowledgeArchetype::default(),
842            fidelity: None,
843            revision_count: 0,
844            status: None,
845            validity: None,
846        }];
847
848        let result = k.import_facts(incoming, ImportMerge::Replace, "imp-1");
849        assert_eq!(result.replaced, 1);
850        let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
851        assert_eq!(current.len(), 1);
852        assert_eq!(current[0].value, "MySQL");
853    }
854
855    #[test]
856    fn import_adds_new_facts() {
857        let policy = default_policy();
858        let k_root = tempfile::tempdir().unwrap();
859        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
860        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
861
862        let incoming = vec![KnowledgeFact {
863            anchors: Vec::new(),
864            review: None,
865            capture: Default::default(),
866            origin: crate::core::knowledge::FactOrigin::Local,
867            category: "security".into(),
868            key: "auth".into(),
869            value: "JWT".into(),
870            source_session: "import".into(),
871            confidence: 0.9,
872            created_at: Utc::now(),
873            last_confirmed: Utc::now(),
874            retrieval_count: 0,
875            last_retrieved: None,
876            valid_from: Some(Utc::now()),
877            valid_until: None,
878            supersedes: None,
879            confirmation_count: 1,
880            feedback_up: 0,
881            feedback_down: 0,
882            last_feedback: None,
883            privacy: FactPrivacy::default(),
884            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
885            imported_from: None,
886            archetype: KnowledgeArchetype::default(),
887            fidelity: None,
888            revision_count: 0,
889            status: None,
890            validity: None,
891        }];
892
893        let result = k.import_facts(incoming, ImportMerge::SkipExisting, "imp-1");
894        assert_eq!(result.added, 1);
895        assert_eq!(k.facts.iter().filter(|f| f.is_current()).count(), 2);
896    }
897
898    #[test]
899    fn parse_simple_json_array() {
900        let data = r#"[
901            {"category": "arch", "key": "db", "value": "PostgreSQL"},
902            {"category": "security", "key": "auth", "value": "JWT", "confidence": 0.9}
903        ]"#;
904        let facts = parse_import_data(data).unwrap();
905        assert_eq!(facts.len(), 2);
906        assert_eq!(facts[0].category, "arch");
907        assert_eq!(facts[1].confidence, 0.9);
908    }
909
910    #[test]
911    fn parse_jsonl_format() {
912        let data = "{\"category\":\"arch\",\"key\":\"db\",\"value\":\"PG\"}\n\
913                    {\"category\":\"security\",\"key\":\"auth\",\"value\":\"JWT\"}";
914        let facts = parse_import_data(data).unwrap();
915        assert_eq!(facts.len(), 2);
916    }
917
918    #[test]
919    fn export_simple_only_current() {
920        let policy = default_policy();
921        let k_root = tempfile::tempdir().unwrap();
922        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
923        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
924        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
925
926        let exported = k.export_simple();
927        assert_eq!(exported.len(), 1);
928        assert_eq!(exported[0].value, "MySQL");
929    }
930
931    #[test]
932    fn import_merge_parse() {
933        assert_eq!(ImportMerge::parse("replace"), Some(ImportMerge::Replace));
934        assert_eq!(ImportMerge::parse("append"), Some(ImportMerge::Append));
935        assert_eq!(
936            ImportMerge::parse("skip-existing"),
937            Some(ImportMerge::SkipExisting)
938        );
939        assert_eq!(
940            ImportMerge::parse("skip_existing"),
941            Some(ImportMerge::SkipExisting)
942        );
943        assert_eq!(ImportMerge::parse("skip"), Some(ImportMerge::SkipExisting));
944        assert!(ImportMerge::parse("invalid").is_none());
945    }
946
947    #[test]
948    fn revision_count_on_new_fact() {
949        let policy = default_policy();
950        let k_root = tempfile::tempdir().unwrap();
951        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
952        k.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
953        let cur = k.facts.iter().find(|f| f.is_current()).unwrap();
954        assert_eq!(cur.revision_count, 1);
955    }
956
957    #[test]
958    fn revision_count_increments_on_confirm() {
959        let policy = default_policy();
960        let k_root = tempfile::tempdir().unwrap();
961        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
962        k.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
963        k.remember("arch", "db", "PostgreSQL", "s2", 0.9, &policy);
964        k.remember("arch", "db", "PostgreSQL", "s3", 0.9, &policy);
965        let cur = k.facts.iter().find(|f| f.is_current()).unwrap();
966        assert_eq!(cur.revision_count, 3);
967        assert_eq!(cur.confirmation_count, 3);
968    }
969
970    #[test]
971    fn revision_count_carries_over_on_supersede() {
972        let policy = default_policy();
973        let k_root = tempfile::tempdir().unwrap();
974        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
975        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
976        k.remember("arch", "db", "PostgreSQL", "s2", 0.9, &policy);
977        assert_eq!(
978            k.facts
979                .iter()
980                .find(|f| f.is_current())
981                .unwrap()
982                .revision_count,
983            2
984        );
985        k.facts[0].confirmation_count = 3;
986        k.remember("arch", "db", "MySQL", "s3", 0.9, &policy);
987        let cur: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
988        assert_eq!(cur.len(), 1);
989        assert_eq!(cur[0].value, "MySQL");
990        assert_eq!(cur[0].revision_count, 3);
991        assert!(cur[0].supersedes.is_some());
992    }
993
994    #[test]
995    fn revision_count_default_zero_for_legacy() {
996        let json = r#"{
997            "category": "test", "key": "k", "value": "v",
998            "source_session": "s", "confidence": 0.8,
999            "created_at": "2024-01-01T00:00:00Z",
1000            "last_confirmed": "2024-01-01T00:00:00Z"
1001        }"#;
1002        let fact: KnowledgeFact = serde_json::from_str(json).unwrap();
1003        assert_eq!(fact.revision_count, 0);
1004    }
1005
1006    #[test]
1007    fn judged_pairs_default_empty_for_legacy() {
1008        let json = r#"{
1009            "project_root": "/test", "project_hash": "abc",
1010            "facts": [], "patterns": [], "history": [],
1011            "updated_at": "2024-01-01T00:00:00Z"
1012        }"#;
1013        let pk: ProjectKnowledge = serde_json::from_str(json).unwrap();
1014        assert!(pk.judged_pairs.is_empty());
1015    }
1016
1017    #[test]
1018    fn cross_key_similar_finds_related_facts() {
1019        let policy = default_policy();
1020        let k_root = tempfile::tempdir().unwrap();
1021        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
1022        k.remember(
1023            "architecture",
1024            "auth",
1025            "JWT RS256 token based authentication with Redis session store",
1026            "s1",
1027            0.9,
1028            &policy,
1029        );
1030        k.remember(
1031            "decision",
1032            "session-model",
1033            "JWT token authentication stored in Redis for session management",
1034            "s1",
1035            0.85,
1036            &policy,
1037        );
1038        k.remember("deploy", "host", "AWS us-east-1", "s1", 0.8, &policy);
1039
1040        let similar = find_cross_key_similar(
1041            "architecture",
1042            "auth",
1043            "JWT RS256 token based authentication with Redis session store",
1044            &k.facts,
1045            &k.judged_pairs,
1046            3,
1047        );
1048        assert!(!similar.is_empty(), "should find session-model as similar");
1049        assert_eq!(similar[0].category, "decision");
1050        assert_eq!(similar[0].key, "session-model");
1051        assert!(similar[0].similarity > 0.35);
1052    }
1053
1054    #[test]
1055    fn cross_key_similar_excludes_same_key() {
1056        let policy = default_policy();
1057        let k_root = tempfile::tempdir().unwrap();
1058        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
1059        k.remember("arch", "db", "PostgreSQL 16", "s1", 0.9, &policy);
1060
1061        let similar =
1062            find_cross_key_similar("arch", "db", "PostgreSQL 16", &k.facts, &k.judged_pairs, 3);
1063        assert!(similar.is_empty());
1064    }
1065
1066    #[test]
1067    fn cross_key_similar_excludes_judged_pairs() {
1068        let policy = default_policy();
1069        let k_root = tempfile::tempdir().unwrap();
1070        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
1071        k.remember(
1072            "architecture",
1073            "auth",
1074            "JWT RS256 token based authentication with Redis",
1075            "s1",
1076            0.9,
1077            &policy,
1078        );
1079        k.remember(
1080            "decision",
1081            "session-model",
1082            "JWT token authentication stored in Redis",
1083            "s1",
1084            0.85,
1085            &policy,
1086        );
1087
1088        k.judged_pairs.push(JudgedPair {
1089            key_a: "architecture/auth".into(),
1090            key_b: "decision/session-model".into(),
1091            verdict: "compatible".into(),
1092            judged_at: Utc::now(),
1093        });
1094
1095        let similar = find_cross_key_similar(
1096            "architecture",
1097            "auth",
1098            "JWT RS256 token based authentication with Redis",
1099            &k.facts,
1100            &k.judged_pairs,
1101            3,
1102        );
1103        assert!(similar.is_empty(), "judged pairs should be excluded");
1104    }
1105
1106    #[test]
1107    fn cross_key_similar_ignores_unrelated_facts() {
1108        let policy = default_policy();
1109        let k_root = tempfile::tempdir().unwrap();
1110        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
1111        k.remember(
1112            "arch",
1113            "db",
1114            "PostgreSQL 16 with pgvector",
1115            "s1",
1116            0.9,
1117            &policy,
1118        );
1119        k.remember("deploy", "host", "AWS us-east-1 region", "s1", 0.8, &policy);
1120
1121        let similar = find_cross_key_similar(
1122            "arch",
1123            "db",
1124            "PostgreSQL 16 with pgvector",
1125            &k.facts,
1126            &k.judged_pairs,
1127            3,
1128        );
1129        assert!(similar.is_empty(), "unrelated facts should not match");
1130    }
1131
1132    #[test]
1133    fn judge_supersedes_archives_target() {
1134        let policy = default_policy();
1135        let k_root = tempfile::tempdir().unwrap();
1136        let mut k = ProjectKnowledge::new(k_root.path().to_str().unwrap());
1137        k.remember("architecture", "auth", "JWT RS256", "s1", 0.9, &policy);
1138        k.remember("decision", "session", "JWT tokens", "s1", 0.85, &policy);
1139
1140        assert!(k.facts.iter().all(KnowledgeFact::is_current));
1141
1142        if let Some(tf) = k
1143            .facts
1144            .iter_mut()
1145            .find(|f| f.category == "decision" && f.key == "session" && f.is_current())
1146        {
1147            tf.valid_until = Some(Utc::now());
1148        }
1149        k.judged_pairs.push(JudgedPair {
1150            key_a: "architecture/auth".into(),
1151            key_b: "decision/session".into(),
1152            verdict: "supersedes".into(),
1153            judged_at: Utc::now(),
1154        });
1155
1156        let cur: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
1157        assert_eq!(cur.len(), 1);
1158        assert_eq!(cur[0].category, "architecture");
1159    }
1160}