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: kronroe::KronroeTimestamp = "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
31#[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// ---------------------------------------------------------------------------
42// Explainable recall
43// ---------------------------------------------------------------------------
44
45/// Per-channel signal breakdown for a recalled fact.
46///
47/// These are the *input signals* that the retrieval engine used to rank
48/// results — they explain what each channel contributed, not the final
49/// composite ranking score. The result ordering in `recall_scored()` is
50/// the authoritative ranking; inspect these fields to understand *why*
51/// a given channel dominated or was weak for a particular fact.
52///
53/// Every variant includes `confidence` — the fact-level confidence score
54/// from the underlying [`Fact`] (default 1.0). This lets callers weight
55/// or filter results by trustworthiness alongside retrieval signals.
56///
57/// The variant indicates which retrieval path produced the result:
58/// - [`Hybrid`] — RRF fusion input signals (text + vector channels).
59///   The engine's two-stage reranker uses these as inputs alongside
60///   temporal feasibility to determine final ordering.
61/// - [`TextOnly`] — fulltext search with BM25 relevance score.
62///
63/// [`Hybrid`]: RecallScore::Hybrid
64/// [`TextOnly`]: RecallScore::TextOnly
65/// [`Fact`]: kronroe::Fact
66#[derive(Debug, Clone, Copy, PartialEq)]
67#[non_exhaustive]
68pub enum RecallScore {
69    /// Input signals from hybrid retrieval (text + vector channels).
70    ///
71    /// The `rrf_score` is the pre-rerank RRF fusion score (sum of
72    /// text + vector contributions). Final result ordering may differ
73    /// because the two-stage reranker applies adaptive weighting and
74    /// temporal feasibility filtering on top of these signals.
75    #[non_exhaustive]
76    Hybrid {
77        /// Pre-rerank RRF fusion score (text + vector sum).
78        rrf_score: f64,
79        /// Text-channel contribution from weighted RRF.
80        text_contrib: f64,
81        /// Vector-channel contribution from weighted RRF.
82        vector_contrib: f64,
83        /// Fact-level confidence \[0.0, 1.0\] from the stored fact.
84        confidence: f32,
85        /// Effective confidence after uncertainty model (age decay × source weight).
86        /// `None` when uncertainty modeling is disabled.
87        effective_confidence: Option<f32>,
88    },
89    /// Result from fulltext-only retrieval.
90    #[non_exhaustive]
91    TextOnly {
92        /// Ordinal rank in the result set (0-indexed).
93        rank: usize,
94        /// Kronroe BM25 relevance score. Higher = stronger lexical match.
95        /// Comparable within a single query but not across queries.
96        bm25_score: f32,
97        /// Fact-level confidence \[0.0, 1.0\] from the stored fact.
98        confidence: f32,
99        /// Effective confidence after uncertainty model (age decay × source weight).
100        /// `None` when uncertainty modeling is disabled.
101        effective_confidence: Option<f32>,
102    },
103}
104
105impl RecallScore {
106    /// Human-readable score tag suitable for debug output or LLM context.
107    ///
108    /// Returns a decimal RRF score `"0.032"` for hybrid results, or
109    /// a BM25 score with rank `"#1 bm25:4.21"` for text-only results.
110    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    /// The fact-level confidence score, regardless of retrieval path.
120    pub fn confidence(&self) -> f32 {
121        match self {
122            RecallScore::Hybrid { confidence, .. } | RecallScore::TextOnly { confidence, .. } => {
123                *confidence
124            }
125        }
126    }
127
128    /// The effective confidence after uncertainty model processing.
129    ///
130    /// Returns `None` when uncertainty modeling is disabled. When `Some`, this
131    /// reflects: `base_confidence × age_decay × source_weight`.
132    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    /// Convert a [`HybridScoreBreakdown`] from the core engine into a
146    /// [`RecallScore::Hybrid`], incorporating the fact's confidence.
147    #[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/// Strategy for deciding which confidence signal drives filtering.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165#[non_exhaustive]
166pub enum ConfidenceFilterMode {
167    /// Filter using raw fact confidence.
168    Base,
169    /// Filter using effective confidence (uncertainty-aware).
170    ///
171    /// Only available when the `uncertainty` feature is enabled. Attempting to
172    /// construct this variant without the feature is a compile-time error.
173    #[cfg(feature = "uncertainty")]
174    Effective,
175}
176
177/// Options for recall queries, controlling retrieval behaviour.
178///
179/// Use [`RecallOptions::new`] to create with defaults, then chain builder
180/// methods to customise. The `#[non_exhaustive]` attribute ensures new
181/// fields can be added without breaking existing callers.
182///
183/// ```rust
184/// use kronroe_agent_memory::RecallOptions;
185///
186/// let opts = RecallOptions::new("what does alice do?")
187///     .with_limit(5)
188///     .with_min_confidence(0.6)
189///     .with_max_scored_rows(2_048);
190/// ```
191#[derive(Debug, Clone)]
192#[non_exhaustive]
193pub struct RecallOptions<'a> {
194    /// The search query text.
195    pub query: &'a str,
196    /// Optional embedding for hybrid retrieval.
197    pub query_embedding: Option<&'a [f32]>,
198    /// Maximum number of results to return (default: 10).
199    pub limit: usize,
200    /// Minimum confidence threshold — facts below this are filtered out.
201    pub min_confidence: Option<f32>,
202    /// Which confidence signal to use when applying `min_confidence`.
203    pub confidence_filter_mode: ConfidenceFilterMode,
204    /// Maximum rows fetched per confidence-filtered recall batch (default: 4,096).
205    ///
206    /// Raising this increases recall depth at the cost of larger per-call work.
207    /// Lowering it improves bounded latency but may reduce results if strong hits
208    /// appear deeper in the result ranking.
209    pub max_scored_rows: usize,
210    /// Whether to run hybrid retrieval when an embedding is provided.
211    ///
212    /// Defaults to `false` in options helpers to preserve the existing
213    /// `recall_*` method ergonomics.
214    #[cfg(feature = "hybrid")]
215    pub use_hybrid: bool,
216    /// Temporal intent for hybrid reranking.
217    #[cfg(feature = "hybrid")]
218    pub temporal_intent: TemporalIntent,
219    /// Temporal operator used when intent is [`TemporalIntent::HistoricalPoint`].
220    #[cfg(feature = "hybrid")]
221    pub temporal_operator: TemporalOperator,
222}
223
224const DEFAULT_MAX_SCORED_ROWS: usize = 4_096;
225
226impl<'a> RecallOptions<'a> {
227    /// Create options with defaults: limit=10, no embedding, no confidence filter.
228    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    /// Set the query embedding for hybrid retrieval.
246    pub fn with_embedding(mut self, embedding: &'a [f32]) -> Self {
247        self.query_embedding = Some(embedding);
248        self
249    }
250
251    /// Set the maximum number of results.
252    pub fn with_limit(mut self, limit: usize) -> Self {
253        self.limit = limit;
254        self
255    }
256
257    /// Set a minimum confidence threshold to filter low-confidence facts.
258    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    /// Set a minimum effective-confidence threshold to filter low-confidence facts.
265    ///
266    /// Effective confidence is calculated as:
267    /// `base_confidence × age_decay × source_weight`.
268    ///
269    /// Only available when the `uncertainty` feature is enabled.
270    #[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    /// Set the maximum rows fetched per batch while applying confidence filters.
278    ///
279    /// Must be at least 1; `recall_scored_with_options` returns a `Search` error
280    /// for non-positive values.
281    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    /// Enable or disable hybrid reranking when a query embedding is provided.
287    ///
288    /// Hybrid is only available when the `hybrid` feature is enabled.
289    /// The default behavior in options-based recall is text-only unless this
290    /// flag is explicitly enabled.
291    #[cfg(feature = "hybrid")]
292    pub fn with_hybrid(mut self, enabled: bool) -> Self {
293        self.use_hybrid = enabled;
294        self
295    }
296
297    /// Provide a temporal recall intent for hybrid reranking.
298    ///
299    /// No effect without the `hybrid` feature.
300    #[cfg(feature = "hybrid")]
301    pub fn with_temporal_intent(mut self, intent: TemporalIntent) -> Self {
302        self.temporal_intent = intent;
303        self
304    }
305
306    /// Provide a temporal operator for historical intent resolution.
307    ///
308    /// No effect without the `hybrid` feature.
309    #[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
335/// High-level agent memory store built on a Kronroe temporal graph.
336///
337/// This is the primary entry point for AI agent developers.
338/// It wraps [`TemporalGraph`] with an API designed for agent use cases.
339pub struct AgentMemory {
340    graph: TemporalGraph,
341}
342
343#[derive(Debug, Clone)]
344pub struct AssertParams {
345    pub valid_from: KronroeTimestamp,
346}
347
348/// A paired correction event linking the invalidated fact and its replacement.
349#[derive(Debug, Clone)]
350pub struct FactCorrection {
351    pub old_fact: Fact,
352    pub new_fact: Fact,
353}
354
355/// Confidence movement between two related facts.
356#[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/// Summary of changes for one entity since a timestamp.
365#[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/// Operational memory-quality snapshot for one entity.
377#[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/// Decision-ready recall result shaped around a concrete user task.
391#[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    /// Open or create an agent memory store at the given path.
417    ///
418    /// ```rust,no_run
419    /// use kronroe_agent_memory::AgentMemory;
420    /// let memory = AgentMemory::open("./my-agent.kronroe").unwrap();
421    /// ```
422    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    /// Create an in-memory agent memory store.
432    ///
433    /// Useful for tests, WASM/browser bindings, and ephemeral workloads.
434    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    /// Store a structured fact with the current time as `valid_from`.
444    ///
445    /// Use this when you already know the structure of the fact.
446    /// For unstructured text, use `remember()` (Phase 1).
447    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    /// Store a structured fact with idempotent retry semantics.
458    ///
459    /// Reusing the same `idempotency_key` returns the original fact ID and
460    /// avoids duplicate writes.
461    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    /// Store a structured fact with idempotent retry semantics and explicit timing.
478    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    /// Store a structured fact with explicit parameters.
496    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    /// Get all currently known facts about an entity (across all predicates).
508    pub fn facts_about(&self, entity: &str) -> Result<Vec<Fact>> {
509        self.graph.all_facts_about(entity)
510    }
511
512    /// Get what was known about an entity for a given predicate at a point in time.
513    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    /// Get currently valid facts for one `(entity, predicate)` pair.
523    pub fn current_facts(&self, entity: &str, predicate: &str) -> Result<Vec<Fact>> {
524        self.graph.current_facts(entity, predicate)
525    }
526
527    /// Return what changed for an entity since a given timestamp.
528    ///
529    /// This is intentionally decision-oriented: it groups newly-recorded facts,
530    /// recently invalidated facts, inferred correction pairs, and confidence shifts.
531    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    /// Produce a health report for one entity's memory state.
631    ///
632    /// The report flags low-confidence active facts, stale high-impact facts,
633    /// and contradiction counts (when contradiction support is enabled).
634    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    /// Build task-focused recall output that is immediately useful for planning
722    /// and execution-oriented workflows.
723    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    /// Full-text search across known facts.
883    ///
884    /// Delegates to core search functionality on the underlying temporal graph.
885    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<Fact>> {
886        self.graph.search(query, limit)
887    }
888
889    /// Correct an existing fact by id, preserving temporal history.
890    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    /// Invalidate an existing fact by id, recording the current time as
900    /// the transaction end.
901    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    // -----------------------------------------------------------------------
907    // Contradiction detection
908    // -----------------------------------------------------------------------
909
910    /// Register common agent-memory singleton predicates.
911    ///
912    /// Called automatically from `open()` when the `contradiction` feature
913    /// is enabled. These predicates typically have at most one active value
914    /// per subject at any point in time.
915    /// Register common agent-memory singleton predicates, preserving any
916    /// existing policy the caller may have set (e.g. `Reject`).
917    #[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    /// Assert a structured fact with contradiction checking.
928    ///
929    /// Returns the fact ID and any detected contradictions. The behavior
930    /// depends on the predicate's conflict policy (set via
931    /// [`register_singleton_predicate`] on the underlying graph).
932    #[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    /// Audit a subject for contradictions across all registered singletons.
944    ///
945    /// Scans only the given subject's facts — cost scales with the
946    /// subject's fact count, not the total database size.
947    #[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    /// Store an unstructured memory episode as one fact.
958    ///
959    /// Subject is the `episode_id`, predicate is `"memory"`, object is `text`.
960    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    /// Store an unstructured memory episode with idempotent retry semantics.
987    ///
988    /// Reusing `idempotency_key` returns the same fact ID and avoids duplicates.
989    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    /// Retrieve memory facts by query.
1005    ///
1006    /// Convenience wrapper over [`recall_scored`](Self::recall_scored) that
1007    /// strips the score breakdowns. Use `recall_scored` when you need
1008    /// per-channel signal visibility.
1009    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    /// Retrieve memory facts by query with an explicit minimum confidence threshold.
1020    ///
1021    /// This shares the same filtering semantics as
1022    /// [`recall_scored_with_options`](Self::recall_scored_with_options), including
1023    /// confidence filtering before final result truncation.
1024    ///
1025    /// ```rust,no_run
1026    /// use kronroe_agent_memory::AgentMemory;
1027    ///
1028    /// let memory = AgentMemory::open("./agent.kronroe").unwrap();
1029    /// memory.assert_with_confidence("alice", "works_at", "Acme", 0.95).unwrap();
1030    /// memory.assert_with_confidence("alice", "worked_at", "Startup", 0.42).unwrap();
1031    ///
1032    /// let facts = memory
1033    ///     .recall_with_min_confidence("alice", None, 10, 0.9)
1034    ///     .unwrap();
1035    /// assert!(facts.iter().all(|f| f.confidence >= 0.9));
1036    /// ```
1037    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    /// Retrieve memory facts by query with per-channel signal breakdowns.
1060    ///
1061    /// Returns a `(Fact, RecallScore)` pair for each result. The result
1062    /// ordering is authoritative — the [`RecallScore`] explains per-channel
1063    /// contributions and fact confidence, not the final composite ranking
1064    /// score (see [`RecallScore`] docs for details).
1065    ///
1066    /// - **Hybrid path** (with embedding): returns [`RecallScore::Hybrid`]
1067    ///   with pre-rerank RRF channel contributions (text, vector) and
1068    ///   fact confidence.
1069    /// - **Text-only path** (no embedding): returns [`RecallScore::TextOnly`]
1070    ///   with ordinal rank, BM25 relevance score, and fact confidence.
1071    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    /// Retrieve memory facts using scored recall plus confidence filtering.
1177    ///
1178    /// Equivalent to [`recall_scored_with_options`](Self::recall_scored_with_options)
1179    /// with only `limit` and `min_confidence` set, preserving the ordering and
1180    /// pagination semantics introduced by the options-based path.
1181    ///
1182    /// ```rust,no_run
1183    /// use kronroe_agent_memory::AgentMemory;
1184    ///
1185    /// let memory = AgentMemory::open("./agent.kronroe").unwrap();
1186    /// memory.assert_with_confidence("alice", "works_at", "Acme", 0.95).unwrap();
1187    /// memory.assert_with_confidence("alice", "visited", "London", 0.55).unwrap();
1188    ///
1189    /// let scored = memory
1190    ///     .recall_scored_with_min_confidence("alice", None, 1, 0.9)
1191    ///     .unwrap();
1192    /// assert_eq!(scored.len(), 1);
1193    /// assert!(scored[0].1.confidence() >= 0.9);
1194    /// ```
1195    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    /// Retrieve memory facts by query while filtering by *effective* confidence.
1218    ///
1219    /// Equivalent to [`recall_scored_with_options`](Self::recall_scored_with_options)
1220    /// with only `limit` and `with_min_effective_confidence` set.
1221    ///
1222    /// Only available when the `uncertainty` feature is enabled.
1223    #[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    /// Build a token-bounded prompt context from recalled facts.
1247    ///
1248    /// Internally uses scored recall so results are ordered by relevance.
1249    /// The output format includes a retrieval score tag for transparency:
1250    ///
1251    /// ```text
1252    /// [2024-06-01] (0.032) alice · works_at · Acme            ← hybrid, full confidence
1253    /// [2024-06-01] (#1 bm25:4.21 conf:0.7) bob · lives_in · NYC  ← text-only, low confidence
1254    /// ```
1255    ///
1256    /// `query_embedding` is used only when the `hybrid` feature is enabled.
1257    /// Without it, the embedding is ignored and fulltext search is used.
1258    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); // rough 1 token ≈ 4 chars
1266        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            // Only show confidence when it deviates from the default (1.0),
1275            // keeping the common case clean and highlighting uncertainty.
1276            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    /// Retrieve memory facts using [`RecallOptions`], with per-channel signal breakdowns.
1300    ///
1301    /// Like [`recall_scored`](Self::recall_scored) but accepts a `RecallOptions`
1302    /// struct for cleaner parameter passing. When `min_confidence` is set, facts
1303    /// below the threshold are filtered from the results. Filtering occurs before
1304    /// truncating to the final `limit`, so low-confidence head matches cannot
1305    /// consume the result budget.
1306    /// By default, filtering uses base fact confidence; call
1307    /// [`RecallOptions::with_min_effective_confidence`] to use uncertainty-aware
1308    /// effective confidence instead.
1309    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                // Hybrid ranking can be non-monotonic as `k` changes, so apply
1347                // one-shot fetch at the confidence budget then filter locally.
1348                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 the latest fetch returned fewer rows than requested,
1422                    // we've reached the end of the result set.
1423                    if scored.len() < fetch_limit {
1424                        break;
1425                    }
1426
1427                    // If we repeatedly fetch windows with zero confident rows,
1428                    // jump directly to the hard budget to avoid repeated rescans.
1429                    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    /// Retrieve memory facts using [`RecallOptions`].
1462    ///
1463    /// Convenience wrapper over [`recall_scored_with_options`](Self::recall_scored_with_options)
1464    /// that strips the score breakdowns.
1465    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    /// Store a structured fact with explicit confidence.
1471    ///
1472    /// Like [`assert`](Self::assert) but allows setting the confidence score
1473    /// (clamped to \[0.0, 1.0\]).
1474    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    /// Store a structured fact with explicit confidence and explicit timing.
1493    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    /// Store a structured fact with explicit source provenance.
1512    ///
1513    /// Like [`assert`](Self::assert) but attaches a source marker (e.g.
1514    /// `"user:owner"`, `"api:linkedin"`) that the uncertainty engine uses
1515    /// for source-weighted confidence.
1516    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    /// Store a structured fact with explicit source provenance and explicit timing.
1537    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    // -----------------------------------------------------------------------
1558    // Uncertainty engine
1559    // -----------------------------------------------------------------------
1560
1561    /// Register default predicate volatilities for common agent-memory predicates.
1562    ///
1563    /// Called automatically from `open()` when the `uncertainty` feature is enabled.
1564    #[cfg(feature = "uncertainty")]
1565    fn register_default_volatilities(graph: &TemporalGraph) -> Result<()> {
1566        use kronroe::PredicateVolatility;
1567        // Volatile: job/location change every few years
1568        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    /// Register a predicate volatility (half-life in days).
1587    ///
1588    /// After `half_life_days`, the age-decay multiplier drops to 0.5.
1589    /// Use `f64::INFINITY` for stable predicates that never decay.
1590    #[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    /// Register a source authority weight.
1598    ///
1599    /// Weight is clamped to \[0.0, 2.0\]. `1.0` = neutral, `>1.0` = boosted,
1600    /// `<1.0` = penalised. Unknown sources default to `1.0`.
1601    #[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    /// Compute effective confidence for a fact at a point in time.
1609    ///
1610    /// Returns `Ok(Some(value))` when uncertainty support is enabled and
1611    /// `Ok(None)` when uncertainty support is disabled in this build.
1612    #[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    /// Compute effective confidence for a fact at a point in time.
1624    ///
1625    /// Returns `Ok(Some(value))` when uncertainty support is enabled and
1626    /// `Ok(None)` when uncertainty support is disabled in this build.
1627    #[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    /// Compute effective confidence for a fact, or `None` if the uncertainty
1638    /// feature is not enabled.
1639    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        // "works_at" is auto-registered as singleton by open()
1942        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        // Verify that auto-registered singletons trigger contradiction detection.
1956        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(); // No contradiction for bob.
1972
1973        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        // Regression: open() must not overwrite a pre-set Reject policy with Warn.
1985        let file = NamedTempFile::new().unwrap();
1986        let path = file.path().to_str().unwrap().to_string();
1987
1988        // First open: set works_at to Reject.
1989        {
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        // Second open via AgentMemory: default registration must not downgrade.
2000        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        // reopen through AgentMemory; default bootstrap should not overwrite custom 1.0
2022        // with default 730.0 days.
2023        {
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        // Query text matches only ep-rust, vector matches only ep-py.
2050        // With hybrid fusion enabled, both signals are used and ep-rust should
2051        // still surface in a top-1 tie-break deterministic setup.
2052        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    // -------------------------------------------------------------------
2058    // Explainable recall tests
2059    // -------------------------------------------------------------------
2060
2061    #[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        // Every result should be TextOnly with sequential ranks and positive BM25.
2073        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        // "Rust Rust Rust" should score higher than "Rust" for query "Rust".
2102        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        // First result should have higher or equal BM25 than second.
2111        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        // Test the convenience accessor works for both variants.
2143        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        // All results should be Hybrid variant with non-negative scores and confidence.
2182        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        // Store with embedding [1, 0], query with orthogonal vector [0, 1]
2220        // but matching text — so text_contrib should dominate.
2221        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        // Text-only path: score tag should include rank and BM25 like "(#1 bm25:X.XX)".
2290        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        // Default confidence (1.0) should NOT show "conf:" — keep output clean.
2304        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        // Hybrid path: score tag should be a decimal like "(0.032)".
2322        assert!(
2323            ctx.contains("(0."),
2324            "context should contain hybrid RRF score tag, got: {ctx}"
2325        );
2326    }
2327
2328    // -- RecallOptions + confidence tests -----------------------------------
2329
2330    #[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        // Store two facts with different confidence levels.
2373        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        // Without filter: both returned.
2379        let all = mem.recall("Rust", None, 10).unwrap();
2380        assert_eq!(all.len(), 2, "both facts should be returned without filter");
2381
2382        // With min_confidence=0.5: only the high-confidence fact.
2383        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        // With uncertainty feature on, effective_confidence should be Some
2939        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        // works_at has default 730-day half-life from register_default_volatilities.
2952        // Assert a fact that's "old" by using the graph directly with a past valid_from.
2953        let past = KronroeTimestamp::now_utc() - KronroeSpan::days(730);
2954        mem.graph
2955            .assert_fact("alice", "works_at", "OldCo", past)
2956            .unwrap();
2957        // Assert a fresh fact
2958        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        // At 730 days (one half-life), works_at effective should be ~0.5
2986        assert!(
2987            old_eff.value < 0.6,
2988            "730-day old works_at should have decayed, got {}",
2989            old_eff.value
2990        );
2991        // born_in is stable, fresh fact should be ~1.0
2992        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}