lean_ctx/core/knowledge/
query.rs1use 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 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 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 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}