1#[cfg(feature = "contradiction")]
32use kronroe::{ConflictPolicy, Contradiction};
33use kronroe::{Fact, FactId, KronroeSpan, KronroeTimestamp, TemporalGraph, Value};
34#[cfg(feature = "hybrid")]
35use kronroe::{HybridScoreBreakdown, HybridSearchParams, TemporalIntent, TemporalOperator};
36use std::collections::HashSet;
37
38pub use kronroe::KronroeError as Error;
39pub type Result<T> = std::result::Result<T, Error>;
40
41#[derive(Debug, Clone, Copy, PartialEq)]
67#[non_exhaustive]
68pub enum RecallScore {
69 #[non_exhaustive]
76 Hybrid {
77 rrf_score: f64,
79 text_contrib: f64,
81 vector_contrib: f64,
83 confidence: f32,
85 effective_confidence: Option<f32>,
88 },
89 #[non_exhaustive]
91 TextOnly {
92 rank: usize,
94 bm25_score: f32,
97 confidence: f32,
99 effective_confidence: Option<f32>,
102 },
103}
104
105impl RecallScore {
106 pub fn display_tag(&self) -> String {
111 match self {
112 RecallScore::Hybrid { rrf_score, .. } => format!("{:.3}", rrf_score),
113 RecallScore::TextOnly {
114 rank, bm25_score, ..
115 } => format!("#{} bm25:{:.2}", rank + 1, bm25_score),
116 }
117 }
118
119 pub fn confidence(&self) -> f32 {
121 match self {
122 RecallScore::Hybrid { confidence, .. } | RecallScore::TextOnly { confidence, .. } => {
123 *confidence
124 }
125 }
126 }
127
128 pub fn effective_confidence(&self) -> Option<f32> {
133 match self {
134 RecallScore::Hybrid {
135 effective_confidence,
136 ..
137 }
138 | RecallScore::TextOnly {
139 effective_confidence,
140 ..
141 } => *effective_confidence,
142 }
143 }
144
145 #[cfg(feature = "hybrid")]
148 fn from_breakdown(
149 b: &HybridScoreBreakdown,
150 confidence: f32,
151 effective_confidence: Option<f32>,
152 ) -> Self {
153 RecallScore::Hybrid {
154 rrf_score: b.final_score,
155 text_contrib: b.text_rrf_contrib,
156 vector_contrib: b.vector_rrf_contrib,
157 confidence,
158 effective_confidence,
159 }
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165#[non_exhaustive]
166pub enum ConfidenceFilterMode {
167 Base,
169 #[cfg(feature = "uncertainty")]
174 Effective,
175}
176
177#[derive(Debug, Clone)]
192#[non_exhaustive]
193pub struct RecallOptions<'a> {
194 pub query: &'a str,
196 pub query_embedding: Option<&'a [f32]>,
198 pub limit: usize,
200 pub min_confidence: Option<f32>,
202 pub confidence_filter_mode: ConfidenceFilterMode,
204 pub max_scored_rows: usize,
210 #[cfg(feature = "hybrid")]
215 pub use_hybrid: bool,
216 #[cfg(feature = "hybrid")]
218 pub temporal_intent: TemporalIntent,
219 #[cfg(feature = "hybrid")]
221 pub temporal_operator: TemporalOperator,
222}
223
224const DEFAULT_MAX_SCORED_ROWS: usize = 4_096;
225
226impl<'a> RecallOptions<'a> {
227 pub fn new(query: &'a str) -> Self {
229 Self {
230 query,
231 query_embedding: None,
232 limit: 10,
233 min_confidence: None,
234 confidence_filter_mode: ConfidenceFilterMode::Base,
235 max_scored_rows: DEFAULT_MAX_SCORED_ROWS,
236 #[cfg(feature = "hybrid")]
237 use_hybrid: false,
238 #[cfg(feature = "hybrid")]
239 temporal_intent: TemporalIntent::Timeless,
240 #[cfg(feature = "hybrid")]
241 temporal_operator: TemporalOperator::Current,
242 }
243 }
244
245 pub fn with_embedding(mut self, embedding: &'a [f32]) -> Self {
247 self.query_embedding = Some(embedding);
248 self
249 }
250
251 pub fn with_limit(mut self, limit: usize) -> Self {
253 self.limit = limit;
254 self
255 }
256
257 pub fn with_min_confidence(mut self, min: f32) -> Self {
259 self.min_confidence = Some(min);
260 self.confidence_filter_mode = ConfidenceFilterMode::Base;
261 self
262 }
263
264 #[cfg(feature = "uncertainty")]
271 pub fn with_min_effective_confidence(mut self, min: f32) -> Self {
272 self.min_confidence = Some(min);
273 self.confidence_filter_mode = ConfidenceFilterMode::Effective;
274 self
275 }
276
277 pub fn with_max_scored_rows(mut self, max_scored_rows: usize) -> Self {
282 self.max_scored_rows = max_scored_rows;
283 self
284 }
285
286 #[cfg(feature = "hybrid")]
292 pub fn with_hybrid(mut self, enabled: bool) -> Self {
293 self.use_hybrid = enabled;
294 self
295 }
296
297 #[cfg(feature = "hybrid")]
301 pub fn with_temporal_intent(mut self, intent: TemporalIntent) -> Self {
302 self.temporal_intent = intent;
303 self
304 }
305
306 #[cfg(feature = "hybrid")]
310 pub fn with_temporal_operator(mut self, operator: TemporalOperator) -> Self {
311 self.temporal_operator = operator;
312 self
313 }
314}
315
316fn normalize_min_confidence(min_confidence: f32) -> Result<f32> {
317 if !min_confidence.is_finite() {
318 return Err(Error::Search(format!(
319 "minimum confidence must be a finite number in [0.0, 1.0], got {min_confidence}"
320 )));
321 }
322
323 Ok(min_confidence.clamp(0.0, 1.0))
324}
325
326fn normalize_fact_confidence(confidence: f32) -> Result<f32> {
327 if !confidence.is_finite() {
328 return Err(Error::Search(
329 "fact confidence must be finite and in [0.0, 1.0], got non-finite value".to_string(),
330 ));
331 }
332 Ok(confidence.clamp(0.0, 1.0))
333}
334
335pub struct AgentMemory {
340 graph: TemporalGraph,
341}
342
343#[derive(Debug, Clone)]
344pub struct AssertParams {
345 pub valid_from: KronroeTimestamp,
346}
347
348#[derive(Debug, Clone)]
350pub struct FactCorrection {
351 pub old_fact: Fact,
352 pub new_fact: Fact,
353}
354
355#[derive(Debug, Clone)]
357pub struct ConfidenceShift {
358 pub from_fact_id: FactId,
359 pub to_fact_id: FactId,
360 pub from_confidence: f32,
361 pub to_confidence: f32,
362}
363
364#[derive(Debug, Clone)]
366pub struct WhatChangedReport {
367 pub entity: String,
368 pub since: KronroeTimestamp,
369 pub predicate_filter: Option<String>,
370 pub new_facts: Vec<Fact>,
371 pub invalidated_facts: Vec<Fact>,
372 pub corrections: Vec<FactCorrection>,
373 pub confidence_shifts: Vec<ConfidenceShift>,
374}
375
376#[derive(Debug, Clone)]
378pub struct MemoryHealthReport {
379 pub entity: String,
380 pub generated_at: KronroeTimestamp,
381 pub predicate_filter: Option<String>,
382 pub total_fact_count: usize,
383 pub active_fact_count: usize,
384 pub low_confidence_facts: Vec<Fact>,
385 pub stale_high_impact_facts: Vec<Fact>,
386 pub contradiction_count: usize,
387 pub recommended_actions: Vec<String>,
388}
389
390#[derive(Debug, Clone)]
392pub struct RecallForTaskReport {
393 pub task: String,
394 pub subject: Option<String>,
395 pub generated_at: KronroeTimestamp,
396 pub horizon_days: i64,
397 pub query_used: String,
398 pub key_facts: Vec<Fact>,
399 pub low_confidence_count: usize,
400 pub stale_high_impact_count: usize,
401 pub contradiction_count: usize,
402 pub watchouts: Vec<String>,
403 pub recommended_next_checks: Vec<String>,
404}
405
406pub fn is_high_impact_predicate(predicate: &str) -> bool {
407 matches!(
408 predicate,
409 "works_at" | "lives_in" | "job_title" | "email" | "phone"
410 )
411}
412
413const CORRECTION_LINK_TOLERANCE_SECONDS: i64 = 2;
414
415impl AgentMemory {
416 pub fn open(path: &str) -> Result<Self> {
423 let graph = TemporalGraph::open(path)?;
424 #[cfg(feature = "contradiction")]
425 Self::register_default_singletons(&graph)?;
426 #[cfg(feature = "uncertainty")]
427 Self::register_default_volatilities(&graph)?;
428 Ok(Self { graph })
429 }
430
431 pub fn open_in_memory() -> Result<Self> {
435 let graph = TemporalGraph::open_in_memory()?;
436 #[cfg(feature = "contradiction")]
437 Self::register_default_singletons(&graph)?;
438 #[cfg(feature = "uncertainty")]
439 Self::register_default_volatilities(&graph)?;
440 Ok(Self { graph })
441 }
442
443 pub fn assert(
448 &self,
449 subject: &str,
450 predicate: &str,
451 object: impl Into<Value>,
452 ) -> Result<FactId> {
453 self.graph
454 .assert_fact(subject, predicate, object, KronroeTimestamp::now_utc())
455 }
456
457 pub fn assert_idempotent(
462 &self,
463 idempotency_key: &str,
464 subject: &str,
465 predicate: &str,
466 object: impl Into<Value>,
467 ) -> Result<FactId> {
468 self.graph.assert_fact_idempotent(
469 idempotency_key,
470 subject,
471 predicate,
472 object,
473 KronroeTimestamp::now_utc(),
474 )
475 }
476
477 pub fn assert_idempotent_with_params(
479 &self,
480 idempotency_key: &str,
481 subject: &str,
482 predicate: &str,
483 object: impl Into<Value>,
484 params: AssertParams,
485 ) -> Result<FactId> {
486 self.graph.assert_fact_idempotent(
487 idempotency_key,
488 subject,
489 predicate,
490 object,
491 params.valid_from,
492 )
493 }
494
495 pub fn assert_with_params(
497 &self,
498 subject: &str,
499 predicate: &str,
500 object: impl Into<Value>,
501 params: AssertParams,
502 ) -> Result<FactId> {
503 self.graph
504 .assert_fact(subject, predicate, object, params.valid_from)
505 }
506
507 pub fn facts_about(&self, entity: &str) -> Result<Vec<Fact>> {
509 self.graph.all_facts_about(entity)
510 }
511
512 pub fn facts_about_at(
514 &self,
515 entity: &str,
516 predicate: &str,
517 at: KronroeTimestamp,
518 ) -> Result<Vec<Fact>> {
519 self.graph.facts_at(entity, predicate, at)
520 }
521
522 pub fn current_facts(&self, entity: &str, predicate: &str) -> Result<Vec<Fact>> {
524 self.graph.current_facts(entity, predicate)
525 }
526
527 pub fn what_changed(
532 &self,
533 entity: &str,
534 since: KronroeTimestamp,
535 predicate_filter: Option<&str>,
536 ) -> Result<WhatChangedReport> {
537 let mut facts = self.graph.all_facts_about(entity)?;
538 if let Some(predicate) = predicate_filter {
539 facts.retain(|fact| fact.predicate == predicate);
540 }
541
542 let mut new_facts: Vec<Fact> = facts
543 .iter()
544 .filter(|fact| fact.recorded_at >= since)
545 .cloned()
546 .collect();
547 new_facts.sort_by_key(|fact| fact.recorded_at);
548
549 let mut invalidated_facts: Vec<Fact> = facts
550 .iter()
551 .filter(|fact| {
552 fact.expired_at
553 .map(|expired| expired >= since)
554 .unwrap_or(false)
555 })
556 .cloned()
557 .collect();
558 invalidated_facts.sort_by_key(|fact| fact.expired_at.unwrap_or(fact.recorded_at));
559
560 let mut corrections = Vec::new();
561 let mut confidence_shifts = Vec::new();
562
563 for new_fact in &new_facts {
564 let exact_match = facts
565 .iter()
566 .filter(|old| {
567 old.id != new_fact.id
568 && old.subject == new_fact.subject
569 && old.predicate == new_fact.predicate
570 && old.expired_at == Some(new_fact.valid_from)
571 && old.recorded_at <= new_fact.recorded_at
572 })
573 .max_by_key(|old| old.recorded_at);
574
575 let old_match = exact_match.or_else(|| {
576 facts
577 .iter()
578 .filter(|old| {
579 old.id != new_fact.id
580 && old.subject == new_fact.subject
581 && old.predicate == new_fact.predicate
582 && old.recorded_at <= new_fact.recorded_at
583 })
584 .filter_map(|old| {
585 old.expired_at.map(|expired| {
586 (old, (expired - new_fact.valid_from).num_seconds().abs())
587 })
588 })
589 .filter(|(_, delta_seconds)| {
590 *delta_seconds <= CORRECTION_LINK_TOLERANCE_SECONDS
591 })
592 .min_by(|(left_fact, left_delta), (right_fact, right_delta)| {
593 left_delta
594 .cmp(right_delta)
595 .then_with(|| right_fact.recorded_at.cmp(&left_fact.recorded_at))
596 })
597 .map(|(old, _)| old)
598 });
599
600 if let Some(old_fact) = old_match {
601 corrections.push(FactCorrection {
602 old_fact: old_fact.clone(),
603 new_fact: new_fact.clone(),
604 });
605 if (old_fact.confidence - new_fact.confidence).abs() > f32::EPSILON {
606 confidence_shifts.push(ConfidenceShift {
607 from_fact_id: old_fact.id.clone(),
608 to_fact_id: new_fact.id.clone(),
609 from_confidence: old_fact.confidence,
610 to_confidence: new_fact.confidence,
611 });
612 }
613 }
614 }
615
616 corrections.sort_by_key(|pair| pair.new_fact.recorded_at);
617 confidence_shifts.sort_by(|left, right| left.to_fact_id.cmp(&right.to_fact_id));
618
619 Ok(WhatChangedReport {
620 entity: entity.to_string(),
621 since,
622 predicate_filter: predicate_filter.map(str::to_string),
623 new_facts,
624 invalidated_facts,
625 corrections,
626 confidence_shifts,
627 })
628 }
629
630 pub fn memory_health(
635 &self,
636 entity: &str,
637 predicate_filter: Option<&str>,
638 low_confidence_threshold: f32,
639 stale_after_days: i64,
640 ) -> Result<MemoryHealthReport> {
641 let threshold = normalize_min_confidence(low_confidence_threshold)?;
642 let stale_days = stale_after_days.max(0);
643
644 let mut facts = self.graph.all_facts_about(entity)?;
645 if let Some(predicate) = predicate_filter {
646 facts.retain(|fact| fact.predicate == predicate);
647 }
648
649 let generated_at = KronroeTimestamp::now_utc();
650 let stale_cutoff = generated_at - KronroeSpan::days(stale_days);
651 let active_facts: Vec<Fact> = facts
652 .iter()
653 .filter(|fact| fact.is_currently_valid())
654 .cloned()
655 .collect();
656
657 let mut low_confidence_facts: Vec<Fact> = active_facts
658 .iter()
659 .filter(|fact| fact.confidence < threshold)
660 .cloned()
661 .collect();
662 low_confidence_facts.sort_by(|left, right| {
663 left.confidence
664 .partial_cmp(&right.confidence)
665 .unwrap_or(std::cmp::Ordering::Equal)
666 });
667
668 let mut stale_high_impact_facts: Vec<Fact> = active_facts
669 .iter()
670 .filter(|fact| {
671 is_high_impact_predicate(&fact.predicate) && fact.valid_from <= stale_cutoff
672 })
673 .cloned()
674 .collect();
675 stale_high_impact_facts.sort_by_key(|fact| fact.valid_from);
676
677 #[cfg(feature = "contradiction")]
678 let contradiction_count = if let Some(predicate) = predicate_filter {
679 self.graph.detect_contradictions(entity, predicate)?.len()
680 } else {
681 self.audit(entity)?.len()
682 };
683 #[cfg(not(feature = "contradiction"))]
684 let contradiction_count = 0usize;
685
686 let mut recommended_actions = Vec::new();
687 if contradiction_count > 0 {
688 recommended_actions.push(format!(
689 "Resolve {contradiction_count} contradiction(s) before relying on this memory."
690 ));
691 }
692 if !low_confidence_facts.is_empty() {
693 recommended_actions.push(format!(
694 "Review {} low-confidence active fact(s).",
695 low_confidence_facts.len()
696 ));
697 }
698 if !stale_high_impact_facts.is_empty() {
699 recommended_actions.push(format!(
700 "Refresh {} stale high-impact fact(s).",
701 stale_high_impact_facts.len()
702 ));
703 }
704 if recommended_actions.is_empty() {
705 recommended_actions.push("No immediate memory health issues detected.".to_string());
706 }
707
708 Ok(MemoryHealthReport {
709 entity: entity.to_string(),
710 generated_at,
711 predicate_filter: predicate_filter.map(str::to_string),
712 total_fact_count: facts.len(),
713 active_fact_count: active_facts.len(),
714 low_confidence_facts,
715 stale_high_impact_facts,
716 contradiction_count,
717 recommended_actions,
718 })
719 }
720
721 pub fn recall_for_task(
724 &self,
725 task: &str,
726 subject: Option<&str>,
727 now: Option<KronroeTimestamp>,
728 horizon_days: Option<i64>,
729 limit: usize,
730 #[cfg(feature = "hybrid")] query_embedding: Option<&[f32]>,
731 #[cfg(not(feature = "hybrid"))] _query_embedding: Option<&[f32]>,
732 ) -> Result<RecallForTaskReport> {
733 if limit == 0 {
734 return Err(Error::Search(
735 "recall_for_task limit must be >= 1".to_string(),
736 ));
737 }
738
739 let generated_at = now.unwrap_or_else(KronroeTimestamp::now_utc);
740 let horizon_days = horizon_days.unwrap_or(30).max(1);
741 let subject = subject.and_then(|raw| {
742 let trimmed = raw.trim();
743 (!trimmed.is_empty()).then_some(trimmed)
744 });
745
746 let query_used = if let Some(subject) = subject {
747 format!("{task} {subject}")
748 } else {
749 task.to_string()
750 };
751
752 let opts = RecallOptions::new(&query_used).with_limit(limit);
753 #[cfg(feature = "hybrid")]
754 let opts = if let Some(embedding) = query_embedding {
755 opts.with_embedding(embedding).with_hybrid(true)
756 } else {
757 opts
758 };
759 #[cfg(not(feature = "hybrid"))]
760 if _query_embedding.is_some() {
761 return Err(Error::Search(
762 "query_embedding requires the hybrid feature".to_string(),
763 ));
764 }
765
766 let key_facts: Vec<Fact> = if let Some(subject) = subject {
767 let mut subject_facts = Vec::new();
768 let mut seen_fact_ids: HashSet<FactId> = HashSet::new();
769 let mut fetch_limit = limit.clamp(1, DEFAULT_MAX_SCORED_ROWS);
770
771 loop {
772 let scored =
773 self.recall_scored_with_options(&opts.clone().with_limit(fetch_limit))?;
774 if scored.is_empty() {
775 break;
776 }
777
778 for (fact, _) in &scored {
779 if fact.subject == subject && seen_fact_ids.insert(fact.id.clone()) {
780 subject_facts.push(fact.clone());
781 }
782 }
783
784 if subject_facts.len() >= limit {
785 subject_facts.truncate(limit);
786 break;
787 }
788 if scored.len() < fetch_limit || fetch_limit >= DEFAULT_MAX_SCORED_ROWS {
789 break;
790 }
791 fetch_limit = (fetch_limit.saturating_mul(2)).min(DEFAULT_MAX_SCORED_ROWS);
792 }
793
794 subject_facts
795 } else {
796 self.recall_scored_with_options(&opts)?
797 .into_iter()
798 .map(|(fact, _)| fact)
799 .collect()
800 };
801 let low_confidence_count = key_facts
802 .iter()
803 .filter(|fact| fact.confidence < 0.7)
804 .count();
805
806 let stale_cutoff = generated_at - KronroeSpan::days(horizon_days);
807 let stale_high_impact_count = key_facts
808 .iter()
809 .filter(|fact| {
810 is_high_impact_predicate(&fact.predicate) && fact.valid_from <= stale_cutoff
811 })
812 .count();
813
814 #[cfg(feature = "contradiction")]
815 let contradiction_count = if let Some(subject) = subject {
816 self.audit(subject)?.len()
817 } else {
818 0
819 };
820 #[cfg(not(feature = "contradiction"))]
821 let contradiction_count = 0usize;
822
823 let mut watchouts = Vec::new();
824 if key_facts.is_empty() {
825 watchouts.push("No matching facts were found for this task context.".to_string());
826 }
827 if low_confidence_count > 0 {
828 watchouts.push(format!(
829 "{low_confidence_count} key fact(s) are low confidence (< 0.7)."
830 ));
831 }
832 if stale_high_impact_count > 0 {
833 watchouts.push(format!(
834 "{stale_high_impact_count} high-impact key fact(s) are stale for the selected horizon."
835 ));
836 }
837 if contradiction_count > 0 {
838 watchouts.push(format!(
839 "{contradiction_count} contradiction(s) exist for the subject."
840 ));
841 }
842
843 let mut recommended_next_checks = Vec::new();
844 if key_facts.is_empty() {
845 recommended_next_checks
846 .push("Ask a clarifying follow-up question before acting.".to_string());
847 }
848 if low_confidence_count > 0 {
849 recommended_next_checks
850 .push("Verify low-confidence facts with the latest source of truth.".to_string());
851 }
852 if stale_high_impact_count > 0 {
853 recommended_next_checks.push(
854 "Refresh stale high-impact facts (employment, location, role, contact)."
855 .to_string(),
856 );
857 }
858 if contradiction_count > 0 {
859 recommended_next_checks
860 .push("Resolve contradictions before generating irreversible actions.".to_string());
861 }
862 if recommended_next_checks.is_empty() {
863 recommended_next_checks
864 .push("Proceed with the top facts and monitor for new updates.".to_string());
865 }
866
867 Ok(RecallForTaskReport {
868 task: task.to_string(),
869 subject: subject.map(str::to_string),
870 generated_at,
871 horizon_days,
872 query_used,
873 key_facts,
874 low_confidence_count,
875 stale_high_impact_count,
876 contradiction_count,
877 watchouts,
878 recommended_next_checks,
879 })
880 }
881
882 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<Fact>> {
886 self.graph.search(query, limit)
887 }
888
889 pub fn correct_fact(
891 &self,
892 fact_id: impl AsRef<str>,
893 new_value: impl Into<Value>,
894 ) -> Result<FactId> {
895 self.graph
896 .correct_fact(fact_id, new_value, KronroeTimestamp::now_utc())
897 }
898
899 pub fn invalidate_fact(&self, fact_id: impl AsRef<str>) -> Result<()> {
902 self.graph
903 .invalidate_fact(fact_id, KronroeTimestamp::now_utc())
904 }
905
906 #[cfg(feature = "contradiction")]
918 fn register_default_singletons(graph: &TemporalGraph) -> Result<()> {
919 for predicate in &["works_at", "lives_in", "job_title", "email", "phone"] {
920 if !graph.is_singleton_predicate(predicate)? {
921 graph.register_singleton_predicate(predicate, ConflictPolicy::Warn)?;
922 }
923 }
924 Ok(())
925 }
926
927 #[cfg(feature = "contradiction")]
933 pub fn assert_checked(
934 &self,
935 subject: &str,
936 predicate: &str,
937 object: impl Into<Value>,
938 ) -> Result<(FactId, Vec<Contradiction>)> {
939 self.graph
940 .assert_fact_checked(subject, predicate, object, KronroeTimestamp::now_utc())
941 }
942
943 #[cfg(feature = "contradiction")]
948 pub fn audit(&self, subject: &str) -> Result<Vec<Contradiction>> {
949 let singleton_preds = self.graph.singleton_predicates()?;
950 let mut contradictions = Vec::new();
951 for predicate in &singleton_preds {
952 contradictions.extend(self.graph.detect_contradictions(subject, predicate)?);
953 }
954 Ok(contradictions)
955 }
956
957 pub fn remember(
961 &self,
962 text: &str,
963 episode_id: &str,
964 #[cfg(feature = "hybrid")] embedding: Option<Vec<f32>>,
965 #[cfg(not(feature = "hybrid"))] _embedding: Option<Vec<f32>>,
966 ) -> Result<FactId> {
967 #[cfg(feature = "hybrid")]
968 if let Some(emb) = embedding {
969 return self.graph.assert_fact_with_embedding(
970 episode_id,
971 "memory",
972 text.to_string(),
973 KronroeTimestamp::now_utc(),
974 emb,
975 );
976 }
977
978 self.graph.assert_fact(
979 episode_id,
980 "memory",
981 text.to_string(),
982 KronroeTimestamp::now_utc(),
983 )
984 }
985
986 pub fn remember_idempotent(
990 &self,
991 idempotency_key: &str,
992 text: &str,
993 episode_id: &str,
994 ) -> Result<FactId> {
995 self.graph.assert_fact_idempotent(
996 idempotency_key,
997 episode_id,
998 "memory",
999 text.to_string(),
1000 KronroeTimestamp::now_utc(),
1001 )
1002 }
1003
1004 pub fn recall(
1010 &self,
1011 query: &str,
1012 query_embedding: Option<&[f32]>,
1013 limit: usize,
1014 ) -> Result<Vec<Fact>> {
1015 self.recall_scored(query, query_embedding, limit)
1016 .map(|scored| scored.into_iter().map(|(fact, _)| fact).collect())
1017 }
1018
1019 pub fn recall_with_min_confidence(
1038 &self,
1039 query: &str,
1040 #[cfg(feature = "hybrid")] query_embedding: Option<&[f32]>,
1041 #[cfg(not(feature = "hybrid"))] _query_embedding: Option<&[f32]>,
1042 limit: usize,
1043 min_confidence: f32,
1044 ) -> Result<Vec<Fact>> {
1045 let opts = RecallOptions::new(query)
1046 .with_limit(limit)
1047 .with_min_confidence(min_confidence);
1048
1049 #[cfg(feature = "hybrid")]
1050 let opts = if let Some(embedding) = query_embedding {
1051 opts.with_embedding(embedding).with_hybrid(true)
1052 } else {
1053 opts
1054 };
1055
1056 self.recall_with_options(&opts)
1057 }
1058
1059 pub fn recall_scored(
1072 &self,
1073 query: &str,
1074 #[cfg(feature = "hybrid")] query_embedding: Option<&[f32]>,
1075 #[cfg(not(feature = "hybrid"))] _query_embedding: Option<&[f32]>,
1076 limit: usize,
1077 ) -> Result<Vec<(Fact, RecallScore)>> {
1078 #[cfg(feature = "hybrid")]
1079 let mut opts = RecallOptions::new(query).with_limit(limit);
1080 #[cfg(not(feature = "hybrid"))]
1081 let opts = RecallOptions::new(query).with_limit(limit);
1082 #[cfg(feature = "hybrid")]
1083 if let Some(embedding) = query_embedding {
1084 opts = opts
1085 .with_embedding(embedding)
1086 .with_hybrid(true)
1087 .with_temporal_intent(TemporalIntent::Timeless)
1088 .with_temporal_operator(TemporalOperator::Current);
1089 }
1090 self.recall_scored_with_options(&opts)
1091 }
1092
1093 #[cfg(feature = "hybrid")]
1094 fn recall_scored_internal(
1095 &self,
1096 query: &str,
1097 query_embedding: Option<&[f32]>,
1098 limit: usize,
1099 intent: TemporalIntent,
1100 operator: TemporalOperator,
1101 ) -> Result<Vec<(Fact, RecallScore)>> {
1102 if let Some(emb) = query_embedding {
1103 let params = HybridSearchParams {
1104 k: limit,
1105 intent,
1106 operator,
1107 ..HybridSearchParams::default()
1108 };
1109 let hits = self.graph.search_hybrid(query, emb, params, None)?;
1110 let mut scored = Vec::with_capacity(hits.len());
1111 for (fact, breakdown) in hits {
1112 if !fact.is_currently_valid() {
1113 continue;
1114 }
1115 let confidence = fact.confidence;
1116 let eff = self.compute_effective_confidence(&fact)?;
1117 scored.push((
1118 fact,
1119 RecallScore::from_breakdown(&breakdown, confidence, eff),
1120 ));
1121 }
1122 return Ok(scored);
1123 }
1124
1125 let scored_facts = self.graph.search_scored(query, limit)?;
1126 let mut scored = Vec::with_capacity(scored_facts.len());
1127 for (i, (fact, bm25)) in scored_facts.into_iter().enumerate() {
1128 if !fact.is_currently_valid() {
1129 continue;
1130 }
1131 let confidence = fact.confidence;
1132 let eff = self.compute_effective_confidence(&fact)?;
1133 scored.push((
1134 fact,
1135 RecallScore::TextOnly {
1136 rank: i,
1137 bm25_score: bm25,
1138 confidence,
1139 effective_confidence: eff,
1140 },
1141 ));
1142 }
1143 Ok(scored)
1144 }
1145
1146 #[cfg(not(feature = "hybrid"))]
1147 fn recall_scored_internal(
1148 &self,
1149 query: &str,
1150 _query_embedding: Option<&[f32]>,
1151 limit: usize,
1152 _intent: (),
1153 _operator: (),
1154 ) -> Result<Vec<(Fact, RecallScore)>> {
1155 let scored_facts = self.graph.search_scored(query, limit)?;
1156 let mut scored = Vec::with_capacity(scored_facts.len());
1157 for (i, (fact, bm25)) in scored_facts.into_iter().enumerate() {
1158 if !fact.is_currently_valid() {
1159 continue;
1160 }
1161 let confidence = fact.confidence;
1162 let eff = self.compute_effective_confidence(&fact)?;
1163 scored.push((
1164 fact,
1165 RecallScore::TextOnly {
1166 rank: i,
1167 bm25_score: bm25,
1168 confidence,
1169 effective_confidence: eff,
1170 },
1171 ));
1172 }
1173 Ok(scored)
1174 }
1175
1176 pub fn recall_scored_with_min_confidence(
1196 &self,
1197 query: &str,
1198 #[cfg(feature = "hybrid")] query_embedding: Option<&[f32]>,
1199 #[cfg(not(feature = "hybrid"))] _query_embedding: Option<&[f32]>,
1200 limit: usize,
1201 min_confidence: f32,
1202 ) -> Result<Vec<(Fact, RecallScore)>> {
1203 let opts = RecallOptions::new(query)
1204 .with_limit(limit)
1205 .with_min_confidence(min_confidence);
1206
1207 #[cfg(feature = "hybrid")]
1208 let opts = if let Some(embedding) = query_embedding {
1209 opts.with_embedding(embedding).with_hybrid(true)
1210 } else {
1211 opts
1212 };
1213
1214 self.recall_scored_with_options(&opts)
1215 }
1216
1217 #[cfg(feature = "uncertainty")]
1224 pub fn recall_scored_with_min_effective_confidence(
1225 &self,
1226 query: &str,
1227 #[cfg(feature = "hybrid")] query_embedding: Option<&[f32]>,
1228 #[cfg(not(feature = "hybrid"))] _query_embedding: Option<&[f32]>,
1229 limit: usize,
1230 min_effective_confidence: f32,
1231 ) -> Result<Vec<(Fact, RecallScore)>> {
1232 let opts = RecallOptions::new(query)
1233 .with_limit(limit)
1234 .with_min_effective_confidence(min_effective_confidence);
1235
1236 #[cfg(feature = "hybrid")]
1237 let opts = if let Some(embedding) = query_embedding {
1238 opts.with_embedding(embedding).with_hybrid(true)
1239 } else {
1240 opts
1241 };
1242
1243 self.recall_scored_with_options(&opts)
1244 }
1245
1246 pub fn assemble_context(
1259 &self,
1260 query: &str,
1261 query_embedding: Option<&[f32]>,
1262 max_tokens: usize,
1263 ) -> Result<String> {
1264 let scored = self.recall_scored(query, query_embedding, 20)?;
1265 let char_budget = max_tokens.saturating_mul(4); let mut context = String::new();
1267
1268 for (fact, score) in &scored {
1269 let object = match &fact.object {
1270 Value::Text(s) | Value::Entity(s) => s.clone(),
1271 Value::Number(n) => n.to_string(),
1272 Value::Boolean(b) => b.to_string(),
1273 };
1274 let conf_tag = if (score.confidence() - 1.0).abs() > f32::EPSILON {
1277 format!(" conf:{:.1}", score.confidence())
1278 } else {
1279 String::new()
1280 };
1281 let line = format!(
1282 "[{}] ({}{}) {} · {} · {}\n",
1283 fact.valid_from.date_ymd(),
1284 score.display_tag(),
1285 conf_tag,
1286 fact.subject,
1287 fact.predicate,
1288 object
1289 );
1290 if context.len() + line.len() > char_budget {
1291 break;
1292 }
1293 context.push_str(&line);
1294 }
1295
1296 Ok(context)
1297 }
1298
1299 pub fn recall_scored_with_options(
1310 &self,
1311 opts: &RecallOptions<'_>,
1312 ) -> Result<Vec<(Fact, RecallScore)>> {
1313 let score_for_filter = |score: &RecallScore| match opts.confidence_filter_mode {
1314 ConfidenceFilterMode::Base => score.confidence(),
1315 #[cfg(feature = "uncertainty")]
1316 ConfidenceFilterMode::Effective => score
1317 .effective_confidence()
1318 .unwrap_or_else(|| score.confidence()),
1319 };
1320 #[cfg(feature = "hybrid")]
1321 let query_embedding_for_path = if opts.use_hybrid {
1322 opts.query_embedding
1323 } else {
1324 None
1325 };
1326 #[cfg(not(feature = "hybrid"))]
1327 let query_embedding_for_path = None;
1328
1329 match opts.min_confidence {
1330 Some(min_confidence) => {
1331 let min_confidence = normalize_min_confidence(min_confidence)?;
1332 if opts.limit == 0 {
1333 return Ok(Vec::new());
1334 }
1335 if opts.max_scored_rows == 0 {
1336 return Err(Error::Search(
1337 "max_scored_rows must be at least 1".to_string(),
1338 ));
1339 }
1340 let max_scored_rows = opts.max_scored_rows;
1341 #[cfg(feature = "hybrid")]
1342 let is_hybrid_request = query_embedding_for_path.is_some();
1343 #[cfg(not(feature = "hybrid"))]
1344 let is_hybrid_request = false;
1345
1346 if is_hybrid_request {
1349 let scored = self.recall_scored_internal(
1350 opts.query,
1351 query_embedding_for_path,
1352 max_scored_rows,
1353 #[cfg(feature = "hybrid")]
1354 opts.temporal_intent,
1355 #[cfg(feature = "hybrid")]
1356 opts.temporal_operator,
1357 #[cfg(not(feature = "hybrid"))]
1358 (),
1359 #[cfg(not(feature = "hybrid"))]
1360 (),
1361 )?;
1362 let mut filtered = Vec::new();
1363
1364 for (fact, score) in scored {
1365 if score_for_filter(&score) >= min_confidence {
1366 filtered.push((fact, score));
1367 if filtered.len() >= opts.limit {
1368 break;
1369 }
1370 }
1371 }
1372
1373 return Ok(filtered);
1374 }
1375
1376 let mut filtered = Vec::new();
1377 let mut seen_fact_ids: HashSet<FactId> = HashSet::new();
1378 let mut fetch_limit = opts.limit.max(1).min(max_scored_rows);
1379 let mut consecutive_no_confidence_batches = 0u8;
1380
1381 loop {
1382 let scored = self.recall_scored_internal(
1383 opts.query,
1384 query_embedding_for_path,
1385 fetch_limit,
1386 #[cfg(feature = "hybrid")]
1387 opts.temporal_intent,
1388 #[cfg(feature = "hybrid")]
1389 opts.temporal_operator,
1390 #[cfg(not(feature = "hybrid"))]
1391 (),
1392 #[cfg(not(feature = "hybrid"))]
1393 (),
1394 )?;
1395 let mut newly_seen = 0usize;
1396 let mut newly_confident = 0usize;
1397
1398 if scored.is_empty() {
1399 break;
1400 }
1401
1402 for (fact, score) in scored.iter() {
1403 if !seen_fact_ids.insert(fact.id.clone()) {
1404 continue;
1405 }
1406 newly_seen += 1;
1407
1408 if score_for_filter(score) >= min_confidence {
1409 filtered.push((fact.clone(), *score));
1410 newly_confident += 1;
1411 if filtered.len() >= opts.limit {
1412 return Ok(filtered);
1413 }
1414 }
1415 }
1416
1417 if newly_seen == 0 || fetch_limit >= max_scored_rows {
1418 break;
1419 }
1420
1421 if scored.len() < fetch_limit {
1424 break;
1425 }
1426
1427 if newly_confident == 0 {
1430 consecutive_no_confidence_batches =
1431 consecutive_no_confidence_batches.saturating_add(1);
1432 if consecutive_no_confidence_batches >= 2 {
1433 fetch_limit = max_scored_rows;
1434 continue;
1435 }
1436 } else {
1437 consecutive_no_confidence_batches = 0;
1438 }
1439
1440 fetch_limit = (fetch_limit.saturating_mul(2)).min(max_scored_rows);
1441 }
1442
1443 Ok(filtered)
1444 }
1445 None => self.recall_scored_internal(
1446 opts.query,
1447 query_embedding_for_path,
1448 opts.limit,
1449 #[cfg(feature = "hybrid")]
1450 opts.temporal_intent,
1451 #[cfg(feature = "hybrid")]
1452 opts.temporal_operator,
1453 #[cfg(not(feature = "hybrid"))]
1454 (),
1455 #[cfg(not(feature = "hybrid"))]
1456 (),
1457 ),
1458 }
1459 }
1460
1461 pub fn recall_with_options(&self, opts: &RecallOptions<'_>) -> Result<Vec<Fact>> {
1466 self.recall_scored_with_options(opts)
1467 .map(|scored| scored.into_iter().map(|(fact, _)| fact).collect())
1468 }
1469
1470 pub fn assert_with_confidence(
1475 &self,
1476 subject: &str,
1477 predicate: &str,
1478 object: impl Into<Value>,
1479 confidence: f32,
1480 ) -> Result<FactId> {
1481 self.assert_with_confidence_with_params(
1482 subject,
1483 predicate,
1484 object,
1485 AssertParams {
1486 valid_from: KronroeTimestamp::now_utc(),
1487 },
1488 confidence,
1489 )
1490 }
1491
1492 pub fn assert_with_confidence_with_params(
1494 &self,
1495 subject: &str,
1496 predicate: &str,
1497 object: impl Into<Value>,
1498 params: AssertParams,
1499 confidence: f32,
1500 ) -> Result<FactId> {
1501 let confidence = normalize_fact_confidence(confidence)?;
1502 self.graph.assert_fact_with_confidence(
1503 subject,
1504 predicate,
1505 object,
1506 params.valid_from,
1507 confidence,
1508 )
1509 }
1510
1511 pub fn assert_with_source(
1517 &self,
1518 subject: &str,
1519 predicate: &str,
1520 object: impl Into<Value>,
1521 confidence: f32,
1522 source: &str,
1523 ) -> Result<FactId> {
1524 self.assert_with_source_with_params(
1525 subject,
1526 predicate,
1527 object,
1528 AssertParams {
1529 valid_from: KronroeTimestamp::now_utc(),
1530 },
1531 confidence,
1532 source,
1533 )
1534 }
1535
1536 pub fn assert_with_source_with_params(
1538 &self,
1539 subject: &str,
1540 predicate: &str,
1541 object: impl Into<Value>,
1542 params: AssertParams,
1543 confidence: f32,
1544 source: &str,
1545 ) -> Result<FactId> {
1546 let confidence = normalize_fact_confidence(confidence)?;
1547 self.graph.assert_fact_with_source(
1548 subject,
1549 predicate,
1550 object,
1551 params.valid_from,
1552 confidence,
1553 source,
1554 )
1555 }
1556
1557 #[cfg(feature = "uncertainty")]
1565 fn register_default_volatilities(graph: &TemporalGraph) -> Result<()> {
1566 use kronroe::PredicateVolatility;
1567 let defaults = [
1569 ("works_at", PredicateVolatility::new(730.0)),
1570 ("job_title", PredicateVolatility::new(730.0)),
1571 ("lives_in", PredicateVolatility::new(1095.0)),
1572 ("email", PredicateVolatility::new(1460.0)),
1573 ("phone", PredicateVolatility::new(1095.0)),
1574 ("born_in", PredicateVolatility::stable()),
1575 ("full_name", PredicateVolatility::stable()),
1576 ];
1577
1578 for (predicate, volatility) in defaults {
1579 if graph.predicate_volatility(predicate)?.is_none() {
1580 graph.register_predicate_volatility(predicate, volatility)?;
1581 }
1582 }
1583 Ok(())
1584 }
1585
1586 #[cfg(feature = "uncertainty")]
1591 pub fn register_volatility(&self, predicate: &str, half_life_days: f64) -> Result<()> {
1592 use kronroe::PredicateVolatility;
1593 self.graph
1594 .register_predicate_volatility(predicate, PredicateVolatility::new(half_life_days))
1595 }
1596
1597 #[cfg(feature = "uncertainty")]
1602 pub fn register_source_weight(&self, source: &str, weight: f32) -> Result<()> {
1603 use kronroe::SourceWeight;
1604 self.graph
1605 .register_source_weight(source, SourceWeight::new(weight))
1606 }
1607
1608 #[cfg(feature = "uncertainty")]
1613 pub fn effective_confidence_for_fact(
1614 &self,
1615 fact: &Fact,
1616 at: KronroeTimestamp,
1617 ) -> Result<Option<f32>> {
1618 self.graph
1619 .effective_confidence(fact, at)
1620 .map(|eff| Some(eff.value))
1621 }
1622
1623 #[cfg(not(feature = "uncertainty"))]
1628 pub fn effective_confidence_for_fact(
1629 &self,
1630 fact: &Fact,
1631 at: KronroeTimestamp,
1632 ) -> Result<Option<f32>> {
1633 let _ = (fact, at);
1634 Ok(None)
1635 }
1636
1637 fn compute_effective_confidence(&self, fact: &Fact) -> Result<Option<f32>> {
1640 self.effective_confidence_for_fact(fact, KronroeTimestamp::now_utc())
1641 }
1642}
1643
1644#[cfg(test)]
1645mod tests {
1646 use super::*;
1647 use tempfile::NamedTempFile;
1648
1649 fn open_temp_memory() -> (AgentMemory, NamedTempFile) {
1650 let file = NamedTempFile::new().unwrap();
1651 let path = file.path().to_str().unwrap().to_string();
1652 let memory = AgentMemory::open(&path).unwrap();
1653 (memory, file)
1654 }
1655
1656 #[test]
1657 fn assert_and_retrieve() {
1658 let (memory, _tmp) = open_temp_memory();
1659 memory.assert("alice", "works_at", "Acme").unwrap();
1660
1661 let facts = memory.facts_about("alice").unwrap();
1662 assert_eq!(facts.len(), 1);
1663 assert_eq!(facts[0].predicate, "works_at");
1664 }
1665
1666 #[test]
1667 fn multiple_facts_about_entity() {
1668 let (memory, _tmp) = open_temp_memory();
1669
1670 memory
1671 .assert("freya", "attends", "Sunrise Primary")
1672 .unwrap();
1673 memory.assert("freya", "has_ehcp", true).unwrap();
1674 memory.assert("freya", "key_worker", "Sarah Jones").unwrap();
1675
1676 let facts = memory.facts_about("freya").unwrap();
1677 assert_eq!(facts.len(), 3);
1678 }
1679
1680 #[test]
1681 fn test_remember_stores_fact() {
1682 let (mem, _tmp) = open_temp_memory();
1683 let id = mem.remember("Alice loves Rust", "ep-001", None).unwrap();
1684 assert!(id.as_str().starts_with("kf_"));
1685 assert_eq!(id.as_str().len(), 29);
1686
1687 let facts = mem.facts_about("ep-001").unwrap();
1688 assert_eq!(facts.len(), 1);
1689 assert_eq!(facts[0].subject, "ep-001");
1690 assert_eq!(facts[0].predicate, "memory");
1691 assert!(matches!(&facts[0].object, Value::Text(t) if t == "Alice loves Rust"));
1692 }
1693
1694 #[test]
1695 fn test_assert_idempotent_dedupes_same_key() {
1696 let (mem, _tmp) = open_temp_memory();
1697 let first = mem
1698 .assert_idempotent("evt-1", "alice", "works_at", "Acme")
1699 .unwrap();
1700 let second = mem
1701 .assert_idempotent("evt-1", "alice", "works_at", "Acme")
1702 .unwrap();
1703 assert_eq!(first, second);
1704
1705 let facts = mem.facts_about("alice").unwrap();
1706 assert_eq!(facts.len(), 1);
1707 }
1708
1709 #[test]
1710 fn test_assert_idempotent_with_params_uses_valid_from() {
1711 let (mem, _tmp) = open_temp_memory();
1712 let valid_from = KronroeTimestamp::now_utc() - KronroeSpan::days(10);
1713 let first = mem
1714 .assert_idempotent_with_params(
1715 "evt-param-1",
1716 "alice",
1717 "works_at",
1718 "Acme",
1719 AssertParams { valid_from },
1720 )
1721 .unwrap();
1722 let second = mem
1723 .assert_idempotent_with_params(
1724 "evt-param-1",
1725 "alice",
1726 "works_at",
1727 "Acme",
1728 AssertParams {
1729 valid_from: KronroeTimestamp::now_utc(),
1730 },
1731 )
1732 .unwrap();
1733 assert_eq!(first, second);
1734
1735 let facts = mem.facts_about("alice").unwrap();
1736 assert_eq!(facts.len(), 1);
1737 assert!((facts[0].valid_from - valid_from).num_seconds().abs() < 1);
1738 }
1739
1740 #[test]
1741 fn test_remember_idempotent_dedupes_same_key() {
1742 let (mem, _tmp) = open_temp_memory();
1743 let first = mem
1744 .remember_idempotent("evt-memory-1", "Alice loves Rust", "ep-001")
1745 .unwrap();
1746 let second = mem
1747 .remember_idempotent("evt-memory-1", "Alice loves Rust", "ep-001")
1748 .unwrap();
1749 assert_eq!(first, second);
1750
1751 let facts = mem.facts_about("ep-001").unwrap();
1752 assert_eq!(facts.len(), 1);
1753 }
1754
1755 #[test]
1756 fn test_recall_returns_matching_facts() {
1757 let (mem, _tmp) = open_temp_memory();
1758 mem.remember("Alice loves Rust programming", "ep-001", None)
1759 .unwrap();
1760 mem.remember("Bob prefers Python for data science", "ep-002", None)
1761 .unwrap();
1762
1763 let results = mem.recall("Rust", None, 5).unwrap();
1764 assert!(!results.is_empty(), "should find Rust-related facts");
1765 let has_rust = results
1766 .iter()
1767 .any(|f| matches!(&f.object, Value::Text(t) if t.contains("Rust")));
1768 assert!(has_rust);
1769 }
1770
1771 #[test]
1772 fn recall_for_task_returns_subject_focused_report() {
1773 let (mem, _tmp) = open_temp_memory();
1774 let old = KronroeTimestamp::now_utc() - KronroeSpan::days(120);
1775 mem.assert_with_confidence_with_params(
1776 "alice",
1777 "works_at",
1778 "Acme",
1779 AssertParams { valid_from: old },
1780 0.65,
1781 )
1782 .unwrap();
1783 mem.assert_with_confidence("alice", "project", "Renewal Q2", 0.95)
1784 .unwrap();
1785 mem.assert_with_confidence("bob", "project", "Other deal", 0.99)
1786 .unwrap();
1787
1788 let report = mem
1789 .recall_for_task(
1790 "prepare renewal call",
1791 Some("alice"),
1792 None,
1793 Some(90),
1794 10,
1795 None,
1796 )
1797 .unwrap();
1798
1799 assert_eq!(report.subject.as_deref(), Some("alice"));
1800 assert!(
1801 !report.key_facts.is_empty(),
1802 "expected task facts for alice"
1803 );
1804 assert!(
1805 report.key_facts.iter().all(|fact| fact.subject == "alice"),
1806 "task report should stay focused on the requested subject"
1807 );
1808 assert!(report.low_confidence_count >= 1);
1809 assert!(report.stale_high_impact_count >= 1);
1810 assert!(!report.recommended_next_checks.is_empty());
1811 }
1812
1813 #[test]
1814 fn recall_for_task_subject_scope_fetches_beyond_initial_limit() {
1815 let (mem, _tmp) = open_temp_memory();
1816 for i in 0..20 {
1817 mem.assert_with_confidence(
1818 &format!("bob-{i}"),
1819 "project_note",
1820 "alice project urgent",
1821 0.95,
1822 )
1823 .unwrap();
1824 }
1825 mem.assert_with_confidence("alice", "project_note", "project", 0.9)
1826 .unwrap();
1827
1828 let report = mem
1829 .recall_for_task("project", Some("alice"), None, Some(30), 1, None)
1830 .unwrap();
1831
1832 assert_eq!(report.subject.as_deref(), Some("alice"));
1833 assert_eq!(report.key_facts.len(), 1);
1834 assert_eq!(report.key_facts[0].subject, "alice");
1835 }
1836
1837 #[test]
1838 fn test_assemble_context_returns_string() {
1839 let (mem, _tmp) = open_temp_memory();
1840 mem.remember("Alice is a Rust expert", "ep-001", None)
1841 .unwrap();
1842 mem.remember("Bob is a Python expert", "ep-002", None)
1843 .unwrap();
1844
1845 let ctx = mem.assemble_context("expert", None, 500).unwrap();
1846 assert!(!ctx.is_empty(), "context should not be empty");
1847 assert!(
1848 ctx.contains("expert"),
1849 "context should contain relevant facts"
1850 );
1851 }
1852
1853 #[test]
1854 fn test_assemble_context_respects_token_limit() {
1855 let (mem, _tmp) = open_temp_memory();
1856 for i in 0..20 {
1857 mem.remember(
1858 &format!("fact number {} is quite long and wordy", i),
1859 &format!("ep-{}", i),
1860 None,
1861 )
1862 .unwrap();
1863 }
1864 let ctx = mem.assemble_context("fact", None, 50).unwrap();
1865 assert!(ctx.len() <= 220, "context should respect max_tokens");
1866 }
1867
1868 #[cfg(feature = "hybrid")]
1869 #[test]
1870 fn test_remember_with_embedding() {
1871 let (mem, _tmp) = open_temp_memory();
1872 let id = mem
1873 .remember("Bob likes Python", "ep-002", Some(vec![0.1f32, 0.2, 0.3]))
1874 .unwrap();
1875 assert!(id.as_str().starts_with("kf_"));
1876 assert_eq!(id.as_str().len(), 29);
1877 }
1878
1879 #[cfg(feature = "hybrid")]
1880 #[test]
1881 fn test_recall_with_query_embedding() {
1882 let (mem, _tmp) = open_temp_memory();
1883 mem.remember("Rust systems", "ep-rust", Some(vec![1.0f32, 0.0]))
1884 .unwrap();
1885 mem.remember("Python notebooks", "ep-py", Some(vec![0.0f32, 1.0]))
1886 .unwrap();
1887
1888 let hits = mem.recall("language", Some(&[1.0, 0.0]), 1).unwrap();
1889 assert_eq!(hits.len(), 1);
1890 assert_eq!(hits[0].subject, "ep-rust");
1891 }
1892
1893 #[cfg(feature = "hybrid")]
1894 #[test]
1895 fn recall_for_task_accepts_query_embedding_for_hybrid_path() {
1896 let (mem, _tmp) = open_temp_memory();
1897 mem.remember(
1898 "Alice focuses on Rust API reliability",
1899 "alice",
1900 Some(vec![1.0, 0.0]),
1901 )
1902 .unwrap();
1903 mem.remember("Bob focuses on ML experiments", "bob", Some(vec![0.0, 1.0]))
1904 .unwrap();
1905
1906 let report = mem
1907 .recall_for_task(
1908 "prepare reliability review",
1909 Some("alice"),
1910 None,
1911 Some(30),
1912 5,
1913 Some(&[1.0, 0.0]),
1914 )
1915 .unwrap();
1916 assert!(!report.key_facts.is_empty());
1917 assert_eq!(report.subject.as_deref(), Some("alice"));
1918 }
1919
1920 #[cfg(feature = "hybrid")]
1921 #[test]
1922 fn recall_with_embedding_without_hybrid_toggle_is_text_scored() {
1923 let (mem, _tmp) = open_temp_memory();
1924 mem.remember("Rust systems", "ep-rust", Some(vec![1.0f32, 0.0]))
1925 .unwrap();
1926 mem.remember("Python notebooks", "ep-py", Some(vec![0.0f32, 1.0]))
1927 .unwrap();
1928
1929 let opts = RecallOptions::new("Rust")
1930 .with_embedding(&[1.0, 0.0])
1931 .with_limit(2);
1932 let results = mem.recall_scored_with_options(&opts).unwrap();
1933 assert!(!results.is_empty());
1934 assert!(matches!(results[0].1, RecallScore::TextOnly { .. }));
1935 }
1936
1937 #[cfg(feature = "contradiction")]
1938 #[test]
1939 fn assert_checked_detects_contradiction() {
1940 let (mem, _tmp) = open_temp_memory();
1941 mem.assert("alice", "works_at", "Acme").unwrap();
1943 let (id, contradictions) = mem
1944 .assert_checked("alice", "works_at", "Beta Corp")
1945 .unwrap();
1946 assert!(!id.as_str().is_empty());
1947 assert_eq!(contradictions.len(), 1);
1948 assert_eq!(contradictions[0].predicate, "works_at");
1949 }
1950
1951 #[cfg(feature = "contradiction")]
1952 #[test]
1953 fn default_singletons_registered() {
1954 let (mem, _tmp) = open_temp_memory();
1955 mem.assert("bob", "lives_in", "London").unwrap();
1957 let (_, contradictions) = mem.assert_checked("bob", "lives_in", "Paris").unwrap();
1958 assert_eq!(
1959 contradictions.len(),
1960 1,
1961 "lives_in should be a registered singleton"
1962 );
1963 }
1964
1965 #[cfg(feature = "contradiction")]
1966 #[test]
1967 fn audit_returns_contradictions_for_subject() {
1968 let (mem, _tmp) = open_temp_memory();
1969 mem.assert("alice", "works_at", "Acme").unwrap();
1970 mem.assert("alice", "works_at", "Beta").unwrap();
1971 mem.assert("bob", "works_at", "Gamma").unwrap(); let contradictions = mem.audit("alice").unwrap();
1974 assert_eq!(contradictions.len(), 1);
1975 assert_eq!(contradictions[0].subject, "alice");
1976
1977 let bob_contradictions = mem.audit("bob").unwrap();
1978 assert!(bob_contradictions.is_empty());
1979 }
1980
1981 #[cfg(feature = "contradiction")]
1982 #[test]
1983 fn reject_policy_survives_reopen() {
1984 let file = NamedTempFile::new().unwrap();
1986 let path = file.path().to_str().unwrap().to_string();
1987
1988 {
1990 let graph = kronroe::TemporalGraph::open(&path).unwrap();
1991 graph
1992 .register_singleton_predicate("works_at", ConflictPolicy::Reject)
1993 .unwrap();
1994 graph
1995 .assert_fact("alice", "works_at", "Acme", KronroeTimestamp::now_utc())
1996 .unwrap();
1997 }
1998
1999 let mem = AgentMemory::open(&path).unwrap();
2001 let result = mem.assert_checked("alice", "works_at", "Beta Corp");
2002 assert!(
2003 result.is_err(),
2004 "Reject policy should survive AgentMemory::open() reopen"
2005 );
2006 }
2007
2008 #[cfg(feature = "uncertainty")]
2009 #[test]
2010 fn default_volatility_registration_preserves_custom_entry() {
2011 let file = NamedTempFile::new().unwrap();
2012 let path = file.path().to_str().unwrap().to_string();
2013
2014 {
2015 let graph = kronroe::TemporalGraph::open(&path).unwrap();
2016 graph
2017 .register_predicate_volatility("works_at", kronroe::PredicateVolatility::new(1.0))
2018 .unwrap();
2019 }
2020
2021 {
2024 let _mem = AgentMemory::open(&path).unwrap();
2025 }
2026
2027 let graph = kronroe::TemporalGraph::open(&path).unwrap();
2028 let vol = graph
2029 .predicate_volatility("works_at")
2030 .unwrap()
2031 .expect("volatility should be persisted");
2032
2033 assert!(
2034 (vol.half_life_days - 1.0).abs() < f64::EPSILON,
2035 "custom volatility should survive default bootstrap, got {}",
2036 vol.half_life_days
2037 );
2038 }
2039
2040 #[cfg(feature = "hybrid")]
2041 #[test]
2042 fn test_recall_hybrid_uses_text_and_vector_signals() {
2043 let (mem, _tmp) = open_temp_memory();
2044 mem.remember("rare-rust-token", "ep-rust", Some(vec![1.0f32, 0.0]))
2045 .unwrap();
2046 mem.remember("completely different", "ep-py", Some(vec![0.0f32, 1.0]))
2047 .unwrap();
2048
2049 let hits = mem.recall("rare-rust-token", Some(&[0.0, 1.0]), 1).unwrap();
2053 assert_eq!(hits.len(), 1);
2054 assert_eq!(hits[0].subject, "ep-rust");
2055 }
2056
2057 #[test]
2062 fn recall_scored_text_only_returns_ranks_and_bm25() {
2063 let (mem, _tmp) = open_temp_memory();
2064 mem.remember("Alice loves Rust programming", "ep-001", None)
2065 .unwrap();
2066 mem.remember("Bob also enjoys Rust deeply", "ep-002", None)
2067 .unwrap();
2068
2069 let scored = mem.recall_scored("Rust", None, 5).unwrap();
2070 assert!(!scored.is_empty(), "should find Rust-related facts");
2071
2072 for (i, (_fact, score)) in scored.iter().enumerate() {
2074 match score {
2075 RecallScore::TextOnly {
2076 rank,
2077 bm25_score,
2078 confidence,
2079 ..
2080 } => {
2081 assert_eq!(*rank, i);
2082 assert!(
2083 *bm25_score > 0.0,
2084 "BM25 should be positive, got {bm25_score}"
2085 );
2086 assert!(
2087 (*confidence - 1.0).abs() < f32::EPSILON,
2088 "default confidence should be 1.0"
2089 );
2090 }
2091 RecallScore::Hybrid { .. } => {
2092 panic!("expected TextOnly variant without embedding")
2093 }
2094 }
2095 }
2096 }
2097
2098 #[test]
2099 fn recall_scored_bm25_higher_for_better_match() {
2100 let (mem, _tmp) = open_temp_memory();
2101 mem.remember("Rust Rust Rust programming language", "ep-strong", None)
2103 .unwrap();
2104 mem.remember("I once heard of Rust somewhere", "ep-weak", None)
2105 .unwrap();
2106
2107 let scored = mem.recall_scored("Rust", None, 5).unwrap();
2108 assert!(scored.len() >= 2);
2109
2110 let bm25_first = match scored[0].1 {
2112 RecallScore::TextOnly { bm25_score, .. } => bm25_score,
2113 _ => panic!("expected TextOnly"),
2114 };
2115 let bm25_second = match scored[1].1 {
2116 RecallScore::TextOnly { bm25_score, .. } => bm25_score,
2117 _ => panic!("expected TextOnly"),
2118 };
2119 assert!(
2120 bm25_first >= bm25_second,
2121 "first result should have higher BM25: {bm25_first} vs {bm25_second}"
2122 );
2123 }
2124
2125 #[test]
2126 fn recall_scored_preserves_fact_content() {
2127 let (mem, _tmp) = open_temp_memory();
2128 mem.remember("Kronroe is a temporal graph database", "ep-001", None)
2129 .unwrap();
2130
2131 let scored = mem.recall_scored("temporal", None, 5).unwrap();
2132 assert_eq!(scored.len(), 1);
2133
2134 let (fact, _score) = &scored[0];
2135 assert_eq!(fact.subject, "ep-001");
2136 assert_eq!(fact.predicate, "memory");
2137 assert!(matches!(&fact.object, Value::Text(t) if t.contains("temporal")));
2138 }
2139
2140 #[test]
2141 fn recall_score_confidence_accessor() {
2142 let text = RecallScore::TextOnly {
2144 rank: 0,
2145 bm25_score: 1.0,
2146 confidence: 0.8,
2147 effective_confidence: None,
2148 };
2149 assert!((text.confidence() - 0.8).abs() < f32::EPSILON);
2150 }
2151
2152 #[cfg(feature = "hybrid")]
2153 #[test]
2154 fn recall_score_confidence_accessor_hybrid() {
2155 let hybrid = RecallScore::Hybrid {
2156 rrf_score: 0.1,
2157 text_contrib: 0.05,
2158 vector_contrib: 0.05,
2159 confidence: 0.9,
2160 effective_confidence: None,
2161 };
2162 assert!((hybrid.confidence() - 0.9).abs() < f32::EPSILON);
2163 }
2164
2165 #[cfg(feature = "hybrid")]
2166 #[test]
2167 fn recall_scored_hybrid_returns_breakdown() {
2168 let (mem, _tmp) = open_temp_memory();
2169 mem.remember(
2170 "Rust systems programming",
2171 "ep-rust",
2172 Some(vec![1.0f32, 0.0]),
2173 )
2174 .unwrap();
2175 mem.remember("Python data science", "ep-py", Some(vec![0.0f32, 1.0]))
2176 .unwrap();
2177
2178 let scored = mem.recall_scored("Rust", Some(&[1.0, 0.0]), 2).unwrap();
2179 assert!(!scored.is_empty());
2180
2181 for (_fact, score) in &scored {
2183 match score {
2184 RecallScore::Hybrid {
2185 rrf_score,
2186 text_contrib,
2187 vector_contrib,
2188 confidence,
2189 ..
2190 } => {
2191 assert!(
2192 *rrf_score >= 0.0,
2193 "RRF score should be non-negative, got {rrf_score}"
2194 );
2195 assert!(
2196 *text_contrib >= 0.0,
2197 "text contrib should be non-negative, got {text_contrib}"
2198 );
2199 assert!(
2200 *vector_contrib >= 0.0,
2201 "vector contrib should be non-negative, got {vector_contrib}"
2202 );
2203 assert!(
2204 (*confidence - 1.0).abs() < f32::EPSILON,
2205 "default confidence should be 1.0"
2206 );
2207 }
2208 RecallScore::TextOnly { .. } => {
2209 panic!("expected Hybrid variant with embedding")
2210 }
2211 }
2212 }
2213 }
2214
2215 #[cfg(feature = "hybrid")]
2216 #[test]
2217 fn recall_scored_hybrid_text_dominant_has_higher_text_contrib() {
2218 let (mem, _tmp) = open_temp_memory();
2219 mem.remember(
2222 "unique-xyzzy-token for testing",
2223 "ep-text",
2224 Some(vec![1.0f32, 0.0]),
2225 )
2226 .unwrap();
2227
2228 let scored = mem
2229 .recall_scored("unique-xyzzy-token", Some(&[0.0, 1.0]), 1)
2230 .unwrap();
2231 assert_eq!(scored.len(), 1);
2232
2233 match &scored[0].1 {
2234 RecallScore::Hybrid {
2235 text_contrib,
2236 vector_contrib,
2237 ..
2238 } => {
2239 assert!(
2240 text_contrib > vector_contrib,
2241 "text should dominate when query text matches but vector is orthogonal: \
2242 text={text_contrib}, vector={vector_contrib}"
2243 );
2244 }
2245 _ => panic!("expected Hybrid variant"),
2246 }
2247 }
2248
2249 #[test]
2250 fn recall_score_display_tag() {
2251 let text_score = RecallScore::TextOnly {
2252 rank: 0,
2253 bm25_score: 4.21,
2254 confidence: 1.0,
2255 effective_confidence: None,
2256 };
2257 assert_eq!(text_score.display_tag(), "#1 bm25:4.21");
2258
2259 let text_score_5 = RecallScore::TextOnly {
2260 rank: 4,
2261 bm25_score: 1.50,
2262 confidence: 1.0,
2263 effective_confidence: None,
2264 };
2265 assert_eq!(text_score_5.display_tag(), "#5 bm25:1.50");
2266 }
2267
2268 #[cfg(feature = "hybrid")]
2269 #[test]
2270 fn recall_score_display_tag_hybrid() {
2271 let hybrid_score = RecallScore::Hybrid {
2272 rrf_score: 0.0325,
2273 text_contrib: 0.02,
2274 vector_contrib: 0.0125,
2275 confidence: 1.0,
2276 effective_confidence: None,
2277 };
2278 assert_eq!(hybrid_score.display_tag(), "0.033");
2279 }
2280
2281 #[test]
2282 fn assemble_context_includes_score_tag() {
2283 let (mem, _tmp) = open_temp_memory();
2284 mem.remember("Alice is a Rust expert", "ep-001", None)
2285 .unwrap();
2286
2287 let ctx = mem.assemble_context("Rust", None, 500).unwrap();
2288 assert!(!ctx.is_empty());
2289 assert!(
2291 ctx.contains("(#1 bm25:"),
2292 "context should contain text-only rank+bm25 tag, got: {ctx}"
2293 );
2294 }
2295
2296 #[test]
2297 fn assemble_context_omits_confidence_at_default() {
2298 let (mem, _tmp) = open_temp_memory();
2299 mem.remember("Alice is a Rust expert", "ep-001", None)
2300 .unwrap();
2301
2302 let ctx = mem.assemble_context("Rust", None, 500).unwrap();
2303 assert!(
2305 !ctx.contains("conf:"),
2306 "default confidence should not appear in context, got: {ctx}"
2307 );
2308 }
2309
2310 #[cfg(feature = "hybrid")]
2311 #[test]
2312 fn assemble_context_hybrid_includes_rrf_score() {
2313 let (mem, _tmp) = open_temp_memory();
2314 mem.remember("Rust systems", "ep-rust", Some(vec![1.0f32, 0.0]))
2315 .unwrap();
2316
2317 let ctx = mem
2318 .assemble_context("Rust", Some(&[1.0, 0.0]), 500)
2319 .unwrap();
2320 assert!(!ctx.is_empty());
2321 assert!(
2323 ctx.contains("(0."),
2324 "context should contain hybrid RRF score tag, got: {ctx}"
2325 );
2326 }
2327
2328 #[test]
2331 fn recall_options_default_limit() {
2332 let opts = RecallOptions::new("test query");
2333 assert_eq!(opts.limit, 10);
2334 assert!(opts.query_embedding.is_none());
2335 assert!(opts.min_confidence.is_none());
2336 assert_eq!(opts.max_scored_rows, 4_096);
2337 }
2338
2339 #[test]
2340 fn assert_with_confidence_round_trip() {
2341 let (mem, _tmp) = open_temp_memory();
2342 mem.assert_with_confidence("alice", "works_at", "Acme", 0.8)
2343 .unwrap();
2344
2345 let facts = mem.facts_about("alice").unwrap();
2346 assert_eq!(facts.len(), 1);
2347 assert!(
2348 (facts[0].confidence - 0.8).abs() < f32::EPSILON,
2349 "confidence should be 0.8, got {}",
2350 facts[0].confidence,
2351 );
2352 }
2353
2354 #[test]
2355 fn assert_with_confidence_rejects_non_finite() {
2356 let (mem, _tmp) = open_temp_memory();
2357
2358 for confidence in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
2359 let err = mem.assert_with_confidence("alice", "works_at", "Rust", confidence);
2360 match err {
2361 Err(Error::Search(msg)) => {
2362 assert!(msg.contains("finite"), "unexpected search error: {msg}")
2363 }
2364 _ => panic!("expected search error for confidence={confidence:?}"),
2365 }
2366 }
2367 }
2368
2369 #[test]
2370 fn recall_with_min_confidence_filters() {
2371 let (mem, _tmp) = open_temp_memory();
2372 mem.assert_with_confidence("ep-low", "memory", "low confidence fact about Rust", 0.3)
2374 .unwrap();
2375 mem.assert_with_confidence("ep-high", "memory", "high confidence fact about Rust", 0.9)
2376 .unwrap();
2377
2378 let all = mem.recall("Rust", None, 10).unwrap();
2380 assert_eq!(all.len(), 2, "both facts should be returned without filter");
2381
2382 let opts = RecallOptions::new("Rust")
2384 .with_limit(10)
2385 .with_min_confidence(0.5);
2386 let filtered = mem.recall_with_options(&opts).unwrap();
2387 assert_eq!(
2388 filtered.len(),
2389 1,
2390 "only high-confidence fact should pass filter"
2391 );
2392 assert!(
2393 (filtered[0].confidence - 0.9).abs() < f32::EPSILON,
2394 "surviving fact should have confidence 0.9, got {}",
2395 filtered[0].confidence,
2396 );
2397 }
2398
2399 #[test]
2400 fn assemble_context_shows_confidence_tag() {
2401 let (mem, _tmp) = open_temp_memory();
2402 mem.assert_with_confidence("ep-test", "memory", "notable fact about testing", 0.7)
2403 .unwrap();
2404
2405 let ctx = mem.assemble_context("testing", None, 500).unwrap();
2406 assert!(
2407 ctx.contains("conf:0.7"),
2408 "context should include conf:0.7 tag for non-default confidence, got: {ctx}"
2409 );
2410 }
2411
2412 #[test]
2413 fn recall_scored_with_options_respects_limit() {
2414 let (mem, _tmp) = open_temp_memory();
2415 for i in 0..5 {
2416 mem.assert_with_confidence(
2417 &format!("ep-{i}"),
2418 "memory",
2419 format!("fact number {i} about coding"),
2420 1.0,
2421 )
2422 .unwrap();
2423 }
2424
2425 let opts = RecallOptions::new("coding").with_limit(2);
2426 let results = mem.recall_scored_with_options(&opts).unwrap();
2427 assert!(
2428 results.len() <= 2,
2429 "should respect limit=2, got {} results",
2430 results.len(),
2431 );
2432 }
2433
2434 #[test]
2435 fn recall_scored_with_options_filters_confidence_before_limit() {
2436 let (mem, _tmp) = open_temp_memory();
2437 mem.assert_with_confidence("low-1", "memory", "rust rust rust rust rust", 0.2)
2438 .unwrap();
2439 mem.assert_with_confidence("low-2", "memory", "rust rust rust rust rust", 0.1)
2440 .unwrap();
2441 mem.assert_with_confidence("high", "memory", "rust", 0.9)
2442 .unwrap();
2443
2444 let opts = RecallOptions::new("rust")
2445 .with_limit(1)
2446 .with_min_confidence(0.9);
2447 let results = mem.recall_scored_with_options(&opts).unwrap();
2448
2449 assert_eq!(
2450 results.len(),
2451 1,
2452 "expected one surviving result after filtering"
2453 );
2454 assert_eq!(results[0].0.subject, "high");
2455 assert!(
2456 (results[0].1.confidence() - 0.9).abs() < f32::EPSILON,
2457 "surviving result should keep confidence=0.9"
2458 );
2459 }
2460
2461 #[test]
2462 fn recall_scored_with_options_normalizes_min_confidence_bounds() {
2463 let (mem, _tmp) = open_temp_memory();
2464 mem.assert_with_confidence("high", "memory", "rust", 1.0)
2465 .unwrap();
2466 mem.assert_with_confidence("low", "memory", "rust", 0.1)
2467 .unwrap();
2468
2469 let opts = RecallOptions::new("rust")
2470 .with_limit(2)
2471 .with_min_confidence(2.0);
2472 let results = mem.recall_scored_with_options(&opts).unwrap();
2473 assert_eq!(
2474 results.len(),
2475 1,
2476 "min confidence above 1.0 should be clamped to 1.0"
2477 );
2478 assert!(
2479 (results[0].1.confidence() - 1.0).abs() < f32::EPSILON,
2480 "surviving row should use clamped threshold 1.0"
2481 );
2482
2483 let opts = RecallOptions::new("rust")
2484 .with_limit(2)
2485 .with_min_confidence(-1.0);
2486 let results = mem.recall_scored_with_options(&opts).unwrap();
2487 assert_eq!(
2488 results.len(),
2489 2,
2490 "min confidence below 0.0 should be clamped to 0.0"
2491 );
2492 }
2493
2494 #[test]
2495 fn recall_scored_with_options_rejects_non_finite_min_confidence() {
2496 let (mem, _tmp) = open_temp_memory();
2497 mem.assert_with_confidence("ep", "memory", "rust", 1.0)
2498 .unwrap();
2499
2500 let opts = RecallOptions::new("rust")
2501 .with_limit(2)
2502 .with_min_confidence(f32::NAN);
2503 let err = mem.recall_scored_with_options(&opts).unwrap_err();
2504 match err {
2505 Error::Search(msg) => assert!(
2506 msg.contains("minimum confidence"),
2507 "unexpected search error: {msg}"
2508 ),
2509 _ => panic!("expected search error for NaN min confidence, got {err:?}"),
2510 }
2511 }
2512
2513 #[test]
2514 fn recall_scored_with_options_respects_scored_rows_cap() {
2515 let (mem, _tmp) = open_temp_memory();
2516 for i in 0..5 {
2517 mem.assert_with_confidence(&format!("ep-{i}"), "memory", "rust and memory", 1.0)
2518 .unwrap();
2519 }
2520
2521 let opts = RecallOptions::new("rust")
2522 .with_limit(5)
2523 .with_min_confidence(0.0)
2524 .with_max_scored_rows(2);
2525 let results = mem.recall_scored_with_options(&opts).unwrap();
2526 assert_eq!(
2527 results.len(),
2528 2,
2529 "max_scored_rows should bound the effective recall window in filtered mode"
2530 );
2531 }
2532
2533 #[cfg(feature = "uncertainty")]
2534 #[test]
2535 fn recall_scored_with_options_effective_confidence_respects_scored_rows_cap() {
2536 let (mem, _tmp) = open_temp_memory();
2537 for i in 0..5 {
2538 mem.assert_with_source(
2539 &format!("ep-{i}"),
2540 "memory",
2541 "rust and memory",
2542 1.0,
2543 "user:owner",
2544 )
2545 .unwrap();
2546 }
2547
2548 let opts = RecallOptions::new("rust")
2549 .with_limit(5)
2550 .with_min_effective_confidence(0.5)
2551 .with_max_scored_rows(2);
2552 let results = mem.recall_scored_with_options(&opts).unwrap();
2553 assert_eq!(
2554 results.len(),
2555 2,
2556 "effective-confidence path should honor max_scored_rows cap"
2557 );
2558 }
2559
2560 #[cfg(all(feature = "hybrid", feature = "uncertainty"))]
2561 #[test]
2562 fn recall_scored_with_options_hybrid_effective_confidence_respects_scored_rows_cap() {
2563 let (mem, _tmp) = open_temp_memory();
2564 for i in 0..5 {
2565 mem.remember(
2566 "rust memory entry",
2567 &format!("ep-{i}"),
2568 Some(vec![1.0f32, 0.0]),
2569 )
2570 .unwrap();
2571 }
2572
2573 let opts = RecallOptions::new("rust")
2574 .with_embedding(&[1.0, 0.0])
2575 .with_limit(5)
2576 .with_min_effective_confidence(0.0)
2577 .with_max_scored_rows(2);
2578 let results = mem.recall_scored_with_options(&opts).unwrap();
2579 assert_eq!(
2580 results.len(),
2581 2,
2582 "hybrid effective-confidence path should honor max_scored_rows cap"
2583 );
2584 }
2585
2586 #[test]
2587 fn recall_scored_with_options_rejects_zero_max_scored_rows() {
2588 let (mem, _tmp) = open_temp_memory();
2589 mem.assert_with_confidence("ep", "memory", "rust", 1.0)
2590 .unwrap();
2591
2592 let opts = RecallOptions::new("rust")
2593 .with_limit(1)
2594 .with_min_confidence(0.0)
2595 .with_max_scored_rows(0);
2596 let err = mem.recall_scored_with_options(&opts).unwrap_err();
2597 match err {
2598 Error::Search(msg) => assert!(
2599 msg.contains("max_scored_rows"),
2600 "unexpected search error: {msg}"
2601 ),
2602 _ => panic!("expected search error for max_scored_rows=0, got {err:?}"),
2603 }
2604 }
2605
2606 #[test]
2607 fn recall_with_min_confidence_method_filters_before_limit() {
2608 let (mem, _tmp) = open_temp_memory();
2609 mem.assert_with_confidence("low-1", "memory", "rust rust rust rust rust", 0.2)
2610 .unwrap();
2611 mem.assert_with_confidence("low-2", "memory", "rust rust rust rust rust", 0.1)
2612 .unwrap();
2613 mem.assert_with_confidence("high", "memory", "rust", 0.9)
2614 .unwrap();
2615
2616 let results = mem
2617 .recall_with_min_confidence("Rust", None, 1, 0.9)
2618 .unwrap();
2619
2620 assert_eq!(
2621 results.len(),
2622 1,
2623 "expected one surviving result after filtering"
2624 );
2625 assert_eq!(results[0].subject, "high");
2626 }
2627
2628 #[test]
2629 fn recall_scored_with_min_confidence_method_respects_limit() {
2630 let (mem, _tmp) = open_temp_memory();
2631 mem.assert_with_confidence("low", "memory", "rust rust rust rust", 0.2)
2632 .unwrap();
2633 mem.assert_with_confidence("high-2", "memory", "rust", 0.95)
2634 .unwrap();
2635 mem.assert_with_confidence("high-1", "memory", "rust", 0.98)
2636 .unwrap();
2637
2638 let scored = mem
2639 .recall_scored_with_min_confidence("Rust", None, 2, 0.9)
2640 .unwrap();
2641
2642 assert_eq!(scored.len(), 2, "expected exactly 2 surviving results");
2643 assert!(scored[0].1.confidence() >= 0.9);
2644 assert!(scored[1].1.confidence() >= 0.9);
2645 }
2646
2647 #[test]
2648 fn recall_scored_with_min_confidence_matches_options_path() {
2649 let (mem, _tmp) = open_temp_memory();
2650 mem.assert_with_confidence("low", "memory", "rust rust rust", 0.2)
2651 .unwrap();
2652 mem.assert_with_confidence("high", "memory", "rust", 0.95)
2653 .unwrap();
2654 mem.assert_with_confidence("high-2", "memory", "rust for sure", 0.99)
2655 .unwrap();
2656
2657 let method_results = mem
2658 .recall_scored_with_min_confidence("Rust", None, 2, 0.9)
2659 .unwrap()
2660 .into_iter()
2661 .map(|(fact, _)| fact.id)
2662 .collect::<Vec<_>>();
2663
2664 let opts = RecallOptions::new("Rust")
2665 .with_limit(2)
2666 .with_min_confidence(0.9);
2667 let options_results = mem
2668 .recall_scored_with_options(&opts)
2669 .unwrap()
2670 .into_iter()
2671 .map(|(fact, _)| fact.id)
2672 .collect::<Vec<_>>();
2673
2674 assert_eq!(method_results, options_results);
2675 }
2676
2677 #[test]
2678 fn assert_with_source_round_trip() {
2679 let (mem, _tmp) = open_temp_memory();
2680 mem.assert_with_source("alice", "works_at", "Acme", 0.9, "user:owner")
2681 .unwrap();
2682
2683 let facts = mem.facts_about("alice").unwrap();
2684 assert_eq!(facts.len(), 1);
2685 assert_eq!(facts[0].source.as_deref(), Some("user:owner"));
2686 assert!((facts[0].confidence - 0.9).abs() < f32::EPSILON);
2687 }
2688
2689 #[test]
2690 fn assert_with_confidence_with_params_uses_valid_from() {
2691 let (mem, _tmp) = open_temp_memory();
2692 let valid_from = KronroeTimestamp::now_utc() - KronroeSpan::days(90);
2693 mem.assert_with_confidence_with_params(
2694 "alice",
2695 "worked_at",
2696 "Acme",
2697 AssertParams { valid_from },
2698 0.7,
2699 )
2700 .unwrap();
2701
2702 let facts = mem.facts_about("alice").unwrap();
2703 assert_eq!(facts.len(), 1);
2704 assert!((facts[0].valid_from - valid_from).num_seconds().abs() < 1);
2705 assert!((facts[0].confidence - 0.7).abs() < f32::EPSILON);
2706 }
2707
2708 #[test]
2709 fn assert_with_source_with_params_uses_valid_from() {
2710 let (mem, _tmp) = open_temp_memory();
2711 let valid_from = KronroeTimestamp::now_utc() - KronroeSpan::days(45);
2712 mem.assert_with_source_with_params(
2713 "alice",
2714 "works_at",
2715 "Acme",
2716 AssertParams { valid_from },
2717 0.85,
2718 "agent:planner",
2719 )
2720 .unwrap();
2721
2722 let facts = mem.facts_about("alice").unwrap();
2723 assert_eq!(facts.len(), 1);
2724 assert_eq!(facts[0].source.as_deref(), Some("agent:planner"));
2725 assert_eq!(facts[0].predicate, "works_at");
2726 assert!((facts[0].valid_from - valid_from).num_seconds().abs() < 1);
2727 assert!((facts[0].confidence - 0.85).abs() < f32::EPSILON);
2728 }
2729
2730 #[test]
2731 fn what_changed_reports_new_invalidated_and_confidence_shift() {
2732 let (mem, _tmp) = open_temp_memory();
2733 let original_id = mem
2734 .assert_with_params(
2735 "alice",
2736 "works_at",
2737 "Acme",
2738 AssertParams {
2739 valid_from: KronroeTimestamp::now_utc() - KronroeSpan::days(365),
2740 },
2741 )
2742 .unwrap();
2743
2744 let since = KronroeTimestamp::now_utc();
2745 mem.invalidate_fact(&original_id).unwrap();
2746
2747 let original_fact = mem
2748 .facts_about("alice")
2749 .unwrap()
2750 .into_iter()
2751 .find(|fact| fact.id == original_id)
2752 .expect("original fact should still exist in history");
2753 let replacement_valid_from = original_fact
2754 .expired_at
2755 .expect("invalidated fact should have expired_at");
2756
2757 let replacement_id = mem
2758 .assert_with_confidence_with_params(
2759 "alice",
2760 "works_at",
2761 "Beta Corp",
2762 AssertParams {
2763 valid_from: replacement_valid_from,
2764 },
2765 0.6,
2766 )
2767 .unwrap();
2768
2769 let report = mem
2770 .what_changed("alice", since, Some("works_at"))
2771 .expect("what_changed should succeed");
2772 assert_eq!(report.new_facts.len(), 1);
2773 assert_eq!(report.new_facts[0].id, replacement_id);
2774 assert_eq!(report.invalidated_facts.len(), 1);
2775 assert_eq!(report.invalidated_facts[0].id, original_id);
2776 assert_eq!(report.corrections.len(), 1);
2777 assert_eq!(report.corrections[0].old_fact.id, original_id);
2778 assert_eq!(report.corrections[0].new_fact.id, replacement_id);
2779 assert_eq!(report.confidence_shifts.len(), 1);
2780 assert_eq!(report.confidence_shifts[0].from_fact_id, original_id);
2781 assert_eq!(report.confidence_shifts[0].to_fact_id, replacement_id);
2782 }
2783
2784 #[test]
2785 fn what_changed_links_correction_with_small_timestamp_jitter() {
2786 let (mem, _tmp) = open_temp_memory();
2787 let original_id = mem
2788 .assert_with_params(
2789 "alice",
2790 "works_at",
2791 "Acme",
2792 AssertParams {
2793 valid_from: KronroeTimestamp::now_utc() - KronroeSpan::days(30),
2794 },
2795 )
2796 .unwrap();
2797
2798 let since = KronroeTimestamp::now_utc();
2799 mem.invalidate_fact(&original_id).unwrap();
2800
2801 let original_fact = mem
2802 .facts_about("alice")
2803 .unwrap()
2804 .into_iter()
2805 .find(|fact| fact.id == original_id)
2806 .expect("original fact should exist in history");
2807 let expired_at = original_fact
2808 .expired_at
2809 .expect("expired_at should be present after invalidation");
2810 let jittered_valid_from = expired_at + KronroeSpan::milliseconds(900);
2811
2812 mem.assert_with_confidence_with_params(
2813 "alice",
2814 "works_at",
2815 "Beta Corp",
2816 AssertParams {
2817 valid_from: jittered_valid_from,
2818 },
2819 0.65,
2820 )
2821 .unwrap();
2822
2823 let report = mem
2824 .what_changed("alice", since, Some("works_at"))
2825 .expect("what_changed should succeed");
2826 assert_eq!(
2827 report.corrections.len(),
2828 1,
2829 "sub-second drift should still link as a correction"
2830 );
2831 }
2832
2833 #[test]
2834 fn what_changed_does_not_link_far_apart_replacements() {
2835 let (mem, _tmp) = open_temp_memory();
2836 let original_id = mem
2837 .assert_with_params(
2838 "alice",
2839 "works_at",
2840 "Acme",
2841 AssertParams {
2842 valid_from: KronroeTimestamp::now_utc() - KronroeSpan::days(30),
2843 },
2844 )
2845 .unwrap();
2846
2847 let since = KronroeTimestamp::now_utc();
2848 mem.invalidate_fact(&original_id).unwrap();
2849
2850 let original_fact = mem
2851 .facts_about("alice")
2852 .unwrap()
2853 .into_iter()
2854 .find(|fact| fact.id == original_id)
2855 .expect("original fact should exist in history");
2856 let expired_at = original_fact
2857 .expired_at
2858 .expect("expired_at should be present after invalidation");
2859 let distant_valid_from = expired_at + KronroeSpan::seconds(10);
2860
2861 mem.assert_with_confidence_with_params(
2862 "alice",
2863 "works_at",
2864 "Beta Corp",
2865 AssertParams {
2866 valid_from: distant_valid_from,
2867 },
2868 0.65,
2869 )
2870 .unwrap();
2871
2872 let report = mem
2873 .what_changed("alice", since, Some("works_at"))
2874 .expect("what_changed should succeed");
2875 assert_eq!(
2876 report.corrections.len(),
2877 0,
2878 "larger timing gaps should not be auto-linked as corrections"
2879 );
2880 }
2881
2882 #[test]
2883 fn memory_health_reports_low_confidence_and_stale_high_impact() {
2884 let (mem, _tmp) = open_temp_memory();
2885 let old = KronroeTimestamp::now_utc() - KronroeSpan::days(200);
2886
2887 mem.assert_with_confidence_with_params(
2888 "alice",
2889 "nickname",
2890 "Bex",
2891 AssertParams { valid_from: old },
2892 0.4,
2893 )
2894 .unwrap();
2895 mem.assert_with_confidence_with_params(
2896 "alice",
2897 "email",
2898 "alice@example.com",
2899 AssertParams { valid_from: old },
2900 0.9,
2901 )
2902 .unwrap();
2903
2904 let report = mem
2905 .memory_health("alice", None, 0.7, 90)
2906 .expect("memory_health should succeed");
2907 assert_eq!(report.total_fact_count, 2);
2908 assert_eq!(report.active_fact_count, 2);
2909 assert_eq!(report.low_confidence_facts.len(), 1);
2910 assert_eq!(report.low_confidence_facts[0].predicate, "nickname");
2911 assert_eq!(report.stale_high_impact_facts.len(), 1);
2912 assert_eq!(report.stale_high_impact_facts[0].predicate, "email");
2913 assert_eq!(report.contradiction_count, 0);
2914 assert!(
2915 report
2916 .recommended_actions
2917 .iter()
2918 .any(|entry| entry.contains("low-confidence")),
2919 "expected low-confidence action"
2920 );
2921 assert!(
2922 report
2923 .recommended_actions
2924 .iter()
2925 .any(|entry| entry.contains("stale high-impact")),
2926 "expected stale high-impact action"
2927 );
2928 }
2929
2930 #[cfg(feature = "uncertainty")]
2931 #[test]
2932 fn recall_includes_effective_confidence() {
2933 let (mem, _tmp) = open_temp_memory();
2934 mem.assert("alice", "works_at", "Acme").unwrap();
2935
2936 let scored = mem.recall_scored("alice", None, 10).unwrap();
2937 assert!(!scored.is_empty());
2938 let eff = scored[0].1.effective_confidence();
2940 assert!(
2941 eff.is_some(),
2942 "expected Some effective_confidence, got None"
2943 );
2944 assert!(eff.unwrap() > 0.0);
2945 }
2946
2947 #[cfg(feature = "uncertainty")]
2948 #[test]
2949 fn volatile_predicate_decays() {
2950 let (mem, _tmp) = open_temp_memory();
2951 let past = KronroeTimestamp::now_utc() - KronroeSpan::days(730);
2954 mem.graph
2955 .assert_fact("alice", "works_at", "OldCo", past)
2956 .unwrap();
2957 mem.graph
2959 .assert_fact("alice", "born_in", "London", KronroeTimestamp::now_utc())
2960 .unwrap();
2961
2962 let old_eff = mem
2963 .graph
2964 .effective_confidence(
2965 mem.facts_about("alice")
2966 .unwrap()
2967 .iter()
2968 .find(|f| f.predicate == "works_at")
2969 .unwrap(),
2970 KronroeTimestamp::now_utc(),
2971 )
2972 .unwrap();
2973 let fresh_eff = mem
2974 .graph
2975 .effective_confidence(
2976 mem.facts_about("alice")
2977 .unwrap()
2978 .iter()
2979 .find(|f| f.predicate == "born_in")
2980 .unwrap(),
2981 KronroeTimestamp::now_utc(),
2982 )
2983 .unwrap();
2984
2985 assert!(
2987 old_eff.value < 0.6,
2988 "730-day old works_at should have decayed, got {}",
2989 old_eff.value
2990 );
2991 assert!(
2993 fresh_eff.value > 0.9,
2994 "fresh born_in should be near 1.0, got {}",
2995 fresh_eff.value
2996 );
2997 }
2998
2999 #[cfg(feature = "uncertainty")]
3000 #[test]
3001 fn source_weight_affects_confidence() {
3002 let (mem, _tmp) = open_temp_memory();
3003 mem.register_source_weight("trusted", 1.5).unwrap();
3004 mem.register_source_weight("untrusted", 0.5).unwrap();
3005
3006 mem.assert_with_source("alice", "works_at", "TrustCo", 1.0, "trusted")
3007 .unwrap();
3008 mem.assert_with_source("bob", "works_at", "SketchCo", 1.0, "untrusted")
3009 .unwrap();
3010
3011 let alice_facts = mem.facts_about("alice").unwrap();
3012 let bob_facts = mem.facts_about("bob").unwrap();
3013
3014 let alice_eff = mem
3015 .graph
3016 .effective_confidence(&alice_facts[0], KronroeTimestamp::now_utc())
3017 .unwrap();
3018 let bob_eff = mem
3019 .graph
3020 .effective_confidence(&bob_facts[0], KronroeTimestamp::now_utc())
3021 .unwrap();
3022
3023 assert!(
3024 alice_eff.value > bob_eff.value,
3025 "trusted source should have higher effective confidence: {} vs {}",
3026 alice_eff.value,
3027 bob_eff.value
3028 );
3029 }
3030
3031 #[cfg(feature = "uncertainty")]
3032 #[test]
3033 fn effective_confidence_for_fact_returns_some() {
3034 let (mem, _tmp) = open_temp_memory();
3035 mem.assert_with_source("alice", "works_at", "Acme", 0.9, "user:owner")
3036 .unwrap();
3037
3038 let fact = mem.facts_about("alice").unwrap().remove(0);
3039 let eff = mem
3040 .effective_confidence_for_fact(&fact, KronroeTimestamp::now_utc())
3041 .unwrap()
3042 .expect("uncertainty-enabled builds should return effective confidence");
3043
3044 assert!(
3045 eff > 0.0,
3046 "effective confidence should be positive for a fresh fact, got {eff}"
3047 );
3048 }
3049}