Skip to main content

roboticus_agent/
retrieval.rs

1use roboticus_core::config::MemoryConfig;
2use roboticus_db::Database;
3
4use crate::context::{ComplexityLevel, token_budget};
5use crate::memory::MemoryBudgetManager;
6
7/// Retrieves and formats memories from all five tiers for injection into the LLM prompt.
8pub struct MemoryRetriever {
9    budget_manager: MemoryBudgetManager,
10    hybrid_weight: f64,
11    /// Half-life (in days) for episodic memory decay during retrieval re-ranking.
12    /// Older episodic results have their similarity score discounted so that
13    /// recent memories surface above stale ones with similar cosine proximity.
14    decay_half_life_days: f64,
15}
16
17impl MemoryRetriever {
18    pub fn new(config: MemoryConfig) -> Self {
19        let hybrid_weight = config.hybrid_weight;
20        Self {
21            budget_manager: MemoryBudgetManager::new(config),
22            hybrid_weight,
23            decay_half_life_days: 7.0, // sensible default: 1-week half-life
24        }
25    }
26
27    /// Override the episodic decay half-life (in days) used during retrieval re-ranking.
28    pub fn with_decay_half_life(mut self, days: f64) -> Self {
29        self.decay_half_life_days = days;
30        self
31    }
32
33    /// Retrieve memories from all tiers and format them into a single string
34    /// for context injection. Token budgets are respected per-tier.
35    pub fn retrieve(
36        &self,
37        db: &Database,
38        session_id: &str,
39        query: &str,
40        query_embedding: Option<&[f32]>,
41        complexity: ComplexityLevel,
42    ) -> String {
43        self.retrieve_with_ann(db, session_id, query, query_embedding, complexity, None)
44    }
45
46    /// Like `retrieve`, but optionally uses an ANN index for O(log n) nearest-neighbor
47    /// search instead of brute-force cosine scan.
48    pub fn retrieve_with_ann(
49        &self,
50        db: &Database,
51        session_id: &str,
52        query: &str,
53        query_embedding: Option<&[f32]>,
54        complexity: ComplexityLevel,
55        ann_index: Option<&roboticus_db::ann::AnnIndex>,
56    ) -> String {
57        let total_budget = token_budget(complexity);
58        let budgets = self.budget_manager.allocate_budgets(total_budget);
59
60        let mut sections = Vec::new();
61
62        if let Some(s) = self.retrieve_working(db, session_id, budgets.working) {
63            sections.push(s);
64        }
65
66        // Try ANN index first for relevant memories; fall back to brute-force hybrid search
67        let relevant = if let (Some(ann), Some(emb)) = (ann_index, query_embedding) {
68            ann.search(emb, 10).map(|results| {
69                results
70                    .into_iter()
71                    .map(|r| roboticus_db::embeddings::SearchResult {
72                        source_table: r.source_table,
73                        source_id: r.source_id,
74                        content_preview: r.content_preview,
75                        similarity: r.similarity,
76                    })
77                    .collect::<Vec<_>>()
78            })
79        } else {
80            None
81        };
82        let mut relevant = relevant.unwrap_or_else(|| {
83            roboticus_db::embeddings::hybrid_search(
84                db,
85                query,
86                query_embedding,
87                10,
88                self.hybrid_weight,
89            )
90            .unwrap_or_default()
91        });
92
93        // Decay re-ranking: discount episodic results by age so recent memories
94        // surface above stale ones with similar cosine proximity.
95        if self.decay_half_life_days > 0.0 {
96            self.rerank_episodic_by_decay(db, &mut relevant);
97        }
98
99        if let Some(s) = self.format_relevant(&relevant, budgets.episodic + budgets.semantic) {
100            sections.push(s);
101        }
102
103        if let Some(s) = self.retrieve_procedural(db, budgets.procedural) {
104            sections.push(s);
105        }
106
107        if let Some(s) = self.retrieve_relationships(db, query, budgets.relationship) {
108            sections.push(s);
109        }
110
111        if sections.is_empty() {
112            return String::new();
113        }
114
115        format!("[Active Memory]\n{}", sections.join("\n\n"))
116    }
117
118    fn retrieve_working(
119        &self,
120        db: &Database,
121        session_id: &str,
122        budget_tokens: usize,
123    ) -> Option<String> {
124        if budget_tokens == 0 {
125            return None;
126        }
127
128        let entries = roboticus_db::memory::retrieve_working(db, session_id)
129            .inspect_err(
130                |e| tracing::warn!(error = %e, session_id, "working memory retrieval failed"),
131            )
132            .ok()?;
133        if entries.is_empty() {
134            return None;
135        }
136
137        let mut text = String::from("[Working Memory]\n");
138        let mut used = estimate_tokens(&text);
139
140        for entry in &entries {
141            // `turn_summary` mirrors prior assistant output and can cause
142            // repetitive self-priming when injected into subsequent prompts.
143            if entry.entry_type.eq_ignore_ascii_case("turn_summary") {
144                continue;
145            }
146            let line = format!("- [{}] {}\n", entry.entry_type, entry.content);
147            let line_tokens = estimate_tokens(&line);
148            if used + line_tokens > budget_tokens {
149                break;
150            }
151            text.push_str(&line);
152            used += line_tokens;
153        }
154
155        if text.len() > "[Working Memory]\n".len() {
156            Some(text)
157        } else {
158            None
159        }
160    }
161
162    fn format_relevant(
163        &self,
164        results: &[roboticus_db::embeddings::SearchResult],
165        budget_tokens: usize,
166    ) -> Option<String> {
167        if budget_tokens == 0 || results.is_empty() {
168            return None;
169        }
170
171        let mut text = String::from("[Relevant Memories]\n");
172        let mut used = estimate_tokens(&text);
173
174        for result in results {
175            let line = format!(
176                "- [{} | sim={:.2}] {}\n",
177                result.source_table, result.similarity, result.content_preview,
178            );
179            let line_tokens = estimate_tokens(&line);
180            if used + line_tokens > budget_tokens {
181                break;
182            }
183            text.push_str(&line);
184            used += line_tokens;
185        }
186
187        if text.len() > "[Relevant Memories]\n".len() {
188            Some(text)
189        } else {
190            None
191        }
192    }
193
194    /// Re-rank search results by applying time-decay to episodic entries.
195    ///
196    /// For results from the `episodic_memory` table, look up their `created_at`
197    /// timestamp and scale the similarity score by an exponential decay factor.
198    /// Non-episodic results are left untouched.  The result list is re-sorted
199    /// by the adjusted similarity in descending order.
200    fn rerank_episodic_by_decay(
201        &self,
202        db: &Database,
203        results: &mut [roboticus_db::embeddings::SearchResult],
204    ) {
205        let now = chrono::Utc::now();
206
207        // Batch-query: collect all episodic IDs, look them up in one pass,
208        // then apply decay.  This avoids N separate queries holding the DB
209        // connection open in a loop.
210        let episodic_ids: Vec<&str> = results
211            .iter()
212            .filter(|r| r.source_table == "episodic_memory")
213            .map(|r| r.source_id.as_str())
214            .collect();
215
216        if episodic_ids.is_empty() {
217            return;
218        }
219
220        // Build a HashMap<id, age_days> from a single DB access
221        let age_map: std::collections::HashMap<String, f64> = {
222            let conn = db.conn();
223            let placeholders: Vec<String> =
224                (1..=episodic_ids.len()).map(|i| format!("?{i}")).collect();
225            let sql = format!(
226                "SELECT id, created_at FROM episodic_memory WHERE id IN ({})",
227                placeholders.join(", ")
228            );
229            let mut stmt = match conn.prepare(&sql) {
230                Ok(s) => s,
231                Err(_) => return,
232            };
233            let rows = match stmt
234                .query_map(roboticus_db::params_from_iter(episodic_ids.iter()), |row| {
235                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
236                }) {
237                Ok(r) => r,
238                Err(_) => return,
239            };
240            rows.filter_map(|r| {
241                r.inspect_err(|e| tracing::warn!("skipping corrupted episodic row: {e}"))
242                    .ok()
243            })
244            .filter_map(|(id, ts)| {
245                chrono::DateTime::parse_from_rfc3339(&ts)
246                    .ok()
247                    .map(|created| {
248                        // Age in days. Future timestamps (clock skew) yield a
249                        // negative chrono::Duration whose .to_std() returns Err,
250                        // mapping to age=0 (fresh). This is correct: only the
251                        // agent writes to episodic_memory so future-dated entries
252                        // are clock-skew artifacts, not attacker-injectable.
253                        let age = (now - created.with_timezone(&chrono::Utc))
254                            .to_std()
255                            .map(|d| d.as_secs_f64() / 86_400.0)
256                            .unwrap_or(0.0);
257                        (id, age)
258                    })
259            })
260            .collect()
261        }; // conn dropped here — DB connection released before mutation loop
262
263        for result in results.iter_mut() {
264            if result.source_table != "episodic_memory" {
265                continue;
266            }
267            if result.source_id.is_empty() {
268                // FTS-only results have no source_id and can't be looked up
269                // in episodic_memory. Apply a conservative default penalty so
270                // they don't bypass decay and outrank properly-aged results.
271                result.similarity *= 0.5;
272                continue;
273            }
274            if let Some(&age) = age_map.get(&result.source_id) {
275                let decay_factor = (0.5_f64).powf(age / self.decay_half_life_days);
276                // Floor at 0.05 so very old memories remain findable — they
277                // rank lower but never become completely invisible.
278                let clamped = decay_factor.max(0.05);
279                result.similarity *= clamped;
280            }
281        }
282
283        // Re-sort by adjusted similarity, descending
284        results.sort_by(|a, b| {
285            b.similarity
286                .partial_cmp(&a.similarity)
287                .unwrap_or(std::cmp::Ordering::Equal)
288        });
289    }
290
291    fn retrieve_procedural(&self, db: &Database, budget_tokens: usize) -> Option<String> {
292        if budget_tokens == 0 {
293            return None;
294        }
295
296        // Retrieve all procedural entries and present those with meaningful history
297        let conn = db.conn();
298        let mut stmt = conn
299            .prepare(
300                "SELECT name, steps, success_count, failure_count FROM procedural_memory \
301                 WHERE success_count > 0 OR failure_count > 0 \
302                 ORDER BY success_count + failure_count DESC LIMIT 5",
303            )
304            .ok()?;
305
306        let rows: Vec<(String, String, i64, i64)> = stmt
307            .query_map([], |row| {
308                Ok((
309                    row.get::<_, String>(0)?,
310                    row.get::<_, String>(1)?,
311                    row.get::<_, i64>(2)?,
312                    row.get::<_, i64>(3)?,
313                ))
314            })
315            .inspect_err(|e| tracing::warn!("failed to query tool experience: {e}"))
316            .ok()?
317            .filter_map(|r| {
318                r.inspect_err(|e| tracing::warn!("skipping corrupted tool experience row: {e}"))
319                    .ok()
320            })
321            .collect();
322
323        if rows.is_empty() {
324            return None;
325        }
326
327        let mut text = String::from("[Tool Experience]\n");
328        let mut used = estimate_tokens(&text);
329
330        for (name, _steps, successes, failures) in &rows {
331            let total = *successes + *failures;
332            let rate = if total > 0 {
333                (*successes as f64 / total as f64 * 100.0) as u32
334            } else {
335                0
336            };
337            let line = format!("- {name}: {successes}/{total} success ({rate}%)\n");
338            let line_tokens = estimate_tokens(&line);
339            if used + line_tokens > budget_tokens {
340                break;
341            }
342            text.push_str(&line);
343            used += line_tokens;
344        }
345
346        if text.len() > "[Tool Experience]\n".len() {
347            Some(text)
348        } else {
349            None
350        }
351    }
352
353    fn retrieve_relationships(
354        &self,
355        db: &Database,
356        query: &str,
357        budget_tokens: usize,
358    ) -> Option<String> {
359        if budget_tokens == 0 {
360            return None;
361        }
362
363        let conn = db.conn();
364        let mut stmt = conn
365            .prepare(
366                "SELECT entity_id, entity_name, trust_score, interaction_count \
367                 FROM relationship_memory ORDER BY interaction_count DESC LIMIT 5",
368            )
369            .ok()?;
370
371        let rows: Vec<(String, Option<String>, f64, i64)> = stmt
372            .query_map([], |row| {
373                Ok((
374                    row.get::<_, String>(0)?,
375                    row.get::<_, Option<String>>(1)?,
376                    row.get::<_, f64>(2)?,
377                    row.get::<_, i64>(3)?,
378                ))
379            })
380            .inspect_err(|e| tracing::warn!("failed to query relationship memory: {e}"))
381            .ok()?
382            .filter_map(|r| {
383                r.inspect_err(|e| tracing::warn!("skipping corrupted relationship row: {e}"))
384                    .ok()
385            })
386            .collect();
387
388        if rows.is_empty() {
389            return None;
390        }
391
392        // Only include entities that might be relevant: name appears in query, or high interaction count
393        let query_lower = query.to_lowercase();
394        let relevant: Vec<_> = rows
395            .into_iter()
396            .filter(|(id, name, _, count)| {
397                *count > 2
398                    || query_lower.contains(&id.to_lowercase())
399                    || name
400                        .as_ref()
401                        .is_some_and(|n| query_lower.contains(&n.to_lowercase()))
402            })
403            .collect();
404
405        if relevant.is_empty() {
406            return None;
407        }
408
409        let mut text = String::from("[Known Entities]\n");
410        let mut used = estimate_tokens(&text);
411
412        for (entity_id, name, trust, count) in &relevant {
413            let display = name.as_deref().unwrap_or(entity_id);
414            let line = format!("- {display}: trust={trust:.1}, interactions={count}\n");
415            let line_tokens = estimate_tokens(&line);
416            if used + line_tokens > budget_tokens {
417                break;
418            }
419            text.push_str(&line);
420            used += line_tokens;
421        }
422
423        if text.len() > "[Known Entities]\n".len() {
424            Some(text)
425        } else {
426            None
427        }
428    }
429}
430
431fn estimate_tokens(text: &str) -> usize {
432    text.len().div_ceil(4)
433}
434
435// ── Content chunking ────────────────────────────────────────────
436
437pub struct ChunkConfig {
438    pub max_tokens: usize,
439    pub overlap_tokens: usize,
440}
441
442impl Default for ChunkConfig {
443    fn default() -> Self {
444        Self {
445            max_tokens: 512,
446            overlap_tokens: 64,
447        }
448    }
449}
450
451pub struct Chunk {
452    pub text: String,
453    pub index: usize,
454    pub start_char: usize,
455    pub end_char: usize,
456}
457
458/// Snap a byte offset to the nearest char boundary at or before `pos`.
459fn floor_char_boundary(text: &str, pos: usize) -> usize {
460    if pos >= text.len() {
461        return text.len();
462    }
463    let mut p = pos;
464    while p > 0 && !text.is_char_boundary(p) {
465        p -= 1;
466    }
467    p
468}
469
470/// Split text into overlapping chunks for embedding.
471pub fn chunk_text(text: &str, config: &ChunkConfig) -> Vec<Chunk> {
472    if text.is_empty() || config.max_tokens == 0 {
473        return Vec::new();
474    }
475
476    let max_bytes = config.max_tokens * 4;
477    let overlap_bytes = config.overlap_tokens * 4;
478
479    if text.len() <= max_bytes {
480        return vec![Chunk {
481            text: text.to_string(),
482            index: 0,
483            start_char: 0,
484            end_char: text.len(),
485        }];
486    }
487
488    let step = max_bytes.saturating_sub(overlap_bytes).max(1);
489    let mut chunks = Vec::new();
490    let mut start = 0;
491
492    while start < text.len() {
493        let raw_end = floor_char_boundary(text, (start + max_bytes).min(text.len()));
494
495        let end = find_break_point(text, start, raw_end);
496
497        chunks.push(Chunk {
498            text: text[start..end].to_string(),
499            index: chunks.len(),
500            start_char: start,
501            end_char: end,
502        });
503
504        if end >= text.len() {
505            break;
506        }
507
508        let advance = step.min(end - start).max(1);
509        start = floor_char_boundary(text, start + advance);
510    }
511
512    chunks
513}
514
515fn find_break_point(text: &str, start: usize, raw_end: usize) -> usize {
516    if raw_end >= text.len() {
517        return text.len();
518    }
519
520    let search_start = floor_char_boundary(text, start + (raw_end - start) / 2);
521    let window = &text[search_start..raw_end];
522
523    if let Some(pos) = window.rfind("\n\n") {
524        return search_start + pos + 2;
525    }
526    for delim in [". ", ".\n", "? ", "! "] {
527        if let Some(pos) = window.rfind(delim) {
528            return search_start + pos + delim.len();
529        }
530    }
531    if let Some(pos) = window.rfind(' ') {
532        return search_start + pos + 1;
533    }
534
535    raw_end
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    fn test_db() -> Database {
543        Database::new(":memory:").unwrap()
544    }
545
546    fn default_config() -> MemoryConfig {
547        MemoryConfig::default()
548    }
549
550    #[test]
551    fn retriever_empty_db_returns_empty() {
552        let db = test_db();
553        let retriever = MemoryRetriever::new(default_config());
554        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
555        let result = retriever.retrieve(&db, &session_id, "hello", None, ComplexityLevel::L1);
556        assert!(result.is_empty());
557    }
558
559    #[test]
560    fn retriever_returns_working_memory() {
561        let db = test_db();
562        let retriever = MemoryRetriever::new(default_config());
563        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
564
565        roboticus_db::memory::store_working(&db, &session_id, "goal", "find documentation", 8)
566            .unwrap();
567
568        let result = retriever.retrieve(&db, &session_id, "hello", None, ComplexityLevel::L2);
569        assert!(result.contains("Working Memory"));
570        assert!(result.contains("find documentation"));
571    }
572
573    #[test]
574    fn retriever_skips_turn_summary_working_entries() {
575        let db = test_db();
576        let retriever = MemoryRetriever::new(default_config());
577        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
578
579        roboticus_db::memory::store_working(
580            &db,
581            &session_id,
582            "turn_summary",
583            "Good to be back on familiar ground.",
584            9,
585        )
586        .unwrap();
587        roboticus_db::memory::store_working(&db, &session_id, "goal", "fix Telegram loop", 8)
588            .unwrap();
589
590        let result = retriever.retrieve(&db, &session_id, "telegram", None, ComplexityLevel::L2);
591        assert!(result.contains("Working Memory"));
592        assert!(result.contains("fix Telegram loop"));
593        assert!(!result.contains("Good to be back on familiar ground."));
594    }
595
596    #[test]
597    fn retriever_returns_relevant_memories() {
598        let db = test_db();
599        let retriever = MemoryRetriever::new(default_config());
600        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
601
602        roboticus_db::memory::store_semantic(&db, "facts", "sky", "the sky is blue", 0.9).unwrap();
603
604        let result = retriever.retrieve(&db, &session_id, "sky", None, ComplexityLevel::L2);
605        assert!(result.contains("Active Memory"));
606    }
607
608    #[test]
609    fn retriever_returns_procedural_experience() {
610        let db = test_db();
611        let retriever = MemoryRetriever::new(default_config());
612        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
613
614        roboticus_db::memory::store_procedural(&db, "web_search", "search the web").unwrap();
615        roboticus_db::memory::record_procedural_success(&db, "web_search").unwrap();
616        roboticus_db::memory::record_procedural_success(&db, "web_search").unwrap();
617
618        let result = retriever.retrieve(&db, &session_id, "search", None, ComplexityLevel::L2);
619        assert!(result.contains("Tool Experience"));
620        assert!(result.contains("web_search"));
621    }
622
623    #[test]
624    fn retriever_returns_relationships() {
625        let db = test_db();
626        let retriever = MemoryRetriever::new(default_config());
627        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
628
629        roboticus_db::memory::store_relationship(&db, "user-1", "Jon", 0.9).unwrap();
630        // Need > 2 interactions or name in query
631        let result = retriever.retrieve(&db, &session_id, "Jon", None, ComplexityLevel::L2);
632        assert!(result.contains("Known Entities") || result.contains("Jon"));
633    }
634
635    #[test]
636    fn retriever_respects_zero_budget() {
637        let config = MemoryConfig {
638            working_budget_pct: 0.0,
639            episodic_budget_pct: 0.0,
640            semantic_budget_pct: 0.0,
641            procedural_budget_pct: 0.0,
642            relationship_budget_pct: 100.0,
643            ..default_config()
644        };
645        let db = test_db();
646        let retriever = MemoryRetriever::new(config);
647        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
648
649        roboticus_db::memory::store_working(&db, &session_id, "goal", "test", 5).unwrap();
650
651        let result = retriever.retrieve(&db, &session_id, "test", None, ComplexityLevel::L0);
652        assert!(!result.contains("Working Memory"));
653    }
654
655    // ── Chunker tests ───────────────────────────────────────────
656
657    #[test]
658    fn chunk_empty_text() {
659        let chunks = chunk_text("", &ChunkConfig::default());
660        assert!(chunks.is_empty());
661    }
662
663    #[test]
664    fn chunk_short_text() {
665        let text = "This is a short sentence.";
666        let chunks = chunk_text(text, &ChunkConfig::default());
667        assert_eq!(chunks.len(), 1);
668        assert_eq!(chunks[0].text, text);
669        assert_eq!(chunks[0].index, 0);
670    }
671
672    #[test]
673    fn chunk_long_text_produces_overlapping_chunks() {
674        let text = "word ".repeat(1000);
675        let config = ChunkConfig {
676            max_tokens: 50,
677            overlap_tokens: 10,
678        };
679        let chunks = chunk_text(&text, &config);
680        assert!(chunks.len() > 1);
681
682        for (i, chunk) in chunks.iter().enumerate() {
683            assert_eq!(chunk.index, i);
684            assert!(!chunk.text.is_empty());
685        }
686
687        // Verify continuity: each chunk's start is before the previous chunk's end
688        for i in 1..chunks.len() {
689            assert!(chunks[i].start_char < chunks[i - 1].end_char);
690        }
691    }
692
693    #[test]
694    fn chunk_respects_sentence_boundaries() {
695        let text = "First sentence. Second sentence. Third sentence. Fourth sentence. Fifth sentence. \
696                    Sixth sentence. Seventh sentence. Eighth sentence. Ninth sentence. Tenth sentence.";
697        let config = ChunkConfig {
698            max_tokens: 20,
699            overlap_tokens: 5,
700        };
701        let chunks = chunk_text(text, &config);
702        // Chunks should end at sentence boundaries when possible
703        for chunk in &chunks {
704            if chunk.end_char < text.len() {
705                let ends_at_boundary = chunk.text.ends_with(". ")
706                    || chunk.text.ends_with('.')
707                    || chunk.text.ends_with(' ');
708                assert!(
709                    ends_at_boundary,
710                    "chunk should end at a boundary: {:?}",
711                    &chunk.text[chunk.text.len().saturating_sub(10)..]
712                );
713            }
714        }
715    }
716
717    #[test]
718    fn chunk_covers_full_text() {
719        let text = "a ".repeat(500);
720        let config = ChunkConfig {
721            max_tokens: 25,
722            overlap_tokens: 5,
723        };
724        let chunks = chunk_text(&text, &config);
725
726        assert_eq!(chunks.first().unwrap().start_char, 0);
727        assert_eq!(chunks.last().unwrap().end_char, text.len());
728    }
729
730    #[test]
731    fn chunk_zero_max_tokens() {
732        let chunks = chunk_text(
733            "some text",
734            &ChunkConfig {
735                max_tokens: 0,
736                overlap_tokens: 0,
737            },
738        );
739        assert!(chunks.is_empty());
740    }
741
742    #[test]
743    fn estimate_tokens_basic() {
744        assert_eq!(estimate_tokens(""), 0);
745        assert_eq!(estimate_tokens("abcd"), 1);
746        assert_eq!(estimate_tokens("hello world!"), 3);
747    }
748
749    #[test]
750    fn chunk_multibyte_does_not_panic() {
751        let text = "Hello \u{1F600} world. ".repeat(200);
752        let config = ChunkConfig {
753            max_tokens: 20,
754            overlap_tokens: 5,
755        };
756        let chunks = chunk_text(&text, &config);
757        assert!(chunks.len() > 1);
758        for chunk in &chunks {
759            assert!(!chunk.text.is_empty());
760            // Verify each chunk is valid UTF-8 (would panic on slice if not)
761            let _ = chunk.text.as_bytes();
762        }
763    }
764
765    #[test]
766    fn chunk_cjk_text() {
767        let text = "\u{4F60}\u{597D}\u{4E16}\u{754C} ".repeat(300);
768        let config = ChunkConfig {
769            max_tokens: 15,
770            overlap_tokens: 3,
771        };
772        let chunks = chunk_text(&text, &config);
773        assert!(chunks.len() > 1);
774        assert_eq!(chunks.first().unwrap().start_char, 0);
775        assert_eq!(chunks.last().unwrap().end_char, text.len());
776    }
777
778    #[test]
779    fn floor_char_boundary_ascii() {
780        let text = "hello world";
781        assert_eq!(floor_char_boundary(text, 5), 5);
782        assert_eq!(floor_char_boundary(text, 0), 0);
783        assert_eq!(floor_char_boundary(text, 100), text.len());
784    }
785
786    #[test]
787    fn floor_char_boundary_multibyte() {
788        // "café" = c(1) a(1) f(1) é(2) = 5 bytes total
789        let text = "caf\u{00E9}";
790        assert_eq!(text.len(), 5);
791        // Position 4 is inside the 2-byte é, should snap back to 3
792        assert_eq!(floor_char_boundary(text, 4), 3);
793        // Position 3 is a valid boundary (start of é)
794        assert_eq!(floor_char_boundary(text, 3), 3);
795        // Position 5 >= len, returns len
796        assert_eq!(floor_char_boundary(text, 5), 5);
797    }
798
799    #[test]
800    fn floor_char_boundary_emoji() {
801        let text = "a\u{1F600}b"; // a(1) + emoji(4) + b(1) = 6 bytes
802        assert_eq!(text.len(), 6);
803        // Position 2 is inside the emoji
804        assert_eq!(floor_char_boundary(text, 2), 1);
805        // Position 5 is the start of 'b'
806        assert_eq!(floor_char_boundary(text, 5), 5);
807    }
808
809    #[test]
810    fn estimate_tokens_rounding() {
811        // div_ceil(1, 4) = 1
812        assert_eq!(estimate_tokens("a"), 1);
813        // div_ceil(5, 4) = 2
814        assert_eq!(estimate_tokens("abcde"), 2);
815        // div_ceil(8, 4) = 2
816        assert_eq!(estimate_tokens("abcdefgh"), 2);
817    }
818
819    #[test]
820    fn retriever_with_procedural_no_history() {
821        // Procedural with no success/failure counts should return None
822        let db = test_db();
823        let retriever = MemoryRetriever::new(default_config());
824        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
825
826        roboticus_db::memory::store_procedural(&db, "unused_tool", "a tool").unwrap();
827
828        let result = retriever.retrieve(&db, &session_id, "test", None, ComplexityLevel::L2);
829        assert!(
830            !result.contains("Tool Experience"),
831            "tools with no success/failure should not appear"
832        );
833    }
834
835    #[test]
836    fn chunk_with_paragraph_breaks() {
837        let text = "Paragraph one content.\n\nParagraph two content.\n\nParagraph three content.\n\n\
838                    Paragraph four content.\n\nParagraph five content.";
839        let config = ChunkConfig {
840            max_tokens: 15,
841            overlap_tokens: 3,
842        };
843        let chunks = chunk_text(text, &config);
844        // Should prefer breaking at paragraph boundaries
845        for chunk in &chunks {
846            if chunk.end_char < text.len() {
847                // Many chunks should end at paragraph breaks
848                let last_few = &chunk.text[chunk.text.len().saturating_sub(5)..];
849                let has_good_break =
850                    last_few.contains('\n') || last_few.contains(". ") || last_few.ends_with(' ');
851                assert!(has_good_break, "chunk should end at a reasonable boundary");
852            }
853        }
854    }
855
856    #[test]
857    fn chunk_config_default() {
858        let config = ChunkConfig::default();
859        assert_eq!(config.max_tokens, 512);
860        assert_eq!(config.overlap_tokens, 64);
861    }
862
863    #[test]
864    fn find_break_point_at_end_of_text() {
865        let text = "Hello world.";
866        assert_eq!(find_break_point(text, 0, text.len()), text.len());
867    }
868
869    #[test]
870    fn retriever_relationships_high_interaction_count() {
871        let db = test_db();
872        let retriever = MemoryRetriever::new(default_config());
873        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
874
875        // store_relationship uses ON CONFLICT to increment interaction_count
876        // Calling it 4 times gives interaction_count > 2
877        for _ in 0..4 {
878            roboticus_db::memory::store_relationship(&db, "alice", "Alice Smith", 0.8).unwrap();
879        }
880
881        // Query that doesn't contain "alice" but high interaction count should still include it
882        let result = retriever.retrieve(
883            &db,
884            &session_id,
885            "some random query",
886            None,
887            ComplexityLevel::L2,
888        );
889        assert!(
890            result.contains("Known Entities") && result.contains("Alice Smith"),
891            "high interaction count entity should appear in results"
892        );
893    }
894}