Skip to main content

kronroe_agent_memory/
agent_memory.rs

1//! High-level agent memory API built on Kronroe.
2//!
3//! Designed to be a drop-in alternative to Graphiti / mem0 / MemGPT —
4//! without the server, without Neo4j, without Python.
5//!
6//! # Usage
7//!
8//! ```rust,no_run
9//! use kronroe_agent_memory::AgentMemory;
10//!
11//! let memory = AgentMemory::open("./my-agent.kronroe").unwrap();
12//!
13//! // Store a structured fact directly
14//! memory.assert("alice", "works_at", "Acme").unwrap();
15//!
16//! // Query everything known about an entity
17//! let facts = memory.facts_about("alice").unwrap();
18//!
19//! // Query what we knew at a point in time
20//! let past: chrono::DateTime<chrono::Utc> = "2024-03-01T00:00:00Z".parse().unwrap();
21//! let then = memory.facts_about_at("alice", "works_at", past).unwrap();
22//! ```
23//!
24//! # Phase 1 API
25//!
26//! This crate exposes a practical Phase 1 surface:
27//! - `remember(text, episode_id, embedding)` — store episodic memory
28//! - `recall(query, query_embedding, limit)` — retrieve matching facts
29//! - `assemble_context(query, query_embedding, max_tokens)` — build LLM context
30
31use 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// ---------------------------------------------------------------------------
43// Explainable recall
44// ---------------------------------------------------------------------------
45
46/// Per-channel signal breakdown for a recalled fact.
47///
48/// These are the *input signals* that the retrieval engine used to rank
49/// results — they explain what each channel contributed, not the final
50/// composite ranking score. The result ordering in `recall_scored()` is
51/// the authoritative ranking; inspect these fields to understand *why*
52/// a given channel dominated or was weak for a particular fact.
53///
54/// Every variant includes `confidence` — the fact-level confidence score
55/// from the underlying [`Fact`] (default 1.0). This lets callers weight
56/// or filter results by trustworthiness alongside retrieval signals.
57///
58/// The variant indicates which retrieval path produced the result:
59/// - [`Hybrid`] — RRF fusion input signals (text + vector channels).
60///   The engine's two-stage reranker uses these as inputs alongside
61///   temporal feasibility to determine final ordering.
62/// - [`TextOnly`] — fulltext search with BM25 relevance score.
63///
64/// [`Hybrid`]: RecallScore::Hybrid
65/// [`TextOnly`]: RecallScore::TextOnly
66/// [`Fact`]: kronroe::Fact
67#[derive(Debug, Clone, Copy, PartialEq)]
68#[non_exhaustive]
69pub enum RecallScore {
70    /// Input signals from hybrid retrieval (text + vector channels).
71    ///
72    /// The `rrf_score` is the pre-rerank RRF fusion score (sum of
73    /// text + vector contributions). Final result ordering may differ
74    /// because the two-stage reranker applies adaptive weighting and
75    /// temporal feasibility filtering on top of these signals.
76    #[non_exhaustive]
77    Hybrid {
78        /// Pre-rerank RRF fusion score (text + vector sum).
79        rrf_score: f64,
80        /// Text-channel contribution from weighted RRF.
81        text_contrib: f64,
82        /// Vector-channel contribution from weighted RRF.
83        vector_contrib: f64,
84        /// Fact-level confidence \[0.0, 1.0\] from the stored fact.
85        confidence: f32,
86        /// Effective confidence after uncertainty model (age decay × source weight).
87        /// `None` when uncertainty modeling is disabled.
88        effective_confidence: Option<f32>,
89    },
90    /// Result from fulltext-only retrieval.
91    #[non_exhaustive]
92    TextOnly {
93        /// Ordinal rank in the result set (0-indexed).
94        rank: usize,
95        /// Tantivy BM25 relevance score. Higher = stronger lexical match.
96        /// Comparable within a single query but not across queries.
97        bm25_score: f32,
98        /// Fact-level confidence \[0.0, 1.0\] from the stored fact.
99        confidence: f32,
100        /// Effective confidence after uncertainty model (age decay × source weight).
101        /// `None` when uncertainty modeling is disabled.
102        effective_confidence: Option<f32>,
103    },
104}
105
106impl RecallScore {
107    /// Human-readable score tag suitable for debug output or LLM context.
108    ///
109    /// Returns a decimal RRF score `"0.032"` for hybrid results, or
110    /// a BM25 score with rank `"#1 bm25:4.21"` for text-only results.
111    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    /// The fact-level confidence score, regardless of retrieval path.
121    pub fn confidence(&self) -> f32 {
122        match self {
123            RecallScore::Hybrid { confidence, .. } | RecallScore::TextOnly { confidence, .. } => {
124                *confidence
125            }
126        }
127    }
128
129    /// The effective confidence after uncertainty model processing.
130    ///
131    /// Returns `None` when uncertainty modeling is disabled. When `Some`, this
132    /// reflects: `base_confidence × age_decay × source_weight`.
133    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    /// Convert a [`HybridScoreBreakdown`] from the core engine into a
147    /// [`RecallScore::Hybrid`], incorporating the fact's confidence.
148    #[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/// Strategy for deciding which confidence signal drives filtering.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum ConfidenceFilterMode {
168    /// Filter using raw fact confidence.
169    Base,
170    /// Filter using effective confidence (uncertainty-aware).
171    ///
172    /// Only available when the `uncertainty` feature is enabled. Attempting to
173    /// construct this variant without the feature is a compile-time error.
174    #[cfg(feature = "uncertainty")]
175    Effective,
176}
177
178/// Options for recall queries, controlling retrieval behaviour.
179///
180/// Use [`RecallOptions::new`] to create with defaults, then chain builder
181/// methods to customise. The `#[non_exhaustive]` attribute ensures new
182/// fields can be added without breaking existing callers.
183///
184/// ```rust
185/// use kronroe_agent_memory::RecallOptions;
186///
187/// let opts = RecallOptions::new("what does alice do?")
188///     .with_limit(5)
189///     .with_min_confidence(0.6)
190///     .with_max_scored_rows(2_048);
191/// ```
192#[derive(Debug, Clone)]
193#[non_exhaustive]
194pub struct RecallOptions<'a> {
195    /// The search query text.
196    pub query: &'a str,
197    /// Optional embedding for hybrid retrieval.
198    pub query_embedding: Option<&'a [f32]>,
199    /// Maximum number of results to return (default: 10).
200    pub limit: usize,
201    /// Minimum confidence threshold — facts below this are filtered out.
202    pub min_confidence: Option<f32>,
203    /// Which confidence signal to use when applying `min_confidence`.
204    pub confidence_filter_mode: ConfidenceFilterMode,
205    /// Maximum rows fetched per confidence-filtered recall batch (default: 4,096).
206    ///
207    /// Raising this increases recall depth at the cost of larger per-call work.
208    /// Lowering it improves bounded latency but may reduce results if strong hits
209    /// appear deeper in the result ranking.
210    pub max_scored_rows: usize,
211    /// Whether to run hybrid retrieval when an embedding is provided.
212    ///
213    /// Defaults to `false` in options helpers to preserve the existing
214    /// `recall_*` method ergonomics.
215    #[cfg(feature = "hybrid")]
216    pub use_hybrid: bool,
217    /// Temporal intent for hybrid reranking.
218    #[cfg(feature = "hybrid")]
219    pub temporal_intent: TemporalIntent,
220    /// Temporal operator used when intent is [`TemporalIntent::HistoricalPoint`].
221    #[cfg(feature = "hybrid")]
222    pub temporal_operator: TemporalOperator,
223}
224
225const DEFAULT_MAX_SCORED_ROWS: usize = 4_096;
226
227impl<'a> RecallOptions<'a> {
228    /// Create options with defaults: limit=10, no embedding, no confidence filter.
229    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    /// Set the query embedding for hybrid retrieval.
247    pub fn with_embedding(mut self, embedding: &'a [f32]) -> Self {
248        self.query_embedding = Some(embedding);
249        self
250    }
251
252    /// Set the maximum number of results.
253    pub fn with_limit(mut self, limit: usize) -> Self {
254        self.limit = limit;
255        self
256    }
257
258    /// Set a minimum confidence threshold to filter low-confidence facts.
259    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    /// Set a minimum effective-confidence threshold to filter low-confidence facts.
266    ///
267    /// Effective confidence is calculated as:
268    /// `base_confidence × age_decay × source_weight`.
269    ///
270    /// Only available when the `uncertainty` feature is enabled.
271    #[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    /// Set the maximum rows fetched per batch while applying confidence filters.
279    ///
280    /// Must be at least 1; `recall_scored_with_options` returns a `Search` error
281    /// for non-positive values.
282    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    /// Enable or disable hybrid reranking when a query embedding is provided.
288    ///
289    /// Hybrid is only available when the `hybrid` feature is enabled.
290    /// The default behavior in options-based recall is text-only unless this
291    /// flag is explicitly enabled.
292    #[cfg(feature = "hybrid")]
293    pub fn with_hybrid(mut self, enabled: bool) -> Self {
294        self.use_hybrid = enabled;
295        self
296    }
297
298    /// Provide a temporal recall intent for hybrid reranking.
299    ///
300    /// No effect without the `hybrid` feature.
301    #[cfg(feature = "hybrid")]
302    pub fn with_temporal_intent(mut self, intent: TemporalIntent) -> Self {
303        self.temporal_intent = intent;
304        self
305    }
306
307    /// Provide a temporal operator for historical intent resolution.
308    ///
309    /// No effect without the `hybrid` feature.
310    #[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
336/// High-level agent memory store built on a Kronroe temporal graph.
337///
338/// This is the primary entry point for AI agent developers.
339/// It wraps [`TemporalGraph`] with an API designed for agent use cases.
340pub 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    /// Open or create an agent memory store at the given path.
351    ///
352    /// ```rust,no_run
353    /// use kronroe_agent_memory::AgentMemory;
354    /// let memory = AgentMemory::open("./my-agent.kronroe").unwrap();
355    /// ```
356    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    /// Create an in-memory agent memory store.
366    ///
367    /// Useful for tests, WASM/browser bindings, and ephemeral workloads.
368    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    /// Store a structured fact with the current time as `valid_from`.
378    ///
379    /// Use this when you already know the structure of the fact.
380    /// For unstructured text, use `remember()` (Phase 1).
381    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    /// Store a structured fact with idempotent retry semantics.
392    ///
393    /// Reusing the same `idempotency_key` returns the original fact ID and
394    /// avoids duplicate writes.
395    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    /// Store a structured fact with idempotent retry semantics and explicit timing.
407    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    /// Store a structured fact with explicit parameters.
425    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    /// Get all currently known facts about an entity (across all predicates).
437    pub fn facts_about(&self, entity: &str) -> Result<Vec<Fact>> {
438        self.graph.all_facts_about(entity)
439    }
440
441    /// Get what was known about an entity for a given predicate at a point in time.
442    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    /// Get currently valid facts for one `(entity, predicate)` pair.
452    pub fn current_facts(&self, entity: &str, predicate: &str) -> Result<Vec<Fact>> {
453        self.graph.current_facts(entity, predicate)
454    }
455
456    /// Full-text search across known facts.
457    ///
458    /// Delegates to core search functionality on the underlying temporal graph.
459    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<Fact>> {
460        self.graph.search(query, limit)
461    }
462
463    /// Correct an existing fact by id, preserving temporal history.
464    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    /// Invalidate an existing fact by id, recording the current time as
469    /// the transaction end.
470    pub fn invalidate_fact(&self, fact_id: &FactId) -> Result<()> {
471        self.graph.invalidate_fact(fact_id, Utc::now())
472    }
473
474    // -----------------------------------------------------------------------
475    // Contradiction detection
476    // -----------------------------------------------------------------------
477
478    /// Register common agent-memory singleton predicates.
479    ///
480    /// Called automatically from `open()` when the `contradiction` feature
481    /// is enabled. These predicates typically have at most one active value
482    /// per subject at any point in time.
483    /// Register common agent-memory singleton predicates, preserving any
484    /// existing policy the caller may have set (e.g. `Reject`).
485    #[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    /// Assert a structured fact with contradiction checking.
496    ///
497    /// Returns the fact ID and any detected contradictions. The behavior
498    /// depends on the predicate's conflict policy (set via
499    /// [`register_singleton_predicate`] on the underlying graph).
500    #[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    /// Audit a subject for contradictions across all registered singletons.
512    ///
513    /// Scans only the given subject's facts — cost scales with the
514    /// subject's fact count, not the total database size.
515    #[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    /// Store an unstructured memory episode as one fact.
526    ///
527    /// Subject is the `episode_id`, predicate is `"memory"`, object is `text`.
528    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    /// Store an unstructured memory episode with idempotent retry semantics.
551    ///
552    /// Reusing `idempotency_key` returns the same fact ID and avoids duplicates.
553    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    /// Retrieve memory facts by query.
569    ///
570    /// Convenience wrapper over [`recall_scored`](Self::recall_scored) that
571    /// strips the score breakdowns. Use `recall_scored` when you need
572    /// per-channel signal visibility.
573    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    /// Retrieve memory facts by query with an explicit minimum confidence threshold.
584    ///
585    /// This shares the same filtering semantics as
586    /// [`recall_scored_with_options`](Self::recall_scored_with_options), including
587    /// confidence filtering before final result truncation.
588    ///
589    /// ```rust,no_run
590    /// use kronroe_agent_memory::AgentMemory;
591    ///
592    /// let memory = AgentMemory::open("./agent.kronroe").unwrap();
593    /// memory.assert_with_confidence("alice", "works_at", "Acme", 0.95).unwrap();
594    /// memory.assert_with_confidence("alice", "worked_at", "Startup", 0.42).unwrap();
595    ///
596    /// let facts = memory
597    ///     .recall_with_min_confidence("alice", None, 10, 0.9)
598    ///     .unwrap();
599    /// assert!(facts.iter().all(|f| f.confidence >= 0.9));
600    /// ```
601    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    /// Retrieve memory facts by query with per-channel signal breakdowns.
624    ///
625    /// Returns a `(Fact, RecallScore)` pair for each result. The result
626    /// ordering is authoritative — the [`RecallScore`] explains per-channel
627    /// contributions and fact confidence, not the final composite ranking
628    /// score (see [`RecallScore`] docs for details).
629    ///
630    /// - **Hybrid path** (with embedding): returns [`RecallScore::Hybrid`]
631    ///   with pre-rerank RRF channel contributions (text, vector) and
632    ///   fact confidence.
633    /// - **Text-only path** (no embedding): returns [`RecallScore::TextOnly`]
634    ///   with ordinal rank, BM25 relevance score, and fact confidence.
635    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    /// Retrieve memory facts using scored recall plus confidence filtering.
741    ///
742    /// Equivalent to [`recall_scored_with_options`](Self::recall_scored_with_options)
743    /// with only `limit` and `min_confidence` set, preserving the ordering and
744    /// pagination semantics introduced by the options-based path.
745    ///
746    /// ```rust,no_run
747    /// use kronroe_agent_memory::AgentMemory;
748    ///
749    /// let memory = AgentMemory::open("./agent.kronroe").unwrap();
750    /// memory.assert_with_confidence("alice", "works_at", "Acme", 0.95).unwrap();
751    /// memory.assert_with_confidence("alice", "visited", "London", 0.55).unwrap();
752    ///
753    /// let scored = memory
754    ///     .recall_scored_with_min_confidence("alice", None, 1, 0.9)
755    ///     .unwrap();
756    /// assert_eq!(scored.len(), 1);
757    /// assert!(scored[0].1.confidence() >= 0.9);
758    /// ```
759    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    /// Retrieve memory facts by query while filtering by *effective* confidence.
782    ///
783    /// Equivalent to [`recall_scored_with_options`](Self::recall_scored_with_options)
784    /// with only `limit` and `with_min_effective_confidence` set.
785    ///
786    /// Only available when the `uncertainty` feature is enabled.
787    #[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    /// Build a token-bounded prompt context from recalled facts.
811    ///
812    /// Internally uses scored recall so results are ordered by relevance.
813    /// The output format includes a retrieval score tag for transparency:
814    ///
815    /// ```text
816    /// [2024-06-01] (0.032) alice · works_at · Acme            ← hybrid, full confidence
817    /// [2024-06-01] (#1 bm25:4.21 conf:0.7) bob · lives_in · NYC  ← text-only, low confidence
818    /// ```
819    ///
820    /// `query_embedding` is used only when the `hybrid` feature is enabled.
821    /// Without it, the embedding is ignored and fulltext search is used.
822    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); // rough 1 token ≈ 4 chars
830        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            // Only show confidence when it deviates from the default (1.0),
839            // keeping the common case clean and highlighting uncertainty.
840            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    /// Retrieve memory facts using [`RecallOptions`], with per-channel signal breakdowns.
864    ///
865    /// Like [`recall_scored`](Self::recall_scored) but accepts a `RecallOptions`
866    /// struct for cleaner parameter passing. When `min_confidence` is set, facts
867    /// below the threshold are filtered from the results. Filtering occurs before
868    /// truncating to the final `limit`, so low-confidence head matches cannot
869    /// consume the result budget.
870    /// By default, filtering uses base fact confidence; call
871    /// [`RecallOptions::with_min_effective_confidence`] to use uncertainty-aware
872    /// effective confidence instead.
873    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                // Hybrid ranking can be non-monotonic as `k` changes, so apply
911                // one-shot fetch at the confidence budget then filter locally.
912                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 the latest fetch returned fewer rows than requested,
986                    // we've reached the end of the result set.
987                    if scored.len() < fetch_limit {
988                        break;
989                    }
990
991                    // If we repeatedly fetch windows with zero confident rows,
992                    // jump directly to the hard budget to avoid repeated rescans.
993                    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    /// Retrieve memory facts using [`RecallOptions`].
1026    ///
1027    /// Convenience wrapper over [`recall_scored_with_options`](Self::recall_scored_with_options)
1028    /// that strips the score breakdowns.
1029    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    /// Store a structured fact with explicit confidence.
1035    ///
1036    /// Like [`assert`](Self::assert) but allows setting the confidence score
1037    /// (clamped to \[0.0, 1.0\]).
1038    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    /// Store a structured fact with explicit confidence and explicit timing.
1057    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    /// Store a structured fact with explicit source provenance.
1076    ///
1077    /// Like [`assert`](Self::assert) but attaches a source marker (e.g.
1078    /// `"user:owner"`, `"api:linkedin"`) that the uncertainty engine uses
1079    /// for source-weighted confidence.
1080    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    /// Store a structured fact with explicit source provenance and explicit timing.
1101    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    // -----------------------------------------------------------------------
1122    // Uncertainty engine
1123    // -----------------------------------------------------------------------
1124
1125    /// Register default predicate volatilities for common agent-memory predicates.
1126    ///
1127    /// Called automatically from `open()` when the `uncertainty` feature is enabled.
1128    #[cfg(feature = "uncertainty")]
1129    fn register_default_volatilities(graph: &TemporalGraph) -> Result<()> {
1130        use kronroe::PredicateVolatility;
1131        // Volatile: job/location change every few years
1132        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    /// Register a predicate volatility (half-life in days).
1151    ///
1152    /// After `half_life_days`, the age-decay multiplier drops to 0.5.
1153    /// Use `f64::INFINITY` for stable predicates that never decay.
1154    #[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    /// Register a source authority weight.
1162    ///
1163    /// Weight is clamped to \[0.0, 2.0\]. `1.0` = neutral, `>1.0` = boosted,
1164    /// `<1.0` = penalised. Unknown sources default to `1.0`.
1165    #[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    /// Compute effective confidence for a fact at a point in time.
1173    ///
1174    /// Returns `Ok(Some(value))` when uncertainty support is enabled and
1175    /// `Ok(None)` when uncertainty support is disabled in this build.
1176    #[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    /// Compute effective confidence for a fact at a point in time.
1188    ///
1189    /// Returns `Ok(Some(value))` when uncertainty support is enabled and
1190    /// `Ok(None)` when uncertainty support is disabled in this build.
1191    #[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    /// Compute effective confidence for a fact, or `None` if the uncertainty
1202    /// feature is not enabled.
1203    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        // "works_at" is auto-registered as singleton by open()
1411        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        // Verify that auto-registered singletons trigger contradiction detection.
1425        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(); // No contradiction for bob.
1441
1442        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        // Regression: open() must not overwrite a pre-set Reject policy with Warn.
1454        let file = NamedTempFile::new().unwrap();
1455        let path = file.path().to_str().unwrap().to_string();
1456
1457        // First open: set works_at to Reject.
1458        {
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        // Second open via AgentMemory: default registration must not downgrade.
1469        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        // reopen through AgentMemory; default bootstrap should not overwrite custom 1.0
1491        // with default 730.0 days.
1492        {
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        // Query text matches only ep-rust, vector matches only ep-py.
1519        // With hybrid fusion enabled, both signals are used and ep-rust should
1520        // still surface in a top-1 tie-break deterministic setup.
1521        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    // -------------------------------------------------------------------
1527    // Explainable recall tests
1528    // -------------------------------------------------------------------
1529
1530    #[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        // Every result should be TextOnly with sequential ranks and positive BM25.
1542        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        // "Rust Rust Rust" should score higher than "Rust" for query "Rust".
1571        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        // First result should have higher or equal BM25 than second.
1580        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        // Test the convenience accessor works for both variants.
1612        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        // All results should be Hybrid variant with non-negative scores and confidence.
1651        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        // Store with embedding [1, 0], query with orthogonal vector [0, 1]
1689        // but matching text — so text_contrib should dominate.
1690        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        // Text-only path: score tag should include rank and BM25 like "(#1 bm25:X.XX)".
1759        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        // Default confidence (1.0) should NOT show "conf:" — keep output clean.
1773        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        // Hybrid path: score tag should be a decimal like "(0.032)".
1791        assert!(
1792            ctx.contains("(0."),
1793            "context should contain hybrid RRF score tag, got: {ctx}"
1794        );
1795    }
1796
1797    // -- RecallOptions + confidence tests -----------------------------------
1798
1799    #[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        // Store two facts with different confidence levels.
1842        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        // Without filter: both returned.
1848        let all = mem.recall("Rust", None, 10).unwrap();
1849        assert_eq!(all.len(), 2, "both facts should be returned without filter");
1850
1851        // With min_confidence=0.5: only the high-confidence fact.
1852        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        // With uncertainty feature on, effective_confidence should be Some
2208        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        // works_at has default 730-day half-life from register_default_volatilities.
2221        // Assert a fact that's "old" by using the graph directly with a past valid_from.
2222        let past = Utc::now() - chrono::Duration::days(730);
2223        mem.graph
2224            .assert_fact("alice", "works_at", "OldCo", past)
2225            .unwrap();
2226        // Assert a fresh fact
2227        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        // At 730 days (one half-life), works_at effective should be ~0.5
2255        assert!(
2256            old_eff.value < 0.6,
2257            "730-day old works_at should have decayed, got {}",
2258            old_eff.value
2259        );
2260        // born_in is stable, fresh fact should be ~1.0
2261        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}