Skip to main content

zeph_memory/semantic/
recall.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use futures::{StreamExt as _, TryStreamExt as _};
10use zeph_llm::provider::{LlmProvider as _, Message, MessageVisibility};
11
12/// Approximate characters per token (conservative estimate for mixed content).
13const CHARS_PER_TOKEN: usize = 4;
14
15/// Target chunk size in characters (~400 tokens).
16const CHUNK_CHARS: usize = 400 * CHARS_PER_TOKEN;
17
18/// Overlap between adjacent chunks in characters (~80 tokens).
19const CHUNK_OVERLAP_CHARS: usize = 80 * CHARS_PER_TOKEN;
20
21/// Split `text` into overlapping chunks suitable for embedding.
22///
23/// For text shorter than `CHUNK_CHARS`, returns a single chunk.
24/// Splits at UTF-8 character boundaries on paragraph (`\n\n`), line (`\n`),
25/// space (` `), or raw character boundaries as a last resort.
26fn chunk_text(text: &str) -> Vec<&str> {
27    if text.len() <= CHUNK_CHARS {
28        return vec![text];
29    }
30
31    let mut chunks = Vec::new();
32    let mut start = 0;
33
34    while start < text.len() {
35        let end = if start + CHUNK_CHARS >= text.len() {
36            text.len()
37        } else {
38            // Find a clean UTF-8 char boundary at or before start + CHUNK_CHARS.
39            let boundary = text.floor_char_boundary(start + CHUNK_CHARS);
40            // Prefer to split at a paragraph or line break for cleaner chunks.
41            let slice = &text[start..boundary];
42            if let Some(pos) = slice.rfind("\n\n") {
43                start + pos + 2
44            } else if let Some(pos) = slice.rfind('\n') {
45                start + pos + 1
46            } else if let Some(pos) = slice.rfind(' ') {
47                start + pos + 1
48            } else {
49                boundary
50            }
51        };
52
53        chunks.push(&text[start..end]);
54        if end >= text.len() {
55            break;
56        }
57        // Next chunk starts with overlap, but must always advance past the
58        // current position to prevent infinite loops when rfind finds a match
59        // very early in the slice (end barely advances, overlap rewinds start).
60        let next = end.saturating_sub(CHUNK_OVERLAP_CHARS);
61        let new_start = text.ceil_char_boundary(next);
62        start = if new_start > start { new_start } else { end };
63    }
64
65    chunks
66}
67
68use crate::admission::{AdmissionDecision, log_admission_decision};
69use crate::embedding_store::{MessageKind, SearchFilter};
70use crate::error::MemoryError;
71use crate::store::admission_training::AdmissionTrainingInput;
72use crate::types::{ConversationId, MessageId};
73
74use super::SemanticMemory;
75use super::algorithms::{apply_mmr, apply_temporal_decay};
76
77/// Tool execution metadata stored as Qdrant payload fields alongside embeddings.
78///
79/// Stored as payload — NOT prepended to content — to avoid corrupting embedding vectors.
80#[derive(Debug, Clone, Default)]
81pub struct EmbedContext {
82    pub tool_name: Option<String>,
83    pub exit_code: Option<i32>,
84    pub timestamp: Option<String>,
85}
86
87#[derive(Debug)]
88pub struct RecalledMessage {
89    pub message: Message,
90    pub score: f32,
91}
92
93/// Maximum number of concurrent background embed tasks per `SemanticMemory` instance.
94const MAX_EMBED_BG_TASKS: usize = 64;
95
96/// Rate-limit window (seconds) for the "failed to ensure Qdrant collection" warning.
97const QDRANT_WARN_WINDOW_SECS: u64 = 10;
98
99/// Whether enough time has passed since the last suppressed warning to emit a new one.
100fn should_emit_qdrant_warn(last: u64, now: u64, window_secs: u64) -> bool {
101    now.saturating_sub(last) >= window_secs
102}
103
104/// Log a Qdrant `ensure_collection` failure, rate-limited to one WARN per
105/// [`QDRANT_WARN_WINDOW_SECS`] across all background embed call sites sharing `last_warn`.
106fn warn_qdrant_ensure_failure(last_warn: &AtomicU64, log_tag: &str, err: &MemoryError) {
107    let now = std::time::SystemTime::now()
108        .duration_since(std::time::UNIX_EPOCH)
109        .unwrap_or_default()
110        .as_secs();
111    let last = last_warn.load(Ordering::Relaxed);
112    if should_emit_qdrant_warn(last, now, QDRANT_WARN_WINDOW_SECS) {
113        last_warn.store(now, Ordering::Relaxed);
114        tracing::warn!("{log_tag}: failed to ensure Qdrant collection: {err:#}");
115    } else {
116        tracing::debug!("{log_tag}: failed to ensure Qdrant collection (suppressed): {err:#}");
117    }
118}
119
120/// Shared arguments for background embed tasks.
121///
122/// Deliberately slim: only what [`embed_chunk_and_store_bg`] itself needs. Per-chunk store
123/// arguments (`embedding_model`, `conversation_id`, `role`, category/tool metadata) are
124/// captured by the caller-supplied `store_chunk` closure instead, since they vary by variant.
125struct EmbedBgArgs {
126    qdrant: Arc<crate::embedding_store::EmbeddingStore>,
127    embed_provider: zeph_llm::any::AnyProvider,
128    message_id: MessageId,
129    content: String,
130    last_qdrant_warn: Arc<AtomicU64>,
131}
132
133/// Background task: embed content chunks and store each via `store_chunk`.
134///
135/// All errors are logged as warnings; the function never panics. Shared by
136/// `embed_and_store_regular`, `embed_chunks_with_tool_context`, and
137/// `embed_and_store_with_category` — the only difference between them is how each chunk
138/// is stored, expressed here as a boxed-future closure to sidestep borrow-checker fights
139/// over an in-loop `.await` on a stored future type.
140async fn embed_chunk_and_store_bg<F>(args: EmbedBgArgs, log_tag: &'static str, store_chunk: F)
141where
142    F: Fn(u32, Vec<f32>) -> Pin<Box<dyn Future<Output = Result<(), MemoryError>> + Send>> + Send,
143{
144    let EmbedBgArgs {
145        qdrant,
146        embed_provider,
147        message_id,
148        content,
149        last_qdrant_warn,
150    } = args;
151    let chunks = chunk_text(&content);
152    let chunk_count = chunks.len();
153
154    let vectors = match embed_provider.embed_batch(&chunks).await {
155        Ok(v) => v,
156        Err(e) => {
157            tracing::warn!("{log_tag}: failed to embed chunks for msg {message_id}: {e:#}");
158            return;
159        }
160    };
161
162    let Some(first) = vectors.first() else {
163        return;
164    };
165    if let Err(e) = qdrant.ensure_collection_for_vector(first).await {
166        warn_qdrant_ensure_failure(&last_qdrant_warn, log_tag, &e);
167        return;
168    }
169
170    for (chunk_index, vector) in vectors.into_iter().enumerate() {
171        let chunk_index_u32 = u32::try_from(chunk_index).unwrap_or(u32::MAX);
172        if let Err(e) = store_chunk(chunk_index_u32, vector).await {
173            tracing::warn!(
174                "{log_tag}: failed to store chunk {chunk_index}/{chunk_count} \
175                 for msg {message_id}: {e:#}"
176            );
177        }
178    }
179}
180
181/// Outcome of [`SemanticMemory::run_admission_gate`].
182enum AdmissionOutcome {
183    /// A-MAC rejected the message; the training sample was already recorded.
184    Reject,
185    /// A-MAC admitted the message (or no `AdmissionControl` is configured), carrying the
186    /// decision onward so the caller can record the training sample once the outcome of
187    /// any downstream quality gate and the `SQLite` write are known.
188    Proceed(Option<AdmissionDecision>),
189}
190
191/// Compute `recall_graph_hela`'s outer hard timeout from the same [`crate::graph::HelaSpreadParams`]
192/// that bound the inner call, so the outer bound can never again be tighter than what it wraps
193/// (#5785). `hela_spreading_recall` checks `step_budget` once for the anchor ANN, once per BFS hop
194/// (up to `spread_depth`, clamped to `[1, 6]`) for the edge-fetch, and once for the final
195/// vectors-batch — `spread_depth + 2` gated stages in the worst case, not a fixed count.
196fn hela_outer_timeout(params: &crate::graph::HelaSpreadParams) -> std::time::Duration {
197    let embed_component = params
198        .embed_timeout
199        .unwrap_or(std::time::Duration::from_secs(5));
200    let step_stages = params.spread_depth.clamp(1, 6) + 2;
201    let step_component = params
202        .step_budget
203        .unwrap_or(std::time::Duration::from_millis(80))
204        * step_stages;
205    embed_component + step_component + std::time::Duration::from_millis(250)
206}
207
208impl SemanticMemory {
209    /// Save a message to `SQLite` and optionally embed and store in Qdrant.
210    ///
211    /// Returns `Ok(Some(message_id))` when admitted and persisted.
212    /// Returns `Ok(None)` when A-MAC admission control rejects the message (not an error).
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if the `SQLite` save fails. Embedding failures are logged but not
217    /// propagated.
218    #[cfg_attr(
219        feature = "profiling",
220        tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
221    )]
222    pub async fn remember(
223        &self,
224        conversation_id: ConversationId,
225        role: &str,
226        content: &str,
227        goal_text: Option<&str>,
228    ) -> Result<Option<MessageId>, MemoryError> {
229        self.remember_with_provenance(conversation_id, role, content, goal_text, None, None)
230            .await
231    }
232
233    /// Save a message to `SQLite` and optionally embed and store in Qdrant, tagging the write
234    /// with its origin (issue #6490).
235    ///
236    /// `source_kind`/`trust_level` should be the `as_str()` output of
237    /// `zeph_sanitizer::ContentSourceKind`/`ContentTrustLevel`. `None` leaves both columns
238    /// `NULL` — see [`crate::store::SqliteStore::save_message_with_provenance`].
239    ///
240    /// Returns `Ok(Some(message_id))` when admitted and persisted.
241    /// Returns `Ok(None)` when A-MAC admission control rejects the message (not an error).
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if the `SQLite` save fails. Embedding failures are logged but not
246    /// propagated.
247    #[cfg_attr(
248        feature = "profiling",
249        tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
250    )]
251    pub async fn remember_with_provenance(
252        &self,
253        conversation_id: ConversationId,
254        role: &str,
255        content: &str,
256        goal_text: Option<&str>,
257        source_kind: Option<&str>,
258        trust_level: Option<&str>,
259    ) -> Result<Option<MessageId>, MemoryError> {
260        let admission_decision = match self
261            .run_admission_gate(conversation_id, role, content, goal_text)
262            .await
263        {
264            AdmissionOutcome::Reject => return Ok(None),
265            AdmissionOutcome::Proceed(decision) => decision,
266        };
267
268        if self
269            .run_quality_gate(conversation_id, role, content, admission_decision.as_ref())
270            .await
271        {
272            return Ok(None);
273        }
274
275        let message_id = self
276            .sqlite
277            .save_message_with_provenance(
278                conversation_id,
279                role,
280                content,
281                "[]",
282                MessageVisibility::Both,
283                source_kind,
284                trust_level,
285            )
286            .await?;
287
288        self.record_admission_sample_opt(
289            conversation_id,
290            role,
291            content,
292            admission_decision.as_ref(),
293            Some(message_id),
294        )
295        .await;
296
297        self.embed_and_store_regular(message_id, conversation_id, role, content, trust_level);
298
299        Ok(Some(message_id))
300    }
301
302    /// Save a message with pre-serialized parts JSON to `SQLite` and optionally embed in Qdrant.
303    ///
304    /// Returns `Ok((Some(message_id), embedding_stored))` when admitted and persisted.
305    /// Returns `Ok((None, false))` when A-MAC admission control rejects the message.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if the `SQLite` save fails.
310    #[cfg_attr(
311        feature = "profiling",
312        tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
313    )]
314    pub async fn remember_with_parts(
315        &self,
316        conversation_id: ConversationId,
317        role: &str,
318        content: &str,
319        parts_json: &str,
320        goal_text: Option<&str>,
321    ) -> Result<(Option<MessageId>, bool), MemoryError> {
322        self.remember_with_parts_and_provenance(
323            conversation_id,
324            role,
325            content,
326            parts_json,
327            goal_text,
328            None,
329            None,
330        )
331        .await
332    }
333
334    /// Save a message with pre-serialized parts JSON to `SQLite` and optionally embed in
335    /// Qdrant, tagging the write with its origin (issue #6490).
336    ///
337    /// `source_kind`/`trust_level` should be the `as_str()` output of
338    /// `zeph_sanitizer::ContentSourceKind`/`ContentTrustLevel`. `None` leaves both columns
339    /// `NULL` — see [`crate::store::SqliteStore::save_message_with_provenance`]. This is the
340    /// primary write path for tool-output messages persisted via
341    /// `zeph-agent-persistence::PersistenceService::persist_message`.
342    ///
343    /// Returns `Ok((Some(message_id), embedding_stored))` when admitted and persisted.
344    /// Returns `Ok((None, false))` when A-MAC admission control rejects the message.
345    ///
346    /// # Errors
347    ///
348    /// Returns an error if the `SQLite` save fails.
349    #[cfg_attr(
350        feature = "profiling",
351        tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
352    )]
353    #[allow(clippy::too_many_arguments)]
354    pub async fn remember_with_parts_and_provenance(
355        &self,
356        conversation_id: ConversationId,
357        role: &str,
358        content: &str,
359        parts_json: &str,
360        goal_text: Option<&str>,
361        source_kind: Option<&str>,
362        trust_level: Option<&str>,
363    ) -> Result<(Option<MessageId>, bool), MemoryError> {
364        let admission_decision = match self
365            .run_admission_gate(conversation_id, role, content, goal_text)
366            .await
367        {
368            AdmissionOutcome::Reject => return Ok((None, false)),
369            AdmissionOutcome::Proceed(decision) => decision,
370        };
371
372        if self
373            .run_quality_gate(conversation_id, role, content, admission_decision.as_ref())
374            .await
375        {
376            return Ok((None, false));
377        }
378
379        let message_id = self
380            .sqlite
381            .save_message_with_provenance(
382                conversation_id,
383                role,
384                content,
385                parts_json,
386                MessageVisibility::Both,
387                source_kind,
388                trust_level,
389            )
390            .await?;
391
392        self.record_admission_sample_opt(
393            conversation_id,
394            role,
395            content,
396            admission_decision.as_ref(),
397            Some(message_id),
398        )
399        .await;
400
401        let embedding_stored =
402            self.embed_and_store_regular(message_id, conversation_id, role, content, trust_level);
403
404        Ok((Some(message_id), embedding_stored))
405    }
406
407    /// Save a tool output to `SQLite` and embed with tool metadata in Qdrant payload.
408    ///
409    /// Tool metadata (`tool_name`, `exit_code`, `timestamp`) is stored as Qdrant payload fields
410    /// so it is available for filtering without corrupting the embedding vector.
411    ///
412    /// Returns `Ok(Some(message_id))` when admitted and persisted.
413    /// Returns `Ok(None)` when A-MAC admission control rejects the message.
414    ///
415    /// # Errors
416    ///
417    /// Returns an error if the `SQLite` save fails.
418    #[cfg_attr(
419        feature = "profiling",
420        tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
421    )]
422    pub async fn remember_tool_output(
423        &self,
424        conversation_id: ConversationId,
425        role: &str,
426        content: &str,
427        parts_json: &str,
428        embed_ctx: EmbedContext,
429    ) -> Result<(Option<MessageId>, bool), MemoryError> {
430        // No quality gate here: tool output is not subject to the reference-completeness /
431        // information-value checks applied to conversational messages.
432        let admission_decision = match self
433            .run_admission_gate(conversation_id, role, content, None)
434            .await
435        {
436            AdmissionOutcome::Reject => return Ok((None, false)),
437            AdmissionOutcome::Proceed(decision) => decision,
438        };
439
440        let message_id = self
441            .sqlite
442            .save_message_with_parts(conversation_id, role, content, parts_json)
443            .await?;
444
445        self.record_admission_sample_opt(
446            conversation_id,
447            role,
448            content,
449            admission_decision.as_ref(),
450            Some(message_id),
451        )
452        .await;
453
454        let embedding_stored = self.embed_chunks_with_tool_context(
455            message_id,
456            conversation_id,
457            role,
458            content,
459            embed_ctx,
460        );
461
462        Ok((Some(message_id), embedding_stored))
463    }
464
465    /// Save a categorized message to `SQLite` and embed with category payload in Qdrant.
466    ///
467    /// The `category` is stored in both the `messages.category` column and as a Qdrant payload
468    /// field for recall filtering. Uses A-MAC admission gate.
469    ///
470    /// Returns `Ok(Some(message_id))` when admitted; `Ok(None)` when rejected.
471    ///
472    /// # Errors
473    ///
474    /// Returns an error if the `SQLite` save fails.
475    #[cfg_attr(
476        feature = "profiling",
477        tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
478    )]
479    pub async fn remember_categorized(
480        &self,
481        conversation_id: ConversationId,
482        role: &str,
483        content: &str,
484        category: Option<&str>,
485        goal_text: Option<&str>,
486    ) -> Result<Option<MessageId>, MemoryError> {
487        // No quality gate here: categorized writes (e.g. persona facts, structured summaries)
488        // bypass the reference-completeness / information-value checks applied to `remember`.
489        let admission_decision = match self
490            .run_admission_gate(conversation_id, role, content, goal_text)
491            .await
492        {
493            AdmissionOutcome::Reject => return Ok(None),
494            AdmissionOutcome::Proceed(decision) => decision,
495        };
496
497        let message_id = self
498            .sqlite
499            .save_message_with_category(conversation_id, role, content, category)
500            .await?;
501
502        self.record_admission_sample_opt(
503            conversation_id,
504            role,
505            content,
506            admission_decision.as_ref(),
507            Some(message_id),
508        )
509        .await;
510
511        self.embed_and_store_with_category(message_id, conversation_id, role, content, category);
512
513        Ok(Some(message_id))
514    }
515
516    /// Evaluate the A-MAC admission gate shared by all `remember*` variants.
517    ///
518    /// On rejection, records the training sample (with no `message_id`, since the message
519    /// is never persisted) and returns [`AdmissionOutcome::Reject`]. When no
520    /// [`crate::admission::AdmissionControl`] is configured, always proceeds with `None`.
521    async fn run_admission_gate(
522        &self,
523        conversation_id: ConversationId,
524        role: &str,
525        content: &str,
526        goal_text: Option<&str>,
527    ) -> AdmissionOutcome {
528        let Some(admission) = &self.admission_control else {
529            return AdmissionOutcome::Proceed(None);
530        };
531        let decision = admission
532            .evaluate(
533                content,
534                role,
535                self.effective_embed_provider(),
536                self.qdrant.as_ref(),
537                goal_text,
538            )
539            .await;
540        let preview: String = content.chars().take(100).collect();
541        log_admission_decision(&decision, &preview, role, admission.threshold());
542        if !decision.admitted {
543            self.record_admission_sample(conversation_id, role, content, &decision, None)
544                .await;
545            return AdmissionOutcome::Reject;
546        }
547        AdmissionOutcome::Proceed(Some(decision))
548    }
549
550    /// Evaluate the optional quality gate. Only called by [`Self::remember`] and
551    /// [`Self::remember_with_parts`] — `remember_tool_output` and `remember_categorized`
552    /// deliberately skip it (see their doc comments).
553    ///
554    /// Returns `true` when the gate rejects the content, having already recorded the
555    /// training sample (with no `message_id`, since the message is never persisted).
556    async fn run_quality_gate(
557        &self,
558        conversation_id: ConversationId,
559        role: &str,
560        content: &str,
561        admission_decision: Option<&AdmissionDecision>,
562    ) -> bool {
563        let Some(gate) = &self.quality_gate else {
564            return false;
565        };
566        let recent_embeddings = self
567            .fetch_recent_embeddings(conversation_id, gate.config().recent_window)
568            .await;
569        if gate
570            .evaluate(content, self.effective_embed_provider(), &recent_embeddings)
571            .await
572            .is_none()
573        {
574            return false;
575        }
576        if let Some(decision) = admission_decision {
577            self.record_admission_sample(conversation_id, role, content, decision, None)
578                .await;
579        }
580        true
581    }
582
583    /// Fetch embeddings for the most recent `limit` messages in `conversation_id`, for use as
584    /// the `recent_embeddings` window in [`crate::quality_gate::QualityGate::evaluate`] (#6387).
585    ///
586    /// Reuses the same `SqliteStore::load_history` + `EmbeddingStore::get_vectors` pair the MMR
587    /// re-ranking path uses (see [`Self::recall_merge_and_rank`]) rather than a bespoke query.
588    /// Called before the candidate message is persisted, so the returned window never includes it.
589    ///
590    /// Fails open: returns an empty vec (which makes `information_value` score as novel) when no
591    /// vector store is attached, `limit == 0`, or any lookup step errors.
592    async fn fetch_recent_embeddings(
593        &self,
594        conversation_id: ConversationId,
595        limit: usize,
596    ) -> Vec<Vec<f32>> {
597        let Some(qdrant) = &self.qdrant else {
598            return Vec::new();
599        };
600        if limit == 0 {
601            return Vec::new();
602        }
603        let limit_u32 = u32::try_from(limit).unwrap_or(u32::MAX);
604        let recent_messages = match self.sqlite.load_history(conversation_id, limit_u32).await {
605            Ok(messages) => messages,
606            Err(e) => {
607                tracing::warn!("quality_gate: failed to load recent history: {e:#}");
608                return Vec::new();
609            }
610        };
611        let ids: Vec<MessageId> = recent_messages
612            .iter()
613            .filter_map(|m| m.metadata.db_id)
614            .map(MessageId)
615            .collect();
616        if ids.is_empty() {
617            return Vec::new();
618        }
619        match qdrant.get_vectors(&ids).await {
620            Ok(vec_map) => vec_map.into_values().collect(),
621            Err(e) => {
622                tracing::warn!("quality_gate: failed to fetch recent embeddings: {e:#}");
623                Vec::new()
624            }
625        }
626    }
627
628    /// Record the admission training sample for a message that passed every gate, when an
629    /// A-MAC decision was made (no-op when `admission_control` is unconfigured).
630    async fn record_admission_sample_opt(
631        &self,
632        conversation_id: ConversationId,
633        role: &str,
634        content: &str,
635        decision: Option<&AdmissionDecision>,
636        message_id: Option<MessageId>,
637    ) {
638        if let Some(decision) = decision {
639            self.record_admission_sample(conversation_id, role, content, decision, message_id)
640                .await;
641        }
642    }
643
644    /// Record an A-MAC admission decision as an RL training sample.
645    ///
646    /// Best-effort: failures are logged at debug level and never propagated, since training
647    /// data collection must not affect the write path it observes. Records both admitted and
648    /// rejected decisions so the training set avoids survivorship bias (see
649    /// `crate::store::admission_training` module docs). `message_id` is `None` when the
650    /// message was rejected (by A-MAC or a downstream quality gate) and never persisted.
651    async fn record_admission_sample(
652        &self,
653        conversation_id: ConversationId,
654        role: &str,
655        content: &str,
656        decision: &AdmissionDecision,
657        message_id: Option<MessageId>,
658    ) {
659        let features_json = match serde_json::to_string(&decision.factors) {
660            Ok(json) => json,
661            Err(e) => {
662                tracing::debug!(error = %e, "admission training: failed to serialize factors");
663                return;
664            }
665        };
666        if let Err(e) = self
667            .sqlite
668            .record_admission_training(AdmissionTrainingInput {
669                message_id,
670                conversation_id,
671                content,
672                role,
673                composite_score: decision.composite_score,
674                was_admitted: decision.admitted,
675                features_json: &features_json,
676            })
677            .await
678        {
679            tracing::debug!(error = %e, "admission training: failed to record sample (non-fatal)");
680        }
681    }
682
683    /// Recall messages filtered by category.
684    ///
685    /// When `category` is `None`, behaves identically to [`Self::recall`].
686    ///
687    /// # Errors
688    ///
689    /// Returns an error if the search fails.
690    pub async fn recall_with_category(
691        &self,
692        query: &str,
693        limit: usize,
694        filter: Option<SearchFilter>,
695        category: Option<&str>,
696    ) -> Result<Vec<RecalledMessage>, MemoryError> {
697        let filter_with_category = filter.map(|mut f| {
698            f.category = category.map(str::to_owned);
699            f
700        });
701        self.recall(query, limit, filter_with_category).await
702    }
703
704    /// Reap completed background embed tasks (non-blocking).
705    ///
706    /// Call at turn boundaries to release handles for finished tasks.
707    pub fn reap_embed_tasks(&self) {
708        if let Ok(mut tasks) = self.embed_tasks.lock() {
709            while tasks.try_join_next().is_some() {}
710        }
711    }
712
713    /// Spawn `fut` as a bounded background embed task.
714    ///
715    /// If the task limit is reached, the task is dropped and a debug message is logged.
716    fn spawn_embed_bg<F>(&self, fut: F) -> bool
717    where
718        F: std::future::Future<Output = ()> + Send + 'static,
719    {
720        let Ok(mut tasks) = self.embed_tasks.lock() else {
721            return false;
722        };
723        // Reap any finished tasks before checking capacity.
724        while tasks.try_join_next().is_some() {}
725        if tasks.len() >= MAX_EMBED_BG_TASKS {
726            tracing::debug!("background embed task limit reached, skipping");
727            return false;
728        }
729        tasks.spawn(fut);
730        true
731    }
732
733    /// Embed content chunks and store each with an optional category payload field.
734    ///
735    /// Spawns a bounded background task; returns immediately.
736    fn embed_and_store_with_category(
737        &self,
738        message_id: MessageId,
739        conversation_id: ConversationId,
740        role: &str,
741        content: &str,
742        category: Option<&str>,
743    ) -> bool {
744        let Some(qdrant) = self.qdrant.clone() else {
745            return false;
746        };
747        let embed_provider = self.effective_embed_provider().clone();
748        if !embed_provider.supports_embeddings() {
749            return false;
750        }
751        let store_qdrant = Arc::clone(&qdrant);
752        let embedding_model = self.embedding_model.clone();
753        let role = role.to_owned();
754        let category = category.map(str::to_owned);
755        let store_chunk =
756            move |chunk_index: u32,
757                  vector: Vec<f32>|
758                  -> Pin<Box<dyn Future<Output = Result<(), MemoryError>> + Send>> {
759                let qdrant = Arc::clone(&store_qdrant);
760                let embedding_model = embedding_model.clone();
761                let role = role.clone();
762                let category = category.clone();
763                Box::pin(async move {
764                    qdrant
765                        .store_with_category(
766                            message_id,
767                            conversation_id,
768                            &role,
769                            vector,
770                            MessageKind::Regular,
771                            &embedding_model,
772                            chunk_index,
773                            category.as_deref(),
774                            // `remember_categorized` writers (persona facts, structured
775                            // summaries) do not currently carry provenance (issue #6490
776                            // scoped write-time tagging covers `remember`/`remember_with_parts`/
777                            // `save_only` — the primary tool-output and interactive-save paths).
778                            None,
779                        )
780                        .await
781                        .map(|_| ())
782                })
783            };
784        self.spawn_embed_bg(embed_chunk_and_store_bg(
785            EmbedBgArgs {
786                qdrant,
787                embed_provider,
788                message_id,
789                content: content.to_owned(),
790                last_qdrant_warn: Arc::clone(&self.last_qdrant_warn),
791            },
792            "bg embed_category",
793            store_chunk,
794        ))
795    }
796
797    /// Embed content chunks and store each as a regular (non-tool) message vector.
798    ///
799    /// Spawns a bounded background task; returns immediately.
800    fn embed_and_store_regular(
801        &self,
802        message_id: MessageId,
803        conversation_id: ConversationId,
804        role: &str,
805        content: &str,
806        trust_level: Option<&str>,
807    ) -> bool {
808        let Some(qdrant) = self.qdrant.clone() else {
809            return false;
810        };
811        let embed_provider = self.effective_embed_provider().clone();
812        if !embed_provider.supports_embeddings() {
813            return false;
814        }
815        let store_qdrant = Arc::clone(&qdrant);
816        let embedding_model = self.embedding_model.clone();
817        let role = role.to_owned();
818        let trust_level = trust_level.map(str::to_owned);
819        let store_chunk =
820            move |chunk_index: u32,
821                  vector: Vec<f32>|
822                  -> Pin<Box<dyn Future<Output = Result<(), MemoryError>> + Send>> {
823                let qdrant = Arc::clone(&store_qdrant);
824                let embedding_model = embedding_model.clone();
825                let role = role.clone();
826                let trust_level = trust_level.clone();
827                Box::pin(async move {
828                    qdrant
829                        .store(
830                            message_id,
831                            conversation_id,
832                            &role,
833                            vector,
834                            MessageKind::Regular,
835                            &embedding_model,
836                            chunk_index,
837                            trust_level.as_deref(),
838                        )
839                        .await
840                        .map(|_| ())
841                })
842            };
843        self.spawn_embed_bg(embed_chunk_and_store_bg(
844            EmbedBgArgs {
845                qdrant,
846                embed_provider,
847                message_id,
848                content: content.to_owned(),
849                last_qdrant_warn: Arc::clone(&self.last_qdrant_warn),
850            },
851            "bg embed_regular",
852            store_chunk,
853        ))
854    }
855
856    /// Embed content chunks, enriching Qdrant payload with tool metadata when present.
857    ///
858    /// Spawns a bounded background task; returns immediately.
859    fn embed_chunks_with_tool_context(
860        &self,
861        message_id: MessageId,
862        conversation_id: ConversationId,
863        role: &str,
864        content: &str,
865        embed_ctx: EmbedContext,
866    ) -> bool {
867        let Some(qdrant) = self.qdrant.clone() else {
868            return false;
869        };
870        let embed_provider = self.effective_embed_provider().clone();
871        if !embed_provider.supports_embeddings() {
872            return false;
873        }
874        let store_qdrant = Arc::clone(&qdrant);
875        let embedding_model = self.embedding_model.clone();
876        let role = role.to_owned();
877        let store_chunk =
878            move |chunk_index: u32,
879                  vector: Vec<f32>|
880                  -> Pin<Box<dyn Future<Output = Result<(), MemoryError>> + Send>> {
881                let qdrant = Arc::clone(&store_qdrant);
882                let embedding_model = embedding_model.clone();
883                let role = role.clone();
884                let embed_ctx = embed_ctx.clone();
885                Box::pin(async move {
886                    if let Some(tool_name) = embed_ctx.tool_name {
887                        qdrant
888                            .store_with_tool_context(
889                                message_id,
890                                conversation_id,
891                                &role,
892                                vector,
893                                MessageKind::Regular,
894                                &embedding_model,
895                                chunk_index,
896                                &tool_name,
897                                embed_ctx.exit_code,
898                                embed_ctx.timestamp.as_deref(),
899                                // `remember_tool_output` is not currently on the primary
900                                // production tool-output write path (that goes through
901                                // `remember_with_parts`/`save_only` via `PersistenceService`;
902                                // issue #6490 scopes provenance tagging to those call sites).
903                                None,
904                            )
905                            .await
906                            .map(|_| ())
907                    } else {
908                        qdrant
909                            .store(
910                                message_id,
911                                conversation_id,
912                                &role,
913                                vector,
914                                MessageKind::Regular,
915                                &embedding_model,
916                                chunk_index,
917                                None,
918                            )
919                            .await
920                            .map(|_| ())
921                    }
922                })
923            };
924        self.spawn_embed_bg(embed_chunk_and_store_bg(
925            EmbedBgArgs {
926                qdrant,
927                embed_provider,
928                message_id,
929                content: content.to_owned(),
930                last_qdrant_warn: Arc::clone(&self.last_qdrant_warn),
931            },
932            "bg embed_tool",
933            store_chunk,
934        ))
935    }
936
937    /// Save a message to `SQLite` without generating an embedding.
938    ///
939    /// Use this when embedding is intentionally skipped (e.g. autosave disabled for assistant).
940    ///
941    /// # Errors
942    ///
943    /// Returns an error if the `SQLite` save fails.
944    pub async fn save_only(
945        &self,
946        conversation_id: ConversationId,
947        role: &str,
948        content: &str,
949        parts_json: &str,
950    ) -> Result<MessageId, MemoryError> {
951        self.save_only_with_provenance(conversation_id, role, content, parts_json, None, None)
952            .await
953    }
954
955    /// Save a message to `SQLite` without generating an embedding, tagging the write with its
956    /// origin (issue #6490).
957    ///
958    /// `source_kind`/`trust_level` should be the `as_str()` output of
959    /// `zeph_sanitizer::ContentSourceKind`/`ContentTrustLevel`. `None` leaves both columns
960    /// `NULL` — see [`crate::store::SqliteStore::save_message_with_provenance`].
961    ///
962    /// # Errors
963    ///
964    /// Returns an error if the `SQLite` save fails.
965    pub async fn save_only_with_provenance(
966        &self,
967        conversation_id: ConversationId,
968        role: &str,
969        content: &str,
970        parts_json: &str,
971        source_kind: Option<&str>,
972        trust_level: Option<&str>,
973    ) -> Result<MessageId, MemoryError> {
974        self.sqlite
975            .save_message_with_provenance(
976                conversation_id,
977                role,
978                content,
979                parts_json,
980                MessageVisibility::Both,
981                source_kind,
982                trust_level,
983            )
984            .await
985    }
986
987    /// Recall relevant messages using hybrid search (vector + FTS5 keyword).
988    ///
989    /// When Qdrant is available, runs both vector and keyword searches, then merges
990    /// results using weighted scoring. When Qdrant is unavailable, falls back to
991    /// FTS5-only keyword search.
992    ///
993    /// # Errors
994    ///
995    /// Returns an error if embedding generation, Qdrant search, or FTS5 query fails.
996    #[cfg_attr(
997        feature = "profiling",
998        tracing::instrument(name = "memory.recall", skip_all, fields(query_len = %query.len(), result_count = tracing::field::Empty, top_score = tracing::field::Empty))
999    )]
1000    pub async fn recall(
1001        &self,
1002        query: &str,
1003        limit: usize,
1004        filter: Option<SearchFilter>,
1005    ) -> Result<Vec<RecalledMessage>, MemoryError> {
1006        let conversation_id = filter.as_ref().and_then(|f| f.conversation_id);
1007
1008        tracing::debug!(
1009            query_len = query.len(),
1010            limit,
1011            has_filter = filter.is_some(),
1012            conversation_id = conversation_id.map(|c| c.0),
1013            has_qdrant = self.qdrant.is_some(),
1014            "recall: starting hybrid search"
1015        );
1016
1017        let keyword_results = match self
1018            .sqlite
1019            .keyword_search(query, self.effective_depth(limit), conversation_id)
1020            .await
1021        {
1022            Ok(results) => results,
1023            Err(e) => {
1024                tracing::warn!("FTS5 keyword search failed: {e:#}");
1025                Vec::new()
1026            }
1027        };
1028
1029        let vector_results = if let Some(qdrant) = &self.qdrant
1030            && self.effective_embed_provider().supports_embeddings()
1031        {
1032            let embed_input = self.apply_search_prompt(query);
1033            let query_vector = match tokio::time::timeout(
1034                self.embed_timeout,
1035                self.effective_embed_provider().embed(&embed_input),
1036            )
1037            .await
1038            {
1039                Ok(Ok(v)) => v,
1040                Ok(Err(e)) => return Err(e.into()),
1041                Err(_) => {
1042                    tracing::warn!("recall_semantic: embed timed out, returning empty results");
1043                    return Ok(Vec::new());
1044                }
1045            };
1046            let query_vector = self.apply_query_bias(query, query_vector).await;
1047            qdrant.ensure_collection_for_vector(&query_vector).await?;
1048            qdrant
1049                .search(&query_vector, self.effective_depth(limit), filter)
1050                .await?
1051        } else {
1052            Vec::new()
1053        };
1054
1055        let results = self
1056            .recall_merge_and_rank(keyword_results, vector_results, limit, None)
1057            .await?;
1058        #[cfg(feature = "profiling")]
1059        {
1060            let span = tracing::Span::current();
1061            span.record("result_count", results.len());
1062            if let Some(top) = results.first() {
1063                span.record("top_score", top.score);
1064            }
1065        }
1066        Ok(results)
1067    }
1068
1069    #[cfg_attr(
1070        feature = "profiling",
1071        tracing::instrument(name = "memory.recall.fts5", skip_all, fields(query_len = %query.len()))
1072    )]
1073    pub(super) async fn recall_fts5_raw(
1074        &self,
1075        query: &str,
1076        limit: usize,
1077        conversation_id: Option<ConversationId>,
1078    ) -> Result<Vec<(MessageId, f64)>, MemoryError> {
1079        self.sqlite
1080            .keyword_search(query, self.effective_depth(limit), conversation_id)
1081            .await
1082    }
1083
1084    #[cfg_attr(
1085        feature = "profiling",
1086        tracing::instrument(name = "memory.recall.vectors", skip_all, fields(query_len = %query.len()))
1087    )]
1088    pub(super) async fn recall_vectors_raw(
1089        &self,
1090        query: &str,
1091        limit: usize,
1092        filter: Option<SearchFilter>,
1093    ) -> Result<Vec<crate::embedding_store::SearchResult>, MemoryError> {
1094        let Some(qdrant) = &self.qdrant else {
1095            return Ok(Vec::new());
1096        };
1097        if !self.effective_embed_provider().supports_embeddings() {
1098            return Ok(Vec::new());
1099        }
1100        let embed_input = self.apply_search_prompt(query);
1101        let query_vector = match tokio::time::timeout(
1102            self.embed_timeout,
1103            self.effective_embed_provider().embed(&embed_input),
1104        )
1105        .await
1106        {
1107            Ok(Ok(v)) => v,
1108            Ok(Err(e)) => return Err(e.into()),
1109            Err(_) => {
1110                tracing::warn!("recall_vectors_raw: embed timed out, returning empty results");
1111                return Ok(Vec::new());
1112            }
1113        };
1114        let query_vector = self.apply_query_bias(query, query_vector).await;
1115        qdrant.ensure_collection_for_vector(&query_vector).await?;
1116        qdrant
1117            .search(&query_vector, self.effective_depth(limit), filter)
1118            .await
1119    }
1120
1121    /// Merge raw keyword and vector results, apply weighted scoring, temporal decay, and MMR
1122    /// re-ranking, then resolve to `RecalledMessage` objects.
1123    ///
1124    /// This is the shared post-processing step used by all recall paths.
1125    ///
1126    /// # Errors
1127    ///
1128    /// Returns an error if the `SQLite` `messages_by_ids` query fails.
1129    #[cfg_attr(
1130        feature = "profiling",
1131        tracing::instrument(name = "memory.recall.merge_and_rank", skip_all, fields(kw_count = keyword_results.len(), vec_count = vector_results.len()))
1132    )]
1133    #[allow(clippy::cast_possible_truncation, clippy::too_many_lines)]
1134    pub(super) async fn recall_merge_and_rank(
1135        &self,
1136        keyword_results: Vec<(MessageId, f64)>,
1137        vector_results: Vec<crate::embedding_store::SearchResult>,
1138        limit: usize,
1139        goal_entity_id: Option<i64>,
1140    ) -> Result<Vec<RecalledMessage>, MemoryError> {
1141        tracing::debug!(
1142            vector_count = vector_results.len(),
1143            keyword_count = keyword_results.len(),
1144            limit,
1145            "recall: merging search results"
1146        );
1147
1148        let mut scores: std::collections::HashMap<MessageId, f64> =
1149            std::collections::HashMap::new();
1150
1151        if !vector_results.is_empty() {
1152            let max_vs = vector_results
1153                .iter()
1154                .map(|r| r.score)
1155                .fold(f32::NEG_INFINITY, f32::max);
1156            let norm = if max_vs > 0.0 { max_vs } else { 1.0 };
1157            for r in &vector_results {
1158                let normalized = f64::from(r.score / norm);
1159                *scores.entry(r.message_id).or_default() += normalized * self.vector_weight;
1160            }
1161        }
1162
1163        if !keyword_results.is_empty() {
1164            let max_ks = keyword_results
1165                .iter()
1166                .map(|r| r.1)
1167                .fold(f64::NEG_INFINITY, f64::max);
1168            let norm = if max_ks > 0.0 { max_ks } else { 1.0 };
1169            for &(msg_id, score) in &keyword_results {
1170                let normalized = score / norm;
1171                *scores.entry(msg_id).or_default() += normalized * self.keyword_weight;
1172            }
1173        }
1174
1175        if scores.is_empty() {
1176            tracing::debug!("recall: empty merge, no overlapping scores");
1177            return Ok(Vec::new());
1178        }
1179
1180        let mut ranked: Vec<(MessageId, f64)> = scores.into_iter().collect();
1181        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1182
1183        tracing::debug!(
1184            merged = ranked.len(),
1185            top_score = ranked.first().map(|r| r.1),
1186            bottom_score = ranked.last().map(|r| r.1),
1187            vector_weight = %self.vector_weight,
1188            keyword_weight = %self.keyword_weight,
1189            "recall: weighted merge complete"
1190        );
1191
1192        if self.temporal_decay.is_enabled() && self.temporal_decay_half_life_days > 0 {
1193            let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1194            match self.sqlite.message_timestamps(&ids).await {
1195                Ok(timestamps) => {
1196                    apply_temporal_decay(
1197                        &mut ranked,
1198                        &timestamps,
1199                        self.temporal_decay_half_life_days,
1200                    );
1201                    ranked
1202                        .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1203                    tracing::debug!(
1204                        half_life_days = self.temporal_decay_half_life_days,
1205                        top_score_after = ranked.first().map(|r| r.1),
1206                        "recall: temporal decay applied"
1207                    );
1208                }
1209                Err(e) => {
1210                    tracing::warn!("temporal decay: failed to fetch timestamps: {e:#}");
1211                }
1212            }
1213        }
1214
1215        if self.mmr_reranking.is_enabled() && !vector_results.is_empty() {
1216            if let Some(qdrant) = &self.qdrant {
1217                let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1218                match qdrant.get_vectors(&ids).await {
1219                    Ok(vec_map) if !vec_map.is_empty() => {
1220                        let ranked_len_before = ranked.len();
1221                        ranked = apply_mmr(&ranked, &vec_map, self.mmr_lambda, limit);
1222                        tracing::debug!(
1223                            before = ranked_len_before,
1224                            after = ranked.len(),
1225                            lambda = %self.mmr_lambda,
1226                            "recall: mmr re-ranked"
1227                        );
1228                    }
1229                    Ok(_) => {
1230                        ranked.truncate(limit);
1231                    }
1232                    Err(e) => {
1233                        tracing::warn!("MMR: failed to fetch vectors: {e:#}");
1234                        ranked.truncate(limit);
1235                    }
1236                }
1237            } else {
1238                ranked.truncate(limit);
1239            }
1240        } else {
1241            ranked.truncate(limit);
1242        }
1243
1244        if self.importance_scoring.is_enabled() && !ranked.is_empty() {
1245            let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1246            match self.sqlite.fetch_importance_scores(&ids).await {
1247                Ok(scores) => {
1248                    for (msg_id, score) in &mut ranked {
1249                        if let Some(&imp) = scores.get(msg_id) {
1250                            *score += imp * self.importance_weight;
1251                        }
1252                    }
1253                    ranked
1254                        .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1255                    tracing::debug!(
1256                        importance_weight = %self.importance_weight,
1257                        "recall: importance scores blended"
1258                    );
1259                }
1260                Err(e) => {
1261                    tracing::warn!("importance scoring: failed to fetch scores: {e:#}");
1262                }
1263            }
1264        }
1265
1266        // Apply tier boost: semantic-tier messages receive an additive bonus so distilled facts
1267        // rank above episodic messages with the same base score. Additive (not multiplicative)
1268        // so the effect is consistent regardless of base score magnitude.
1269        if (self.tier_boost_semantic - 1.0).abs() > f64::EPSILON && !ranked.is_empty() {
1270            let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1271            match self.sqlite.fetch_tiers(&ids).await {
1272                Ok(tiers) => {
1273                    let bonus = self.tier_boost_semantic - 1.0;
1274                    let mut boosted = false;
1275                    for (msg_id, score) in &mut ranked {
1276                        if tiers.get(msg_id).map(String::as_str) == Some("semantic") {
1277                            *score += bonus;
1278                            boosted = true;
1279                        }
1280                    }
1281                    if boosted {
1282                        ranked.sort_by(|a, b| {
1283                            b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
1284                        });
1285                        tracing::debug!(
1286                            tier_boost = %self.tier_boost_semantic,
1287                            "recall: semantic tier boost applied"
1288                        );
1289                    }
1290                }
1291                Err(e) => {
1292                    tracing::warn!("tier boost: failed to fetch tiers: {e:#}");
1293                }
1294            }
1295        }
1296
1297        // Five-signal scoring (issue #4374): gated by enabled flag and non-baseline weights.
1298        if let Some(fs) = &self.five_signal
1299            && !fs.weights.is_baseline()
1300        {
1301            self.apply_five_signal_scoring(&mut ranked, fs, goal_entity_id)
1302                .await;
1303        }
1304
1305        let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1306
1307        // Log access events for the returned facts.
1308        if let Some(fs) = &self.five_signal {
1309            for id in &ids {
1310                fs.access_cache
1311                    .log_access(*id, "message", &fs.session_id)
1312                    .await;
1313            }
1314            fs.metrics.inc_recall();
1315        }
1316
1317        if !ids.is_empty()
1318            && let Err(e) = self.batch_increment_access_count(ids.clone()).await
1319        {
1320            tracing::warn!("recall: failed to increment access counts: {e:#}");
1321        }
1322
1323        // Update RL admission training data: mark recalled messages as positive examples.
1324        if let Err(e) = self.sqlite.mark_training_recalled(&ids).await {
1325            tracing::debug!(
1326                error = %e,
1327                "recall: failed to mark training data as recalled (non-fatal)"
1328            );
1329        }
1330
1331        let messages = self.sqlite.messages_by_ids(&ids).await?;
1332        let msg_map: std::collections::HashMap<MessageId, _> = messages.into_iter().collect();
1333
1334        let recalled: Vec<RecalledMessage> = ranked
1335            .iter()
1336            .filter_map(|(msg_id, score)| {
1337                msg_map.get(msg_id).map(|msg| RecalledMessage {
1338                    message: msg.clone(),
1339                    #[expect(clippy::cast_possible_truncation)]
1340                    score: *score as f32,
1341                })
1342            })
1343            .collect();
1344
1345        tracing::debug!(final_count = recalled.len(), "recall: final results");
1346
1347        Ok(recalled)
1348    }
1349
1350    /// Apply five-signal scoring to the ranked candidate list (issue #4374).
1351    ///
1352    /// Fetches access frequency, causal distance, and novelty signals. Access frequency
1353    /// and novelty require DB I/O; causal distance requires a BFS traversal (cached per
1354    /// goal entity). All three signals use per-candidate values — no static neutral fallback.
1355    async fn apply_five_signal_scoring(
1356        &self,
1357        ranked: &mut [(MessageId, f64)],
1358        fs: &crate::five_signal::FiveSignalRuntime,
1359        goal_entity_id: Option<i64>,
1360    ) {
1361        use crate::five_signal::causal_distance::CausalDistanceComputer;
1362        use crate::five_signal::scoring::{CandidateSignals, apply_five_signal_scoring};
1363        use sqlx::Row as _;
1364
1365        let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1366
1367        // Load per-candidate access frequency scores.
1368        let freq_map = match fs
1369            .access_cache
1370            .load_for_candidates(&fs.session_id, &ids)
1371            .await
1372        {
1373            Ok(m) => m,
1374            Err(e) => {
1375                tracing::warn!(error = %e, "five_signal: failed to load access frequencies (skipping)");
1376                return;
1377            }
1378        };
1379
1380        // Batch-fetch `created_at` timestamps for novelty computation.
1381        let created_at_map: std::collections::HashMap<MessageId, i64> = {
1382            let id_vals: Vec<i64> = ids.iter().map(|id| id.0).collect();
1383            let placeholders = zeph_db::placeholder_list(1, id_vals.len());
1384            let created_at_epoch =
1385                <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::epoch_from_col("created_at");
1386            let sql = format!(
1387                "SELECT id, {created_at_epoch} AS created_at FROM messages \
1388                 WHERE id IN ({placeholders}) AND deleted_at IS NULL"
1389            );
1390            let mut q = sqlx::query(sqlx::AssertSqlSafe(sql));
1391            for id in &id_vals {
1392                q = q.bind(id);
1393            }
1394            match q.fetch_all(&fs.pool).await {
1395                Ok(rows) => rows
1396                    .iter()
1397                    .map(|row| {
1398                        (
1399                            MessageId(row.get::<i64, _>("id")),
1400                            row.get::<i64, _>("created_at"),
1401                        )
1402                    })
1403                    .collect(),
1404                Err(e) => {
1405                    tracing::warn!(error = %e, "five_signal: failed to fetch created_at (skipping novelty)");
1406                    std::collections::HashMap::new()
1407                }
1408            }
1409        };
1410
1411        // Compute per-candidate causal distances (BFS from current goal entity).
1412        // FR-006: when goal_entity_id is None, compute() returns an empty map and all
1413        // candidates receive the neutral causal score via distance_to_score(neutral_distance).
1414        let causal_distance_map: std::collections::HashMap<i64, u32> = {
1415            let entity_ids: Vec<i64> = ids.iter().map(|id| id.0).collect();
1416            match fs
1417                .causal_computer
1418                .compute(goal_entity_id, &entity_ids)
1419                .await
1420            {
1421                Ok(m) => m,
1422                Err(e) => {
1423                    tracing::warn!(error = %e, "five_signal: causal BFS failed (using neutral)");
1424                    std::collections::HashMap::new()
1425                }
1426            }
1427        };
1428        let neutral_causal_score =
1429            CausalDistanceComputer::distance_to_score(fs.config.neutral_causal_distance);
1430
1431        let mut signals_map = std::collections::HashMap::with_capacity(ids.len());
1432        for &(msg_id, base_score) in ranked.iter() {
1433            let frequency = freq_map.get(&msg_id).copied().unwrap_or(0.0);
1434            // Recency and relevance are approximated from the hybrid score: since the
1435            // existing score blends both signals equally, half each preserves baseline ranking.
1436            let half = base_score / 2.0;
1437            let fact_created_at = created_at_map
1438                .get(&msg_id)
1439                .copied()
1440                .unwrap_or(fs.session_start);
1441            let novelty = fs.novelty_computer.compute(fact_created_at);
1442            let causal = causal_distance_map
1443                .get(&msg_id.0)
1444                .map_or(neutral_causal_score, |&d| {
1445                    CausalDistanceComputer::distance_to_score(d)
1446                });
1447            signals_map.insert(
1448                msg_id,
1449                CandidateSignals {
1450                    recency: half,
1451                    relevance: half,
1452                    frequency,
1453                    causal,
1454                    novelty,
1455                },
1456            );
1457        }
1458
1459        apply_five_signal_scoring(ranked, &fs.weights, &signals_map);
1460
1461        tracing::debug!(
1462            candidate_count = ids.len(),
1463            "recall: five-signal scoring applied"
1464        );
1465    }
1466
1467    /// Routed search stage: dispatch to keyword-only, vector-only, or hybrid retrieval
1468    /// per `route`, returning the raw `(keyword, vector)` pair for the shared
1469    /// merge-and-rank pipeline. Shared by [`Self::recall_routed`] and
1470    /// [`Self::recall_routed_async`] — those differ only in how `route` is obtained
1471    /// (sync `MemoryRouter::route` vs async `AsyncMemoryRouter::route_async`).
1472    async fn recall_by_route(
1473        &self,
1474        route: crate::router::MemoryRoute,
1475        query: &str,
1476        limit: usize,
1477        filter: Option<crate::embedding_store::SearchFilter>,
1478    ) -> Result<
1479        (
1480            Vec<(crate::types::MessageId, f64)>,
1481            Vec<crate::embedding_store::SearchResult>,
1482        ),
1483        MemoryError,
1484    > {
1485        use crate::router::MemoryRoute;
1486
1487        let conversation_id = filter.as_ref().and_then(|f| f.conversation_id);
1488
1489        let results: (
1490            Vec<(crate::types::MessageId, f64)>,
1491            Vec<crate::embedding_store::SearchResult>,
1492        ) = match route {
1493            MemoryRoute::Keyword => {
1494                let kw = self.recall_fts5_raw(query, limit, conversation_id).await?;
1495                (kw, Vec::new())
1496            }
1497            MemoryRoute::Hybrid => {
1498                let kw = match self.recall_fts5_raw(query, limit, conversation_id).await {
1499                    Ok(r) => r,
1500                    Err(e) => {
1501                        tracing::warn!("FTS5 keyword search failed: {e:#}");
1502                        Vec::new()
1503                    }
1504                };
1505                let vr = self.recall_vectors_raw(query, limit, filter).await?;
1506                (kw, vr)
1507            }
1508            // Episodic: FTS5 keyword search with an optional timestamp-range filter.
1509            // Temporal keywords are stripped from the query before passing to FTS5 to
1510            // prevent BM25 score distortion (e.g. "yesterday" matching messages that
1511            // literally contain the word "yesterday" regardless of actual relevance).
1512            // Vector search is skipped for speed; temporal decay in recall_merge_and_rank
1513            // provides recency boosting for the FTS5 results.
1514            // Known trade-off (MVP): semantically similar but lexically different messages
1515            // may be missed. See issue #1629 for a future hybrid_temporal mode.
1516            MemoryRoute::Episodic => {
1517                let range = crate::router::resolve_temporal_range(query, chrono::Utc::now());
1518                let cleaned = crate::router::strip_temporal_keywords(query);
1519                let search_query = if cleaned.is_empty() { query } else { &cleaned };
1520                let kw = if let Some(ref r) = range {
1521                    self.sqlite
1522                        .keyword_search_with_time_range(
1523                            search_query,
1524                            limit,
1525                            conversation_id,
1526                            r.after.as_deref(),
1527                            r.before.as_deref(),
1528                        )
1529                        .await?
1530                } else {
1531                    self.recall_fts5_raw(search_query, limit, conversation_id)
1532                        .await?
1533                };
1534                tracing::debug!(
1535                    has_range = range.is_some(),
1536                    cleaned_query = %search_query,
1537                    keyword_count = kw.len(),
1538                    "recall: episodic path"
1539                );
1540                (kw, Vec::new())
1541            }
1542            // Graph routing triggers graph_recall separately in agent/context.rs.
1543            // For the message-based recall, behave like Hybrid.
1544            MemoryRoute::Graph => {
1545                let kw = match self.recall_fts5_raw(query, limit, conversation_id).await {
1546                    Ok(r) => r,
1547                    Err(e) => {
1548                        tracing::warn!("FTS5 keyword search failed (graph→hybrid fallback): {e:#}");
1549                        Vec::new()
1550                    }
1551                };
1552                let vr = self.recall_vectors_raw(query, limit, filter).await?;
1553                (kw, vr)
1554            }
1555            _ => {
1556                let vr = self.recall_vectors_raw(query, limit, filter).await?;
1557                (Vec::new(), vr)
1558            }
1559        };
1560        Ok(results)
1561    }
1562
1563    /// Recall messages using query-aware routing.
1564    ///
1565    /// Delegates to FTS5-only, vector-only, or hybrid search based on the router decision,
1566    /// then runs the shared merge and ranking pipeline.
1567    ///
1568    /// * `goal_entity_id` — optional goal entity for causal distance scoring; when `None`, the
1569    ///   causal distance signal contribution is zero (FR-006).
1570    ///
1571    /// # Errors
1572    ///
1573    /// Returns an error if any underlying search or database operation fails.
1574    #[cfg_attr(
1575        feature = "profiling",
1576        tracing::instrument(name = "memory.recall", skip_all, fields(query_len = %query.len(), result_count = tracing::field::Empty))
1577    )]
1578    pub async fn recall_routed(
1579        &self,
1580        query: &str,
1581        limit: usize,
1582        filter: Option<SearchFilter>,
1583        router: &dyn crate::router::MemoryRouter,
1584        goal_entity_id: Option<i64>,
1585    ) -> Result<Vec<RecalledMessage>, MemoryError> {
1586        let route = router.route(query);
1587        tracing::debug!(?route, query_len = query.len(), "memory routing decision");
1588
1589        let (keyword_results, vector_results) =
1590            self.recall_by_route(route, query, limit, filter).await?;
1591
1592        tracing::debug!(
1593            keyword_count = keyword_results.len(),
1594            vector_count = vector_results.len(),
1595            "recall: routed search results"
1596        );
1597
1598        self.recall_merge_and_rank(keyword_results, vector_results, limit, goal_entity_id)
1599            .await
1600    }
1601
1602    /// Async variant of [`recall_routed`](Self::recall_routed) that uses
1603    /// [`AsyncMemoryRouter::route_async`](crate::router::AsyncMemoryRouter::route_async) when
1604    /// available, enabling LLM-based routing for `LlmRouter` and `HybridRouter`.
1605    ///
1606    /// Falls back to [`recall_routed`](Self::recall_routed) for routers that only implement
1607    /// the sync `MemoryRouter` trait (e.g. `HeuristicRouter`).
1608    ///
1609    /// * `goal_entity_id` — optional goal entity for causal distance scoring; when `None`, the
1610    ///   causal distance signal contribution is zero (FR-006).
1611    ///
1612    /// # Errors
1613    ///
1614    /// Returns an error if any underlying search or database operation fails.
1615    #[cfg_attr(
1616        feature = "profiling",
1617        tracing::instrument(name = "memory.recall", skip_all, fields(query_len = %query.len(), result_count = tracing::field::Empty))
1618    )]
1619    pub async fn recall_routed_async(
1620        &self,
1621        query: &str,
1622        limit: usize,
1623        filter: Option<crate::embedding_store::SearchFilter>,
1624        router: &dyn crate::router::AsyncMemoryRouter,
1625        goal_entity_id: Option<i64>,
1626    ) -> Result<Vec<RecalledMessage>, MemoryError> {
1627        let decision = router.route_async(query).await;
1628        let route = decision.route;
1629        tracing::debug!(
1630            ?route,
1631            confidence = decision.confidence,
1632            query_len = query.len(),
1633            "memory routing decision (async)"
1634        );
1635
1636        let (keyword_results, vector_results) =
1637            self.recall_by_route(route, query, limit, filter).await?;
1638
1639        tracing::debug!(
1640            keyword_count = keyword_results.len(),
1641            vector_count = vector_results.len(),
1642            "recall: routed search results (async)"
1643        );
1644
1645        self.recall_merge_and_rank(keyword_results, vector_results, limit, goal_entity_id)
1646            .await
1647    }
1648
1649    /// Retrieve graph facts relevant to `query` via BFS traversal.
1650    ///
1651    /// Returns an empty `Vec` if no `graph_store` is configured.
1652    ///
1653    /// # Parameters
1654    ///
1655    /// - `at_timestamp`: when `Some`, only edges valid at that `SQLite` datetime string are returned.
1656    ///   When `None`, only currently active edges are used.
1657    /// - `temporal_decay_rate`: non-negative decay rate (1/day). `0.0` preserves original ordering.
1658    ///
1659    /// # Errors
1660    ///
1661    /// Returns an error if the underlying graph query fails.
1662    #[cfg_attr(
1663        feature = "profiling",
1664        tracing::instrument(name = "memory.recall_graph", skip_all, fields(result_count = tracing::field::Empty))
1665    )]
1666    pub async fn recall_graph(
1667        &self,
1668        query: &str,
1669        limit: usize,
1670        max_hops: u32,
1671        at_timestamp: Option<&str>,
1672        temporal_decay_rate: f64,
1673        edge_types: &[crate::graph::EdgeType],
1674    ) -> Result<Vec<crate::graph::types::GraphFact>, MemoryError> {
1675        let Some(store) = &self.graph_store else {
1676            return Ok(Vec::new());
1677        };
1678
1679        tracing::debug!(
1680            query_len = query.len(),
1681            limit,
1682            max_hops,
1683            "graph: starting recall"
1684        );
1685
1686        let results = crate::graph::retrieval::graph_recall(
1687            store,
1688            self.qdrant.as_deref(),
1689            &self.provider,
1690            query,
1691            limit,
1692            max_hops,
1693            at_timestamp,
1694            temporal_decay_rate,
1695            edge_types,
1696            self.hebbian_reinforcement.is_enabled(),
1697            self.hebbian_lr,
1698            self.embed_timeout,
1699        )
1700        .await?;
1701
1702        tracing::debug!(result_count = results.len(), "graph: recall complete");
1703        #[cfg(feature = "profiling")]
1704        tracing::Span::current().record("result_count", results.len());
1705
1706        Ok(results)
1707    }
1708
1709    /// Retrieve graph facts via SYNAPSE spreading activation.
1710    ///
1711    /// Delegates to [`crate::graph::retrieval::graph_recall_activated`].
1712    /// Used in place of [`Self::recall_graph`] when `spreading_activation.enabled = true`.
1713    ///
1714    /// # Errors
1715    ///
1716    /// Returns an error if the underlying graph query fails.
1717    #[cfg_attr(
1718        feature = "profiling",
1719        tracing::instrument(name = "memory.recall_graph", skip_all, fields(result_count = tracing::field::Empty))
1720    )]
1721    pub async fn recall_graph_activated(
1722        &self,
1723        query: &str,
1724        limit: usize,
1725        params: crate::graph::SpreadingActivationParams,
1726        edge_types: &[crate::graph::EdgeType],
1727    ) -> Result<Vec<crate::graph::activation::ActivatedFact>, MemoryError> {
1728        let Some(store) = &self.graph_store else {
1729            return Ok(Vec::new());
1730        };
1731
1732        tracing::debug!(
1733            query_len = query.len(),
1734            limit,
1735            "spreading activation: starting graph recall"
1736        );
1737
1738        let embeddings = self.qdrant.as_deref();
1739        let results = crate::graph::retrieval::graph_recall_activated(
1740            store,
1741            embeddings,
1742            &self.provider,
1743            query,
1744            limit,
1745            params,
1746            edge_types,
1747            self.hebbian_reinforcement.is_enabled(),
1748            self.hebbian_lr,
1749            self.embed_timeout,
1750        )
1751        .await?;
1752
1753        tracing::debug!(
1754            result_count = results.len(),
1755            "spreading activation: graph recall complete"
1756        );
1757
1758        Ok(results)
1759    }
1760
1761    /// View-aware graph recall covering both spreading-activation and BFS code paths.
1762    ///
1763    /// - When `sa_params.is_some()`: delegates to [`Self::recall_graph_activated`],
1764    ///   mapping each `ActivatedFact` into a `RecalledFact` with `activation_score: Some(_)`.
1765    /// - When `sa_params.is_none()`: delegates to [`Self::recall_graph`],
1766    ///   mapping each `GraphFact` into a `RecalledFact` with `activation_score: None`.
1767    ///
1768    /// View enrichment runs **after** the base retrieval step on the returned set:
1769    /// - `Head`: no additional I/O; output is byte-equivalent to the legacy paths.
1770    /// - `ZoomIn`: fetches source-message snippet for provenance (bulk SQL).
1771    /// - `ZoomOut`: expands 1-hop neighbors per fact (capped at `neighbor_cap`).
1772    ///
1773    /// When `view = Head` and `sa_params = None`, this function is **byte-identical** to
1774    /// calling `recall_graph` directly and then formatting with the assembler helper.
1775    ///
1776    /// # Errors
1777    ///
1778    /// Returns [`crate::error::MemoryError`] if the base recall or any enrichment query fails.
1779    ///
1780    /// # Examples
1781    ///
1782    /// ```no_run
1783    /// use zeph_memory::{RecallView, RecalledFact};
1784    ///
1785    /// # async fn example(mem: &zeph_memory::semantic::SemanticMemory) {
1786    /// let facts = mem
1787    ///     .recall_graph_view("tell me about Rust", 5, RecallView::Head, 3, 2, 0.0, &[], None)
1788    ///     .await
1789    ///     .unwrap_or_default();
1790    /// # }
1791    /// ```
1792    #[allow(clippy::too_many_arguments, clippy::too_many_lines)] // single-pass enrichment pipeline: splitting would lose readability
1793    #[cfg_attr(
1794        feature = "profiling",
1795        tracing::instrument(
1796            name = "memory.recall.graph_view",
1797            skip_all,
1798            fields(view = ?view, result_count = tracing::field::Empty)
1799        )
1800    )]
1801    pub async fn recall_graph_view(
1802        &self,
1803        query: &str,
1804        limit: usize,
1805        view: crate::recall_view::RecallView,
1806        neighbor_cap: usize,
1807        bfs_max_hops: u32,
1808        temporal_decay_rate: f64,
1809        edge_types: &[crate::graph::EdgeType],
1810        sa_params: Option<crate::graph::SpreadingActivationParams>,
1811    ) -> Result<Vec<crate::recall_view::RecalledFact>, MemoryError> {
1812        use crate::recall_view::RecalledFact;
1813
1814        // Step 1: base retrieval.
1815        let recalled: Vec<RecalledFact> = if let Some(params) = sa_params {
1816            let activated = self
1817                .recall_graph_activated(query, limit, params, edge_types)
1818                .await?;
1819            activated
1820                .into_iter()
1821                .map(RecalledFact::from_activated_fact)
1822                .collect()
1823        } else {
1824            let facts = self
1825                .recall_graph(
1826                    query,
1827                    limit,
1828                    bfs_max_hops,
1829                    None,
1830                    temporal_decay_rate,
1831                    edge_types,
1832                )
1833                .await?;
1834            facts
1835                .into_iter()
1836                .map(RecalledFact::from_graph_fact)
1837                .collect()
1838        };
1839
1840        let enriched = self
1841            .enrich_recall_view(recalled, view, neighbor_cap, limit, edge_types)
1842            .await?;
1843
1844        #[cfg(feature = "profiling")]
1845        tracing::Span::current().record("result_count", enriched.len());
1846        Ok(enriched)
1847    }
1848
1849    /// Apply `ZoomIn`/`ZoomOut` view enrichment to an already-retrieved fact set.
1850    ///
1851    /// Shared by [`Self::recall_graph_view`] and by callers that dispatch on
1852    /// [`crate::graph::EdgeType`]-agnostic retrieval strategies (BFS, A*, `WaterCircles`, beam
1853    /// search, SYNAPSE spreading activation) rather than going through `recall_graph_view`'s own
1854    /// strategy switch — view-aware enrichment (source-message provenance, 1-hop neighbor
1855    /// expansion) is orthogonal to which traversal algorithm produced the base fact set, so it is
1856    /// applied as a separate post-processing pass here instead of being duplicated per strategy.
1857    ///
1858    /// - `Head`: no-op, returned as-is.
1859    /// - `ZoomIn`: adds a source-message provenance snippet to each fact.
1860    /// - `ZoomOut`: `ZoomIn` enrichment plus 1-hop neighbor expansion (capped at `neighbor_cap`
1861    ///   per fact, `limit * neighbor_cap` total).
1862    ///
1863    /// # Errors
1864    ///
1865    /// Returns [`MemoryError`] only if constructing the future itself fails; individual
1866    /// provenance/neighbor lookups degrade gracefully (logged via `tracing::warn!`, leaving the
1867    /// affected fact's enrichment fields at their prior value) rather than failing the whole call.
1868    ///
1869    /// # Examples
1870    ///
1871    /// ```no_run
1872    /// use zeph_memory::{RecallView, RecalledFact};
1873    ///
1874    /// # async fn example(mem: &zeph_memory::semantic::SemanticMemory, facts: Vec<RecalledFact>) {
1875    /// let enriched = mem
1876    ///     .enrich_recall_view(facts, RecallView::ZoomOut, 3, 5, &[])
1877    ///     .await
1878    ///     .unwrap_or_default();
1879    /// # }
1880    /// ```
1881    #[allow(clippy::too_many_lines)] // single-pass enrichment pipeline: splitting would lose readability
1882    pub async fn enrich_recall_view(
1883        &self,
1884        mut recalled: Vec<crate::recall_view::RecalledFact>,
1885        view: crate::recall_view::RecallView,
1886        neighbor_cap: usize,
1887        limit: usize,
1888        edge_types: &[crate::graph::EdgeType],
1889    ) -> Result<Vec<crate::recall_view::RecalledFact>, MemoryError> {
1890        use crate::recall_view::RecallView;
1891
1892        // Head view — no enrichment needed.
1893        if view == RecallView::Head {
1894            return Ok(recalled);
1895        }
1896
1897        // Zoom-In / Zoom-Out — fetch provenance snippets.
1898        if matches!(view, RecallView::ZoomIn | RecallView::ZoomOut) {
1899            let edge_ids: Vec<i64> = recalled.iter().filter_map(|r| r.fact.edge_id).collect();
1900
1901            if !edge_ids.is_empty()
1902                && let Some(ref store) = self.graph_store
1903            {
1904                // Bulk fetch source_message_id for all edge ids.
1905                const MAX_IDS: usize = 490;
1906                let mut edge_to_msg: std::collections::HashMap<i64, MessageId> =
1907                    std::collections::HashMap::new();
1908                for chunk in edge_ids.chunks(MAX_IDS) {
1909                    match store.source_message_ids_for_edges(chunk).await {
1910                        Ok(pairs) => {
1911                            for (eid, mid) in pairs {
1912                                edge_to_msg.insert(eid, mid);
1913                            }
1914                        }
1915                        Err(e) => {
1916                            tracing::warn!(error = %e, "enrich_recall_view: provenance fetch failed");
1917                        }
1918                    }
1919                }
1920
1921                // For facts that have a source_message_id (from SA path), prefer that.
1922                for rf in &mut recalled {
1923                    if rf.provenance_message_id.is_none()
1924                        && let Some(eid) = rf.fact.edge_id
1925                    {
1926                        rf.provenance_message_id = edge_to_msg.get(&eid).copied();
1927                    }
1928                }
1929
1930                // Bulk fetch message snippets.
1931                let msg_ids: Vec<MessageId> = recalled
1932                    .iter()
1933                    .filter_map(|r| r.provenance_message_id)
1934                    .collect::<std::collections::HashSet<_>>()
1935                    .into_iter()
1936                    .collect();
1937
1938                if !msg_ids.is_empty() {
1939                    match self.sqlite.messages_by_ids(&msg_ids).await {
1940                        Ok(messages) => {
1941                            let mut mid_to_snippet: std::collections::HashMap<MessageId, String> =
1942                                messages
1943                                    .into_iter()
1944                                    .map(|(id, msg)| {
1945                                        let raw = &msg.content;
1946                                        let scrubbed: String = raw
1947                                            .chars()
1948                                            .map(|c| match c {
1949                                                '\n' | '\r' | '<' | '>' => ' ',
1950                                                other => other,
1951                                            })
1952                                            .take(200)
1953                                            .collect();
1954                                        (id, scrubbed)
1955                                    })
1956                                    .collect();
1957                            for rf in &mut recalled {
1958                                if let Some(mid) = rf.provenance_message_id {
1959                                    rf.provenance_snippet = mid_to_snippet.remove(&mid);
1960                                }
1961                            }
1962                        }
1963                        Err(e) => {
1964                            tracing::warn!(error = %e, "enrich_recall_view: message snippet fetch failed");
1965                        }
1966                    }
1967                }
1968            }
1969        }
1970
1971        // Zoom-Out — expand 1-hop neighbors.
1972        if view == RecallView::ZoomOut
1973            && let Some(ref store) = self.graph_store
1974        {
1975            // Dedup key: use the canonical fact text when entity names are absent (SA path
1976            // does not resolve entity names, leaving them as empty strings, which would cause
1977            // all SA-path facts to collide on the ("", rel, "", type) key).
1978            type DedupeKey = (String, String, String, crate::graph::EdgeType);
1979            let make_key = |f: &crate::graph::types::GraphFact| -> DedupeKey {
1980                if f.entity_name.is_empty() || f.target_name.is_empty() {
1981                    (
1982                        f.fact.clone(),
1983                        f.relation.clone(),
1984                        String::new(),
1985                        f.edge_type,
1986                    )
1987                } else {
1988                    (
1989                        f.entity_name.clone(),
1990                        f.relation.clone(),
1991                        f.target_name.clone(),
1992                        f.edge_type,
1993                    )
1994                }
1995            };
1996            let mut seen: std::collections::HashSet<DedupeKey> =
1997                recalled.iter().map(|r| make_key(&r.fact)).collect();
1998
1999            let total_neighbor_cap = limit * neighbor_cap;
2000            let mut total_neighbors = 0usize;
2001
2002            for rf in &mut recalled {
2003                if total_neighbors >= total_neighbor_cap {
2004                    break;
2005                }
2006                // Use edge_id as seed for 1-hop BFS via the source entity.
2007                // We retrieve neighbors using the graph store's BFS on the source entity.
2008                let source_entity_id = match rf.fact.edge_id {
2009                    Some(eid) => match store.source_entity_id_for_edge(eid).await {
2010                        Ok(Some(id)) => id,
2011                        _ => continue,
2012                    },
2013                    None => continue,
2014                };
2015
2016                let neighbors = match store
2017                    .bfs_edges_at_depth(source_entity_id, 1, edge_types)
2018                    .await
2019                {
2020                    Ok(edges) => edges,
2021                    Err(e) => {
2022                        tracing::warn!(error = %e, "enrich_recall_view: zoom_out bfs failed");
2023                        continue;
2024                    }
2025                };
2026
2027                let mut added = 0usize;
2028                for n_edge in neighbors {
2029                    if added >= neighbor_cap || total_neighbors >= total_neighbor_cap {
2030                        break;
2031                    }
2032                    let key = make_key(&n_edge.fact);
2033                    if seen.insert(key) {
2034                        rf.neighbors.push(n_edge.fact);
2035                        added += 1;
2036                        total_neighbors += 1;
2037                    }
2038                }
2039            }
2040        }
2041
2042        Ok(recalled)
2043    }
2044
2045    /// Retrieve graph facts via A* shortest-path traversal.
2046    ///
2047    /// Delegates to [`crate::graph::retrieval_astar::graph_recall_astar`].
2048    ///
2049    /// # Errors
2050    ///
2051    /// Returns an error if the underlying graph query fails.
2052    pub async fn recall_graph_astar(
2053        &self,
2054        query: &str,
2055        limit: usize,
2056        max_hops: u32,
2057        temporal_decay_rate: f64,
2058        edge_types: &[crate::graph::EdgeType],
2059    ) -> Result<Vec<crate::graph::types::GraphFact>, MemoryError> {
2060        let Some(store) = &self.graph_store else {
2061            return Ok(Vec::new());
2062        };
2063        crate::graph::retrieval_astar::graph_recall_astar(
2064            store,
2065            self.qdrant.as_deref(),
2066            &self.provider,
2067            query,
2068            limit,
2069            max_hops,
2070            edge_types,
2071            temporal_decay_rate,
2072            self.hebbian_reinforcement.is_enabled(),
2073            self.hebbian_lr,
2074            self.query_sensitive_cost,
2075            self.embed_timeout,
2076        )
2077        .await
2078    }
2079
2080    /// Retrieve graph facts via `WaterCircles` concentric BFS.
2081    ///
2082    /// Delegates to [`crate::graph::retrieval_watercircles::graph_recall_watercircles`].
2083    ///
2084    /// # Errors
2085    ///
2086    /// Returns an error if the underlying graph query fails.
2087    pub async fn recall_graph_watercircles(
2088        &self,
2089        query: &str,
2090        limit: usize,
2091        max_hops: u32,
2092        ring_limit: usize,
2093        temporal_decay_rate: f64,
2094        edge_types: &[crate::graph::EdgeType],
2095    ) -> Result<Vec<crate::graph::types::GraphFact>, MemoryError> {
2096        let Some(store) = &self.graph_store else {
2097            return Ok(Vec::new());
2098        };
2099        crate::graph::retrieval_watercircles::graph_recall_watercircles(
2100            store,
2101            self.qdrant.as_deref(),
2102            &self.provider,
2103            query,
2104            limit,
2105            max_hops,
2106            ring_limit,
2107            edge_types,
2108            temporal_decay_rate,
2109            self.hebbian_reinforcement.is_enabled(),
2110            self.hebbian_lr,
2111            self.embed_timeout,
2112        )
2113        .await
2114    }
2115
2116    /// Retrieve graph facts via beam search.
2117    ///
2118    /// Delegates to [`crate::graph::retrieval_beam::graph_recall_beam`].
2119    ///
2120    /// # Errors
2121    ///
2122    /// Returns an error if the underlying graph query fails.
2123    pub async fn recall_graph_beam(
2124        &self,
2125        query: &str,
2126        limit: usize,
2127        beam_width: usize,
2128        max_hops: u32,
2129        temporal_decay_rate: f64,
2130        edge_types: &[crate::graph::EdgeType],
2131    ) -> Result<Vec<crate::graph::types::GraphFact>, MemoryError> {
2132        let Some(store) = &self.graph_store else {
2133            return Ok(Vec::new());
2134        };
2135        crate::graph::retrieval_beam::graph_recall_beam(
2136            store,
2137            self.qdrant.as_deref(),
2138            &self.provider,
2139            query,
2140            limit,
2141            beam_width,
2142            max_hops,
2143            edge_types,
2144            temporal_decay_rate,
2145            self.hebbian_reinforcement.is_enabled(),
2146            self.hebbian_lr,
2147            self.embed_timeout,
2148        )
2149        .await
2150    }
2151
2152    /// Classify query intent and return the strategy name for hybrid dispatch.
2153    ///
2154    /// Returns one of: `"astar"`, `"watercircles"`, `"beam_search"`, `"synapse"`.
2155    /// Falls back to `"synapse"` on any LLM error.
2156    pub async fn classify_graph_strategy(&self, query: &str) -> String {
2157        crate::graph::strategy_classifier::classify_retrieval_strategy(&self.provider, query).await
2158    }
2159
2160    /// Retrieve graph facts via HL-F5 spreading activation from the top-1 ANN anchor (#3346).
2161    ///
2162    /// Returns an empty vec when no graph store is configured, Qdrant is unavailable,
2163    /// or `hebbian_spread.enabled = false`. The outer timeout is derived from `params`
2164    /// (embed timeout + `(spread_depth.clamp(1, 6) + 2)` × step budget + a fixed margin) so
2165    /// it always stays strictly larger than the inner timeouts it wraps — a hardcoded outer
2166    /// bound tighter than the inner `embed_timeout` default silently aborted every call
2167    /// (#5785). This still ensures the agent loop is never blocked indefinitely by a stalled
2168    /// Qdrant response.
2169    ///
2170    /// # Errors
2171    ///
2172    /// Returns an error if the embed call or any database query fails.
2173    #[cfg_attr(
2174        feature = "profiling",
2175        tracing::instrument(
2176            name = "memory.recall_graph_hela",
2177            skip_all,
2178            fields(result_count = tracing::field::Empty)
2179        )
2180    )]
2181    pub async fn recall_graph_hela(
2182        &self,
2183        query: &str,
2184        limit: usize,
2185        params: crate::graph::HelaSpreadParams,
2186    ) -> Result<Vec<crate::graph::HelaFact>, MemoryError> {
2187        let Some(store) = &self.graph_store else {
2188            return Ok(Vec::new());
2189        };
2190        let Some(embeddings) = &self.qdrant else {
2191            return Ok(Vec::new());
2192        };
2193
2194        let store = Arc::clone(store);
2195        let embeddings = Arc::clone(embeddings);
2196        let provider = self.provider.clone();
2197        let hebbian_enabled = self.hebbian_reinforcement.is_enabled();
2198        let hebbian_lr = self.hebbian_lr;
2199
2200        // Single source of truth for the outer bound: see `hela_outer_timeout` — it must
2201        // exceed everything it wraps (the embed call plus every step-budget-gated stage,
2202        // scaled by `spread_depth`), or the outer timeout fires before the inner ones ever
2203        // get a chance to run (#5785).
2204        let outer_timeout = hela_outer_timeout(&params);
2205
2206        let results = tokio::time::timeout(
2207            outer_timeout,
2208            crate::graph::hela_spreading_recall(
2209                &store,
2210                &embeddings,
2211                &provider,
2212                query,
2213                limit,
2214                &params,
2215                hebbian_enabled,
2216                hebbian_lr,
2217            ),
2218        )
2219        .await
2220        .unwrap_or_else(|_| {
2221            tracing::warn!(
2222                outer_timeout_ms = outer_timeout.as_millis(),
2223                "memory.recall_graph_hela: outer timeout exceeded"
2224            );
2225            Ok(Vec::new())
2226        })?;
2227
2228        #[cfg(feature = "profiling")]
2229        tracing::Span::current().record("result_count", results.len());
2230
2231        Ok(results)
2232    }
2233
2234    /// Increment access count and update `last_accessed` for a batch of message IDs.
2235    ///
2236    /// Skips the update if `message_ids` is empty to avoid an invalid `IN ()` clause.
2237    ///
2238    /// # Errors
2239    ///
2240    /// Returns an error if the `SQLite` update fails.
2241    async fn batch_increment_access_count(
2242        &self,
2243        message_ids: Vec<MessageId>,
2244    ) -> Result<(), MemoryError> {
2245        if message_ids.is_empty() {
2246            return Ok(());
2247        }
2248        self.sqlite.increment_access_counts(&message_ids).await
2249    }
2250
2251    /// Check whether an embedding exists for a given message ID.
2252    ///
2253    /// # Errors
2254    ///
2255    /// Returns an error if the `SQLite` query fails.
2256    pub async fn has_embedding(&self, message_id: MessageId) -> Result<bool, MemoryError> {
2257        match &self.qdrant {
2258            Some(qdrant) => qdrant.has_embedding(message_id).await,
2259            None => Ok(false),
2260        }
2261    }
2262
2263    /// Embed all messages that do not yet have embeddings.
2264    ///
2265    /// Processes unembedded messages in micro-batches of 32, using `buffer_unordered(4)` for
2266    /// concurrent embedding within each batch. Bounded peak memory: at most 32 messages of content
2267    /// plus their embedding vectors are live at any time.
2268    ///
2269    /// When `progress_tx` is `Some`, sends `Some(BackfillProgress)` after each message and
2270    /// `None` on completion (or on timeout/error in the caller).
2271    ///
2272    /// Returns the count of successfully embedded messages.
2273    ///
2274    /// # Errors
2275    ///
2276    /// Returns an error if collection initialization or the streaming query setup fails.
2277    /// Individual embedding failures are logged but do not stop processing.
2278    pub async fn embed_missing(
2279        &self,
2280        progress_tx: Option<tokio::sync::watch::Sender<Option<super::BackfillProgress>>>,
2281    ) -> Result<usize, MemoryError> {
2282        if self.qdrant.is_none() || !self.effective_embed_provider().supports_embeddings() {
2283            return Ok(0);
2284        }
2285
2286        let total = self.sqlite.count_unembedded_messages().await?;
2287        if total == 0 {
2288            return Ok(0);
2289        }
2290
2291        if let Some(tx) = &progress_tx {
2292            let _ = tx.send(Some(super::BackfillProgress { done: 0, total }));
2293        }
2294
2295        let mut done = 0usize;
2296        let mut succeeded = 0usize;
2297
2298        loop {
2299            const BATCH_SIZE: usize = 32;
2300            const BATCH_SIZE_I64: i64 = 32;
2301            let rows: Vec<_> = self
2302                .sqlite
2303                .stream_unembedded_messages(BATCH_SIZE_I64)
2304                .try_collect()
2305                .await?;
2306
2307            if rows.is_empty() {
2308                break;
2309            }
2310
2311            let batch_len = rows.len();
2312
2313            let results: Vec<bool> = futures::stream::iter(rows)
2314                .map(|(msg_id, conv_id, role, content)| async move {
2315                    // Backfill sweep for pre-existing unembedded messages: provenance was
2316                    // never recorded for these rows (written before issue #6490), so `None`
2317                    // is the correct "unknown" tag rather than an implicit trust claim.
2318                    self.embed_and_store_regular(msg_id, conv_id, &role, &content, None)
2319                })
2320                .buffer_unordered(4)
2321                .collect()
2322                .await;
2323
2324            for ok in &results {
2325                done += 1;
2326                if *ok {
2327                    succeeded += 1;
2328                }
2329                if let Some(tx) = &progress_tx {
2330                    let _ = tx.send(Some(super::BackfillProgress { done, total }));
2331                }
2332            }
2333
2334            let batch_succeeded = results.iter().filter(|&&b| b).count();
2335            if batch_succeeded > 0 {
2336                tracing::debug!("Backfill batch: {batch_succeeded}/{batch_len} embedded");
2337            }
2338
2339            if batch_len < BATCH_SIZE {
2340                break;
2341            }
2342        }
2343
2344        if let Some(tx) = &progress_tx {
2345            let _ = tx.send(None);
2346        }
2347
2348        if done > 0 {
2349            tracing::info!("Embedded {succeeded}/{total} missing messages");
2350        }
2351        Ok(succeeded)
2352    }
2353}
2354
2355#[cfg(test)]
2356mod tests {
2357    use super::*;
2358
2359    /// #5785 edge case: the outer timeout multiplier must scale with `spread_depth`, not stay
2360    /// fixed at 3 — `hela_spreading_recall` gates `spread_depth + 2` stages (anchor ANN + one
2361    /// edge-fetch check per BFS hop + vectors-batch), so a fixed 3× multiplier under-provisions
2362    /// the outer bound for any `spread_depth > 1` and can reintroduce the outer/inner inversion.
2363    #[test]
2364    fn hela_outer_timeout_scales_with_spread_depth() {
2365        let step_budget = std::time::Duration::from_millis(80);
2366        let embed_timeout = std::time::Duration::from_secs(5);
2367        let margin = std::time::Duration::from_millis(250);
2368
2369        for depth in 1..=6u32 {
2370            let params = crate::graph::HelaSpreadParams {
2371                spread_depth: depth,
2372                step_budget: Some(step_budget),
2373                embed_timeout: Some(embed_timeout),
2374                ..Default::default()
2375            };
2376            let expected = embed_timeout + step_budget * (depth + 2) + margin;
2377            assert_eq!(
2378                hela_outer_timeout(&params),
2379                expected,
2380                "outer timeout must scale with spread_depth={depth}"
2381            );
2382        }
2383    }
2384
2385    /// `spread_depth` above the algorithm's own `[1, 6]` clamp must not blow up the outer
2386    /// timeout unboundedly — the formula clamps identically to `hela_spreading_recall`'s own
2387    /// `spread_depth.clamp(1, 6)`.
2388    #[test]
2389    fn hela_outer_timeout_clamps_spread_depth_above_six() {
2390        let step_budget = std::time::Duration::from_millis(80);
2391        let embed_timeout = std::time::Duration::from_secs(5);
2392        let params_over = crate::graph::HelaSpreadParams {
2393            spread_depth: 50,
2394            step_budget: Some(step_budget),
2395            embed_timeout: Some(embed_timeout),
2396            ..Default::default()
2397        };
2398        let params_clamped = crate::graph::HelaSpreadParams {
2399            spread_depth: 6,
2400            step_budget: Some(step_budget),
2401            embed_timeout: Some(embed_timeout),
2402            ..Default::default()
2403        };
2404        assert_eq!(
2405            hela_outer_timeout(&params_over),
2406            hela_outer_timeout(&params_clamped),
2407            "spread_depth above 6 must clamp identically to the algorithm's own [1, 6] bound"
2408        );
2409    }
2410
2411    /// When `step_budget`/`embed_timeout` are `None` (disabled), the outer timeout must fall
2412    /// back to safe finite defaults rather than becoming unbounded — the outer bound is a hard
2413    /// safety net independent of whether the caller opted out of the inner per-step guards.
2414    #[test]
2415    fn hela_outer_timeout_falls_back_when_params_disabled() {
2416        let params = crate::graph::HelaSpreadParams {
2417            spread_depth: 2,
2418            step_budget: None,
2419            embed_timeout: None,
2420            ..Default::default()
2421        };
2422        let expected = std::time::Duration::from_secs(5)
2423            + std::time::Duration::from_millis(80) * 4
2424            + std::time::Duration::from_millis(250);
2425        assert_eq!(hela_outer_timeout(&params), expected);
2426    }
2427
2428    #[test]
2429    fn embed_context_default_all_none() {
2430        let ctx = EmbedContext::default();
2431        assert!(ctx.tool_name.is_none());
2432        assert!(ctx.exit_code.is_none());
2433        assert!(ctx.timestamp.is_none());
2434    }
2435
2436    #[test]
2437    fn embed_context_fields_set_correctly() {
2438        let ctx = EmbedContext {
2439            tool_name: Some("shell".to_string()),
2440            exit_code: Some(0),
2441            timestamp: Some("2026-04-04T00:00:00Z".to_string()),
2442        };
2443        assert_eq!(ctx.tool_name.as_deref(), Some("shell"));
2444        assert_eq!(ctx.exit_code, Some(0));
2445        assert_eq!(ctx.timestamp.as_deref(), Some("2026-04-04T00:00:00Z"));
2446    }
2447
2448    #[test]
2449    fn embed_context_non_zero_exit_code() {
2450        let ctx = EmbedContext {
2451            tool_name: Some("shell".to_string()),
2452            exit_code: Some(1),
2453            timestamp: None,
2454        };
2455        assert_eq!(ctx.exit_code, Some(1));
2456        assert!(ctx.timestamp.is_none());
2457    }
2458
2459    async fn make_semantic_memory() -> crate::semantic::SemanticMemory {
2460        let sqlite = crate::store::SqliteStore::new(":memory:").await.unwrap();
2461        make_semantic_memory_with_sqlite(sqlite)
2462    }
2463
2464    /// Build a `SemanticMemory` around a caller-supplied store.
2465    ///
2466    /// Split out of [`make_semantic_memory`] so tests that need a real `PostgreSQL`-backed
2467    /// pool (e.g. `apply_five_signal_scoring_decodes_created_at_on_postgres`) can supply one
2468    /// directly instead of going through `SqliteStore::new(":memory:")`, which always routes
2469    /// through `ActiveDriver` and fails to parse `:memory:` as a Postgres URL once the
2470    /// `postgres` feature is active.
2471    fn make_semantic_memory_with_sqlite(
2472        sqlite: crate::store::SqliteStore,
2473    ) -> crate::semantic::SemanticMemory {
2474        use std::sync::Arc;
2475        use std::sync::atomic::AtomicU64;
2476        use zeph_llm::any::AnyProvider;
2477        use zeph_llm::mock::MockProvider;
2478
2479        let provider = AnyProvider::Mock(MockProvider::default());
2480        crate::semantic::SemanticMemory {
2481            sqlite,
2482            qdrant: None,
2483            provider,
2484            embed_provider: None,
2485            embedding_model: "test-model".into(),
2486            vector_weight: 0.7,
2487            keyword_weight: 0.3,
2488            temporal_decay: crate::semantic::TemporalDecay::Disabled,
2489            temporal_decay_half_life_days: 30,
2490            mmr_reranking: crate::semantic::MmrReranking::Disabled,
2491            mmr_lambda: 0.7,
2492            importance_scoring: crate::semantic::ImportanceScoring::Disabled,
2493            importance_weight: 0.15,
2494            token_counter: Arc::new(crate::token_counter::TokenCounter::new()),
2495            graph_store: None,
2496            experience: None,
2497            community_detection_failures: Arc::new(AtomicU64::new(0)),
2498            graph_extraction_count: Arc::new(AtomicU64::new(0)),
2499            graph_extraction_failures: Arc::new(AtomicU64::new(0)),
2500            last_qdrant_warn: Arc::new(AtomicU64::new(0)),
2501            tier_boost_semantic: 1.3,
2502            admission_control: None,
2503            quality_gate: None,
2504            key_facts_dedup_threshold: 0.95,
2505            embed_tasks: std::sync::Mutex::new(tokio::task::JoinSet::new()),
2506            retrieval_depth: 0,
2507            search_prompt_template: String::new(),
2508            depth_below_limit_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2509            missing_placeholder_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2510            reasoning: None,
2511            query_bias_correction: crate::semantic::QueryBiasCorrection::Disabled,
2512            query_bias_profile_weight: 0.25,
2513            profile_centroid: tokio::sync::RwLock::new(None),
2514            profile_centroid_ttl_secs: 300,
2515            hebbian_reinforcement: crate::semantic::HebbianReinforcement::Disabled,
2516            hebbian_lr: 0.1,
2517            hebbian_spread: crate::HelaSpreadRuntime::default(),
2518            retrieval_failure_logger: None,
2519            summarization_llm_timeout_secs: 60,
2520            query_sensitive_cost: false,
2521            five_signal: None,
2522            embed_timeout: std::time::Duration::from_secs(5),
2523            graph_cancel: std::sync::Mutex::new(Vec::new()),
2524        }
2525    }
2526
2527    #[tokio::test]
2528    async fn spawn_embed_bg_returns_true_when_capacity_available() {
2529        let memory = make_semantic_memory().await;
2530        let dispatched = memory.spawn_embed_bg(std::future::ready(()));
2531        assert!(
2532            dispatched,
2533            "spawn_embed_bg must return true when a task was successfully spawned"
2534        );
2535    }
2536
2537    #[tokio::test]
2538    async fn spawn_embed_bg_returns_false_at_capacity() {
2539        let memory = make_semantic_memory().await;
2540
2541        // Fill the JoinSet to the limit with never-completing futures.
2542        {
2543            let mut tasks = memory.embed_tasks.lock().unwrap();
2544            for _ in 0..MAX_EMBED_BG_TASKS {
2545                tasks.spawn(std::future::pending::<()>());
2546            }
2547        }
2548
2549        let dispatched = memory.spawn_embed_bg(std::future::ready(()));
2550        assert!(
2551            !dispatched,
2552            "spawn_embed_bg must return false when the task limit is reached"
2553        );
2554    }
2555
2556    #[test]
2557    fn qdrant_warn_rate_limit_suppresses_within_window() {
2558        // First call: last=0, now=100 → should emit (diff >= 10)
2559        assert!(
2560            should_emit_qdrant_warn(0, 100, 10),
2561            "first call must not be suppressed"
2562        );
2563
2564        // Second call 5s later: now=105, last=100 → should be suppressed (diff < 10)
2565        assert!(
2566            !should_emit_qdrant_warn(100, 105, 10),
2567            "call within 10s window must be suppressed"
2568        );
2569
2570        // Third call 10s after first: now=110, last=100 → should emit again
2571        assert!(
2572            should_emit_qdrant_warn(100, 110, 10),
2573            "call after window expiry must not be suppressed"
2574        );
2575    }
2576
2577    /// Regression test for issue #5364: `apply_five_signal_scoring`'s `created_at` batch
2578    /// fetch built its `IN (...)` list correctly via `placeholder_list`, but decoded the
2579    /// `created_at` column directly as `i64` — which fails on `PostgreSQL`, where
2580    /// `messages.created_at` is `TIMESTAMPTZ`, not an integer. Fixed by wrapping the
2581    /// column in the dialect's `epoch_from_col` (the same helper already used by
2582    /// `graph_store::decay_edge_retrieval_counts` and `snapshot::export_snapshot`).
2583    ///
2584    /// `apply_five_signal_scoring` does not read `self` — only the `fs` parameter and
2585    /// `ranked` — so a plain in-memory `self` receiver is fine; only `fs` needs the real
2586    /// PostgreSQL-backed pool that the `created_at` query actually executes against.
2587    #[cfg(feature = "test-utils")]
2588    #[tokio::test]
2589    #[ignore = "requires Docker"]
2590    async fn apply_five_signal_scoring_decodes_created_at_on_postgres() {
2591        use std::sync::Arc;
2592        use testcontainers::runners::AsyncRunner as _;
2593        use testcontainers_modules::postgres::Postgres;
2594        use zeph_config::memory::FiveSignalConfig;
2595
2596        let image = Postgres::default();
2597        let container = image.start().await.expect("docker must be available");
2598        let host = container.get_host().await.unwrap();
2599        let port = container.get_host_port_ipv4(5432).await.unwrap();
2600        let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");
2601        let pool = zeph_db::DbConfig { url, pool_size: 5 }
2602            .connect()
2603            .await
2604            .expect("failed to connect to PG");
2605
2606        let pg_store = crate::store::SqliteStore::from_pool(pool.clone())
2607            .await
2608            .unwrap();
2609        let cid = pg_store.create_conversation().await.unwrap();
2610
2611        // Session starts at a fixed epoch; one message is "fresh" (created at session
2612        // start, novelty ~= 1.0), one is "stale" (created 20 days later, novelty << 1.0
2613        // at decay_rate=0.1) — isolates the novelty signal so the score difference is
2614        // attributable only to the created_at value actually fetched from Postgres.
2615        let session_start = 1_700_000_000_i64;
2616        let fresh = pg_store.save_message(cid, "user", "fresh").await.unwrap();
2617        let stale = pg_store.save_message(cid, "user", "stale").await.unwrap();
2618
2619        for (id, epoch) in [(fresh, session_start), (stale, session_start + 20 * 86_400)] {
2620            #[expect(clippy::cast_precision_loss)]
2621            let epoch_f = epoch as f64;
2622            sqlx::query(zeph_db::sql!(
2623                "UPDATE messages SET created_at = to_timestamp(?) WHERE id = ?"
2624            ))
2625            .bind(epoch_f)
2626            .bind(id)
2627            .execute(&pool)
2628            .await
2629            .unwrap();
2630        }
2631
2632        let graph_store = Arc::new(crate::graph::GraphStore::new(pool.clone()));
2633        let config = FiveSignalConfig {
2634            w_recency: 0.0,
2635            w_relevance: 0.0,
2636            w_frequency: 0.0,
2637            w_causal: 0.0,
2638            w_novelty: 1.0,
2639            ..FiveSignalConfig::default()
2640        };
2641        let fs = crate::five_signal::FiveSignalRuntime::new(
2642            config,
2643            pool,
2644            graph_store,
2645            None,
2646            session_start,
2647            "sess-novelty-test",
2648        );
2649
2650        // `make_semantic_memory()` cannot be used here: it calls `SqliteStore::new(":memory:")`,
2651        // which under the `postgres` feature routes through `ActiveDriver = PostgresDriver` and
2652        // fails trying to parse `:memory:` as a Postgres URL. `apply_five_signal_scoring` does
2653        // not read `self`, so any valid receiver works — build it directly from the same
2654        // Postgres-backed `pg_store` used above instead.
2655        let memory = make_semantic_memory_with_sqlite(pg_store);
2656        let mut ranked = vec![(fresh, 0.0), (stale, 0.0)];
2657        memory
2658            .apply_five_signal_scoring(&mut ranked, &fs, None)
2659            .await;
2660
2661        assert_eq!(
2662            ranked[0].0, fresh,
2663            "fresher message (created_at == session_start) must rank first by novelty"
2664        );
2665        assert!(
2666            (ranked[0].1 - 1.0).abs() < 1e-6,
2667            "message created at session_start must have novelty ~1.0, got {}",
2668            ranked[0].1
2669        );
2670        assert!(
2671            ranked[1].1 < ranked[0].1,
2672            "message created 20 days later must have strictly lower novelty"
2673        );
2674    }
2675
2676    #[test]
2677    fn qdrant_warn_rate_limit_shared_across_concurrent_sites() {
2678        // All 3 WARN sites (bg embed_regular/embed_tool/embed_category) share one
2679        // Arc<AtomicU64> via `SemanticMemory::last_qdrant_warn`. Simulate site A warning
2680        // at t=100, then site B attempting at t=105 — must be suppressed, mirroring the
2681        // exact check `warn_qdrant_ensure_failure` performs against the shared atomic.
2682        let shared = Arc::new(AtomicU64::new(0));
2683
2684        let site_a = Arc::clone(&shared);
2685        let site_b = Arc::clone(&shared);
2686
2687        let now_a = 100u64;
2688        let last_a = site_a.load(Ordering::Relaxed);
2689        if should_emit_qdrant_warn(last_a, now_a, QDRANT_WARN_WINDOW_SECS) {
2690            site_a.store(now_a, Ordering::Relaxed);
2691        }
2692
2693        let now_b = 105u64;
2694        let last_b = site_b.load(Ordering::Relaxed);
2695        let warn_b = should_emit_qdrant_warn(last_b, now_b, QDRANT_WARN_WINDOW_SECS);
2696        assert!(
2697            !warn_b,
2698            "site B must be suppressed because site A already warned within the window"
2699        );
2700    }
2701
2702    #[test]
2703    fn warn_qdrant_ensure_failure_updates_shared_atomic_once() {
2704        let shared = Arc::new(AtomicU64::new(0));
2705        let err = MemoryError::InvalidInput("boom".into());
2706
2707        warn_qdrant_ensure_failure(&shared, "site A", &err);
2708        let after_first = shared.load(Ordering::Relaxed);
2709        assert!(after_first > 0, "first call must record a warn timestamp");
2710
2711        // Immediately calling again (same instant, well within the window) must not
2712        // move the stored timestamp forward, since the second call is suppressed.
2713        warn_qdrant_ensure_failure(&shared, "site B", &err);
2714        assert_eq!(
2715            shared.load(Ordering::Relaxed),
2716            after_first,
2717            "suppressed call must not overwrite the shared warn timestamp"
2718        );
2719    }
2720}