1use std::collections::HashSet;
27
28use mentedb_core::error::MenteResult;
29use mentedb_core::memory::{AttributeValue, MemoryNode, MemoryType};
30use mentedb_core::types::{AgentId, MemoryId, UserId};
31
32use crate::MenteDb;
33
34pub const ATTR_INJECTION_SHOWN: &str = "injection_shown";
36pub const ATTR_INJECTION_USED: &str = "injection_used";
39
40#[derive(Debug, Clone)]
42pub struct InjectionConfig {
43 pub candidate_pool: usize,
45 pub mmr_lambda: f32,
47 pub knee_gap_ratio: f32,
49 pub demotion_shown_min: i64,
51 pub demotion_factor: f32,
53 pub used_similarity: f32,
55 pub use_reinforcement: f32,
57}
58
59impl Default for InjectionConfig {
60 fn default() -> Self {
61 Self {
62 candidate_pool: 48,
63 mmr_lambda: 0.7,
64 knee_gap_ratio: 2.0,
65 demotion_shown_min: 5,
66 demotion_factor: 0.5,
67 used_similarity: 0.6,
68 use_reinforcement: 0.05,
69 }
70 }
71}
72
73pub struct InjectionQuery<'a> {
75 pub embedding: &'a [f32],
77 pub query_text: Option<&'a str>,
79 pub session_id: Option<&'a str>,
82 pub exclude_ids: &'a [MemoryId],
84 pub max_items: usize,
86 pub max_episodic: usize,
88 pub agent_id: Option<AgentId>,
91 pub user_id: Option<UserId>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum SelectionReason {
100 Pinned,
102 Relevant,
104}
105
106pub struct InjectionCandidate {
108 pub node: MemoryNode,
109 pub score: f32,
112 pub reason: SelectionReason,
113}
114
115fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
116 if a.len() != b.len() || a.is_empty() {
117 return 0.0;
118 }
119 let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
120 let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
121 let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
122 if na == 0.0 || nb == 0.0 {
123 return 0.0;
124 }
125 dot / (na * nb)
126}
127
128fn attr_count(node: &MemoryNode, key: &str) -> i64 {
129 match node.attributes.get(key) {
130 Some(AttributeValue::Integer(n)) => *n,
131 _ => 0,
132 }
133}
134
135fn bump_attr(node: &mut MemoryNode, key: &str) {
136 let next = attr_count(node, key) + 1;
137 node.attributes
138 .insert(key.to_string(), AttributeValue::Integer(next));
139}
140
141fn has_tag(node: &MemoryNode, tag: &str) -> bool {
142 node.tags.iter().any(|t| t == tag)
143}
144
145fn now_us() -> u64 {
146 std::time::SystemTime::now()
147 .duration_since(std::time::UNIX_EPOCH)
148 .unwrap_or_default()
149 .as_micros() as u64
150}
151
152fn knee_cutoff(scores: &[f32], gap_ratio: f32) -> usize {
155 if scores.len() <= 1 {
156 return scores.len();
157 }
158 let mut best_ratio = 0.0f32;
159 let mut cut = scores.len();
160 for i in 0..scores.len() - 1 {
161 let hi = scores[i];
162 let lo = scores[i + 1].max(f32::EPSILON);
163 let ratio = hi / lo;
164 if ratio > best_ratio {
165 best_ratio = ratio;
166 cut = i + 1;
167 }
168 }
169 if best_ratio >= gap_ratio {
170 cut
171 } else {
172 scores.len()
173 }
174}
175
176impl MenteDb {
177 pub fn recall_for_injection(
180 &self,
181 query: &InjectionQuery<'_>,
182 ) -> MenteResult<Vec<InjectionCandidate>> {
183 let cfg = self.cognitive_config.injection_config.clone();
184 let excluded: HashSet<MemoryId> = query.exclude_ids.iter().copied().collect();
185 let session_tag = query.session_id.map(|s| format!("session:{s}"));
186
187 let hits = self
189 .recall_hybrid_scoped_at_mode(
190 query.embedding,
191 query.query_text,
192 cfg.candidate_pool,
193 now_us(),
194 None,
195 false,
196 None,
197 query.agent_id,
198 query.user_id,
199 )
200 .unwrap_or_default();
201
202 let mut scored: Vec<(MemoryNode, f32)> = Vec::new();
203 for (id, score) in hits {
204 if excluded.contains(&id) {
205 continue;
206 }
207 let Ok(node) = self.get_memory(id) else {
208 continue;
209 };
210 if has_tag(&node, "action")
211 || has_tag(&node, "scope:always")
212 || has_tag(&node, "ghost-memory")
213 {
214 continue;
219 }
220 if let Some(ref st) = session_tag
221 && has_tag(&node, st)
222 {
223 continue;
224 }
225 let shown = attr_count(&node, ATTR_INJECTION_SHOWN);
228 let used = attr_count(&node, ATTR_INJECTION_USED);
229 let adjusted = if shown >= cfg.demotion_shown_min && used == 0 {
230 score * cfg.demotion_factor
231 } else {
232 score
233 };
234 scored.push((node, adjusted));
235 }
236
237 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
238 let scores: Vec<f32> = scored.iter().map(|(_, s)| *s).collect();
239 scored.truncate(knee_cutoff(&scores, cfg.knee_gap_ratio));
240
241 let top_score = scored
243 .first()
244 .map(|(_, s)| *s)
245 .unwrap_or(1.0)
246 .max(f32::EPSILON);
247 let mut remaining: Vec<(MemoryNode, f32)> = scored;
248 let mut selected: Vec<InjectionCandidate> = Vec::new();
249 let mut episodic_count = 0usize;
250
251 while selected.len() < query.max_items && !remaining.is_empty() {
252 let mut best_idx: Option<usize> = None;
253 let mut best_value = f32::NEG_INFINITY;
254 for (idx, (node, score)) in remaining.iter().enumerate() {
255 if node.memory_type == MemoryType::Episodic && episodic_count >= query.max_episodic
256 {
257 continue;
258 }
259 let redundancy = selected
260 .iter()
261 .map(|s| cosine_similarity(&node.embedding, &s.node.embedding))
262 .fold(0.0f32, f32::max);
263 let value =
264 cfg.mmr_lambda * (score / top_score) - (1.0 - cfg.mmr_lambda) * redundancy;
265 if value > best_value {
266 best_value = value;
267 best_idx = Some(idx);
268 }
269 }
270 let Some(idx) = best_idx else {
271 break;
272 };
273 let (node, score) = remaining.remove(idx);
274 if node.memory_type == MemoryType::Episodic {
275 episodic_count += 1;
276 }
277 selected.push(InjectionCandidate {
278 node,
279 score,
280 reason: SelectionReason::Relevant,
281 });
282 }
283
284 let mut result: Vec<InjectionCandidate> = Vec::new();
288 let page_ids: Vec<_> = {
289 let pm = self.page_map.read();
290 pm.values().copied().collect()
291 };
292 for pid in page_ids {
293 if let Ok(node) = self.storage.load_memory(pid)
294 && has_tag(&node, "scope:always")
295 && !excluded.contains(&node.id)
296 && crate::agent_visible(node.agent_id, query.agent_id)
297 && crate::user_visible(node.user_id, query.user_id)
298 {
299 result.push(InjectionCandidate {
300 node,
301 score: 0.0,
302 reason: SelectionReason::Pinned,
303 });
304 }
305 }
306 result.extend(selected);
307 Ok(result)
308 }
309
310 pub fn record_injection_outcome(
315 &self,
316 shown: &[MemoryId],
317 reply_embedding: Option<&[f32]>,
318 ) -> MenteResult<(usize, usize)> {
319 let cfg = self.cognitive_config.injection_config.clone();
320 let mut updated = 0usize;
321 let mut used_total = 0usize;
322 let now = now_us();
323
324 for id in shown {
325 let pid = {
326 let pm = self.page_map.read();
327 match pm.get(id) {
328 Some(p) => *p,
329 None => continue,
330 }
331 };
332 let Ok(mut node) = self.storage.load_memory(pid) else {
333 continue;
334 };
335 bump_attr(&mut node, ATTR_INJECTION_SHOWN);
336
337 let used = reply_embedding
338 .map(|re| cosine_similarity(&node.embedding, re) >= cfg.used_similarity)
339 .unwrap_or(false);
340 if used {
341 bump_attr(&mut node, ATTR_INJECTION_USED);
342 node.access_count = node.access_count.saturating_add(1);
343 node.accessed_at = now;
344 node.salience = (node.salience + cfg.use_reinforcement).min(1.0);
345 used_total += 1;
346 }
347
348 self.storage.update_memory(pid, &node)?;
351 updated += 1;
352 }
353 Ok((updated, used_total))
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use mentedb_core::types::AgentId;
361 use uuid::Uuid;
362
363 #[test]
364 fn knee_cuts_at_largest_gap() {
365 let ratio = InjectionConfig::default().knee_gap_ratio;
366 assert_eq!(knee_cutoff(&[1.0, 0.9, 0.8, 0.1, 0.09], ratio), 3);
367 assert_eq!(knee_cutoff(&[1.0, 0.9, 0.8, 0.7], ratio), 4);
369 assert_eq!(knee_cutoff(&[1.0], ratio), 1);
370 assert_eq!(knee_cutoff(&[], ratio), 0);
371 }
372
373 #[test]
374 fn attr_counters_roundtrip() {
375 let mut node = MemoryNode::new(
376 AgentId(Uuid::nil()),
377 MemoryType::Semantic,
378 "x".into(),
379 vec![0.0; 4],
380 );
381 assert_eq!(attr_count(&node, ATTR_INJECTION_SHOWN), 0);
382 bump_attr(&mut node, ATTR_INJECTION_SHOWN);
383 bump_attr(&mut node, ATTR_INJECTION_SHOWN);
384 assert_eq!(attr_count(&node, ATTR_INJECTION_SHOWN), 2);
385 }
386}