Skip to main content

zeph_memory/
tiered_retrieval.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `MemFlow` tiered intent-driven retrieval pipeline (issue #3712).
5//!
6//! Classifies each recall query into one of three intent tiers and dispatches to the
7//! cheapest sufficient backend, assembling evidence within a configurable token budget.
8//!
9//! # Tiers
10//!
11//! | Tier | Backend | Top-k | Graph hops |
12//! |------|---------|-------|-----------|
13//! | `ProfileLookup` | Keyword / persona | 3 | 0 |
14//! | `TargetedRetrieval` | Hybrid | 10 | 1 |
15//! | `DeepReasoning` | Hybrid + graph | 20 | 2 |
16//!
17//! The classifier maps the existing [`MemoryRoute`] to an [`IntentClass`]:
18//! - `Keyword | Episodic` → `ProfileLookup`
19//! - `Semantic | Hybrid` → `TargetedRetrieval`
20//! - `Graph` → `DeepReasoning`
21//!
22//! When `classifier_provider` is set and the LLM call fails, the pipeline falls back to
23//! [`HeuristicRouter`] (fail-open, logged at `warn`).
24//!
25//! # Token-budget assembly
26//!
27//! Recall results are truncated to fit within `token_budget`. An optional validation step
28//! asks a lightweight LLM whether the gathered evidence is sufficient; on low confidence,
29//! the pipeline escalates to the next heavier tier (up to `max_escalations`).
30
31use std::collections::HashMap;
32use std::sync::Arc;
33
34pub use zeph_config::memory::TieredRetrievalConfig;
35use zeph_llm::any::AnyProvider;
36
37use crate::embedding_store::SearchFilter;
38use crate::error::MemoryError;
39use crate::router::{HeuristicRouter, HybridRouter, MemoryRoute, MemoryRouter};
40use crate::semantic::RecalledMessage;
41use crate::semantic::SemanticMemory;
42use crate::types::{ConversationId, MessageId};
43
44// ── Intent classification ─────────────────────────────────────────────────────
45
46/// Query intent tier for `MemFlow` tiered retrieval.
47///
48/// Maps to increasing levels of retrieval cost and depth.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50#[non_exhaustive]
51pub enum IntentClass {
52    /// Fast profile/attribute lookup — keyword search, top-k = 3.
53    ProfileLookup,
54    /// Standard semantic retrieval — hybrid search with MMR, top-k = 10.
55    TargetedRetrieval,
56    /// Multi-hop reasoning — hybrid + graph traversal, top-k = 20.
57    DeepReasoning,
58}
59
60impl IntentClass {
61    fn from_route(route: MemoryRoute) -> Self {
62        match route {
63            MemoryRoute::Keyword | MemoryRoute::Episodic => Self::ProfileLookup,
64            MemoryRoute::Graph => Self::DeepReasoning,
65            _ => Self::TargetedRetrieval,
66        }
67    }
68
69    fn top_k(self) -> usize {
70        match self {
71            Self::ProfileLookup => 3,
72            Self::TargetedRetrieval => 10,
73            Self::DeepReasoning => 20,
74        }
75    }
76
77    /// Returns the next heavier tier for escalation, or `None` if already at maximum.
78    fn escalate(self) -> Option<Self> {
79        match self {
80            Self::ProfileLookup => Some(Self::TargetedRetrieval),
81            Self::TargetedRetrieval => Some(Self::DeepReasoning),
82            Self::DeepReasoning => None,
83        }
84    }
85}
86
87impl std::fmt::Display for IntentClass {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            Self::ProfileLookup => f.write_str("ProfileLookup"),
91            Self::TargetedRetrieval => f.write_str("TargetedRetrieval"),
92            Self::DeepReasoning => f.write_str("DeepReasoning"),
93        }
94    }
95}
96
97// ── Result ────────────────────────────────────────────────────────────────────
98
99/// Result of tiered retrieval, including evidence and tier metadata.
100#[derive(Debug)]
101pub struct TieredRetrievalResult {
102    /// Retrieved memory entries ordered by relevance score.
103    pub messages: Vec<RecalledMessage>,
104    /// The intent class that produced this result.
105    pub intent: IntentClass,
106    /// Approximate token count of all message content.
107    pub tokens_used: usize,
108    /// Whether the pipeline escalated to a heavier tier due to validation.
109    pub tier_escalated: bool,
110}
111
112// ── Tiered retrieval logic ─────────────────────────────────────────────────────
113
114/// Execute `MemFlow` tiered retrieval for a single query.
115///
116/// Classifies intent, retrieves tier candidates, assembles evidence within budget, and
117/// optionally validates + escalates if evidence is insufficient.
118///
119/// `classifier` should be the provider resolved from
120/// [`TieredRetrievalConfig::classifier_provider`]. When `Some`, a [`HybridRouter`] is
121/// used for LLM-backed intent classification (with [`HeuristicRouter`] as fallback on
122/// LLM failure). When `None`, only the heuristic router is used.
123///
124/// `validator` should be the provider resolved from
125/// [`TieredRetrievalConfig::validator_provider`]. When `Some` and
126/// `config.validation_enabled` is `true`, the validator LLM judges evidence quality and
127/// triggers tier escalation when confidence is low.
128///
129/// `conversation_id` scopes the search to a single conversation. Pass `None` to search globally.
130///
131/// # Errors
132///
133/// Returns an error if any underlying search or database operation fails.
134#[tracing::instrument(name = "memory.tiered.retrieve", skip_all, fields(intent = tracing::field::Empty))]
135pub async fn recall_tiered(
136    memory: &SemanticMemory,
137    query: &str,
138    conversation_id: Option<ConversationId>,
139    classifier: Option<&Arc<AnyProvider>>,
140    validator: Option<&Arc<AnyProvider>>,
141    config: &TieredRetrievalConfig,
142    remaining_budget: Option<usize>,
143) -> Result<TieredRetrievalResult, MemoryError> {
144    let effective_budget =
145        remaining_budget.map_or(config.token_budget, |rb| rb.min(config.token_budget));
146
147    let initial_intent = if let Some(classifier_provider) = classifier {
148        let hybrid = HybridRouter::new(
149            Arc::clone(classifier_provider),
150            MemoryRoute::Hybrid,
151            // 0.7 is the codebase-wide default for HybridRouter confidence threshold
152            0.7,
153        );
154        let decision = if let Ok(d) = tokio::time::timeout(
155            std::time::Duration::from_secs(config.classifier_timeout_secs),
156            hybrid.classify_async(query),
157        )
158        .await
159        {
160            d
161        } else {
162            tracing::warn!("tiered: classifier LLM timed out, falling back to heuristic");
163            HeuristicRouter.route_with_confidence(query)
164        };
165        IntentClass::from_route(decision.route)
166    } else {
167        let decision = HeuristicRouter.route_with_confidence(query);
168        IntentClass::from_route(decision.route)
169    };
170
171    tracing::debug!(intent = %initial_intent, query_len = query.len(), "tiered: classified intent");
172
173    escalation_loop(
174        memory,
175        query,
176        conversation_id,
177        initial_intent,
178        validator,
179        config,
180        effective_budget,
181    )
182    .await
183}
184
185/// Inner escalation loop shared across retrieval entry points.
186///
187/// Iterates through tiers starting at `initial_intent`, retrieving candidates and
188/// validating evidence quality. Escalates to heavier tiers when validation indicates
189/// insufficient evidence.
190#[tracing::instrument(name = "memory.tiered.escalation_loop", skip_all, fields(initial_intent = %initial_intent, max_escalations = config.max_escalations))]
191async fn escalation_loop(
192    memory: &SemanticMemory,
193    query: &str,
194    conversation_id: Option<ConversationId>,
195    initial_intent: IntentClass,
196    validator: Option<&Arc<AnyProvider>>,
197    config: &TieredRetrievalConfig,
198    effective_budget: usize,
199) -> Result<TieredRetrievalResult, MemoryError> {
200    let mut intent = initial_intent;
201    let mut escalations: u8 = 0;
202    let mut tier_escalated = false;
203
204    loop {
205        let raw_candidates = retrieve_tier(memory, query, conversation_id, intent, config).await?;
206
207        let candidates = score_candidates(memory, query, raw_candidates, config).await?;
208
209        let (messages, tokens_used) = assemble_within_budget(candidates, effective_budget);
210
211        // Validate evidence quality if enabled and a validator is available.
212        if config.validation_enabled
213            && escalations < config.max_escalations
214            && let Some(validator_provider) = validator
215            && let Some(next_tier) = intent.escalate()
216        {
217            let sufficient = validate_evidence(
218                validator_provider,
219                query,
220                &messages,
221                config.validation_threshold,
222                config.validator_timeout_secs,
223            )
224            .await;
225            if !sufficient {
226                tracing::debug!(
227                    current_tier = %intent,
228                    next_tier = %next_tier,
229                    escalations,
230                    "tiered: evidence insufficient, escalating tier"
231                );
232                intent = next_tier;
233                escalations += 1;
234                tier_escalated = true;
235                continue;
236            }
237        }
238
239        return Ok(TieredRetrievalResult {
240            messages,
241            intent,
242            tokens_used,
243            tier_escalated,
244        });
245    }
246}
247
248/// Retrieve candidates for the given intent tier from `SemanticMemory`.
249///
250/// For `DeepReasoning` when `config.deep_reasoning_query_conditioned = true`, routes through
251/// query-conditioned graph recall (HELA spreading activation) instead of static-weight BFS (#3994).
252#[tracing::instrument(name = "memory.tiered.retrieve_tier", skip_all, fields(intent = %intent))]
253async fn retrieve_tier(
254    memory: &SemanticMemory,
255    query: &str,
256    conversation_id: Option<ConversationId>,
257    intent: IntentClass,
258    config: &TieredRetrievalConfig,
259) -> Result<Vec<RecalledMessage>, MemoryError> {
260    let top_k = intent.top_k();
261    let heuristic = HeuristicRouter;
262
263    let filter = conversation_id.map(|cid| SearchFilter {
264        conversation_id: Some(cid),
265        role: None,
266        category: None,
267    });
268
269    // DeepReasoning tier: optionally route through query-conditioned HELA recall (#3994).
270    if intent == IntentClass::DeepReasoning && config.deep_reasoning_query_conditioned {
271        use crate::graph::HelaSpreadParams;
272        use zeph_llm::provider::{Message, MessageMetadata, Role};
273        let params = HelaSpreadParams::default();
274        match memory.recall_graph_hela(query, top_k, params).await {
275            Ok(hela_facts) if !hela_facts.is_empty() => {
276                let messages: Vec<RecalledMessage> = hela_facts
277                    .into_iter()
278                    .map(|f| {
279                        let content = format!(
280                            "{} — {} — {}",
281                            f.edge.relation, f.edge.fact, f.edge.canonical_relation
282                        );
283                        RecalledMessage {
284                            message: Message {
285                                role: Role::Assistant,
286                                content,
287                                parts: vec![],
288                                metadata: MessageMetadata::default(),
289                            },
290                            score: f.score,
291                        }
292                    })
293                    .collect();
294                tracing::debug!(
295                    count = messages.len(),
296                    "tiered: DeepReasoning via query-conditioned HELA recall"
297                );
298                return Ok(messages);
299            }
300            Ok(_) => {
301                tracing::debug!("tiered: HELA returned no results, falling back to recall_routed");
302            }
303            Err(e) => {
304                tracing::warn!("tiered: HELA recall failed ({e:#}), falling back to recall_routed");
305            }
306        }
307    }
308
309    // All other tiers (and DeepReasoning fallback) route through recall_routed.
310    memory
311        .recall_routed(query, top_k, filter, &heuristic, None)
312        .await
313}
314
315// ── Five-signal retrieval scoring ─────────────────────────────────────────────
316
317/// Re-score `candidates` using up to five signals and return them sorted by final score.
318///
319/// Overwrites `RecalledMessage::score` with the combined weighted score.
320/// When all signal weights are zero (mis-configuration), logs a debug warning and
321/// returns candidates in original order with scores unchanged.
322///
323/// # Errors
324///
325/// Propagates store errors from timestamp or tier lookups.
326#[allow(clippy::too_many_lines)]
327#[tracing::instrument(name = "memory.tiered.score_candidates", skip_all)]
328async fn score_candidates(
329    memory: &SemanticMemory,
330    query: &str,
331    candidates: Vec<RecalledMessage>,
332    config: &TieredRetrievalConfig,
333) -> Result<Vec<RecalledMessage>, MemoryError> {
334    if candidates.is_empty() {
335        return Ok(candidates);
336    }
337
338    let total_weight = config.similarity_weight
339        + config.recency_weight
340        + config.tfidf_weight
341        + config.cognitive_signal_weight
342        + config.tier_boost_weight;
343
344    if total_weight < f64::EPSILON {
345        tracing::debug!("score_candidates: all signal weights are zero, returning original order");
346        return Ok(candidates);
347    }
348
349    let ids: Vec<MessageId> = candidates
350        .iter()
351        .map(|c| MessageId(c.message.metadata.db_id.unwrap_or(0)))
352        .collect();
353
354    // Fetch timestamps and tiers only when their respective signals are active.
355    let (timestamps_res, tiers_res) = tokio::join!(
356        async {
357            if config.recency_weight > 0.0 {
358                memory.sqlite().message_timestamps(&ids).await
359            } else {
360                Ok(HashMap::new())
361            }
362        },
363        async {
364            if config.tier_boost_weight > 0.0 {
365                memory.sqlite().fetch_tiers(&ids).await
366            } else {
367                Ok(HashMap::new())
368            }
369        },
370    );
371    let timestamps: HashMap<MessageId, i64> = timestamps_res.unwrap_or_else(|e| {
372        tracing::warn!("score_candidates: failed to fetch timestamps: {e:#}");
373        HashMap::new()
374    });
375    let tiers: HashMap<MessageId, String> = tiers_res.unwrap_or_else(|e| {
376        tracing::warn!("score_candidates: failed to fetch tiers: {e:#}");
377        HashMap::new()
378    });
379
380    // Fetch access counts for cognitive signal.
381    let access_counts: HashMap<MessageId, i64> = if config.cognitive_signal_weight > 0.0 {
382        memory
383            .sqlite()
384            .message_access_counts(&ids)
385            .await
386            .unwrap_or_else(|e| {
387                tracing::warn!("score_candidates: failed to fetch access counts: {e:#}");
388                HashMap::new()
389            })
390    } else {
391        HashMap::new()
392    };
393
394    let tfidf_scores = if config.tfidf_weight > 0.0 {
395        compute_tfidf_scores(query, &candidates)
396    } else {
397        vec![0.0_f64; candidates.len()]
398    };
399
400    let max_access: i64 = access_counts.values().copied().max().unwrap_or(0);
401
402    let now_secs = std::time::SystemTime::now()
403        .duration_since(std::time::UNIX_EPOCH)
404        .map_or(0_i64, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
405
406    let mut scored: Vec<RecalledMessage> = candidates
407        .into_iter()
408        .zip(tfidf_scores)
409        .map(|(recalled, tfidf)| {
410            let msg_id = MessageId(recalled.message.metadata.db_id.unwrap_or(0));
411
412            let similarity = f64::from(recalled.score);
413            let recency = if config.recency_weight > 0.0 && config.recency_half_life_days > 0 {
414                let ts = timestamps.get(&msg_id).copied().unwrap_or(now_secs);
415                compute_recency(ts, now_secs, config.recency_half_life_days)
416            } else {
417                0.0
418            };
419
420            let cognitive = if config.cognitive_signal_weight > 0.0 && max_access > 0 {
421                let count = access_counts.get(&msg_id).copied().unwrap_or(0);
422                // Both are i64; precision loss is acceptable for a normalized ratio.
423                #[allow(clippy::cast_precision_loss)]
424                let ratio = count as f64 / max_access as f64;
425                ratio
426            } else {
427                0.0
428            };
429
430            let tier_signal = if config.tier_boost_weight > 0.0 {
431                let tier = tiers.get(&msg_id).map_or("episodic", String::as_str);
432                if tier == "semantic" {
433                    config.semantic_tier_boost
434                } else {
435                    0.0
436                }
437            } else {
438                0.0
439            };
440
441            let final_score = config.similarity_weight * similarity
442                + config.recency_weight * recency
443                + config.tfidf_weight * tfidf
444                + config.cognitive_signal_weight * cognitive
445                + config.tier_boost_weight * tier_signal;
446
447            RecalledMessage {
448                // f64 → f32: deliberate truncation, score precision is adequate.
449                #[allow(clippy::cast_possible_truncation)]
450                score: final_score as f32,
451                ..recalled
452            }
453        })
454        .collect();
455
456    scored.sort_by(|a, b| {
457        b.score
458            .partial_cmp(&a.score)
459            .unwrap_or(std::cmp::Ordering::Equal)
460    });
461
462    Ok(scored)
463}
464
465/// Compute recency score in `[0.0, 1.0]` using exponential half-life decay.
466///
467/// Returns `1.0` for a message created right now and approaches `0.0` for very old messages.
468/// A message that is exactly `half_life_days` old receives a score of `0.5`.
469///
470/// # Precondition
471///
472/// `half_life_days` must be greater than zero. Passing `0` is a programming error and will
473/// panic in debug builds.
474fn compute_recency(created_at_secs: i64, now_secs: i64, half_life_days: u32) -> f64 {
475    debug_assert!(half_life_days > 0, "half_life_days must be > 0");
476    // Precision loss is acceptable: age is a time delta in days, not a financial value.
477    #[allow(clippy::cast_precision_loss)]
478    let age_days = (now_secs - created_at_secs).max(0) as f64 / 86_400.0;
479    let lambda = std::f64::consts::LN_2 / f64::from(half_life_days);
480    (-lambda * age_days).exp()
481}
482
483/// Compute per-candidate TF-IDF scores against `query`, normalised to `[0.0, 1.0]`.
484///
485/// Uses a simplified TF-IDF with BM25-style parameters (k1 = 1.2, b = 0.75).
486/// Scores are normalised by dividing by the maximum score in the batch.
487fn compute_tfidf_scores(query: &str, candidates: &[RecalledMessage]) -> Vec<f64> {
488    const K1: f64 = 1.2;
489    const B: f64 = 0.75;
490
491    let query_terms: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
492
493    if query_terms.is_empty() || candidates.is_empty() {
494        return vec![0.0; candidates.len()];
495    }
496
497    // Tokenise each candidate document.
498    let docs: Vec<Vec<String>> = candidates
499        .iter()
500        .map(|c| {
501            c.message
502                .content
503                .split_whitespace()
504                .map(str::to_lowercase)
505                .collect()
506        })
507        .collect();
508
509    // Precision loss is acceptable for term-frequency ratios over small candidate sets.
510    #[allow(clippy::cast_precision_loss)]
511    let n = docs.len() as f64;
512    #[allow(clippy::cast_precision_loss)]
513    let avg_dl = docs.iter().map(|d| d.len() as f64).sum::<f64>().max(1.0) / n;
514
515    let mut scores = vec![0.0_f64; docs.len()];
516
517    for term in &query_terms {
518        // Document frequency across the candidate set.
519        #[allow(clippy::cast_precision_loss)]
520        let df = docs.iter().filter(|d| d.contains(term)).count() as f64;
521        if df == 0.0 {
522            continue;
523        }
524        // IDF with smoothing.
525        let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
526
527        for (i, doc) in docs.iter().enumerate() {
528            #[allow(clippy::cast_precision_loss)]
529            let dl = doc.len() as f64;
530            #[allow(clippy::cast_precision_loss)]
531            let tf = doc.iter().filter(|t| *t == term).count() as f64;
532            let bm25_tf = (tf * (K1 + 1.0)) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
533            scores[i] += idf * bm25_tf;
534        }
535    }
536
537    // Normalise to [0.0, 1.0].
538    let max_score = scores.iter().copied().fold(0.0_f64, f64::max);
539    if max_score > 0.0 {
540        for s in &mut scores {
541            *s /= max_score;
542        }
543    }
544
545    scores
546}
547
548/// Truncate `candidates` to fit within `budget` tokens.
549///
550/// Uses the same 4 chars-per-token approximation as the rest of the codebase.
551/// Returns the retained messages and the total token count consumed.
552fn assemble_within_budget(
553    candidates: Vec<RecalledMessage>,
554    budget: usize,
555) -> (Vec<RecalledMessage>, usize) {
556    let mut retained = Vec::with_capacity(candidates.len());
557    let mut total_tokens: usize = 0;
558
559    for msg in candidates {
560        let msg_tokens = zeph_common::text::estimate_tokens(&msg.message.content);
561        if total_tokens.saturating_add(msg_tokens) > budget {
562            break;
563        }
564        total_tokens += msg_tokens;
565        retained.push(msg);
566    }
567
568    (retained, total_tokens)
569}
570
571/// Ask the validator LLM whether the gathered evidence is sufficient for the query.
572///
573/// Returns `true` when the validator's confidence is >= `threshold` or when the
574/// call fails (fail-open: prefer serving potentially incomplete evidence over blocking).
575#[tracing::instrument(name = "memory.tiered.validate_evidence", skip_all, fields(threshold, timeout_secs, evidence_count = messages.len()))]
576async fn validate_evidence(
577    provider: &Arc<AnyProvider>,
578    query: &str,
579    messages: &[RecalledMessage],
580    threshold: f32,
581    timeout_secs: u64,
582) -> bool {
583    use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
584
585    if messages.is_empty() {
586        return false;
587    }
588
589    let evidence_snippet = messages
590        .iter()
591        .take(5)
592        .map(|m| {
593            zeph_common::sanitize::strip_control_chars_preserve_whitespace(&m.message.content)
594                .chars()
595                .take(200)
596                .collect::<String>()
597        })
598        .collect::<Vec<_>>()
599        .join("\n---\n");
600
601    let system = "You are an evidence quality judge. \
602        Given a query and evidence snippets, decide if the evidence is sufficient to answer the query. \
603        Respond ONLY with a JSON object: {\"sufficient\": true|false, \"confidence\": 0.0-1.0}";
604
605    let sanitized_query = zeph_common::sanitize::strip_control_chars_preserve_whitespace(query);
606    let user = format!(
607        "<query>{}</query>\n<evidence>{}</evidence>",
608        sanitized_query.chars().take(500).collect::<String>(),
609        evidence_snippet
610    );
611
612    let msgs = vec![
613        Message {
614            role: Role::System,
615            content: system.to_owned(),
616            parts: vec![],
617            metadata: MessageMetadata::default(),
618        },
619        Message {
620            role: Role::User,
621            content: user,
622            parts: vec![],
623            metadata: MessageMetadata::default(),
624        },
625    ];
626
627    match tokio::time::timeout(
628        std::time::Duration::from_secs(timeout_secs),
629        provider.chat(&msgs),
630    )
631    .await
632    {
633        Ok(Ok(raw)) => parse_validation_response(&raw, threshold),
634        Ok(Err(e)) => {
635            tracing::warn!(error = %e, "tiered: validator LLM call failed, treating as sufficient");
636            true
637        }
638        Err(_) => {
639            tracing::warn!("tiered: validator LLM call timed out, treating as sufficient");
640            true
641        }
642    }
643}
644
645fn parse_validation_response(raw: &str, threshold: f32) -> bool {
646    let json_str = raw
647        .find('{')
648        .and_then(|s| raw[s..].rfind('}').map(|e| &raw[s..=s + e]))
649        .unwrap_or("");
650
651    if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_str) {
652        let sufficient = v
653            .get("sufficient")
654            .and_then(serde_json::Value::as_bool)
655            .unwrap_or(true);
656        #[allow(clippy::cast_possible_truncation)]
657        let confidence = v
658            .get("confidence")
659            .and_then(serde_json::Value::as_f64)
660            .map_or(1.0, |c| c.clamp(0.0, 1.0) as f32);
661
662        return sufficient && confidence >= threshold;
663    }
664
665    tracing::debug!("tiered: could not parse validator response, treating as sufficient");
666    true
667}
668
669// ── Tests ─────────────────────────────────────────────────────────────────────
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use crate::router::MemoryRoute;
675    use crate::semantic::RecalledMessage;
676    use zeph_llm::provider::{Message, MessageMetadata, Role};
677
678    fn make_message(content: &str) -> RecalledMessage {
679        RecalledMessage {
680            message: Message {
681                role: Role::User,
682                content: content.to_owned(),
683                parts: vec![],
684                metadata: MessageMetadata::default(),
685            },
686            score: 1.0,
687        }
688    }
689
690    // ── Signal scoring unit tests ─────────────────────────────────────────────
691
692    #[test]
693    fn compute_recency_zero_age_returns_one() {
694        let now = 1_000_000_i64;
695        let score = compute_recency(now, now, 7);
696        assert!((score - 1.0).abs() < 1e-9);
697    }
698
699    #[test]
700    fn compute_recency_half_life_returns_half() {
701        let now = 1_000_000_i64;
702        let half_life_days = 7_u32;
703        let age_secs = i64::from(half_life_days) * 86_400;
704        let score = compute_recency(now - age_secs, now, half_life_days);
705        assert!((score - 0.5).abs() < 1e-9);
706    }
707
708    #[test]
709    fn compute_recency_large_age_approaches_zero() {
710        // 1000 days with 7-day half-life: score ≈ 2^(-1000/7) ≈ 1e-43
711        let now = 1_000_i64 * 86_400;
712        let score = compute_recency(0, now, 7);
713        assert!(score < 1e-6, "score was {score}");
714    }
715
716    #[test]
717    fn compute_recency_future_timestamp_clamped_to_one() {
718        let now = 1_000_000_i64;
719        // created_at in the future → age < 0 → clamped to 0 → score = 1.0
720        let score = compute_recency(now + 86_400, now, 7);
721        assert!((score - 1.0).abs() < 1e-9);
722    }
723
724    #[test]
725    fn compute_tfidf_empty_candidates_returns_empty() {
726        let scores = compute_tfidf_scores("hello", &[]);
727        assert!(scores.is_empty());
728    }
729
730    #[test]
731    fn compute_tfidf_empty_query_returns_zeros() {
732        let candidates = vec![make_message("hello world")];
733        let scores = compute_tfidf_scores("", &candidates);
734        assert_eq!(scores.len(), 1);
735        assert!(scores[0].abs() < f64::EPSILON);
736    }
737
738    #[test]
739    fn compute_tfidf_exact_match_scores_nonzero() {
740        let candidates = vec![
741            make_message("the quick brown fox"),
742            make_message("completely unrelated content"),
743        ];
744        let scores = compute_tfidf_scores("fox", &candidates);
745        assert_eq!(scores.len(), 2);
746        // The message containing "fox" must score higher.
747        assert!(scores[0] > scores[1]);
748    }
749
750    #[test]
751    fn compute_tfidf_no_match_returns_zeros() {
752        let candidates = vec![make_message("apple banana cherry")];
753        let scores = compute_tfidf_scores("zzz xyz", &candidates);
754        assert_eq!(scores.len(), 1);
755        assert!(scores[0].abs() < f64::EPSILON);
756    }
757
758    #[test]
759    fn compute_tfidf_max_score_normalised_to_one() {
760        let candidates = vec![
761            make_message("rust programming language"),
762            make_message("python programming language"),
763            make_message("java is a drink"),
764        ];
765        let scores = compute_tfidf_scores("rust programming", &candidates);
766        let max = scores.iter().copied().fold(f64::NEG_INFINITY, f64::max);
767        assert!((max - 1.0).abs() < 1e-9, "max score must be 1.0, got {max}");
768    }
769
770    #[test]
771    fn score_candidates_empty_input_returns_empty() {
772        // Pure sync test via tokio runtime.
773        let rt = tokio::runtime::Builder::new_current_thread()
774            .enable_all()
775            .build()
776            .unwrap();
777        rt.block_on(async {
778            let memory = crate::testing::mock_semantic_memory()
779                .await
780                .expect("mock_semantic_memory");
781            let config = TieredRetrievalConfig::default();
782            let result = score_candidates(&memory, "query", vec![], &config)
783                .await
784                .expect("score_candidates must not fail on empty input");
785            assert!(result.is_empty());
786        });
787    }
788
789    #[test]
790    fn score_candidates_similarity_weight_reorders_by_score() {
791        let rt = tokio::runtime::Builder::new_current_thread()
792            .enable_all()
793            .build()
794            .unwrap();
795        rt.block_on(async {
796            let memory = crate::testing::mock_semantic_memory()
797                .await
798                .expect("mock_semantic_memory");
799            // similarity_weight = 1.0 activates the scoring formula; candidates provided in
800            // ascending score order to verify that sort_by reorders them descending.
801            let config = TieredRetrievalConfig {
802                similarity_weight: 1.0,
803                ..TieredRetrievalConfig::default()
804            };
805            let candidates = vec![
806                RecalledMessage {
807                    message: make_message("low score").message,
808                    score: 0.1,
809                },
810                RecalledMessage {
811                    message: make_message("high score").message,
812                    score: 0.9,
813                },
814                RecalledMessage {
815                    message: make_message("mid score").message,
816                    score: 0.5,
817                },
818            ];
819            let result = score_candidates(&memory, "query", candidates, &config)
820                .await
821                .expect("score_candidates must not fail");
822            assert_eq!(result.len(), 3);
823            // Descending order: 0.9 → 0.5 → 0.1
824            assert!(
825                result[0].score >= result[1].score,
826                "first score {} must be >= second score {}",
827                result[0].score,
828                result[1].score
829            );
830            assert!(
831                result[1].score >= result[2].score,
832                "second score {} must be >= third score {}",
833                result[1].score,
834                result[2].score
835            );
836            // Highest original score should be ranked first.
837            assert!(
838                (result[0].score - 0.9_f32).abs() < 1e-4,
839                "expected first score ~0.9, got {}",
840                result[0].score
841            );
842        });
843    }
844
845    #[test]
846    fn score_candidates_all_zero_weights_returns_original_order() {
847        let rt = tokio::runtime::Builder::new_current_thread()
848            .enable_all()
849            .build()
850            .unwrap();
851        rt.block_on(async {
852            let memory = crate::testing::mock_semantic_memory()
853                .await
854                .expect("mock_semantic_memory");
855            // All weights zero: score_candidates must return candidates unchanged.
856            let config = TieredRetrievalConfig {
857                similarity_weight: 0.0,
858                recency_weight: 0.0,
859                tfidf_weight: 0.0,
860                cognitive_signal_weight: 0.0,
861                tier_boost_weight: 0.0,
862                ..TieredRetrievalConfig::default()
863            };
864            let candidates = vec![
865                RecalledMessage {
866                    message: make_message("first").message,
867                    score: 0.9,
868                },
869                RecalledMessage {
870                    message: make_message("second").message,
871                    score: 0.1,
872                },
873            ];
874            let result = score_candidates(&memory, "query", candidates, &config)
875                .await
876                .expect("score_candidates must not fail");
877            // Original order preserved because all-zero weights triggers early return.
878            assert!((f64::from(result[0].score) - 0.9).abs() < 1e-6);
879            assert!((f64::from(result[1].score) - 0.1).abs() < 1e-6);
880        });
881    }
882
883    #[test]
884    fn tiered_retrieval_config_signal_weight_defaults() {
885        let cfg = TieredRetrievalConfig::default();
886        assert!((cfg.similarity_weight - 1.0).abs() < f64::EPSILON);
887        assert!(cfg.recency_weight.abs() < f64::EPSILON);
888        assert_eq!(cfg.recency_half_life_days, 7);
889        assert!(cfg.tfidf_weight.abs() < f64::EPSILON);
890        assert!(cfg.cognitive_signal_weight.abs() < f64::EPSILON);
891        assert!(cfg.tier_boost_weight.abs() < f64::EPSILON);
892        assert!((cfg.semantic_tier_boost - 1.0).abs() < f64::EPSILON);
893    }
894
895    #[test]
896    fn intent_class_from_route_mapping() {
897        assert_eq!(
898            IntentClass::from_route(MemoryRoute::Keyword),
899            IntentClass::ProfileLookup
900        );
901        assert_eq!(
902            IntentClass::from_route(MemoryRoute::Episodic),
903            IntentClass::ProfileLookup
904        );
905        assert_eq!(
906            IntentClass::from_route(MemoryRoute::Semantic),
907            IntentClass::TargetedRetrieval
908        );
909        assert_eq!(
910            IntentClass::from_route(MemoryRoute::Hybrid),
911            IntentClass::TargetedRetrieval
912        );
913        assert_eq!(
914            IntentClass::from_route(MemoryRoute::Graph),
915            IntentClass::DeepReasoning
916        );
917    }
918
919    #[test]
920    fn intent_class_top_k() {
921        assert_eq!(IntentClass::ProfileLookup.top_k(), 3);
922        assert_eq!(IntentClass::TargetedRetrieval.top_k(), 10);
923        assert_eq!(IntentClass::DeepReasoning.top_k(), 20);
924    }
925
926    #[test]
927    fn intent_class_escalate_chain() {
928        assert_eq!(
929            IntentClass::ProfileLookup.escalate(),
930            Some(IntentClass::TargetedRetrieval)
931        );
932        assert_eq!(
933            IntentClass::TargetedRetrieval.escalate(),
934            Some(IntentClass::DeepReasoning)
935        );
936        assert_eq!(IntentClass::DeepReasoning.escalate(), None);
937    }
938
939    #[test]
940    fn assemble_within_budget_empty_input() {
941        let (retained, tokens) = assemble_within_budget(vec![], 4096);
942        assert!(retained.is_empty());
943        assert_eq!(tokens, 0);
944    }
945
946    #[test]
947    fn assemble_within_budget_zero_budget_returns_nothing() {
948        let candidates = vec![make_message("hello"), make_message("world")];
949        let (retained, tokens) = assemble_within_budget(candidates, 0);
950        assert!(retained.is_empty(), "budget=0 must retain no messages");
951        assert_eq!(tokens, 0);
952    }
953
954    #[test]
955    fn assemble_within_budget_truncates_at_limit() {
956        // estimate_tokens = chars / 4. Each message: "a " * 400 = 800 chars = 200 tokens.
957        // Budget 250 fits exactly one (200 <= 250) but not two (200 + 200 = 400 > 250).
958        let msg = "a ".repeat(400);
959        let candidates = vec![make_message(&msg), make_message(&msg)];
960        let (retained, tokens) = assemble_within_budget(candidates, 250);
961        assert_eq!(
962            retained.len(),
963            1,
964            "tight budget must keep only first message"
965        );
966        assert_eq!(tokens, 200);
967    }
968
969    #[test]
970    fn parse_validation_response_missing_fields_defaults_to_sufficient() {
971        // Neither "sufficient" nor "confidence" present → defaults: sufficient=true, confidence=1.0
972        let raw = "{}";
973        assert!(
974            parse_validation_response(raw, 0.6),
975            "missing fields must default to sufficient"
976        );
977    }
978
979    #[test]
980    fn tiered_retrieval_config_defaults() {
981        let cfg = TieredRetrievalConfig::default();
982        assert!(!cfg.enabled);
983        assert_eq!(cfg.token_budget, 4096);
984        assert!(!cfg.validation_enabled);
985        assert_eq!(cfg.max_escalations, 1);
986        // Verify config-driven timeout defaults (fix #4250).
987        assert_eq!(cfg.classifier_timeout_secs, 5);
988        assert_eq!(cfg.validator_timeout_secs, 5);
989    }
990
991    #[test]
992    fn tiered_retrieval_config_timeout_fields_propagate() {
993        // Verify that custom timeout values survive a round-trip through the struct.
994        let cfg = TieredRetrievalConfig {
995            classifier_timeout_secs: 10,
996            validator_timeout_secs: 15,
997            ..TieredRetrievalConfig::default()
998        };
999        assert_eq!(cfg.classifier_timeout_secs, 10);
1000        assert_eq!(cfg.validator_timeout_secs, 15);
1001        // Confirm the durations would be built correctly from the fields.
1002        let classifier_dur = std::time::Duration::from_secs(cfg.classifier_timeout_secs);
1003        let validator_dur = std::time::Duration::from_secs(cfg.validator_timeout_secs);
1004        assert_eq!(classifier_dur.as_secs(), 10);
1005        assert_eq!(validator_dur.as_secs(), 15);
1006    }
1007
1008    #[test]
1009    fn parse_validation_response_sufficient() {
1010        let raw = r#"{"sufficient": true, "confidence": 0.9}"#;
1011        assert!(parse_validation_response(raw, 0.6));
1012    }
1013
1014    #[test]
1015    fn parse_validation_response_insufficient() {
1016        let raw = r#"{"sufficient": false, "confidence": 0.4}"#;
1017        assert!(!parse_validation_response(raw, 0.6));
1018    }
1019
1020    #[test]
1021    fn parse_validation_response_low_confidence() {
1022        let raw = r#"{"sufficient": true, "confidence": 0.3}"#;
1023        // threshold = 0.6, confidence 0.3 < 0.6 → insufficient
1024        assert!(!parse_validation_response(raw, 0.6));
1025    }
1026
1027    #[test]
1028    fn parse_validation_response_malformed_json_treats_as_sufficient() {
1029        let raw = "not json at all";
1030        assert!(parse_validation_response(raw, 0.6));
1031    }
1032
1033    #[test]
1034    fn intent_class_display() {
1035        assert_eq!(IntentClass::ProfileLookup.to_string(), "ProfileLookup");
1036        assert_eq!(
1037            IntentClass::TargetedRetrieval.to_string(),
1038            "TargetedRetrieval"
1039        );
1040        assert_eq!(IntentClass::DeepReasoning.to_string(), "DeepReasoning");
1041    }
1042
1043    // ── Async tests ───────────────────────────────────────────────────────────
1044
1045    /// Test 1: `recall_tiered` with `classifier = None` uses the `HeuristicRouter` path.
1046    ///
1047    /// With no classifier provider, the pipeline must route via heuristic, complete without
1048    /// error, and return a result whose intent maps from the heuristic route.
1049    #[tokio::test]
1050    async fn recall_tiered_no_classifier_uses_heuristic_router() {
1051        let memory = crate::testing::mock_semantic_memory()
1052            .await
1053            .expect("mock_semantic_memory");
1054        let config = TieredRetrievalConfig {
1055            enabled: true,
1056            validation_enabled: false,
1057            ..TieredRetrievalConfig::default()
1058        };
1059
1060        let result = recall_tiered(&memory, "what is my name", None, None, None, &config, None)
1061            .await
1062            .expect("recall_tiered must not fail");
1063
1064        // HeuristicRouter classifies "what is my name" via keyword/semantic heuristic.
1065        // The exact tier depends on the heuristic, but the pipeline must complete.
1066        assert!(
1067            !result.tier_escalated,
1068            "no escalation when validation is off"
1069        );
1070        assert!(result.tokens_used <= config.token_budget);
1071    }
1072
1073    /// Test 2: `recall_tiered` with `classifier = Some(...)` exercises the `HybridRouter` path.
1074    ///
1075    /// The mock LLM returns a JSON route decision; the pipeline must parse it and use the
1076    /// resulting intent class.
1077    #[tokio::test]
1078    async fn recall_tiered_with_classifier_uses_hybrid_router() {
1079        use zeph_llm::mock::MockProvider;
1080
1081        let memory = crate::testing::mock_semantic_memory()
1082            .await
1083            .expect("mock_semantic_memory");
1084
1085        // HybridRouter asks the LLM for a route; respond with a valid JSON route decision.
1086        let route_json = r#"{"route": "Semantic", "confidence": 0.9}"#.to_owned();
1087        let mut mock = MockProvider::with_responses(vec![route_json]);
1088        mock.supports_embeddings = true;
1089        mock.embedding = vec![0.1_f32; 384];
1090        let classifier = Arc::new(AnyProvider::Mock(mock));
1091
1092        let config = TieredRetrievalConfig {
1093            enabled: true,
1094            validation_enabled: false,
1095            ..TieredRetrievalConfig::default()
1096        };
1097
1098        let result = recall_tiered(
1099            &memory,
1100            "semantic query about the user",
1101            None,
1102            Some(&classifier),
1103            None,
1104            &config,
1105            None,
1106        )
1107        .await
1108        .expect("recall_tiered with classifier must not fail");
1109
1110        assert!(!result.tier_escalated);
1111        assert!(result.tokens_used <= config.token_budget);
1112    }
1113
1114    /// Test 3: Escalation loop sets `tier_escalated = true` when the validator returns
1115    /// insufficient evidence and a heavier tier is available.
1116    ///
1117    /// Validator response with `{"sufficient": false, "confidence": 0.2}` triggers escalation.
1118    /// After escalation, the second-tier retrieve runs and the result has `tier_escalated = true`.
1119    #[tokio::test]
1120    async fn recall_tiered_escalates_when_evidence_insufficient() {
1121        use zeph_llm::mock::MockProvider;
1122
1123        let memory = crate::testing::mock_semantic_memory()
1124            .await
1125            .expect("mock_semantic_memory");
1126
1127        // First validator response: insufficient. Second: sufficient (prevents infinite loop).
1128        let insufficient = r#"{"sufficient": false, "confidence": 0.1}"#.to_owned();
1129        let sufficient = r#"{"sufficient": true, "confidence": 0.95}"#.to_owned();
1130        let mut validator_mock = MockProvider::with_responses(vec![insufficient, sufficient]);
1131        validator_mock.supports_embeddings = true;
1132        let validator = Arc::new(AnyProvider::Mock(validator_mock));
1133
1134        let config = TieredRetrievalConfig {
1135            enabled: true,
1136            validation_enabled: true,
1137            validation_threshold: 0.6,
1138            max_escalations: 2,
1139            ..TieredRetrievalConfig::default()
1140        };
1141
1142        let result = recall_tiered(
1143            &memory,
1144            "deep query",
1145            None,
1146            None,
1147            Some(&validator),
1148            &config,
1149            None,
1150        )
1151        .await
1152        .expect("escalation path must not fail");
1153
1154        assert!(
1155            result.tier_escalated,
1156            "must set tier_escalated when validator triggers escalation"
1157        );
1158    }
1159
1160    /// Test 4a: `validate_evidence` returns `true` (fail-open) when the validator LLM times out.
1161    ///
1162    /// Uses `with_delay` to force the validator past the configured timeout threshold.
1163    /// The pipeline must treat a timed-out validator as sufficient (fail-open) and not escalate.
1164    #[tokio::test]
1165    async fn validate_evidence_timeout_is_fail_open() {
1166        use zeph_llm::mock::MockProvider;
1167
1168        let memory = crate::testing::mock_semantic_memory()
1169            .await
1170            .expect("mock_semantic_memory");
1171
1172        // Store a message so validate_evidence gets a non-empty slice and actually calls the LLM.
1173        let conv_id = memory
1174            .sqlite()
1175            .create_conversation()
1176            .await
1177            .expect("create_conversation");
1178        memory
1179            .remember(conv_id, "user", "some evidence content", None)
1180            .await
1181            .expect("remember");
1182
1183        // Delay > validator_timeout_secs causes the internal tokio::time::timeout to fire.
1184        let slow_mock = MockProvider::default().with_delay(6_000);
1185        let validator = Arc::new(AnyProvider::Mock(slow_mock));
1186
1187        let config = TieredRetrievalConfig {
1188            enabled: true,
1189            validation_enabled: true,
1190            validation_threshold: 0.6,
1191            max_escalations: 1,
1192            validator_timeout_secs: 5,
1193            ..TieredRetrievalConfig::default()
1194        };
1195
1196        // The slow validator should time out and be treated as sufficient → no escalation.
1197        let result = recall_tiered(
1198            &memory,
1199            "evidence",
1200            None,
1201            None,
1202            Some(&validator),
1203            &config,
1204            None,
1205        )
1206        .await
1207        .expect("timeout path must not propagate as error");
1208
1209        // Fail-open: timed-out validator means no escalation.
1210        assert!(
1211            !result.tier_escalated,
1212            "validator timeout must be treated as sufficient (fail-open)"
1213        );
1214    }
1215
1216    /// Test 4b: `validate_evidence` returns `true` (fail-open) when the validator LLM errors.
1217    ///
1218    /// A failing provider simulates a transient API error. The pipeline must not escalate.
1219    #[tokio::test]
1220    async fn validate_evidence_llm_error_is_fail_open() {
1221        use zeph_llm::mock::MockProvider;
1222
1223        let memory = crate::testing::mock_semantic_memory()
1224            .await
1225            .expect("mock_semantic_memory");
1226
1227        // Store a message so validate_evidence gets a non-empty slice and actually calls the LLM.
1228        let conv_id = memory
1229            .sqlite()
1230            .create_conversation()
1231            .await
1232            .expect("create_conversation");
1233        memory
1234            .remember(conv_id, "user", "some evidence content", None)
1235            .await
1236            .expect("remember");
1237
1238        let failing_mock = MockProvider::failing();
1239        let validator = Arc::new(AnyProvider::Mock(failing_mock));
1240
1241        let config = TieredRetrievalConfig {
1242            enabled: true,
1243            validation_enabled: true,
1244            validation_threshold: 0.6,
1245            max_escalations: 1,
1246            ..TieredRetrievalConfig::default()
1247        };
1248
1249        let result = recall_tiered(
1250            &memory,
1251            "evidence",
1252            None,
1253            None,
1254            Some(&validator),
1255            &config,
1256            None,
1257        )
1258        .await
1259        .expect("LLM error path must not propagate as retrieval error");
1260
1261        assert!(
1262            !result.tier_escalated,
1263            "validator LLM error must be treated as sufficient (fail-open)"
1264        );
1265    }
1266
1267    /// Test 5: `recall_tiered` with a `conversation_id` filter passes it to `retrieve_tier`,
1268    /// which in turn applies a `SearchFilter` scoping the search to that conversation.
1269    ///
1270    /// The pipeline must complete successfully even when the filter yields zero results.
1271    #[tokio::test]
1272    async fn recall_tiered_with_conversation_id_filter() {
1273        let memory = crate::testing::mock_semantic_memory()
1274            .await
1275            .expect("mock_semantic_memory");
1276
1277        let conv_id = ConversationId(42);
1278        let config = TieredRetrievalConfig {
1279            enabled: true,
1280            validation_enabled: false,
1281            ..TieredRetrievalConfig::default()
1282        };
1283
1284        let result = recall_tiered(
1285            &memory,
1286            "what did we discuss",
1287            Some(conv_id),
1288            None,
1289            None,
1290            &config,
1291            None,
1292        )
1293        .await
1294        .expect("conversation-scoped recall must not fail");
1295
1296        // No messages stored for this conversation — result must be empty but valid.
1297        assert!(result.messages.is_empty());
1298        assert_eq!(result.tokens_used, 0);
1299        assert!(!result.tier_escalated);
1300    }
1301
1302    /// Test 6: `deep_reasoning_query_conditioned = true` with an empty HELA graph falls back to
1303    /// `recall_routed` without panicking.
1304    ///
1305    /// `mock_semantic_memory` has no `graph_store`, so `recall_graph_hela` returns
1306    /// `Ok(Vec::new())`.  The code at `tiered_retrieval.rs:307` logs "no results" and falls
1307    /// through to `recall_routed`, which must succeed and return a valid `TieredRetrievalResult`.
1308    #[tokio::test]
1309    async fn deep_reasoning_query_conditioned_true_falls_back_when_hela_empty() {
1310        let memory = crate::testing::mock_semantic_memory()
1311            .await
1312            .expect("mock_semantic_memory");
1313
1314        let config = TieredRetrievalConfig {
1315            deep_reasoning_query_conditioned: true,
1316            validation_enabled: false,
1317            ..TieredRetrievalConfig::default()
1318        };
1319
1320        let result = retrieve_tier(
1321            &memory,
1322            "multi-hop reasoning query",
1323            None,
1324            IntentClass::DeepReasoning,
1325            &config,
1326        )
1327        .await
1328        .expect("retrieve_tier with empty HELA must not fail");
1329
1330        // HELA returned nothing → fallback to recall_routed → empty result (no stored msgs).
1331        assert!(
1332            result.is_empty(),
1333            "expected empty result from fallback recall_routed, got {}",
1334            result.len()
1335        );
1336    }
1337
1338    /// Test 7: `deep_reasoning_query_conditioned = false` with `DeepReasoning` intent completes
1339    /// without panicking.
1340    ///
1341    /// Verifies that the pipeline completes successfully and returns a valid (empty) result when
1342    /// the flag is disabled. The mock has no stored messages, so `recall_routed` returns empty.
1343    #[tokio::test]
1344    async fn deep_reasoning_query_conditioned_false_completes_without_panic() {
1345        let memory = crate::testing::mock_semantic_memory()
1346            .await
1347            .expect("mock_semantic_memory");
1348
1349        let config = TieredRetrievalConfig {
1350            deep_reasoning_query_conditioned: false,
1351            validation_enabled: false,
1352            ..TieredRetrievalConfig::default()
1353        };
1354
1355        let result = retrieve_tier(
1356            &memory,
1357            "multi-hop reasoning query",
1358            None,
1359            IntentClass::DeepReasoning,
1360            &config,
1361        )
1362        .await
1363        .expect("retrieve_tier with deep_reasoning_query_conditioned=false must not fail");
1364
1365        // recall_routed path used; no messages stored so result is empty.
1366        assert!(
1367            result.is_empty(),
1368            "expected empty result from recall_routed path, got {}",
1369            result.len()
1370        );
1371    }
1372}