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 #[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 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#[derive(Debug)]
119pub struct TieredRetrievalResult {
120 pub messages: Vec<RecalledMessage>,
122 pub intent: IntentClass,
124 pub tokens_used: usize,
126 pub tier_escalated: bool,
128}
129
130#[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,
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#[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 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#[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 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 memory
329 .recall_routed(query, top_k, filter, &heuristic, None)
330 .await
331}
332
333#[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 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 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 #[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 #[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
483fn 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 #[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
501fn 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 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 #[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 #[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 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 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
566fn 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#[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#[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 assert!(
1085 !result.tier_escalated,
1086 "no escalation when validation is off"
1087 );
1088 assert!(result.tokens_used <= config.token_budget);
1089 }
1090
1091 #[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 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 #[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 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 #[tokio::test]
1183 async fn validate_evidence_timeout_is_fail_open() {
1184 use zeph_llm::mock::MockProvider;
1185
1186 let memory = crate::testing::mock_semantic_memory()
1187 .await
1188 .expect("mock_semantic_memory");
1189
1190 let conv_id = memory
1192 .sqlite()
1193 .create_conversation()
1194 .await
1195 .expect("create_conversation");
1196 memory
1197 .remember(conv_id, "user", "some evidence content", None)
1198 .await
1199 .expect("remember");
1200
1201 let slow_mock = MockProvider::default().with_delay(6_000);
1203 let validator = Arc::new(AnyProvider::Mock(slow_mock));
1204
1205 let config = TieredRetrievalConfig {
1206 enabled: true,
1207 validation_enabled: true,
1208 validation_threshold: 0.6,
1209 max_escalations: 1,
1210 validator_timeout_secs: 5,
1211 ..TieredRetrievalConfig::default()
1212 };
1213
1214 let result = recall_tiered(
1216 &memory,
1217 "evidence",
1218 None,
1219 None,
1220 Some(&validator),
1221 &config,
1222 None,
1223 )
1224 .await
1225 .expect("timeout path must not propagate as error");
1226
1227 assert!(
1229 !result.tier_escalated,
1230 "validator timeout must be treated as sufficient (fail-open)"
1231 );
1232 }
1233
1234 #[tokio::test]
1238 async fn validate_evidence_llm_error_is_fail_open() {
1239 use zeph_llm::mock::MockProvider;
1240
1241 let memory = crate::testing::mock_semantic_memory()
1242 .await
1243 .expect("mock_semantic_memory");
1244
1245 let conv_id = memory
1247 .sqlite()
1248 .create_conversation()
1249 .await
1250 .expect("create_conversation");
1251 memory
1252 .remember(conv_id, "user", "some evidence content", None)
1253 .await
1254 .expect("remember");
1255
1256 let failing_mock = MockProvider::failing();
1257 let validator = Arc::new(AnyProvider::Mock(failing_mock));
1258
1259 let config = TieredRetrievalConfig {
1260 enabled: true,
1261 validation_enabled: true,
1262 validation_threshold: 0.6,
1263 max_escalations: 1,
1264 ..TieredRetrievalConfig::default()
1265 };
1266
1267 let result = recall_tiered(
1268 &memory,
1269 "evidence",
1270 None,
1271 None,
1272 Some(&validator),
1273 &config,
1274 None,
1275 )
1276 .await
1277 .expect("LLM error path must not propagate as retrieval error");
1278
1279 assert!(
1280 !result.tier_escalated,
1281 "validator LLM error must be treated as sufficient (fail-open)"
1282 );
1283 }
1284
1285 #[tokio::test]
1290 async fn recall_tiered_with_conversation_id_filter() {
1291 let memory = crate::testing::mock_semantic_memory()
1292 .await
1293 .expect("mock_semantic_memory");
1294
1295 let conv_id = ConversationId(42);
1296 let config = TieredRetrievalConfig {
1297 enabled: true,
1298 validation_enabled: false,
1299 ..TieredRetrievalConfig::default()
1300 };
1301
1302 let result = recall_tiered(
1303 &memory,
1304 "what did we discuss",
1305 Some(conv_id),
1306 None,
1307 None,
1308 &config,
1309 None,
1310 )
1311 .await
1312 .expect("conversation-scoped recall must not fail");
1313
1314 assert!(result.messages.is_empty());
1316 assert_eq!(result.tokens_used, 0);
1317 assert!(!result.tier_escalated);
1318 }
1319
1320 #[tokio::test]
1327 async fn deep_reasoning_query_conditioned_true_falls_back_when_hela_empty() {
1328 let memory = crate::testing::mock_semantic_memory()
1329 .await
1330 .expect("mock_semantic_memory");
1331
1332 let config = TieredRetrievalConfig {
1333 deep_reasoning_query_conditioned: true,
1334 validation_enabled: false,
1335 ..TieredRetrievalConfig::default()
1336 };
1337
1338 let result = retrieve_tier(
1339 &memory,
1340 "multi-hop reasoning query",
1341 None,
1342 IntentClass::DeepReasoning,
1343 &config,
1344 )
1345 .await
1346 .expect("retrieve_tier with empty HELA must not fail");
1347
1348 assert!(
1350 result.is_empty(),
1351 "expected empty result from fallback recall_routed, got {}",
1352 result.len()
1353 );
1354 }
1355
1356 #[tokio::test]
1362 async fn deep_reasoning_query_conditioned_false_completes_without_panic() {
1363 let memory = crate::testing::mock_semantic_memory()
1364 .await
1365 .expect("mock_semantic_memory");
1366
1367 let config = TieredRetrievalConfig {
1368 deep_reasoning_query_conditioned: false,
1369 validation_enabled: false,
1370 ..TieredRetrievalConfig::default()
1371 };
1372
1373 let result = retrieve_tier(
1374 &memory,
1375 "multi-hop reasoning query",
1376 None,
1377 IntentClass::DeepReasoning,
1378 &config,
1379 )
1380 .await
1381 .expect("retrieve_tier with deep_reasoning_query_conditioned=false must not fail");
1382
1383 assert!(
1385 result.is_empty(),
1386 "expected empty result from recall_routed path, got {}",
1387 result.len()
1388 );
1389 }
1390}