1use roboticus_core::config::MemoryConfig;
2use roboticus_db::Database;
3
4use crate::context::{ComplexityLevel, token_budget};
5use crate::memory::MemoryBudgetManager;
6
7pub struct MemoryRetriever {
9 budget_manager: MemoryBudgetManager,
10 hybrid_weight: f64,
11 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, }
25 }
26
27 pub fn with_decay_half_life(mut self, days: f64) -> Self {
29 self.decay_half_life_days = days;
30 self
31 }
32
33 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 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 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 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 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 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 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 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 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 }; for result in results.iter_mut() {
264 if result.source_table != "episodic_memory" {
265 continue;
266 }
267 if result.source_id.is_empty() {
268 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 let clamped = decay_factor.max(0.05);
279 result.similarity *= clamped;
280 }
281 }
282
283 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 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 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
435pub 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
458fn 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
470pub 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 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 #[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 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 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 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 let text = "caf\u{00E9}";
790 assert_eq!(text.len(), 5);
791 assert_eq!(floor_char_boundary(text, 4), 3);
793 assert_eq!(floor_char_boundary(text, 3), 3);
795 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"; assert_eq!(text.len(), 6);
803 assert_eq!(floor_char_boundary(text, 2), 1);
805 assert_eq!(floor_char_boundary(text, 5), 5);
807 }
808
809 #[test]
810 fn estimate_tokens_rounding() {
811 assert_eq!(estimate_tokens("a"), 1);
813 assert_eq!(estimate_tokens("abcde"), 2);
815 assert_eq!(estimate_tokens("abcdefgh"), 2);
817 }
818
819 #[test]
820 fn retriever_with_procedural_no_history() {
821 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 for chunk in &chunks {
846 if chunk.end_char < text.len() {
847 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 for _ in 0..4 {
878 roboticus_db::memory::store_relationship(&db, "alice", "Alice Smith", 0.8).unwrap();
879 }
880
881 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}