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