1use chrono::{DateTime, Utc};
32#[cfg(feature = "contradiction")]
33use kronroe::{ConflictPolicy, Contradiction};
34use kronroe::{Fact, FactId, TemporalGraph, Value};
35#[cfg(feature = "hybrid")]
36use kronroe::{HybridScoreBreakdown, HybridSearchParams, TemporalIntent, TemporalOperator};
37use std::collections::HashSet;
38
39pub use kronroe::KronroeError as Error;
40pub type Result<T> = std::result::Result<T, Error>;
41
42#[derive(Debug, Clone, Copy, PartialEq)]
68#[non_exhaustive]
69pub enum RecallScore {
70 #[non_exhaustive]
77 Hybrid {
78 rrf_score: f64,
80 text_contrib: f64,
82 vector_contrib: f64,
84 confidence: f32,
86 effective_confidence: Option<f32>,
89 },
90 #[non_exhaustive]
92 TextOnly {
93 rank: usize,
95 bm25_score: f32,
98 confidence: f32,
100 effective_confidence: Option<f32>,
103 },
104}
105
106impl RecallScore {
107 pub fn display_tag(&self) -> String {
112 match self {
113 RecallScore::Hybrid { rrf_score, .. } => format!("{:.3}", rrf_score),
114 RecallScore::TextOnly {
115 rank, bm25_score, ..
116 } => format!("#{} bm25:{:.2}", rank + 1, bm25_score),
117 }
118 }
119
120 pub fn confidence(&self) -> f32 {
122 match self {
123 RecallScore::Hybrid { confidence, .. } | RecallScore::TextOnly { confidence, .. } => {
124 *confidence
125 }
126 }
127 }
128
129 pub fn effective_confidence(&self) -> Option<f32> {
134 match self {
135 RecallScore::Hybrid {
136 effective_confidence,
137 ..
138 }
139 | RecallScore::TextOnly {
140 effective_confidence,
141 ..
142 } => *effective_confidence,
143 }
144 }
145
146 #[cfg(feature = "hybrid")]
149 fn from_breakdown(
150 b: &HybridScoreBreakdown,
151 confidence: f32,
152 effective_confidence: Option<f32>,
153 ) -> Self {
154 RecallScore::Hybrid {
155 rrf_score: b.final_score,
156 text_contrib: b.text_rrf_contrib,
157 vector_contrib: b.vector_rrf_contrib,
158 confidence,
159 effective_confidence,
160 }
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum ConfidenceFilterMode {
168 Base,
170 #[cfg(feature = "uncertainty")]
175 Effective,
176}
177
178#[derive(Debug, Clone)]
193#[non_exhaustive]
194pub struct RecallOptions<'a> {
195 pub query: &'a str,
197 pub query_embedding: Option<&'a [f32]>,
199 pub limit: usize,
201 pub min_confidence: Option<f32>,
203 pub confidence_filter_mode: ConfidenceFilterMode,
205 pub max_scored_rows: usize,
211 #[cfg(feature = "hybrid")]
216 pub use_hybrid: bool,
217 #[cfg(feature = "hybrid")]
219 pub temporal_intent: TemporalIntent,
220 #[cfg(feature = "hybrid")]
222 pub temporal_operator: TemporalOperator,
223}
224
225const DEFAULT_MAX_SCORED_ROWS: usize = 4_096;
226
227impl<'a> RecallOptions<'a> {
228 pub fn new(query: &'a str) -> Self {
230 Self {
231 query,
232 query_embedding: None,
233 limit: 10,
234 min_confidence: None,
235 confidence_filter_mode: ConfidenceFilterMode::Base,
236 max_scored_rows: DEFAULT_MAX_SCORED_ROWS,
237 #[cfg(feature = "hybrid")]
238 use_hybrid: false,
239 #[cfg(feature = "hybrid")]
240 temporal_intent: TemporalIntent::Timeless,
241 #[cfg(feature = "hybrid")]
242 temporal_operator: TemporalOperator::Current,
243 }
244 }
245
246 pub fn with_embedding(mut self, embedding: &'a [f32]) -> Self {
248 self.query_embedding = Some(embedding);
249 self
250 }
251
252 pub fn with_limit(mut self, limit: usize) -> Self {
254 self.limit = limit;
255 self
256 }
257
258 pub fn with_min_confidence(mut self, min: f32) -> Self {
260 self.min_confidence = Some(min);
261 self.confidence_filter_mode = ConfidenceFilterMode::Base;
262 self
263 }
264
265 #[cfg(feature = "uncertainty")]
272 pub fn with_min_effective_confidence(mut self, min: f32) -> Self {
273 self.min_confidence = Some(min);
274 self.confidence_filter_mode = ConfidenceFilterMode::Effective;
275 self
276 }
277
278 pub fn with_max_scored_rows(mut self, max_scored_rows: usize) -> Self {
283 self.max_scored_rows = max_scored_rows;
284 self
285 }
286
287 #[cfg(feature = "hybrid")]
293 pub fn with_hybrid(mut self, enabled: bool) -> Self {
294 self.use_hybrid = enabled;
295 self
296 }
297
298 #[cfg(feature = "hybrid")]
302 pub fn with_temporal_intent(mut self, intent: TemporalIntent) -> Self {
303 self.temporal_intent = intent;
304 self
305 }
306
307 #[cfg(feature = "hybrid")]
311 pub fn with_temporal_operator(mut self, operator: TemporalOperator) -> Self {
312 self.temporal_operator = operator;
313 self
314 }
315}
316
317fn normalize_min_confidence(min_confidence: f32) -> Result<f32> {
318 if !min_confidence.is_finite() {
319 return Err(Error::Search(format!(
320 "minimum confidence must be a finite number in [0.0, 1.0], got {min_confidence}"
321 )));
322 }
323
324 Ok(min_confidence.clamp(0.0, 1.0))
325}
326
327fn normalize_fact_confidence(confidence: f32) -> Result<f32> {
328 if !confidence.is_finite() {
329 return Err(Error::Search(
330 "fact confidence must be finite and in [0.0, 1.0], got non-finite value".to_string(),
331 ));
332 }
333 Ok(confidence.clamp(0.0, 1.0))
334}
335
336pub struct AgentMemory {
341 graph: TemporalGraph,
342}
343
344#[derive(Debug, Clone)]
345pub struct AssertParams {
346 pub valid_from: DateTime<Utc>,
347}
348
349impl AgentMemory {
350 pub fn open(path: &str) -> Result<Self> {
357 let graph = TemporalGraph::open(path)?;
358 #[cfg(feature = "contradiction")]
359 Self::register_default_singletons(&graph)?;
360 #[cfg(feature = "uncertainty")]
361 Self::register_default_volatilities(&graph)?;
362 Ok(Self { graph })
363 }
364
365 pub fn open_in_memory() -> Result<Self> {
369 let graph = TemporalGraph::open_in_memory()?;
370 #[cfg(feature = "contradiction")]
371 Self::register_default_singletons(&graph)?;
372 #[cfg(feature = "uncertainty")]
373 Self::register_default_volatilities(&graph)?;
374 Ok(Self { graph })
375 }
376
377 pub fn assert(
382 &self,
383 subject: &str,
384 predicate: &str,
385 object: impl Into<Value>,
386 ) -> Result<FactId> {
387 self.graph
388 .assert_fact(subject, predicate, object, Utc::now())
389 }
390
391 pub fn assert_idempotent(
396 &self,
397 idempotency_key: &str,
398 subject: &str,
399 predicate: &str,
400 object: impl Into<Value>,
401 ) -> Result<FactId> {
402 self.graph
403 .assert_fact_idempotent(idempotency_key, subject, predicate, object, Utc::now())
404 }
405
406 pub fn assert_idempotent_with_params(
408 &self,
409 idempotency_key: &str,
410 subject: &str,
411 predicate: &str,
412 object: impl Into<Value>,
413 params: AssertParams,
414 ) -> Result<FactId> {
415 self.graph.assert_fact_idempotent(
416 idempotency_key,
417 subject,
418 predicate,
419 object,
420 params.valid_from,
421 )
422 }
423
424 pub fn assert_with_params(
426 &self,
427 subject: &str,
428 predicate: &str,
429 object: impl Into<Value>,
430 params: AssertParams,
431 ) -> Result<FactId> {
432 self.graph
433 .assert_fact(subject, predicate, object, params.valid_from)
434 }
435
436 pub fn facts_about(&self, entity: &str) -> Result<Vec<Fact>> {
438 self.graph.all_facts_about(entity)
439 }
440
441 pub fn facts_about_at(
443 &self,
444 entity: &str,
445 predicate: &str,
446 at: DateTime<Utc>,
447 ) -> Result<Vec<Fact>> {
448 self.graph.facts_at(entity, predicate, at)
449 }
450
451 pub fn current_facts(&self, entity: &str, predicate: &str) -> Result<Vec<Fact>> {
453 self.graph.current_facts(entity, predicate)
454 }
455
456 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<Fact>> {
460 self.graph.search(query, limit)
461 }
462
463 pub fn correct_fact(&self, fact_id: &FactId, new_value: impl Into<Value>) -> Result<FactId> {
465 self.graph.correct_fact(fact_id, new_value, Utc::now())
466 }
467
468 pub fn invalidate_fact(&self, fact_id: &FactId) -> Result<()> {
471 self.graph.invalidate_fact(fact_id, Utc::now())
472 }
473
474 #[cfg(feature = "contradiction")]
486 fn register_default_singletons(graph: &TemporalGraph) -> Result<()> {
487 for predicate in &["works_at", "lives_in", "job_title", "email", "phone"] {
488 if !graph.is_singleton_predicate(predicate)? {
489 graph.register_singleton_predicate(predicate, ConflictPolicy::Warn)?;
490 }
491 }
492 Ok(())
493 }
494
495 #[cfg(feature = "contradiction")]
501 pub fn assert_checked(
502 &self,
503 subject: &str,
504 predicate: &str,
505 object: impl Into<Value>,
506 ) -> Result<(FactId, Vec<Contradiction>)> {
507 self.graph
508 .assert_fact_checked(subject, predicate, object, Utc::now())
509 }
510
511 #[cfg(feature = "contradiction")]
516 pub fn audit(&self, subject: &str) -> Result<Vec<Contradiction>> {
517 let singleton_preds = self.graph.singleton_predicates()?;
518 let mut contradictions = Vec::new();
519 for predicate in &singleton_preds {
520 contradictions.extend(self.graph.detect_contradictions(subject, predicate)?);
521 }
522 Ok(contradictions)
523 }
524
525 pub fn remember(
529 &self,
530 text: &str,
531 episode_id: &str,
532 #[cfg(feature = "hybrid")] embedding: Option<Vec<f32>>,
533 #[cfg(not(feature = "hybrid"))] _embedding: Option<Vec<f32>>,
534 ) -> Result<FactId> {
535 #[cfg(feature = "hybrid")]
536 if let Some(emb) = embedding {
537 return self.graph.assert_fact_with_embedding(
538 episode_id,
539 "memory",
540 text.to_string(),
541 Utc::now(),
542 emb,
543 );
544 }
545
546 self.graph
547 .assert_fact(episode_id, "memory", text.to_string(), Utc::now())
548 }
549
550 pub fn remember_idempotent(
554 &self,
555 idempotency_key: &str,
556 text: &str,
557 episode_id: &str,
558 ) -> Result<FactId> {
559 self.graph.assert_fact_idempotent(
560 idempotency_key,
561 episode_id,
562 "memory",
563 text.to_string(),
564 Utc::now(),
565 )
566 }
567
568 pub fn recall(
574 &self,
575 query: &str,
576 query_embedding: Option<&[f32]>,
577 limit: usize,
578 ) -> Result<Vec<Fact>> {
579 self.recall_scored(query, query_embedding, limit)
580 .map(|scored| scored.into_iter().map(|(fact, _)| fact).collect())
581 }
582
583 pub fn recall_with_min_confidence(
602 &self,
603 query: &str,
604 #[cfg(feature = "hybrid")] query_embedding: Option<&[f32]>,
605 #[cfg(not(feature = "hybrid"))] _query_embedding: Option<&[f32]>,
606 limit: usize,
607 min_confidence: f32,
608 ) -> Result<Vec<Fact>> {
609 let opts = RecallOptions::new(query)
610 .with_limit(limit)
611 .with_min_confidence(min_confidence);
612
613 #[cfg(feature = "hybrid")]
614 let opts = if let Some(embedding) = query_embedding {
615 opts.with_embedding(embedding).with_hybrid(true)
616 } else {
617 opts
618 };
619
620 self.recall_with_options(&opts)
621 }
622
623 pub fn recall_scored(
636 &self,
637 query: &str,
638 #[cfg(feature = "hybrid")] query_embedding: Option<&[f32]>,
639 #[cfg(not(feature = "hybrid"))] _query_embedding: Option<&[f32]>,
640 limit: usize,
641 ) -> Result<Vec<(Fact, RecallScore)>> {
642 #[cfg(feature = "hybrid")]
643 let mut opts = RecallOptions::new(query).with_limit(limit);
644 #[cfg(not(feature = "hybrid"))]
645 let opts = RecallOptions::new(query).with_limit(limit);
646 #[cfg(feature = "hybrid")]
647 if let Some(embedding) = query_embedding {
648 opts = opts
649 .with_embedding(embedding)
650 .with_hybrid(true)
651 .with_temporal_intent(TemporalIntent::Timeless)
652 .with_temporal_operator(TemporalOperator::Current);
653 }
654 self.recall_scored_with_options(&opts)
655 }
656
657 #[cfg(feature = "hybrid")]
658 fn recall_scored_internal(
659 &self,
660 query: &str,
661 query_embedding: Option<&[f32]>,
662 limit: usize,
663 intent: TemporalIntent,
664 operator: TemporalOperator,
665 ) -> Result<Vec<(Fact, RecallScore)>> {
666 if let Some(emb) = query_embedding {
667 let params = HybridSearchParams {
668 k: limit,
669 intent,
670 operator,
671 ..HybridSearchParams::default()
672 };
673 let hits = self.graph.search_hybrid(query, emb, params, None)?;
674 let mut scored = Vec::with_capacity(hits.len());
675 for (fact, breakdown) in hits {
676 if !fact.is_currently_valid() {
677 continue;
678 }
679 let confidence = fact.confidence;
680 let eff = self.compute_effective_confidence(&fact)?;
681 scored.push((
682 fact,
683 RecallScore::from_breakdown(&breakdown, confidence, eff),
684 ));
685 }
686 return Ok(scored);
687 }
688
689 let scored_facts = self.graph.search_scored(query, limit)?;
690 let mut scored = Vec::with_capacity(scored_facts.len());
691 for (i, (fact, bm25)) in scored_facts.into_iter().enumerate() {
692 if !fact.is_currently_valid() {
693 continue;
694 }
695 let confidence = fact.confidence;
696 let eff = self.compute_effective_confidence(&fact)?;
697 scored.push((
698 fact,
699 RecallScore::TextOnly {
700 rank: i,
701 bm25_score: bm25,
702 confidence,
703 effective_confidence: eff,
704 },
705 ));
706 }
707 Ok(scored)
708 }
709
710 #[cfg(not(feature = "hybrid"))]
711 fn recall_scored_internal(
712 &self,
713 query: &str,
714 _query_embedding: Option<&[f32]>,
715 limit: usize,
716 _intent: (),
717 _operator: (),
718 ) -> Result<Vec<(Fact, RecallScore)>> {
719 let scored_facts = self.graph.search_scored(query, limit)?;
720 let mut scored = Vec::with_capacity(scored_facts.len());
721 for (i, (fact, bm25)) in scored_facts.into_iter().enumerate() {
722 if !fact.is_currently_valid() {
723 continue;
724 }
725 let confidence = fact.confidence;
726 let eff = self.compute_effective_confidence(&fact)?;
727 scored.push((
728 fact,
729 RecallScore::TextOnly {
730 rank: i,
731 bm25_score: bm25,
732 confidence,
733 effective_confidence: eff,
734 },
735 ));
736 }
737 Ok(scored)
738 }
739
740 pub fn recall_scored_with_min_confidence(
760 &self,
761 query: &str,
762 #[cfg(feature = "hybrid")] query_embedding: Option<&[f32]>,
763 #[cfg(not(feature = "hybrid"))] _query_embedding: Option<&[f32]>,
764 limit: usize,
765 min_confidence: f32,
766 ) -> Result<Vec<(Fact, RecallScore)>> {
767 let opts = RecallOptions::new(query)
768 .with_limit(limit)
769 .with_min_confidence(min_confidence);
770
771 #[cfg(feature = "hybrid")]
772 let opts = if let Some(embedding) = query_embedding {
773 opts.with_embedding(embedding).with_hybrid(true)
774 } else {
775 opts
776 };
777
778 self.recall_scored_with_options(&opts)
779 }
780
781 #[cfg(feature = "uncertainty")]
788 pub fn recall_scored_with_min_effective_confidence(
789 &self,
790 query: &str,
791 #[cfg(feature = "hybrid")] query_embedding: Option<&[f32]>,
792 #[cfg(not(feature = "hybrid"))] _query_embedding: Option<&[f32]>,
793 limit: usize,
794 min_effective_confidence: f32,
795 ) -> Result<Vec<(Fact, RecallScore)>> {
796 let opts = RecallOptions::new(query)
797 .with_limit(limit)
798 .with_min_effective_confidence(min_effective_confidence);
799
800 #[cfg(feature = "hybrid")]
801 let opts = if let Some(embedding) = query_embedding {
802 opts.with_embedding(embedding).with_hybrid(true)
803 } else {
804 opts
805 };
806
807 self.recall_scored_with_options(&opts)
808 }
809
810 pub fn assemble_context(
823 &self,
824 query: &str,
825 query_embedding: Option<&[f32]>,
826 max_tokens: usize,
827 ) -> Result<String> {
828 let scored = self.recall_scored(query, query_embedding, 20)?;
829 let char_budget = max_tokens.saturating_mul(4); let mut context = String::new();
831
832 for (fact, score) in &scored {
833 let object = match &fact.object {
834 Value::Text(s) | Value::Entity(s) => s.clone(),
835 Value::Number(n) => n.to_string(),
836 Value::Boolean(b) => b.to_string(),
837 };
838 let conf_tag = if (score.confidence() - 1.0).abs() > f32::EPSILON {
841 format!(" conf:{:.1}", score.confidence())
842 } else {
843 String::new()
844 };
845 let line = format!(
846 "[{}] ({}{}) {} · {} · {}\n",
847 fact.valid_from.format("%Y-%m-%d"),
848 score.display_tag(),
849 conf_tag,
850 fact.subject,
851 fact.predicate,
852 object
853 );
854 if context.len() + line.len() > char_budget {
855 break;
856 }
857 context.push_str(&line);
858 }
859
860 Ok(context)
861 }
862
863 pub fn recall_scored_with_options(
874 &self,
875 opts: &RecallOptions<'_>,
876 ) -> Result<Vec<(Fact, RecallScore)>> {
877 let score_for_filter = |score: &RecallScore| match opts.confidence_filter_mode {
878 ConfidenceFilterMode::Base => score.confidence(),
879 #[cfg(feature = "uncertainty")]
880 ConfidenceFilterMode::Effective => score
881 .effective_confidence()
882 .unwrap_or_else(|| score.confidence()),
883 };
884 #[cfg(feature = "hybrid")]
885 let query_embedding_for_path = if opts.use_hybrid {
886 opts.query_embedding
887 } else {
888 None
889 };
890 #[cfg(not(feature = "hybrid"))]
891 let query_embedding_for_path = None;
892
893 match opts.min_confidence {
894 Some(min_confidence) => {
895 let min_confidence = normalize_min_confidence(min_confidence)?;
896 if opts.limit == 0 {
897 return Ok(Vec::new());
898 }
899 if opts.max_scored_rows == 0 {
900 return Err(Error::Search(
901 "max_scored_rows must be at least 1".to_string(),
902 ));
903 }
904 let max_scored_rows = opts.max_scored_rows;
905 #[cfg(feature = "hybrid")]
906 let is_hybrid_request = query_embedding_for_path.is_some();
907 #[cfg(not(feature = "hybrid"))]
908 let is_hybrid_request = false;
909
910 if is_hybrid_request {
913 let scored = self.recall_scored_internal(
914 opts.query,
915 query_embedding_for_path,
916 max_scored_rows,
917 #[cfg(feature = "hybrid")]
918 opts.temporal_intent,
919 #[cfg(feature = "hybrid")]
920 opts.temporal_operator,
921 #[cfg(not(feature = "hybrid"))]
922 (),
923 #[cfg(not(feature = "hybrid"))]
924 (),
925 )?;
926 let mut filtered = Vec::new();
927
928 for (fact, score) in scored {
929 if score_for_filter(&score) >= min_confidence {
930 filtered.push((fact, score));
931 if filtered.len() >= opts.limit {
932 break;
933 }
934 }
935 }
936
937 return Ok(filtered);
938 }
939
940 let mut filtered = Vec::new();
941 let mut seen_fact_ids: HashSet<String> = HashSet::new();
942 let mut fetch_limit = opts.limit.max(1).min(max_scored_rows);
943 let mut consecutive_no_confidence_batches = 0u8;
944
945 loop {
946 let scored = self.recall_scored_internal(
947 opts.query,
948 query_embedding_for_path,
949 fetch_limit,
950 #[cfg(feature = "hybrid")]
951 opts.temporal_intent,
952 #[cfg(feature = "hybrid")]
953 opts.temporal_operator,
954 #[cfg(not(feature = "hybrid"))]
955 (),
956 #[cfg(not(feature = "hybrid"))]
957 (),
958 )?;
959 let mut newly_seen = 0usize;
960 let mut newly_confident = 0usize;
961
962 if scored.is_empty() {
963 break;
964 }
965
966 for (fact, score) in scored.iter() {
967 if !seen_fact_ids.insert(fact.id.0.clone()) {
968 continue;
969 }
970 newly_seen += 1;
971
972 if score_for_filter(score) >= min_confidence {
973 filtered.push((fact.clone(), *score));
974 newly_confident += 1;
975 if filtered.len() >= opts.limit {
976 return Ok(filtered);
977 }
978 }
979 }
980
981 if newly_seen == 0 || fetch_limit >= max_scored_rows {
982 break;
983 }
984
985 if scored.len() < fetch_limit {
988 break;
989 }
990
991 if newly_confident == 0 {
994 consecutive_no_confidence_batches =
995 consecutive_no_confidence_batches.saturating_add(1);
996 if consecutive_no_confidence_batches >= 2 {
997 fetch_limit = max_scored_rows;
998 continue;
999 }
1000 } else {
1001 consecutive_no_confidence_batches = 0;
1002 }
1003
1004 fetch_limit = (fetch_limit.saturating_mul(2)).min(max_scored_rows);
1005 }
1006
1007 Ok(filtered)
1008 }
1009 None => self.recall_scored_internal(
1010 opts.query,
1011 query_embedding_for_path,
1012 opts.limit,
1013 #[cfg(feature = "hybrid")]
1014 opts.temporal_intent,
1015 #[cfg(feature = "hybrid")]
1016 opts.temporal_operator,
1017 #[cfg(not(feature = "hybrid"))]
1018 (),
1019 #[cfg(not(feature = "hybrid"))]
1020 (),
1021 ),
1022 }
1023 }
1024
1025 pub fn recall_with_options(&self, opts: &RecallOptions<'_>) -> Result<Vec<Fact>> {
1030 self.recall_scored_with_options(opts)
1031 .map(|scored| scored.into_iter().map(|(fact, _)| fact).collect())
1032 }
1033
1034 pub fn assert_with_confidence(
1039 &self,
1040 subject: &str,
1041 predicate: &str,
1042 object: impl Into<Value>,
1043 confidence: f32,
1044 ) -> Result<FactId> {
1045 self.assert_with_confidence_with_params(
1046 subject,
1047 predicate,
1048 object,
1049 AssertParams {
1050 valid_from: Utc::now(),
1051 },
1052 confidence,
1053 )
1054 }
1055
1056 pub fn assert_with_confidence_with_params(
1058 &self,
1059 subject: &str,
1060 predicate: &str,
1061 object: impl Into<Value>,
1062 params: AssertParams,
1063 confidence: f32,
1064 ) -> Result<FactId> {
1065 let confidence = normalize_fact_confidence(confidence)?;
1066 self.graph.assert_fact_with_confidence(
1067 subject,
1068 predicate,
1069 object,
1070 params.valid_from,
1071 confidence,
1072 )
1073 }
1074
1075 pub fn assert_with_source(
1081 &self,
1082 subject: &str,
1083 predicate: &str,
1084 object: impl Into<Value>,
1085 confidence: f32,
1086 source: &str,
1087 ) -> Result<FactId> {
1088 self.assert_with_source_with_params(
1089 subject,
1090 predicate,
1091 object,
1092 AssertParams {
1093 valid_from: Utc::now(),
1094 },
1095 confidence,
1096 source,
1097 )
1098 }
1099
1100 pub fn assert_with_source_with_params(
1102 &self,
1103 subject: &str,
1104 predicate: &str,
1105 object: impl Into<Value>,
1106 params: AssertParams,
1107 confidence: f32,
1108 source: &str,
1109 ) -> Result<FactId> {
1110 let confidence = normalize_fact_confidence(confidence)?;
1111 self.graph.assert_fact_with_source(
1112 subject,
1113 predicate,
1114 object,
1115 params.valid_from,
1116 confidence,
1117 source,
1118 )
1119 }
1120
1121 #[cfg(feature = "uncertainty")]
1129 fn register_default_volatilities(graph: &TemporalGraph) -> Result<()> {
1130 use kronroe::PredicateVolatility;
1131 let defaults = [
1133 ("works_at", PredicateVolatility::new(730.0)),
1134 ("job_title", PredicateVolatility::new(730.0)),
1135 ("lives_in", PredicateVolatility::new(1095.0)),
1136 ("email", PredicateVolatility::new(1460.0)),
1137 ("phone", PredicateVolatility::new(1095.0)),
1138 ("born_in", PredicateVolatility::stable()),
1139 ("full_name", PredicateVolatility::stable()),
1140 ];
1141
1142 for (predicate, volatility) in defaults {
1143 if graph.predicate_volatility(predicate)?.is_none() {
1144 graph.register_predicate_volatility(predicate, volatility)?;
1145 }
1146 }
1147 Ok(())
1148 }
1149
1150 #[cfg(feature = "uncertainty")]
1155 pub fn register_volatility(&self, predicate: &str, half_life_days: f64) -> Result<()> {
1156 use kronroe::PredicateVolatility;
1157 self.graph
1158 .register_predicate_volatility(predicate, PredicateVolatility::new(half_life_days))
1159 }
1160
1161 #[cfg(feature = "uncertainty")]
1166 pub fn register_source_weight(&self, source: &str, weight: f32) -> Result<()> {
1167 use kronroe::SourceWeight;
1168 self.graph
1169 .register_source_weight(source, SourceWeight::new(weight))
1170 }
1171
1172 #[cfg(feature = "uncertainty")]
1177 pub fn effective_confidence_for_fact(
1178 &self,
1179 fact: &Fact,
1180 at: DateTime<Utc>,
1181 ) -> Result<Option<f32>> {
1182 self.graph
1183 .effective_confidence(fact, at)
1184 .map(|eff| Some(eff.value))
1185 }
1186
1187 #[cfg(not(feature = "uncertainty"))]
1192 pub fn effective_confidence_for_fact(
1193 &self,
1194 fact: &Fact,
1195 at: DateTime<Utc>,
1196 ) -> Result<Option<f32>> {
1197 let _ = (fact, at);
1198 Ok(None)
1199 }
1200
1201 fn compute_effective_confidence(&self, fact: &Fact) -> Result<Option<f32>> {
1204 self.effective_confidence_for_fact(fact, Utc::now())
1205 }
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210 use super::*;
1211 use tempfile::NamedTempFile;
1212
1213 fn open_temp_memory() -> (AgentMemory, NamedTempFile) {
1214 let file = NamedTempFile::new().unwrap();
1215 let path = file.path().to_str().unwrap().to_string();
1216 let memory = AgentMemory::open(&path).unwrap();
1217 (memory, file)
1218 }
1219
1220 #[test]
1221 fn assert_and_retrieve() {
1222 let (memory, _tmp) = open_temp_memory();
1223 memory.assert("alice", "works_at", "Acme").unwrap();
1224
1225 let facts = memory.facts_about("alice").unwrap();
1226 assert_eq!(facts.len(), 1);
1227 assert_eq!(facts[0].predicate, "works_at");
1228 }
1229
1230 #[test]
1231 fn multiple_facts_about_entity() {
1232 let (memory, _tmp) = open_temp_memory();
1233
1234 memory
1235 .assert("freya", "attends", "Sunrise Primary")
1236 .unwrap();
1237 memory.assert("freya", "has_ehcp", true).unwrap();
1238 memory.assert("freya", "key_worker", "Sarah Jones").unwrap();
1239
1240 let facts = memory.facts_about("freya").unwrap();
1241 assert_eq!(facts.len(), 3);
1242 }
1243
1244 #[test]
1245 fn test_remember_stores_fact() {
1246 let (mem, _tmp) = open_temp_memory();
1247 let id = mem.remember("Alice loves Rust", "ep-001", None).unwrap();
1248 assert_eq!(id.0.len(), 26);
1249
1250 let facts = mem.facts_about("ep-001").unwrap();
1251 assert_eq!(facts.len(), 1);
1252 assert_eq!(facts[0].subject, "ep-001");
1253 assert_eq!(facts[0].predicate, "memory");
1254 assert!(matches!(&facts[0].object, Value::Text(t) if t == "Alice loves Rust"));
1255 }
1256
1257 #[test]
1258 fn test_assert_idempotent_dedupes_same_key() {
1259 let (mem, _tmp) = open_temp_memory();
1260 let first = mem
1261 .assert_idempotent("evt-1", "alice", "works_at", "Acme")
1262 .unwrap();
1263 let second = mem
1264 .assert_idempotent("evt-1", "alice", "works_at", "Acme")
1265 .unwrap();
1266 assert_eq!(first, second);
1267
1268 let facts = mem.facts_about("alice").unwrap();
1269 assert_eq!(facts.len(), 1);
1270 }
1271
1272 #[test]
1273 fn test_assert_idempotent_with_params_uses_valid_from() {
1274 let (mem, _tmp) = open_temp_memory();
1275 let valid_from = Utc::now() - chrono::Duration::days(10);
1276 let first = mem
1277 .assert_idempotent_with_params(
1278 "evt-param-1",
1279 "alice",
1280 "works_at",
1281 "Acme",
1282 AssertParams { valid_from },
1283 )
1284 .unwrap();
1285 let second = mem
1286 .assert_idempotent_with_params(
1287 "evt-param-1",
1288 "alice",
1289 "works_at",
1290 "Acme",
1291 AssertParams {
1292 valid_from: Utc::now(),
1293 },
1294 )
1295 .unwrap();
1296 assert_eq!(first, second);
1297
1298 let facts = mem.facts_about("alice").unwrap();
1299 assert_eq!(facts.len(), 1);
1300 assert!((facts[0].valid_from - valid_from).num_seconds().abs() < 1);
1301 }
1302
1303 #[test]
1304 fn test_remember_idempotent_dedupes_same_key() {
1305 let (mem, _tmp) = open_temp_memory();
1306 let first = mem
1307 .remember_idempotent("evt-memory-1", "Alice loves Rust", "ep-001")
1308 .unwrap();
1309 let second = mem
1310 .remember_idempotent("evt-memory-1", "Alice loves Rust", "ep-001")
1311 .unwrap();
1312 assert_eq!(first, second);
1313
1314 let facts = mem.facts_about("ep-001").unwrap();
1315 assert_eq!(facts.len(), 1);
1316 }
1317
1318 #[test]
1319 fn test_recall_returns_matching_facts() {
1320 let (mem, _tmp) = open_temp_memory();
1321 mem.remember("Alice loves Rust programming", "ep-001", None)
1322 .unwrap();
1323 mem.remember("Bob prefers Python for data science", "ep-002", None)
1324 .unwrap();
1325
1326 let results = mem.recall("Rust", None, 5).unwrap();
1327 assert!(!results.is_empty(), "should find Rust-related facts");
1328 let has_rust = results
1329 .iter()
1330 .any(|f| matches!(&f.object, Value::Text(t) if t.contains("Rust")));
1331 assert!(has_rust);
1332 }
1333
1334 #[test]
1335 fn test_assemble_context_returns_string() {
1336 let (mem, _tmp) = open_temp_memory();
1337 mem.remember("Alice is a Rust expert", "ep-001", None)
1338 .unwrap();
1339 mem.remember("Bob is a Python expert", "ep-002", None)
1340 .unwrap();
1341
1342 let ctx = mem.assemble_context("expert", None, 500).unwrap();
1343 assert!(!ctx.is_empty(), "context should not be empty");
1344 assert!(
1345 ctx.contains("expert"),
1346 "context should contain relevant facts"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_assemble_context_respects_token_limit() {
1352 let (mem, _tmp) = open_temp_memory();
1353 for i in 0..20 {
1354 mem.remember(
1355 &format!("fact number {} is quite long and wordy", i),
1356 &format!("ep-{}", i),
1357 None,
1358 )
1359 .unwrap();
1360 }
1361 let ctx = mem.assemble_context("fact", None, 50).unwrap();
1362 assert!(ctx.len() <= 220, "context should respect max_tokens");
1363 }
1364
1365 #[cfg(feature = "hybrid")]
1366 #[test]
1367 fn test_remember_with_embedding() {
1368 let (mem, _tmp) = open_temp_memory();
1369 let id = mem
1370 .remember("Bob likes Python", "ep-002", Some(vec![0.1f32, 0.2, 0.3]))
1371 .unwrap();
1372 assert_eq!(id.0.len(), 26);
1373 }
1374
1375 #[cfg(feature = "hybrid")]
1376 #[test]
1377 fn test_recall_with_query_embedding() {
1378 let (mem, _tmp) = open_temp_memory();
1379 mem.remember("Rust systems", "ep-rust", Some(vec![1.0f32, 0.0]))
1380 .unwrap();
1381 mem.remember("Python notebooks", "ep-py", Some(vec![0.0f32, 1.0]))
1382 .unwrap();
1383
1384 let hits = mem.recall("language", Some(&[1.0, 0.0]), 1).unwrap();
1385 assert_eq!(hits.len(), 1);
1386 assert_eq!(hits[0].subject, "ep-rust");
1387 }
1388
1389 #[cfg(feature = "hybrid")]
1390 #[test]
1391 fn recall_with_embedding_without_hybrid_toggle_is_text_scored() {
1392 let (mem, _tmp) = open_temp_memory();
1393 mem.remember("Rust systems", "ep-rust", Some(vec![1.0f32, 0.0]))
1394 .unwrap();
1395 mem.remember("Python notebooks", "ep-py", Some(vec![0.0f32, 1.0]))
1396 .unwrap();
1397
1398 let opts = RecallOptions::new("Rust")
1399 .with_embedding(&[1.0, 0.0])
1400 .with_limit(2);
1401 let results = mem.recall_scored_with_options(&opts).unwrap();
1402 assert!(!results.is_empty());
1403 assert!(matches!(results[0].1, RecallScore::TextOnly { .. }));
1404 }
1405
1406 #[cfg(feature = "contradiction")]
1407 #[test]
1408 fn assert_checked_detects_contradiction() {
1409 let (mem, _tmp) = open_temp_memory();
1410 mem.assert("alice", "works_at", "Acme").unwrap();
1412 let (id, contradictions) = mem
1413 .assert_checked("alice", "works_at", "Beta Corp")
1414 .unwrap();
1415 assert!(!id.0.is_empty());
1416 assert_eq!(contradictions.len(), 1);
1417 assert_eq!(contradictions[0].predicate, "works_at");
1418 }
1419
1420 #[cfg(feature = "contradiction")]
1421 #[test]
1422 fn default_singletons_registered() {
1423 let (mem, _tmp) = open_temp_memory();
1424 mem.assert("bob", "lives_in", "London").unwrap();
1426 let (_, contradictions) = mem.assert_checked("bob", "lives_in", "Paris").unwrap();
1427 assert_eq!(
1428 contradictions.len(),
1429 1,
1430 "lives_in should be a registered singleton"
1431 );
1432 }
1433
1434 #[cfg(feature = "contradiction")]
1435 #[test]
1436 fn audit_returns_contradictions_for_subject() {
1437 let (mem, _tmp) = open_temp_memory();
1438 mem.assert("alice", "works_at", "Acme").unwrap();
1439 mem.assert("alice", "works_at", "Beta").unwrap();
1440 mem.assert("bob", "works_at", "Gamma").unwrap(); let contradictions = mem.audit("alice").unwrap();
1443 assert_eq!(contradictions.len(), 1);
1444 assert_eq!(contradictions[0].subject, "alice");
1445
1446 let bob_contradictions = mem.audit("bob").unwrap();
1447 assert!(bob_contradictions.is_empty());
1448 }
1449
1450 #[cfg(feature = "contradiction")]
1451 #[test]
1452 fn reject_policy_survives_reopen() {
1453 let file = NamedTempFile::new().unwrap();
1455 let path = file.path().to_str().unwrap().to_string();
1456
1457 {
1459 let graph = kronroe::TemporalGraph::open(&path).unwrap();
1460 graph
1461 .register_singleton_predicate("works_at", ConflictPolicy::Reject)
1462 .unwrap();
1463 graph
1464 .assert_fact("alice", "works_at", "Acme", Utc::now())
1465 .unwrap();
1466 }
1467
1468 let mem = AgentMemory::open(&path).unwrap();
1470 let result = mem.assert_checked("alice", "works_at", "Beta Corp");
1471 assert!(
1472 result.is_err(),
1473 "Reject policy should survive AgentMemory::open() reopen"
1474 );
1475 }
1476
1477 #[cfg(feature = "uncertainty")]
1478 #[test]
1479 fn default_volatility_registration_preserves_custom_entry() {
1480 let file = NamedTempFile::new().unwrap();
1481 let path = file.path().to_str().unwrap().to_string();
1482
1483 {
1484 let graph = kronroe::TemporalGraph::open(&path).unwrap();
1485 graph
1486 .register_predicate_volatility("works_at", kronroe::PredicateVolatility::new(1.0))
1487 .unwrap();
1488 }
1489
1490 {
1493 let _mem = AgentMemory::open(&path).unwrap();
1494 }
1495
1496 let graph = kronroe::TemporalGraph::open(&path).unwrap();
1497 let vol = graph
1498 .predicate_volatility("works_at")
1499 .unwrap()
1500 .expect("volatility should be persisted");
1501
1502 assert!(
1503 (vol.half_life_days - 1.0).abs() < f64::EPSILON,
1504 "custom volatility should survive default bootstrap, got {}",
1505 vol.half_life_days
1506 );
1507 }
1508
1509 #[cfg(feature = "hybrid")]
1510 #[test]
1511 fn test_recall_hybrid_uses_text_and_vector_signals() {
1512 let (mem, _tmp) = open_temp_memory();
1513 mem.remember("rare-rust-token", "ep-rust", Some(vec![1.0f32, 0.0]))
1514 .unwrap();
1515 mem.remember("completely different", "ep-py", Some(vec![0.0f32, 1.0]))
1516 .unwrap();
1517
1518 let hits = mem.recall("rare-rust-token", Some(&[0.0, 1.0]), 1).unwrap();
1522 assert_eq!(hits.len(), 1);
1523 assert_eq!(hits[0].subject, "ep-rust");
1524 }
1525
1526 #[test]
1531 fn recall_scored_text_only_returns_ranks_and_bm25() {
1532 let (mem, _tmp) = open_temp_memory();
1533 mem.remember("Alice loves Rust programming", "ep-001", None)
1534 .unwrap();
1535 mem.remember("Bob also enjoys Rust deeply", "ep-002", None)
1536 .unwrap();
1537
1538 let scored = mem.recall_scored("Rust", None, 5).unwrap();
1539 assert!(!scored.is_empty(), "should find Rust-related facts");
1540
1541 for (i, (_fact, score)) in scored.iter().enumerate() {
1543 match score {
1544 RecallScore::TextOnly {
1545 rank,
1546 bm25_score,
1547 confidence,
1548 ..
1549 } => {
1550 assert_eq!(*rank, i);
1551 assert!(
1552 *bm25_score > 0.0,
1553 "BM25 should be positive, got {bm25_score}"
1554 );
1555 assert!(
1556 (*confidence - 1.0).abs() < f32::EPSILON,
1557 "default confidence should be 1.0"
1558 );
1559 }
1560 RecallScore::Hybrid { .. } => {
1561 panic!("expected TextOnly variant without embedding")
1562 }
1563 }
1564 }
1565 }
1566
1567 #[test]
1568 fn recall_scored_bm25_higher_for_better_match() {
1569 let (mem, _tmp) = open_temp_memory();
1570 mem.remember("Rust Rust Rust programming language", "ep-strong", None)
1572 .unwrap();
1573 mem.remember("I once heard of Rust somewhere", "ep-weak", None)
1574 .unwrap();
1575
1576 let scored = mem.recall_scored("Rust", None, 5).unwrap();
1577 assert!(scored.len() >= 2);
1578
1579 let bm25_first = match scored[0].1 {
1581 RecallScore::TextOnly { bm25_score, .. } => bm25_score,
1582 _ => panic!("expected TextOnly"),
1583 };
1584 let bm25_second = match scored[1].1 {
1585 RecallScore::TextOnly { bm25_score, .. } => bm25_score,
1586 _ => panic!("expected TextOnly"),
1587 };
1588 assert!(
1589 bm25_first >= bm25_second,
1590 "first result should have higher BM25: {bm25_first} vs {bm25_second}"
1591 );
1592 }
1593
1594 #[test]
1595 fn recall_scored_preserves_fact_content() {
1596 let (mem, _tmp) = open_temp_memory();
1597 mem.remember("Kronroe is a temporal graph database", "ep-001", None)
1598 .unwrap();
1599
1600 let scored = mem.recall_scored("temporal", None, 5).unwrap();
1601 assert_eq!(scored.len(), 1);
1602
1603 let (fact, _score) = &scored[0];
1604 assert_eq!(fact.subject, "ep-001");
1605 assert_eq!(fact.predicate, "memory");
1606 assert!(matches!(&fact.object, Value::Text(t) if t.contains("temporal")));
1607 }
1608
1609 #[test]
1610 fn recall_score_confidence_accessor() {
1611 let text = RecallScore::TextOnly {
1613 rank: 0,
1614 bm25_score: 1.0,
1615 confidence: 0.8,
1616 effective_confidence: None,
1617 };
1618 assert!((text.confidence() - 0.8).abs() < f32::EPSILON);
1619 }
1620
1621 #[cfg(feature = "hybrid")]
1622 #[test]
1623 fn recall_score_confidence_accessor_hybrid() {
1624 let hybrid = RecallScore::Hybrid {
1625 rrf_score: 0.1,
1626 text_contrib: 0.05,
1627 vector_contrib: 0.05,
1628 confidence: 0.9,
1629 effective_confidence: None,
1630 };
1631 assert!((hybrid.confidence() - 0.9).abs() < f32::EPSILON);
1632 }
1633
1634 #[cfg(feature = "hybrid")]
1635 #[test]
1636 fn recall_scored_hybrid_returns_breakdown() {
1637 let (mem, _tmp) = open_temp_memory();
1638 mem.remember(
1639 "Rust systems programming",
1640 "ep-rust",
1641 Some(vec![1.0f32, 0.0]),
1642 )
1643 .unwrap();
1644 mem.remember("Python data science", "ep-py", Some(vec![0.0f32, 1.0]))
1645 .unwrap();
1646
1647 let scored = mem.recall_scored("Rust", Some(&[1.0, 0.0]), 2).unwrap();
1648 assert!(!scored.is_empty());
1649
1650 for (_fact, score) in &scored {
1652 match score {
1653 RecallScore::Hybrid {
1654 rrf_score,
1655 text_contrib,
1656 vector_contrib,
1657 confidence,
1658 ..
1659 } => {
1660 assert!(
1661 *rrf_score >= 0.0,
1662 "RRF score should be non-negative, got {rrf_score}"
1663 );
1664 assert!(
1665 *text_contrib >= 0.0,
1666 "text contrib should be non-negative, got {text_contrib}"
1667 );
1668 assert!(
1669 *vector_contrib >= 0.0,
1670 "vector contrib should be non-negative, got {vector_contrib}"
1671 );
1672 assert!(
1673 (*confidence - 1.0).abs() < f32::EPSILON,
1674 "default confidence should be 1.0"
1675 );
1676 }
1677 RecallScore::TextOnly { .. } => {
1678 panic!("expected Hybrid variant with embedding")
1679 }
1680 }
1681 }
1682 }
1683
1684 #[cfg(feature = "hybrid")]
1685 #[test]
1686 fn recall_scored_hybrid_text_dominant_has_higher_text_contrib() {
1687 let (mem, _tmp) = open_temp_memory();
1688 mem.remember(
1691 "unique-xyzzy-token for testing",
1692 "ep-text",
1693 Some(vec![1.0f32, 0.0]),
1694 )
1695 .unwrap();
1696
1697 let scored = mem
1698 .recall_scored("unique-xyzzy-token", Some(&[0.0, 1.0]), 1)
1699 .unwrap();
1700 assert_eq!(scored.len(), 1);
1701
1702 match &scored[0].1 {
1703 RecallScore::Hybrid {
1704 text_contrib,
1705 vector_contrib,
1706 ..
1707 } => {
1708 assert!(
1709 text_contrib > vector_contrib,
1710 "text should dominate when query text matches but vector is orthogonal: \
1711 text={text_contrib}, vector={vector_contrib}"
1712 );
1713 }
1714 _ => panic!("expected Hybrid variant"),
1715 }
1716 }
1717
1718 #[test]
1719 fn recall_score_display_tag() {
1720 let text_score = RecallScore::TextOnly {
1721 rank: 0,
1722 bm25_score: 4.21,
1723 confidence: 1.0,
1724 effective_confidence: None,
1725 };
1726 assert_eq!(text_score.display_tag(), "#1 bm25:4.21");
1727
1728 let text_score_5 = RecallScore::TextOnly {
1729 rank: 4,
1730 bm25_score: 1.50,
1731 confidence: 1.0,
1732 effective_confidence: None,
1733 };
1734 assert_eq!(text_score_5.display_tag(), "#5 bm25:1.50");
1735 }
1736
1737 #[cfg(feature = "hybrid")]
1738 #[test]
1739 fn recall_score_display_tag_hybrid() {
1740 let hybrid_score = RecallScore::Hybrid {
1741 rrf_score: 0.0325,
1742 text_contrib: 0.02,
1743 vector_contrib: 0.0125,
1744 confidence: 1.0,
1745 effective_confidence: None,
1746 };
1747 assert_eq!(hybrid_score.display_tag(), "0.033");
1748 }
1749
1750 #[test]
1751 fn assemble_context_includes_score_tag() {
1752 let (mem, _tmp) = open_temp_memory();
1753 mem.remember("Alice is a Rust expert", "ep-001", None)
1754 .unwrap();
1755
1756 let ctx = mem.assemble_context("Rust", None, 500).unwrap();
1757 assert!(!ctx.is_empty());
1758 assert!(
1760 ctx.contains("(#1 bm25:"),
1761 "context should contain text-only rank+bm25 tag, got: {ctx}"
1762 );
1763 }
1764
1765 #[test]
1766 fn assemble_context_omits_confidence_at_default() {
1767 let (mem, _tmp) = open_temp_memory();
1768 mem.remember("Alice is a Rust expert", "ep-001", None)
1769 .unwrap();
1770
1771 let ctx = mem.assemble_context("Rust", None, 500).unwrap();
1772 assert!(
1774 !ctx.contains("conf:"),
1775 "default confidence should not appear in context, got: {ctx}"
1776 );
1777 }
1778
1779 #[cfg(feature = "hybrid")]
1780 #[test]
1781 fn assemble_context_hybrid_includes_rrf_score() {
1782 let (mem, _tmp) = open_temp_memory();
1783 mem.remember("Rust systems", "ep-rust", Some(vec![1.0f32, 0.0]))
1784 .unwrap();
1785
1786 let ctx = mem
1787 .assemble_context("Rust", Some(&[1.0, 0.0]), 500)
1788 .unwrap();
1789 assert!(!ctx.is_empty());
1790 assert!(
1792 ctx.contains("(0."),
1793 "context should contain hybrid RRF score tag, got: {ctx}"
1794 );
1795 }
1796
1797 #[test]
1800 fn recall_options_default_limit() {
1801 let opts = RecallOptions::new("test query");
1802 assert_eq!(opts.limit, 10);
1803 assert!(opts.query_embedding.is_none());
1804 assert!(opts.min_confidence.is_none());
1805 assert_eq!(opts.max_scored_rows, 4_096);
1806 }
1807
1808 #[test]
1809 fn assert_with_confidence_round_trip() {
1810 let (mem, _tmp) = open_temp_memory();
1811 mem.assert_with_confidence("alice", "works_at", "Acme", 0.8)
1812 .unwrap();
1813
1814 let facts = mem.facts_about("alice").unwrap();
1815 assert_eq!(facts.len(), 1);
1816 assert!(
1817 (facts[0].confidence - 0.8).abs() < f32::EPSILON,
1818 "confidence should be 0.8, got {}",
1819 facts[0].confidence,
1820 );
1821 }
1822
1823 #[test]
1824 fn assert_with_confidence_rejects_non_finite() {
1825 let (mem, _tmp) = open_temp_memory();
1826
1827 for confidence in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1828 let err = mem.assert_with_confidence("alice", "works_at", "Rust", confidence);
1829 match err {
1830 Err(Error::Search(msg)) => {
1831 assert!(msg.contains("finite"), "unexpected search error: {msg}")
1832 }
1833 _ => panic!("expected search error for confidence={confidence:?}"),
1834 }
1835 }
1836 }
1837
1838 #[test]
1839 fn recall_with_min_confidence_filters() {
1840 let (mem, _tmp) = open_temp_memory();
1841 mem.assert_with_confidence("ep-low", "memory", "low confidence fact about Rust", 0.3)
1843 .unwrap();
1844 mem.assert_with_confidence("ep-high", "memory", "high confidence fact about Rust", 0.9)
1845 .unwrap();
1846
1847 let all = mem.recall("Rust", None, 10).unwrap();
1849 assert_eq!(all.len(), 2, "both facts should be returned without filter");
1850
1851 let opts = RecallOptions::new("Rust")
1853 .with_limit(10)
1854 .with_min_confidence(0.5);
1855 let filtered = mem.recall_with_options(&opts).unwrap();
1856 assert_eq!(
1857 filtered.len(),
1858 1,
1859 "only high-confidence fact should pass filter"
1860 );
1861 assert!(
1862 (filtered[0].confidence - 0.9).abs() < f32::EPSILON,
1863 "surviving fact should have confidence 0.9, got {}",
1864 filtered[0].confidence,
1865 );
1866 }
1867
1868 #[test]
1869 fn assemble_context_shows_confidence_tag() {
1870 let (mem, _tmp) = open_temp_memory();
1871 mem.assert_with_confidence("ep-test", "memory", "notable fact about testing", 0.7)
1872 .unwrap();
1873
1874 let ctx = mem.assemble_context("testing", None, 500).unwrap();
1875 assert!(
1876 ctx.contains("conf:0.7"),
1877 "context should include conf:0.7 tag for non-default confidence, got: {ctx}"
1878 );
1879 }
1880
1881 #[test]
1882 fn recall_scored_with_options_respects_limit() {
1883 let (mem, _tmp) = open_temp_memory();
1884 for i in 0..5 {
1885 mem.assert_with_confidence(
1886 &format!("ep-{i}"),
1887 "memory",
1888 format!("fact number {i} about coding"),
1889 1.0,
1890 )
1891 .unwrap();
1892 }
1893
1894 let opts = RecallOptions::new("coding").with_limit(2);
1895 let results = mem.recall_scored_with_options(&opts).unwrap();
1896 assert!(
1897 results.len() <= 2,
1898 "should respect limit=2, got {} results",
1899 results.len(),
1900 );
1901 }
1902
1903 #[test]
1904 fn recall_scored_with_options_filters_confidence_before_limit() {
1905 let (mem, _tmp) = open_temp_memory();
1906 mem.assert_with_confidence("low-1", "memory", "rust rust rust rust rust", 0.2)
1907 .unwrap();
1908 mem.assert_with_confidence("low-2", "memory", "rust rust rust rust rust", 0.1)
1909 .unwrap();
1910 mem.assert_with_confidence("high", "memory", "rust", 0.9)
1911 .unwrap();
1912
1913 let opts = RecallOptions::new("rust")
1914 .with_limit(1)
1915 .with_min_confidence(0.9);
1916 let results = mem.recall_scored_with_options(&opts).unwrap();
1917
1918 assert_eq!(
1919 results.len(),
1920 1,
1921 "expected one surviving result after filtering"
1922 );
1923 assert_eq!(results[0].0.subject, "high");
1924 assert!(
1925 (results[0].1.confidence() - 0.9).abs() < f32::EPSILON,
1926 "surviving result should keep confidence=0.9"
1927 );
1928 }
1929
1930 #[test]
1931 fn recall_scored_with_options_normalizes_min_confidence_bounds() {
1932 let (mem, _tmp) = open_temp_memory();
1933 mem.assert_with_confidence("high", "memory", "rust", 1.0)
1934 .unwrap();
1935 mem.assert_with_confidence("low", "memory", "rust", 0.1)
1936 .unwrap();
1937
1938 let opts = RecallOptions::new("rust")
1939 .with_limit(2)
1940 .with_min_confidence(2.0);
1941 let results = mem.recall_scored_with_options(&opts).unwrap();
1942 assert_eq!(
1943 results.len(),
1944 1,
1945 "min confidence above 1.0 should be clamped to 1.0"
1946 );
1947 assert!(
1948 (results[0].1.confidence() - 1.0).abs() < f32::EPSILON,
1949 "surviving row should use clamped threshold 1.0"
1950 );
1951
1952 let opts = RecallOptions::new("rust")
1953 .with_limit(2)
1954 .with_min_confidence(-1.0);
1955 let results = mem.recall_scored_with_options(&opts).unwrap();
1956 assert_eq!(
1957 results.len(),
1958 2,
1959 "min confidence below 0.0 should be clamped to 0.0"
1960 );
1961 }
1962
1963 #[test]
1964 fn recall_scored_with_options_rejects_non_finite_min_confidence() {
1965 let (mem, _tmp) = open_temp_memory();
1966 mem.assert_with_confidence("ep", "memory", "rust", 1.0)
1967 .unwrap();
1968
1969 let opts = RecallOptions::new("rust")
1970 .with_limit(2)
1971 .with_min_confidence(f32::NAN);
1972 let err = mem.recall_scored_with_options(&opts).unwrap_err();
1973 match err {
1974 Error::Search(msg) => assert!(
1975 msg.contains("minimum confidence"),
1976 "unexpected search error: {msg}"
1977 ),
1978 _ => panic!("expected search error for NaN min confidence, got {err:?}"),
1979 }
1980 }
1981
1982 #[test]
1983 fn recall_scored_with_options_respects_scored_rows_cap() {
1984 let (mem, _tmp) = open_temp_memory();
1985 for i in 0..5 {
1986 mem.assert_with_confidence(&format!("ep-{i}"), "memory", "rust and memory", 1.0)
1987 .unwrap();
1988 }
1989
1990 let opts = RecallOptions::new("rust")
1991 .with_limit(5)
1992 .with_min_confidence(0.0)
1993 .with_max_scored_rows(2);
1994 let results = mem.recall_scored_with_options(&opts).unwrap();
1995 assert_eq!(
1996 results.len(),
1997 2,
1998 "max_scored_rows should bound the effective recall window in filtered mode"
1999 );
2000 }
2001
2002 #[cfg(feature = "uncertainty")]
2003 #[test]
2004 fn recall_scored_with_options_effective_confidence_respects_scored_rows_cap() {
2005 let (mem, _tmp) = open_temp_memory();
2006 for i in 0..5 {
2007 mem.assert_with_source(
2008 &format!("ep-{i}"),
2009 "memory",
2010 "rust and memory",
2011 1.0,
2012 "user:owner",
2013 )
2014 .unwrap();
2015 }
2016
2017 let opts = RecallOptions::new("rust")
2018 .with_limit(5)
2019 .with_min_effective_confidence(0.5)
2020 .with_max_scored_rows(2);
2021 let results = mem.recall_scored_with_options(&opts).unwrap();
2022 assert_eq!(
2023 results.len(),
2024 2,
2025 "effective-confidence path should honor max_scored_rows cap"
2026 );
2027 }
2028
2029 #[cfg(all(feature = "hybrid", feature = "uncertainty"))]
2030 #[test]
2031 fn recall_scored_with_options_hybrid_effective_confidence_respects_scored_rows_cap() {
2032 let (mem, _tmp) = open_temp_memory();
2033 for i in 0..5 {
2034 mem.remember(
2035 "rust memory entry",
2036 &format!("ep-{i}"),
2037 Some(vec![1.0f32, 0.0]),
2038 )
2039 .unwrap();
2040 }
2041
2042 let opts = RecallOptions::new("rust")
2043 .with_embedding(&[1.0, 0.0])
2044 .with_limit(5)
2045 .with_min_effective_confidence(0.0)
2046 .with_max_scored_rows(2);
2047 let results = mem.recall_scored_with_options(&opts).unwrap();
2048 assert_eq!(
2049 results.len(),
2050 2,
2051 "hybrid effective-confidence path should honor max_scored_rows cap"
2052 );
2053 }
2054
2055 #[test]
2056 fn recall_scored_with_options_rejects_zero_max_scored_rows() {
2057 let (mem, _tmp) = open_temp_memory();
2058 mem.assert_with_confidence("ep", "memory", "rust", 1.0)
2059 .unwrap();
2060
2061 let opts = RecallOptions::new("rust")
2062 .with_limit(1)
2063 .with_min_confidence(0.0)
2064 .with_max_scored_rows(0);
2065 let err = mem.recall_scored_with_options(&opts).unwrap_err();
2066 match err {
2067 Error::Search(msg) => assert!(
2068 msg.contains("max_scored_rows"),
2069 "unexpected search error: {msg}"
2070 ),
2071 _ => panic!("expected search error for max_scored_rows=0, got {err:?}"),
2072 }
2073 }
2074
2075 #[test]
2076 fn recall_with_min_confidence_method_filters_before_limit() {
2077 let (mem, _tmp) = open_temp_memory();
2078 mem.assert_with_confidence("low-1", "memory", "rust rust rust rust rust", 0.2)
2079 .unwrap();
2080 mem.assert_with_confidence("low-2", "memory", "rust rust rust rust rust", 0.1)
2081 .unwrap();
2082 mem.assert_with_confidence("high", "memory", "rust", 0.9)
2083 .unwrap();
2084
2085 let results = mem
2086 .recall_with_min_confidence("Rust", None, 1, 0.9)
2087 .unwrap();
2088
2089 assert_eq!(
2090 results.len(),
2091 1,
2092 "expected one surviving result after filtering"
2093 );
2094 assert_eq!(results[0].subject, "high");
2095 }
2096
2097 #[test]
2098 fn recall_scored_with_min_confidence_method_respects_limit() {
2099 let (mem, _tmp) = open_temp_memory();
2100 mem.assert_with_confidence("low", "memory", "rust rust rust rust", 0.2)
2101 .unwrap();
2102 mem.assert_with_confidence("high-2", "memory", "rust", 0.95)
2103 .unwrap();
2104 mem.assert_with_confidence("high-1", "memory", "rust", 0.98)
2105 .unwrap();
2106
2107 let scored = mem
2108 .recall_scored_with_min_confidence("Rust", None, 2, 0.9)
2109 .unwrap();
2110
2111 assert_eq!(scored.len(), 2, "expected exactly 2 surviving results");
2112 assert!(scored[0].1.confidence() >= 0.9);
2113 assert!(scored[1].1.confidence() >= 0.9);
2114 }
2115
2116 #[test]
2117 fn recall_scored_with_min_confidence_matches_options_path() {
2118 let (mem, _tmp) = open_temp_memory();
2119 mem.assert_with_confidence("low", "memory", "rust rust rust", 0.2)
2120 .unwrap();
2121 mem.assert_with_confidence("high", "memory", "rust", 0.95)
2122 .unwrap();
2123 mem.assert_with_confidence("high-2", "memory", "rust for sure", 0.99)
2124 .unwrap();
2125
2126 let method_results = mem
2127 .recall_scored_with_min_confidence("Rust", None, 2, 0.9)
2128 .unwrap()
2129 .into_iter()
2130 .map(|(fact, _)| fact.id.0)
2131 .collect::<Vec<_>>();
2132
2133 let opts = RecallOptions::new("Rust")
2134 .with_limit(2)
2135 .with_min_confidence(0.9);
2136 let options_results = mem
2137 .recall_scored_with_options(&opts)
2138 .unwrap()
2139 .into_iter()
2140 .map(|(fact, _)| fact.id.0)
2141 .collect::<Vec<_>>();
2142
2143 assert_eq!(method_results, options_results);
2144 }
2145
2146 #[test]
2147 fn assert_with_source_round_trip() {
2148 let (mem, _tmp) = open_temp_memory();
2149 mem.assert_with_source("alice", "works_at", "Acme", 0.9, "user:owner")
2150 .unwrap();
2151
2152 let facts = mem.facts_about("alice").unwrap();
2153 assert_eq!(facts.len(), 1);
2154 assert_eq!(facts[0].source.as_deref(), Some("user:owner"));
2155 assert!((facts[0].confidence - 0.9).abs() < f32::EPSILON);
2156 }
2157
2158 #[test]
2159 fn assert_with_confidence_with_params_uses_valid_from() {
2160 let (mem, _tmp) = open_temp_memory();
2161 let valid_from = Utc::now() - chrono::Duration::days(90);
2162 mem.assert_with_confidence_with_params(
2163 "alice",
2164 "worked_at",
2165 "Acme",
2166 AssertParams { valid_from },
2167 0.7,
2168 )
2169 .unwrap();
2170
2171 let facts = mem.facts_about("alice").unwrap();
2172 assert_eq!(facts.len(), 1);
2173 assert!((facts[0].valid_from - valid_from).num_seconds().abs() < 1);
2174 assert!((facts[0].confidence - 0.7).abs() < f32::EPSILON);
2175 }
2176
2177 #[test]
2178 fn assert_with_source_with_params_uses_valid_from() {
2179 let (mem, _tmp) = open_temp_memory();
2180 let valid_from = Utc::now() - chrono::Duration::days(45);
2181 mem.assert_with_source_with_params(
2182 "alice",
2183 "works_at",
2184 "Acme",
2185 AssertParams { valid_from },
2186 0.85,
2187 "agent:planner",
2188 )
2189 .unwrap();
2190
2191 let facts = mem.facts_about("alice").unwrap();
2192 assert_eq!(facts.len(), 1);
2193 assert_eq!(facts[0].source.as_deref(), Some("agent:planner"));
2194 assert_eq!(facts[0].predicate, "works_at");
2195 assert!((facts[0].valid_from - valid_from).num_seconds().abs() < 1);
2196 assert!((facts[0].confidence - 0.85).abs() < f32::EPSILON);
2197 }
2198
2199 #[cfg(feature = "uncertainty")]
2200 #[test]
2201 fn recall_includes_effective_confidence() {
2202 let (mem, _tmp) = open_temp_memory();
2203 mem.assert("alice", "works_at", "Acme").unwrap();
2204
2205 let scored = mem.recall_scored("alice", None, 10).unwrap();
2206 assert!(!scored.is_empty());
2207 let eff = scored[0].1.effective_confidence();
2209 assert!(
2210 eff.is_some(),
2211 "expected Some effective_confidence, got None"
2212 );
2213 assert!(eff.unwrap() > 0.0);
2214 }
2215
2216 #[cfg(feature = "uncertainty")]
2217 #[test]
2218 fn volatile_predicate_decays() {
2219 let (mem, _tmp) = open_temp_memory();
2220 let past = Utc::now() - chrono::Duration::days(730);
2223 mem.graph
2224 .assert_fact("alice", "works_at", "OldCo", past)
2225 .unwrap();
2226 mem.graph
2228 .assert_fact("alice", "born_in", "London", Utc::now())
2229 .unwrap();
2230
2231 let old_eff = mem
2232 .graph
2233 .effective_confidence(
2234 mem.facts_about("alice")
2235 .unwrap()
2236 .iter()
2237 .find(|f| f.predicate == "works_at")
2238 .unwrap(),
2239 Utc::now(),
2240 )
2241 .unwrap();
2242 let fresh_eff = mem
2243 .graph
2244 .effective_confidence(
2245 mem.facts_about("alice")
2246 .unwrap()
2247 .iter()
2248 .find(|f| f.predicate == "born_in")
2249 .unwrap(),
2250 Utc::now(),
2251 )
2252 .unwrap();
2253
2254 assert!(
2256 old_eff.value < 0.6,
2257 "730-day old works_at should have decayed, got {}",
2258 old_eff.value
2259 );
2260 assert!(
2262 fresh_eff.value > 0.9,
2263 "fresh born_in should be near 1.0, got {}",
2264 fresh_eff.value
2265 );
2266 }
2267
2268 #[cfg(feature = "uncertainty")]
2269 #[test]
2270 fn source_weight_affects_confidence() {
2271 let (mem, _tmp) = open_temp_memory();
2272 mem.register_source_weight("trusted", 1.5).unwrap();
2273 mem.register_source_weight("untrusted", 0.5).unwrap();
2274
2275 mem.assert_with_source("alice", "works_at", "TrustCo", 1.0, "trusted")
2276 .unwrap();
2277 mem.assert_with_source("bob", "works_at", "SketchCo", 1.0, "untrusted")
2278 .unwrap();
2279
2280 let alice_facts = mem.facts_about("alice").unwrap();
2281 let bob_facts = mem.facts_about("bob").unwrap();
2282
2283 let alice_eff = mem
2284 .graph
2285 .effective_confidence(&alice_facts[0], Utc::now())
2286 .unwrap();
2287 let bob_eff = mem
2288 .graph
2289 .effective_confidence(&bob_facts[0], Utc::now())
2290 .unwrap();
2291
2292 assert!(
2293 alice_eff.value > bob_eff.value,
2294 "trusted source should have higher effective confidence: {} vs {}",
2295 alice_eff.value,
2296 bob_eff.value
2297 );
2298 }
2299
2300 #[cfg(feature = "uncertainty")]
2301 #[test]
2302 fn effective_confidence_for_fact_returns_some() {
2303 let (mem, _tmp) = open_temp_memory();
2304 mem.assert_with_source("alice", "works_at", "Acme", 0.9, "user:owner")
2305 .unwrap();
2306
2307 let fact = mem.facts_about("alice").unwrap().remove(0);
2308 let eff = mem
2309 .effective_confidence_for_fact(&fact, Utc::now())
2310 .unwrap()
2311 .expect("uncertainty-enabled builds should return effective confidence");
2312
2313 assert!(
2314 eff > 0.0,
2315 "effective confidence should be positive for a fresh fact, got {eff}"
2316 );
2317 }
2318}