Skip to main content

lean_ctx/core/knowledge/
query.rs

1use chrono::{DateTime, Duration, Utc};
2
3use super::ranking::sort_fact_for_output;
4use super::types::{KnowledgeFact, ProjectKnowledge};
5use crate::core::cognitive_gate::full_science_enabled;
6use crate::core::memory_scheduler::{initial_state, retrievability};
7
8const DEFAULT_ELAPSED_DAYS: f64 = 7.0;
9
10fn fact_elapsed_days(fact: &KnowledgeFact, now: DateTime<Utc>) -> f64 {
11    match fact.last_retrieved {
12        Some(ts) => ((now - ts).num_seconds() as f64 / 86_400.0).max(0.0),
13        None => DEFAULT_ELAPSED_DAYS,
14    }
15}
16
17fn fsrs_boosted_relevance(fact: &KnowledgeFact, relevance: f32, now: DateTime<Utc>) -> f32 {
18    let elapsed_days = fact_elapsed_days(fact, now);
19    let elapsed_secs = (elapsed_days * 86_400.0).round() as i64;
20    let mut state = initial_state(fact.key.clone(), 3);
21    state.last_review = now - Duration::seconds(elapsed_secs);
22    let ret = retrievability(&state, now).clamp(0.0, 1.0);
23    let multiplier = (1.5_f64 - ret).max(0.1_f64) as f32;
24    relevance * multiplier
25}
26
27impl ProjectKnowledge {
28    fn matching_indices(&self, term: &str, include_session: bool) -> Vec<usize> {
29        let Some(indices) = self.index.token_positions.get(term) else {
30            return if include_session {
31                self.index
32                    .session_token_positions
33                    .get(term)
34                    .cloned()
35                    .unwrap_or_default()
36            } else {
37                Vec::new()
38            };
39        };
40
41        if !include_session {
42            return indices.clone();
43        }
44
45        let Some(session_indices) = self.index.session_token_positions.get(term) else {
46            return indices.clone();
47        };
48        let mut merged = indices.clone();
49        merged.extend(
50            session_indices
51                .iter()
52                .copied()
53                .filter(|idx| indices.binary_search(idx).is_err()),
54        );
55        merged
56    }
57
58    pub fn recall(&self, query: &str) -> Vec<&KnowledgeFact> {
59        let q = query.to_lowercase();
60        let terms: Vec<&str> = q.split_whitespace().collect();
61        if terms.is_empty() {
62            return Vec::new();
63        }
64
65        let mut match_counts: std::collections::HashMap<usize, usize> =
66            std::collections::HashMap::new();
67        for term in &terms {
68            for idx in self.matching_indices(term, true) {
69                if self.facts[idx].is_current() {
70                    *match_counts.entry(idx).or_insert(0) += 1;
71                }
72            }
73        }
74
75        let mut results: Vec<(&KnowledgeFact, f32)> = match_counts
76            .into_iter()
77            .map(|(idx, count)| {
78                let f = &self.facts[idx];
79                let relevance = (count as f32 / terms.len() as f32) * f.quality_score();
80                (f, relevance)
81            })
82            .collect();
83
84        if full_science_enabled() {
85            let now = Utc::now();
86            results = results
87                .into_iter()
88                .map(|(f, relevance)| (f, fsrs_boosted_relevance(f, relevance, now)))
89                .collect();
90        }
91
92        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
93        results.into_iter().map(|(f, _)| f).collect()
94    }
95
96    pub fn recall_by_category(&self, category: &str) -> Vec<&KnowledgeFact> {
97        self.index
98            .category_positions
99            .get(category)
100            .into_iter()
101            .flatten()
102            .filter_map(|&idx| self.facts.get(idx))
103            .filter(|f| f.is_current())
104            .collect()
105    }
106
107    pub fn recall_at_time(&self, query: &str, at: DateTime<Utc>) -> Vec<&KnowledgeFact> {
108        let q = query.to_lowercase();
109        let terms: Vec<&str> = q.split_whitespace().collect();
110        if terms.is_empty() {
111            return Vec::new();
112        }
113
114        let mut match_counts: std::collections::HashMap<usize, usize> =
115            std::collections::HashMap::new();
116        for term in &terms {
117            for idx in self.matching_indices(term, false) {
118                if self.facts[idx].was_valid_at(at) {
119                    *match_counts.entry(idx).or_insert(0) += 1;
120                }
121            }
122        }
123
124        let mut results: Vec<(&KnowledgeFact, f32)> = match_counts
125            .into_iter()
126            .map(|(idx, count)| {
127                let f = &self.facts[idx];
128                (f, count as f32 / terms.len() as f32)
129            })
130            .collect();
131
132        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
133        results.into_iter().map(|(f, _)| f).collect()
134    }
135
136    pub fn timeline(&self, category: &str) -> Vec<&KnowledgeFact> {
137        let mut facts: Vec<&KnowledgeFact> = self
138            .index
139            .category_positions
140            .get(category)
141            .into_iter()
142            .flatten()
143            .filter_map(|&idx| self.facts.get(idx))
144            .collect();
145        facts.sort_by_key(|x| x.created_at);
146        facts
147    }
148
149    pub fn list_rooms(&self) -> Vec<(String, usize)> {
150        let mut categories: std::collections::BTreeMap<String, usize> =
151            std::collections::BTreeMap::new();
152        for f in &self.facts {
153            if f.is_current() {
154                *categories.entry(f.category.clone()).or_insert(0) += 1;
155            }
156        }
157        categories.into_iter().collect()
158    }
159
160    pub fn recall_for_output(&mut self, query: &str, limit: usize) -> (Vec<KnowledgeFact>, usize) {
161        let q = query.to_lowercase();
162        let terms: Vec<&str> = q.split_whitespace().filter(|t| !t.is_empty()).collect();
163        if terms.is_empty() {
164            return (Vec::new(), 0);
165        }
166
167        let mut match_counts: std::collections::HashMap<usize, usize> =
168            std::collections::HashMap::new();
169        for term in &terms {
170            for idx in self.matching_indices(term, true) {
171                if self.facts[idx].is_current() {
172                    *match_counts.entry(idx).or_insert(0) += 1;
173                }
174            }
175        }
176
177        struct Scored {
178            idx: usize,
179            relevance: f32,
180        }
181
182        let mut scored: Vec<Scored> = match_counts
183            .into_iter()
184            .map(|(idx, count)| {
185                let f = &self.facts[idx];
186                let mut relevance = (count as f32 / terms.len() as f32) * f.confidence;
187                // Exact-match boost: an exact hit on the fact key (or category)
188                // should rank above incidental lexical matches (#2363). The +1.0
189                // dominates the [0,1] coverage*confidence base.
190                let key_lower = f.key.to_lowercase();
191                if key_lower == q {
192                    relevance += 1.0;
193                } else if f.category.to_lowercase() == q {
194                    relevance += 0.5;
195                }
196                // Observation tier (#802): a relevant synthesized entity-summary is
197                // orientation — lift it above incidental matches, but keep it below an
198                // exact key hit (+1.0) so a stale summary never buries a precise raw
199                // fact. Balanced, not absolute.
200                if f.is_synthesized_observation() {
201                    relevance += 0.4;
202                }
203                Scored { idx, relevance }
204            })
205            .collect();
206
207        let now = Utc::now();
208        if full_science_enabled() {
209            for s in &mut scored {
210                s.relevance = fsrs_boosted_relevance(&self.facts[s.idx], s.relevance, now);
211            }
212        }
213
214        scored.sort_by(|a, b| {
215            b.relevance
216                .partial_cmp(&a.relevance)
217                .unwrap_or(std::cmp::Ordering::Equal)
218                .then_with(|| sort_fact_for_output(&self.facts[a.idx], &self.facts[b.idx]))
219        });
220
221        let total = scored.len();
222        scored.truncate(limit);
223
224        let mut out: Vec<KnowledgeFact> = Vec::new();
225        for s in scored {
226            if let Some(f) = self.facts.get_mut(s.idx) {
227                f.retrieval_count = f.retrieval_count.saturating_add(1);
228                f.last_retrieved = Some(now);
229                out.push(f.clone());
230            }
231        }
232
233        (out, total)
234    }
235
236    pub fn recall_by_category_for_output(
237        &mut self,
238        category: &str,
239        limit: usize,
240    ) -> (Vec<KnowledgeFact>, usize) {
241        let mut idxs: Vec<usize> = self
242            .index
243            .category_positions
244            .get(category)
245            .into_iter()
246            .flatten()
247            .copied()
248            .filter(|&idx| self.facts[idx].is_current())
249            .collect();
250
251        // Within a category, synthesized observation summaries lead (#802) — a
252        // balanced tier ahead of the usual salience sort, never an absolute override.
253        idxs.sort_by(|a, b| {
254            let (fa, fb) = (&self.facts[*a], &self.facts[*b]);
255            fb.is_synthesized_observation()
256                .cmp(&fa.is_synthesized_observation())
257                .then_with(|| sort_fact_for_output(fa, fb))
258        });
259
260        let total = idxs.len();
261        idxs.truncate(limit);
262
263        let now = Utc::now();
264        let mut out = Vec::new();
265        for idx in idxs {
266            if let Some(f) = self.facts.get_mut(idx) {
267                f.retrieval_count = f.retrieval_count.saturating_add(1);
268                f.last_retrieved = Some(now);
269                out.push(f.clone());
270            }
271        }
272
273        (out, total)
274    }
275}