1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50#[non_exhaustive]
51pub enum IntentClass {
52 ProfileLookup,
54 TargetedRetrieval,
56 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 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#[derive(Debug)]
101pub struct TieredRetrievalResult {
102 pub messages: Vec<RecalledMessage>,
104 pub intent: IntentClass,
106 pub tokens_used: usize,
108 pub tier_escalated: bool,
110}
111
112#[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,
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#[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 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#[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 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 memory
311 .recall_routed(query, top_k, filter, &heuristic, None)
312 .await
313}
314
315#[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 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 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 #[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 #[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
465fn 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 #[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
483fn 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 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 #[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 #[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 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 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
548fn 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#[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#[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 assert!(
1067 !result.tier_escalated,
1068 "no escalation when validation is off"
1069 );
1070 assert!(result.tokens_used <= config.token_budget);
1071 }
1072
1073 #[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 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 #[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 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 #[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 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 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 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 assert!(
1211 !result.tier_escalated,
1212 "validator timeout must be treated as sufficient (fail-open)"
1213 );
1214 }
1215
1216 #[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 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 #[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 assert!(result.messages.is_empty());
1298 assert_eq!(result.tokens_used, 0);
1299 assert!(!result.tier_escalated);
1300 }
1301
1302 #[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 assert!(
1332 result.is_empty(),
1333 "expected empty result from fallback recall_routed, got {}",
1334 result.len()
1335 );
1336 }
1337
1338 #[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 assert!(
1367 result.is_empty(),
1368 "expected empty result from recall_routed path, got {}",
1369 result.len()
1370 );
1371 }
1372}