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