1use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use futures::{StreamExt as _, TryStreamExt as _};
10use zeph_llm::provider::{LlmProvider as _, Message, MessageVisibility};
11
12const CHARS_PER_TOKEN: usize = 4;
14
15const CHUNK_CHARS: usize = 400 * CHARS_PER_TOKEN;
17
18const CHUNK_OVERLAP_CHARS: usize = 80 * CHARS_PER_TOKEN;
20
21fn chunk_text(text: &str) -> Vec<&str> {
27 if text.len() <= CHUNK_CHARS {
28 return vec![text];
29 }
30
31 let mut chunks = Vec::new();
32 let mut start = 0;
33
34 while start < text.len() {
35 let end = if start + CHUNK_CHARS >= text.len() {
36 text.len()
37 } else {
38 let boundary = text.floor_char_boundary(start + CHUNK_CHARS);
40 let slice = &text[start..boundary];
42 if let Some(pos) = slice.rfind("\n\n") {
43 start + pos + 2
44 } else if let Some(pos) = slice.rfind('\n') {
45 start + pos + 1
46 } else if let Some(pos) = slice.rfind(' ') {
47 start + pos + 1
48 } else {
49 boundary
50 }
51 };
52
53 chunks.push(&text[start..end]);
54 if end >= text.len() {
55 break;
56 }
57 let next = end.saturating_sub(CHUNK_OVERLAP_CHARS);
61 let new_start = text.ceil_char_boundary(next);
62 start = if new_start > start { new_start } else { end };
63 }
64
65 chunks
66}
67
68use crate::admission::{AdmissionDecision, log_admission_decision};
69use crate::embedding_store::{MessageKind, SearchFilter};
70use crate::error::MemoryError;
71use crate::store::admission_training::AdmissionTrainingInput;
72use crate::types::{ConversationId, MessageId};
73
74use super::SemanticMemory;
75use super::algorithms::{apply_mmr, apply_temporal_decay};
76
77#[derive(Debug, Clone, Default)]
81pub struct EmbedContext {
82 pub tool_name: Option<String>,
83 pub exit_code: Option<i32>,
84 pub timestamp: Option<String>,
85}
86
87#[derive(Debug)]
88pub struct RecalledMessage {
89 pub message: Message,
90 pub score: f32,
91}
92
93const MAX_EMBED_BG_TASKS: usize = 64;
95
96const QDRANT_WARN_WINDOW_SECS: u64 = 10;
98
99fn should_emit_qdrant_warn(last: u64, now: u64, window_secs: u64) -> bool {
101 now.saturating_sub(last) >= window_secs
102}
103
104fn warn_qdrant_ensure_failure(last_warn: &AtomicU64, log_tag: &str, err: &MemoryError) {
107 let now = std::time::SystemTime::now()
108 .duration_since(std::time::UNIX_EPOCH)
109 .unwrap_or_default()
110 .as_secs();
111 let last = last_warn.load(Ordering::Relaxed);
112 if should_emit_qdrant_warn(last, now, QDRANT_WARN_WINDOW_SECS) {
113 last_warn.store(now, Ordering::Relaxed);
114 tracing::warn!("{log_tag}: failed to ensure Qdrant collection: {err:#}");
115 } else {
116 tracing::debug!("{log_tag}: failed to ensure Qdrant collection (suppressed): {err:#}");
117 }
118}
119
120struct EmbedBgArgs {
126 qdrant: Arc<crate::embedding_store::EmbeddingStore>,
127 embed_provider: zeph_llm::any::AnyProvider,
128 message_id: MessageId,
129 content: String,
130 last_qdrant_warn: Arc<AtomicU64>,
131}
132
133async fn embed_chunk_and_store_bg<F>(args: EmbedBgArgs, log_tag: &'static str, store_chunk: F)
141where
142 F: Fn(u32, Vec<f32>) -> Pin<Box<dyn Future<Output = Result<(), MemoryError>> + Send>> + Send,
143{
144 let EmbedBgArgs {
145 qdrant,
146 embed_provider,
147 message_id,
148 content,
149 last_qdrant_warn,
150 } = args;
151 let chunks = chunk_text(&content);
152 let chunk_count = chunks.len();
153
154 let vectors = match embed_provider.embed_batch(&chunks).await {
155 Ok(v) => v,
156 Err(e) => {
157 tracing::warn!("{log_tag}: failed to embed chunks for msg {message_id}: {e:#}");
158 return;
159 }
160 };
161
162 let Some(first) = vectors.first() else {
163 return;
164 };
165 if let Err(e) = qdrant.ensure_collection_for_vector(first).await {
166 warn_qdrant_ensure_failure(&last_qdrant_warn, log_tag, &e);
167 return;
168 }
169
170 for (chunk_index, vector) in vectors.into_iter().enumerate() {
171 let chunk_index_u32 = u32::try_from(chunk_index).unwrap_or(u32::MAX);
172 if let Err(e) = store_chunk(chunk_index_u32, vector).await {
173 tracing::warn!(
174 "{log_tag}: failed to store chunk {chunk_index}/{chunk_count} \
175 for msg {message_id}: {e:#}"
176 );
177 }
178 }
179}
180
181enum AdmissionOutcome {
183 Reject,
185 Proceed(Option<AdmissionDecision>),
189}
190
191fn hela_outer_timeout(params: &crate::graph::HelaSpreadParams) -> std::time::Duration {
197 let embed_component = params
198 .embed_timeout
199 .unwrap_or(std::time::Duration::from_secs(5));
200 let step_stages = params.spread_depth.clamp(1, 6) + 2;
201 let step_component = params
202 .step_budget
203 .unwrap_or(std::time::Duration::from_millis(80))
204 * step_stages;
205 embed_component + step_component + std::time::Duration::from_millis(250)
206}
207
208impl SemanticMemory {
209 #[cfg_attr(
219 feature = "profiling",
220 tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
221 )]
222 pub async fn remember(
223 &self,
224 conversation_id: ConversationId,
225 role: &str,
226 content: &str,
227 goal_text: Option<&str>,
228 ) -> Result<Option<MessageId>, MemoryError> {
229 self.remember_with_provenance(conversation_id, role, content, goal_text, None, None)
230 .await
231 }
232
233 #[cfg_attr(
248 feature = "profiling",
249 tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
250 )]
251 pub async fn remember_with_provenance(
252 &self,
253 conversation_id: ConversationId,
254 role: &str,
255 content: &str,
256 goal_text: Option<&str>,
257 source_kind: Option<&str>,
258 trust_level: Option<&str>,
259 ) -> Result<Option<MessageId>, MemoryError> {
260 let admission_decision = match self
261 .run_admission_gate(conversation_id, role, content, goal_text)
262 .await
263 {
264 AdmissionOutcome::Reject => return Ok(None),
265 AdmissionOutcome::Proceed(decision) => decision,
266 };
267
268 if self
269 .run_quality_gate(conversation_id, role, content, admission_decision.as_ref())
270 .await
271 {
272 return Ok(None);
273 }
274
275 let message_id = self
276 .sqlite
277 .save_message_with_provenance(
278 conversation_id,
279 role,
280 content,
281 "[]",
282 MessageVisibility::Both,
283 source_kind,
284 trust_level,
285 )
286 .await?;
287
288 self.record_admission_sample_opt(
289 conversation_id,
290 role,
291 content,
292 admission_decision.as_ref(),
293 Some(message_id),
294 )
295 .await;
296
297 self.embed_and_store_regular(message_id, conversation_id, role, content, trust_level);
298
299 Ok(Some(message_id))
300 }
301
302 #[cfg_attr(
311 feature = "profiling",
312 tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
313 )]
314 pub async fn remember_with_parts(
315 &self,
316 conversation_id: ConversationId,
317 role: &str,
318 content: &str,
319 parts_json: &str,
320 goal_text: Option<&str>,
321 ) -> Result<(Option<MessageId>, bool), MemoryError> {
322 self.remember_with_parts_and_provenance(
323 conversation_id,
324 role,
325 content,
326 parts_json,
327 goal_text,
328 None,
329 None,
330 )
331 .await
332 }
333
334 #[cfg_attr(
350 feature = "profiling",
351 tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
352 )]
353 #[allow(clippy::too_many_arguments)]
354 pub async fn remember_with_parts_and_provenance(
355 &self,
356 conversation_id: ConversationId,
357 role: &str,
358 content: &str,
359 parts_json: &str,
360 goal_text: Option<&str>,
361 source_kind: Option<&str>,
362 trust_level: Option<&str>,
363 ) -> Result<(Option<MessageId>, bool), MemoryError> {
364 let admission_decision = match self
365 .run_admission_gate(conversation_id, role, content, goal_text)
366 .await
367 {
368 AdmissionOutcome::Reject => return Ok((None, false)),
369 AdmissionOutcome::Proceed(decision) => decision,
370 };
371
372 if self
373 .run_quality_gate(conversation_id, role, content, admission_decision.as_ref())
374 .await
375 {
376 return Ok((None, false));
377 }
378
379 let message_id = self
380 .sqlite
381 .save_message_with_provenance(
382 conversation_id,
383 role,
384 content,
385 parts_json,
386 MessageVisibility::Both,
387 source_kind,
388 trust_level,
389 )
390 .await?;
391
392 self.record_admission_sample_opt(
393 conversation_id,
394 role,
395 content,
396 admission_decision.as_ref(),
397 Some(message_id),
398 )
399 .await;
400
401 let embedding_stored =
402 self.embed_and_store_regular(message_id, conversation_id, role, content, trust_level);
403
404 Ok((Some(message_id), embedding_stored))
405 }
406
407 #[cfg_attr(
419 feature = "profiling",
420 tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
421 )]
422 pub async fn remember_tool_output(
423 &self,
424 conversation_id: ConversationId,
425 role: &str,
426 content: &str,
427 parts_json: &str,
428 embed_ctx: EmbedContext,
429 ) -> Result<(Option<MessageId>, bool), MemoryError> {
430 let admission_decision = match self
433 .run_admission_gate(conversation_id, role, content, None)
434 .await
435 {
436 AdmissionOutcome::Reject => return Ok((None, false)),
437 AdmissionOutcome::Proceed(decision) => decision,
438 };
439
440 let message_id = self
441 .sqlite
442 .save_message_with_parts(conversation_id, role, content, parts_json)
443 .await?;
444
445 self.record_admission_sample_opt(
446 conversation_id,
447 role,
448 content,
449 admission_decision.as_ref(),
450 Some(message_id),
451 )
452 .await;
453
454 let embedding_stored = self.embed_chunks_with_tool_context(
455 message_id,
456 conversation_id,
457 role,
458 content,
459 embed_ctx,
460 );
461
462 Ok((Some(message_id), embedding_stored))
463 }
464
465 #[cfg_attr(
476 feature = "profiling",
477 tracing::instrument(name = "memory.remember", skip_all, fields(content_len = %content.len()))
478 )]
479 pub async fn remember_categorized(
480 &self,
481 conversation_id: ConversationId,
482 role: &str,
483 content: &str,
484 category: Option<&str>,
485 goal_text: Option<&str>,
486 ) -> Result<Option<MessageId>, MemoryError> {
487 let admission_decision = match self
490 .run_admission_gate(conversation_id, role, content, goal_text)
491 .await
492 {
493 AdmissionOutcome::Reject => return Ok(None),
494 AdmissionOutcome::Proceed(decision) => decision,
495 };
496
497 let message_id = self
498 .sqlite
499 .save_message_with_category(conversation_id, role, content, category)
500 .await?;
501
502 self.record_admission_sample_opt(
503 conversation_id,
504 role,
505 content,
506 admission_decision.as_ref(),
507 Some(message_id),
508 )
509 .await;
510
511 self.embed_and_store_with_category(message_id, conversation_id, role, content, category);
512
513 Ok(Some(message_id))
514 }
515
516 async fn run_admission_gate(
522 &self,
523 conversation_id: ConversationId,
524 role: &str,
525 content: &str,
526 goal_text: Option<&str>,
527 ) -> AdmissionOutcome {
528 let Some(admission) = &self.admission_control else {
529 return AdmissionOutcome::Proceed(None);
530 };
531 let decision = admission
532 .evaluate(
533 content,
534 role,
535 self.effective_embed_provider(),
536 self.qdrant.as_ref(),
537 goal_text,
538 )
539 .await;
540 let preview: String = content.chars().take(100).collect();
541 log_admission_decision(&decision, &preview, role, admission.threshold());
542 if !decision.admitted {
543 self.record_admission_sample(conversation_id, role, content, &decision, None)
544 .await;
545 return AdmissionOutcome::Reject;
546 }
547 AdmissionOutcome::Proceed(Some(decision))
548 }
549
550 async fn run_quality_gate(
557 &self,
558 conversation_id: ConversationId,
559 role: &str,
560 content: &str,
561 admission_decision: Option<&AdmissionDecision>,
562 ) -> bool {
563 let Some(gate) = &self.quality_gate else {
564 return false;
565 };
566 let recent_embeddings = self
567 .fetch_recent_embeddings(conversation_id, gate.config().recent_window)
568 .await;
569 if gate
570 .evaluate(content, self.effective_embed_provider(), &recent_embeddings)
571 .await
572 .is_none()
573 {
574 return false;
575 }
576 if let Some(decision) = admission_decision {
577 self.record_admission_sample(conversation_id, role, content, decision, None)
578 .await;
579 }
580 true
581 }
582
583 async fn fetch_recent_embeddings(
593 &self,
594 conversation_id: ConversationId,
595 limit: usize,
596 ) -> Vec<Vec<f32>> {
597 let Some(qdrant) = &self.qdrant else {
598 return Vec::new();
599 };
600 if limit == 0 {
601 return Vec::new();
602 }
603 let limit_u32 = u32::try_from(limit).unwrap_or(u32::MAX);
604 let recent_messages = match self.sqlite.load_history(conversation_id, limit_u32).await {
605 Ok(messages) => messages,
606 Err(e) => {
607 tracing::warn!("quality_gate: failed to load recent history: {e:#}");
608 return Vec::new();
609 }
610 };
611 let ids: Vec<MessageId> = recent_messages
612 .iter()
613 .filter_map(|m| m.metadata.db_id)
614 .map(MessageId)
615 .collect();
616 if ids.is_empty() {
617 return Vec::new();
618 }
619 match qdrant.get_vectors(&ids).await {
620 Ok(vec_map) => vec_map.into_values().collect(),
621 Err(e) => {
622 tracing::warn!("quality_gate: failed to fetch recent embeddings: {e:#}");
623 Vec::new()
624 }
625 }
626 }
627
628 async fn record_admission_sample_opt(
631 &self,
632 conversation_id: ConversationId,
633 role: &str,
634 content: &str,
635 decision: Option<&AdmissionDecision>,
636 message_id: Option<MessageId>,
637 ) {
638 if let Some(decision) = decision {
639 self.record_admission_sample(conversation_id, role, content, decision, message_id)
640 .await;
641 }
642 }
643
644 async fn record_admission_sample(
652 &self,
653 conversation_id: ConversationId,
654 role: &str,
655 content: &str,
656 decision: &AdmissionDecision,
657 message_id: Option<MessageId>,
658 ) {
659 let features_json = match serde_json::to_string(&decision.factors) {
660 Ok(json) => json,
661 Err(e) => {
662 tracing::debug!(error = %e, "admission training: failed to serialize factors");
663 return;
664 }
665 };
666 if let Err(e) = self
667 .sqlite
668 .record_admission_training(AdmissionTrainingInput {
669 message_id,
670 conversation_id,
671 content,
672 role,
673 composite_score: decision.composite_score,
674 was_admitted: decision.admitted,
675 features_json: &features_json,
676 })
677 .await
678 {
679 tracing::debug!(error = %e, "admission training: failed to record sample (non-fatal)");
680 }
681 }
682
683 pub async fn recall_with_category(
691 &self,
692 query: &str,
693 limit: usize,
694 filter: Option<SearchFilter>,
695 category: Option<&str>,
696 ) -> Result<Vec<RecalledMessage>, MemoryError> {
697 let filter_with_category = filter.map(|mut f| {
698 f.category = category.map(str::to_owned);
699 f
700 });
701 self.recall(query, limit, filter_with_category).await
702 }
703
704 pub fn reap_embed_tasks(&self) {
708 if let Ok(mut tasks) = self.embed_tasks.lock() {
709 while tasks.try_join_next().is_some() {}
710 }
711 }
712
713 fn spawn_embed_bg<F>(&self, fut: F) -> bool
717 where
718 F: std::future::Future<Output = ()> + Send + 'static,
719 {
720 let Ok(mut tasks) = self.embed_tasks.lock() else {
721 return false;
722 };
723 while tasks.try_join_next().is_some() {}
725 if tasks.len() >= MAX_EMBED_BG_TASKS {
726 tracing::debug!("background embed task limit reached, skipping");
727 return false;
728 }
729 tasks.spawn(fut);
730 true
731 }
732
733 fn embed_and_store_with_category(
737 &self,
738 message_id: MessageId,
739 conversation_id: ConversationId,
740 role: &str,
741 content: &str,
742 category: Option<&str>,
743 ) -> bool {
744 let Some(qdrant) = self.qdrant.clone() else {
745 return false;
746 };
747 let embed_provider = self.effective_embed_provider().clone();
748 if !embed_provider.supports_embeddings() {
749 return false;
750 }
751 let store_qdrant = Arc::clone(&qdrant);
752 let embedding_model = self.embedding_model.clone();
753 let role = role.to_owned();
754 let category = category.map(str::to_owned);
755 let store_chunk =
756 move |chunk_index: u32,
757 vector: Vec<f32>|
758 -> Pin<Box<dyn Future<Output = Result<(), MemoryError>> + Send>> {
759 let qdrant = Arc::clone(&store_qdrant);
760 let embedding_model = embedding_model.clone();
761 let role = role.clone();
762 let category = category.clone();
763 Box::pin(async move {
764 qdrant
765 .store_with_category(
766 message_id,
767 conversation_id,
768 &role,
769 vector,
770 MessageKind::Regular,
771 &embedding_model,
772 chunk_index,
773 category.as_deref(),
774 None,
779 )
780 .await
781 .map(|_| ())
782 })
783 };
784 self.spawn_embed_bg(embed_chunk_and_store_bg(
785 EmbedBgArgs {
786 qdrant,
787 embed_provider,
788 message_id,
789 content: content.to_owned(),
790 last_qdrant_warn: Arc::clone(&self.last_qdrant_warn),
791 },
792 "bg embed_category",
793 store_chunk,
794 ))
795 }
796
797 fn embed_and_store_regular(
801 &self,
802 message_id: MessageId,
803 conversation_id: ConversationId,
804 role: &str,
805 content: &str,
806 trust_level: Option<&str>,
807 ) -> bool {
808 let Some(qdrant) = self.qdrant.clone() else {
809 return false;
810 };
811 let embed_provider = self.effective_embed_provider().clone();
812 if !embed_provider.supports_embeddings() {
813 return false;
814 }
815 let store_qdrant = Arc::clone(&qdrant);
816 let embedding_model = self.embedding_model.clone();
817 let role = role.to_owned();
818 let trust_level = trust_level.map(str::to_owned);
819 let store_chunk =
820 move |chunk_index: u32,
821 vector: Vec<f32>|
822 -> Pin<Box<dyn Future<Output = Result<(), MemoryError>> + Send>> {
823 let qdrant = Arc::clone(&store_qdrant);
824 let embedding_model = embedding_model.clone();
825 let role = role.clone();
826 let trust_level = trust_level.clone();
827 Box::pin(async move {
828 qdrant
829 .store(
830 message_id,
831 conversation_id,
832 &role,
833 vector,
834 MessageKind::Regular,
835 &embedding_model,
836 chunk_index,
837 trust_level.as_deref(),
838 )
839 .await
840 .map(|_| ())
841 })
842 };
843 self.spawn_embed_bg(embed_chunk_and_store_bg(
844 EmbedBgArgs {
845 qdrant,
846 embed_provider,
847 message_id,
848 content: content.to_owned(),
849 last_qdrant_warn: Arc::clone(&self.last_qdrant_warn),
850 },
851 "bg embed_regular",
852 store_chunk,
853 ))
854 }
855
856 fn embed_chunks_with_tool_context(
860 &self,
861 message_id: MessageId,
862 conversation_id: ConversationId,
863 role: &str,
864 content: &str,
865 embed_ctx: EmbedContext,
866 ) -> bool {
867 let Some(qdrant) = self.qdrant.clone() else {
868 return false;
869 };
870 let embed_provider = self.effective_embed_provider().clone();
871 if !embed_provider.supports_embeddings() {
872 return false;
873 }
874 let store_qdrant = Arc::clone(&qdrant);
875 let embedding_model = self.embedding_model.clone();
876 let role = role.to_owned();
877 let store_chunk =
878 move |chunk_index: u32,
879 vector: Vec<f32>|
880 -> Pin<Box<dyn Future<Output = Result<(), MemoryError>> + Send>> {
881 let qdrant = Arc::clone(&store_qdrant);
882 let embedding_model = embedding_model.clone();
883 let role = role.clone();
884 let embed_ctx = embed_ctx.clone();
885 Box::pin(async move {
886 if let Some(tool_name) = embed_ctx.tool_name {
887 qdrant
888 .store_with_tool_context(
889 message_id,
890 conversation_id,
891 &role,
892 vector,
893 MessageKind::Regular,
894 &embedding_model,
895 chunk_index,
896 &tool_name,
897 embed_ctx.exit_code,
898 embed_ctx.timestamp.as_deref(),
899 None,
904 )
905 .await
906 .map(|_| ())
907 } else {
908 qdrant
909 .store(
910 message_id,
911 conversation_id,
912 &role,
913 vector,
914 MessageKind::Regular,
915 &embedding_model,
916 chunk_index,
917 None,
918 )
919 .await
920 .map(|_| ())
921 }
922 })
923 };
924 self.spawn_embed_bg(embed_chunk_and_store_bg(
925 EmbedBgArgs {
926 qdrant,
927 embed_provider,
928 message_id,
929 content: content.to_owned(),
930 last_qdrant_warn: Arc::clone(&self.last_qdrant_warn),
931 },
932 "bg embed_tool",
933 store_chunk,
934 ))
935 }
936
937 pub async fn save_only(
945 &self,
946 conversation_id: ConversationId,
947 role: &str,
948 content: &str,
949 parts_json: &str,
950 ) -> Result<MessageId, MemoryError> {
951 self.save_only_with_provenance(conversation_id, role, content, parts_json, None, None)
952 .await
953 }
954
955 pub async fn save_only_with_provenance(
966 &self,
967 conversation_id: ConversationId,
968 role: &str,
969 content: &str,
970 parts_json: &str,
971 source_kind: Option<&str>,
972 trust_level: Option<&str>,
973 ) -> Result<MessageId, MemoryError> {
974 self.sqlite
975 .save_message_with_provenance(
976 conversation_id,
977 role,
978 content,
979 parts_json,
980 MessageVisibility::Both,
981 source_kind,
982 trust_level,
983 )
984 .await
985 }
986
987 #[cfg_attr(
997 feature = "profiling",
998 tracing::instrument(name = "memory.recall", skip_all, fields(query_len = %query.len(), result_count = tracing::field::Empty, top_score = tracing::field::Empty))
999 )]
1000 pub async fn recall(
1001 &self,
1002 query: &str,
1003 limit: usize,
1004 filter: Option<SearchFilter>,
1005 ) -> Result<Vec<RecalledMessage>, MemoryError> {
1006 let conversation_id = filter.as_ref().and_then(|f| f.conversation_id);
1007
1008 tracing::debug!(
1009 query_len = query.len(),
1010 limit,
1011 has_filter = filter.is_some(),
1012 conversation_id = conversation_id.map(|c| c.0),
1013 has_qdrant = self.qdrant.is_some(),
1014 "recall: starting hybrid search"
1015 );
1016
1017 let keyword_results = match self
1018 .sqlite
1019 .keyword_search(query, self.effective_depth(limit), conversation_id)
1020 .await
1021 {
1022 Ok(results) => results,
1023 Err(e) => {
1024 tracing::warn!("FTS5 keyword search failed: {e:#}");
1025 Vec::new()
1026 }
1027 };
1028
1029 let vector_results = if let Some(qdrant) = &self.qdrant
1030 && self.effective_embed_provider().supports_embeddings()
1031 {
1032 let embed_input = self.apply_search_prompt(query);
1033 let query_vector = match tokio::time::timeout(
1034 self.embed_timeout,
1035 self.effective_embed_provider().embed(&embed_input),
1036 )
1037 .await
1038 {
1039 Ok(Ok(v)) => v,
1040 Ok(Err(e)) => return Err(e.into()),
1041 Err(_) => {
1042 tracing::warn!("recall_semantic: embed timed out, returning empty results");
1043 return Ok(Vec::new());
1044 }
1045 };
1046 let query_vector = self.apply_query_bias(query, query_vector).await;
1047 qdrant.ensure_collection_for_vector(&query_vector).await?;
1048 qdrant
1049 .search(&query_vector, self.effective_depth(limit), filter)
1050 .await?
1051 } else {
1052 Vec::new()
1053 };
1054
1055 let results = self
1056 .recall_merge_and_rank(keyword_results, vector_results, limit, None)
1057 .await?;
1058 #[cfg(feature = "profiling")]
1059 {
1060 let span = tracing::Span::current();
1061 span.record("result_count", results.len());
1062 if let Some(top) = results.first() {
1063 span.record("top_score", top.score);
1064 }
1065 }
1066 Ok(results)
1067 }
1068
1069 #[cfg_attr(
1070 feature = "profiling",
1071 tracing::instrument(name = "memory.recall.fts5", skip_all, fields(query_len = %query.len()))
1072 )]
1073 pub(super) async fn recall_fts5_raw(
1074 &self,
1075 query: &str,
1076 limit: usize,
1077 conversation_id: Option<ConversationId>,
1078 ) -> Result<Vec<(MessageId, f64)>, MemoryError> {
1079 self.sqlite
1080 .keyword_search(query, self.effective_depth(limit), conversation_id)
1081 .await
1082 }
1083
1084 #[cfg_attr(
1085 feature = "profiling",
1086 tracing::instrument(name = "memory.recall.vectors", skip_all, fields(query_len = %query.len()))
1087 )]
1088 pub(super) async fn recall_vectors_raw(
1089 &self,
1090 query: &str,
1091 limit: usize,
1092 filter: Option<SearchFilter>,
1093 ) -> Result<Vec<crate::embedding_store::SearchResult>, MemoryError> {
1094 let Some(qdrant) = &self.qdrant else {
1095 return Ok(Vec::new());
1096 };
1097 if !self.effective_embed_provider().supports_embeddings() {
1098 return Ok(Vec::new());
1099 }
1100 let embed_input = self.apply_search_prompt(query);
1101 let query_vector = match tokio::time::timeout(
1102 self.embed_timeout,
1103 self.effective_embed_provider().embed(&embed_input),
1104 )
1105 .await
1106 {
1107 Ok(Ok(v)) => v,
1108 Ok(Err(e)) => return Err(e.into()),
1109 Err(_) => {
1110 tracing::warn!("recall_vectors_raw: embed timed out, returning empty results");
1111 return Ok(Vec::new());
1112 }
1113 };
1114 let query_vector = self.apply_query_bias(query, query_vector).await;
1115 qdrant.ensure_collection_for_vector(&query_vector).await?;
1116 qdrant
1117 .search(&query_vector, self.effective_depth(limit), filter)
1118 .await
1119 }
1120
1121 #[cfg_attr(
1130 feature = "profiling",
1131 tracing::instrument(name = "memory.recall.merge_and_rank", skip_all, fields(kw_count = keyword_results.len(), vec_count = vector_results.len()))
1132 )]
1133 #[allow(clippy::cast_possible_truncation, clippy::too_many_lines)]
1134 pub(super) async fn recall_merge_and_rank(
1135 &self,
1136 keyword_results: Vec<(MessageId, f64)>,
1137 vector_results: Vec<crate::embedding_store::SearchResult>,
1138 limit: usize,
1139 goal_entity_id: Option<i64>,
1140 ) -> Result<Vec<RecalledMessage>, MemoryError> {
1141 tracing::debug!(
1142 vector_count = vector_results.len(),
1143 keyword_count = keyword_results.len(),
1144 limit,
1145 "recall: merging search results"
1146 );
1147
1148 let mut scores: std::collections::HashMap<MessageId, f64> =
1149 std::collections::HashMap::new();
1150
1151 if !vector_results.is_empty() {
1152 let max_vs = vector_results
1153 .iter()
1154 .map(|r| r.score)
1155 .fold(f32::NEG_INFINITY, f32::max);
1156 let norm = if max_vs > 0.0 { max_vs } else { 1.0 };
1157 for r in &vector_results {
1158 let normalized = f64::from(r.score / norm);
1159 *scores.entry(r.message_id).or_default() += normalized * self.vector_weight;
1160 }
1161 }
1162
1163 if !keyword_results.is_empty() {
1164 let max_ks = keyword_results
1165 .iter()
1166 .map(|r| r.1)
1167 .fold(f64::NEG_INFINITY, f64::max);
1168 let norm = if max_ks > 0.0 { max_ks } else { 1.0 };
1169 for &(msg_id, score) in &keyword_results {
1170 let normalized = score / norm;
1171 *scores.entry(msg_id).or_default() += normalized * self.keyword_weight;
1172 }
1173 }
1174
1175 if scores.is_empty() {
1176 tracing::debug!("recall: empty merge, no overlapping scores");
1177 return Ok(Vec::new());
1178 }
1179
1180 let mut ranked: Vec<(MessageId, f64)> = scores.into_iter().collect();
1181 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1182
1183 tracing::debug!(
1184 merged = ranked.len(),
1185 top_score = ranked.first().map(|r| r.1),
1186 bottom_score = ranked.last().map(|r| r.1),
1187 vector_weight = %self.vector_weight,
1188 keyword_weight = %self.keyword_weight,
1189 "recall: weighted merge complete"
1190 );
1191
1192 if self.temporal_decay.is_enabled() && self.temporal_decay_half_life_days > 0 {
1193 let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1194 match self.sqlite.message_timestamps(&ids).await {
1195 Ok(timestamps) => {
1196 apply_temporal_decay(
1197 &mut ranked,
1198 ×tamps,
1199 self.temporal_decay_half_life_days,
1200 );
1201 ranked
1202 .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1203 tracing::debug!(
1204 half_life_days = self.temporal_decay_half_life_days,
1205 top_score_after = ranked.first().map(|r| r.1),
1206 "recall: temporal decay applied"
1207 );
1208 }
1209 Err(e) => {
1210 tracing::warn!("temporal decay: failed to fetch timestamps: {e:#}");
1211 }
1212 }
1213 }
1214
1215 if self.mmr_reranking.is_enabled() && !vector_results.is_empty() {
1216 if let Some(qdrant) = &self.qdrant {
1217 let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1218 match qdrant.get_vectors(&ids).await {
1219 Ok(vec_map) if !vec_map.is_empty() => {
1220 let ranked_len_before = ranked.len();
1221 ranked = apply_mmr(&ranked, &vec_map, self.mmr_lambda, limit);
1222 tracing::debug!(
1223 before = ranked_len_before,
1224 after = ranked.len(),
1225 lambda = %self.mmr_lambda,
1226 "recall: mmr re-ranked"
1227 );
1228 }
1229 Ok(_) => {
1230 ranked.truncate(limit);
1231 }
1232 Err(e) => {
1233 tracing::warn!("MMR: failed to fetch vectors: {e:#}");
1234 ranked.truncate(limit);
1235 }
1236 }
1237 } else {
1238 ranked.truncate(limit);
1239 }
1240 } else {
1241 ranked.truncate(limit);
1242 }
1243
1244 if self.importance_scoring.is_enabled() && !ranked.is_empty() {
1245 let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1246 match self.sqlite.fetch_importance_scores(&ids).await {
1247 Ok(scores) => {
1248 for (msg_id, score) in &mut ranked {
1249 if let Some(&imp) = scores.get(msg_id) {
1250 *score += imp * self.importance_weight;
1251 }
1252 }
1253 ranked
1254 .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1255 tracing::debug!(
1256 importance_weight = %self.importance_weight,
1257 "recall: importance scores blended"
1258 );
1259 }
1260 Err(e) => {
1261 tracing::warn!("importance scoring: failed to fetch scores: {e:#}");
1262 }
1263 }
1264 }
1265
1266 if (self.tier_boost_semantic - 1.0).abs() > f64::EPSILON && !ranked.is_empty() {
1270 let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1271 match self.sqlite.fetch_tiers(&ids).await {
1272 Ok(tiers) => {
1273 let bonus = self.tier_boost_semantic - 1.0;
1274 let mut boosted = false;
1275 for (msg_id, score) in &mut ranked {
1276 if tiers.get(msg_id).map(String::as_str) == Some("semantic") {
1277 *score += bonus;
1278 boosted = true;
1279 }
1280 }
1281 if boosted {
1282 ranked.sort_by(|a, b| {
1283 b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
1284 });
1285 tracing::debug!(
1286 tier_boost = %self.tier_boost_semantic,
1287 "recall: semantic tier boost applied"
1288 );
1289 }
1290 }
1291 Err(e) => {
1292 tracing::warn!("tier boost: failed to fetch tiers: {e:#}");
1293 }
1294 }
1295 }
1296
1297 if let Some(fs) = &self.five_signal
1299 && !fs.weights.is_baseline()
1300 {
1301 self.apply_five_signal_scoring(&mut ranked, fs, goal_entity_id)
1302 .await;
1303 }
1304
1305 let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1306
1307 if let Some(fs) = &self.five_signal {
1309 for id in &ids {
1310 fs.access_cache
1311 .log_access(*id, "message", &fs.session_id)
1312 .await;
1313 }
1314 fs.metrics.inc_recall();
1315 }
1316
1317 if !ids.is_empty()
1318 && let Err(e) = self.batch_increment_access_count(ids.clone()).await
1319 {
1320 tracing::warn!("recall: failed to increment access counts: {e:#}");
1321 }
1322
1323 if let Err(e) = self.sqlite.mark_training_recalled(&ids).await {
1325 tracing::debug!(
1326 error = %e,
1327 "recall: failed to mark training data as recalled (non-fatal)"
1328 );
1329 }
1330
1331 let messages = self.sqlite.messages_by_ids(&ids).await?;
1332 let msg_map: std::collections::HashMap<MessageId, _> = messages.into_iter().collect();
1333
1334 let recalled: Vec<RecalledMessage> = ranked
1335 .iter()
1336 .filter_map(|(msg_id, score)| {
1337 msg_map.get(msg_id).map(|msg| RecalledMessage {
1338 message: msg.clone(),
1339 #[expect(clippy::cast_possible_truncation)]
1340 score: *score as f32,
1341 })
1342 })
1343 .collect();
1344
1345 tracing::debug!(final_count = recalled.len(), "recall: final results");
1346
1347 Ok(recalled)
1348 }
1349
1350 async fn apply_five_signal_scoring(
1356 &self,
1357 ranked: &mut [(MessageId, f64)],
1358 fs: &crate::five_signal::FiveSignalRuntime,
1359 goal_entity_id: Option<i64>,
1360 ) {
1361 use crate::five_signal::causal_distance::CausalDistanceComputer;
1362 use crate::five_signal::scoring::{CandidateSignals, apply_five_signal_scoring};
1363 use sqlx::Row as _;
1364
1365 let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
1366
1367 let freq_map = match fs
1369 .access_cache
1370 .load_for_candidates(&fs.session_id, &ids)
1371 .await
1372 {
1373 Ok(m) => m,
1374 Err(e) => {
1375 tracing::warn!(error = %e, "five_signal: failed to load access frequencies (skipping)");
1376 return;
1377 }
1378 };
1379
1380 let created_at_map: std::collections::HashMap<MessageId, i64> = {
1382 let id_vals: Vec<i64> = ids.iter().map(|id| id.0).collect();
1383 let placeholders = zeph_db::placeholder_list(1, id_vals.len());
1384 let created_at_epoch =
1385 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::epoch_from_col("created_at");
1386 let sql = format!(
1387 "SELECT id, {created_at_epoch} AS created_at FROM messages \
1388 WHERE id IN ({placeholders}) AND deleted_at IS NULL"
1389 );
1390 let mut q = sqlx::query(sqlx::AssertSqlSafe(sql));
1391 for id in &id_vals {
1392 q = q.bind(id);
1393 }
1394 match q.fetch_all(&fs.pool).await {
1395 Ok(rows) => rows
1396 .iter()
1397 .map(|row| {
1398 (
1399 MessageId(row.get::<i64, _>("id")),
1400 row.get::<i64, _>("created_at"),
1401 )
1402 })
1403 .collect(),
1404 Err(e) => {
1405 tracing::warn!(error = %e, "five_signal: failed to fetch created_at (skipping novelty)");
1406 std::collections::HashMap::new()
1407 }
1408 }
1409 };
1410
1411 let causal_distance_map: std::collections::HashMap<i64, u32> = {
1415 let entity_ids: Vec<i64> = ids.iter().map(|id| id.0).collect();
1416 match fs
1417 .causal_computer
1418 .compute(goal_entity_id, &entity_ids)
1419 .await
1420 {
1421 Ok(m) => m,
1422 Err(e) => {
1423 tracing::warn!(error = %e, "five_signal: causal BFS failed (using neutral)");
1424 std::collections::HashMap::new()
1425 }
1426 }
1427 };
1428 let neutral_causal_score =
1429 CausalDistanceComputer::distance_to_score(fs.config.neutral_causal_distance);
1430
1431 let mut signals_map = std::collections::HashMap::with_capacity(ids.len());
1432 for &(msg_id, base_score) in ranked.iter() {
1433 let frequency = freq_map.get(&msg_id).copied().unwrap_or(0.0);
1434 let half = base_score / 2.0;
1437 let fact_created_at = created_at_map
1438 .get(&msg_id)
1439 .copied()
1440 .unwrap_or(fs.session_start);
1441 let novelty = fs.novelty_computer.compute(fact_created_at);
1442 let causal = causal_distance_map
1443 .get(&msg_id.0)
1444 .map_or(neutral_causal_score, |&d| {
1445 CausalDistanceComputer::distance_to_score(d)
1446 });
1447 signals_map.insert(
1448 msg_id,
1449 CandidateSignals {
1450 recency: half,
1451 relevance: half,
1452 frequency,
1453 causal,
1454 novelty,
1455 },
1456 );
1457 }
1458
1459 apply_five_signal_scoring(ranked, &fs.weights, &signals_map);
1460
1461 tracing::debug!(
1462 candidate_count = ids.len(),
1463 "recall: five-signal scoring applied"
1464 );
1465 }
1466
1467 async fn recall_by_route(
1473 &self,
1474 route: crate::router::MemoryRoute,
1475 query: &str,
1476 limit: usize,
1477 filter: Option<crate::embedding_store::SearchFilter>,
1478 ) -> Result<
1479 (
1480 Vec<(crate::types::MessageId, f64)>,
1481 Vec<crate::embedding_store::SearchResult>,
1482 ),
1483 MemoryError,
1484 > {
1485 use crate::router::MemoryRoute;
1486
1487 let conversation_id = filter.as_ref().and_then(|f| f.conversation_id);
1488
1489 let results: (
1490 Vec<(crate::types::MessageId, f64)>,
1491 Vec<crate::embedding_store::SearchResult>,
1492 ) = match route {
1493 MemoryRoute::Keyword => {
1494 let kw = self.recall_fts5_raw(query, limit, conversation_id).await?;
1495 (kw, Vec::new())
1496 }
1497 MemoryRoute::Hybrid => {
1498 let kw = match self.recall_fts5_raw(query, limit, conversation_id).await {
1499 Ok(r) => r,
1500 Err(e) => {
1501 tracing::warn!("FTS5 keyword search failed: {e:#}");
1502 Vec::new()
1503 }
1504 };
1505 let vr = self.recall_vectors_raw(query, limit, filter).await?;
1506 (kw, vr)
1507 }
1508 MemoryRoute::Episodic => {
1517 let range = crate::router::resolve_temporal_range(query, chrono::Utc::now());
1518 let cleaned = crate::router::strip_temporal_keywords(query);
1519 let search_query = if cleaned.is_empty() { query } else { &cleaned };
1520 let kw = if let Some(ref r) = range {
1521 self.sqlite
1522 .keyword_search_with_time_range(
1523 search_query,
1524 limit,
1525 conversation_id,
1526 r.after.as_deref(),
1527 r.before.as_deref(),
1528 )
1529 .await?
1530 } else {
1531 self.recall_fts5_raw(search_query, limit, conversation_id)
1532 .await?
1533 };
1534 tracing::debug!(
1535 has_range = range.is_some(),
1536 cleaned_query = %search_query,
1537 keyword_count = kw.len(),
1538 "recall: episodic path"
1539 );
1540 (kw, Vec::new())
1541 }
1542 MemoryRoute::Graph => {
1545 let kw = match self.recall_fts5_raw(query, limit, conversation_id).await {
1546 Ok(r) => r,
1547 Err(e) => {
1548 tracing::warn!("FTS5 keyword search failed (graph→hybrid fallback): {e:#}");
1549 Vec::new()
1550 }
1551 };
1552 let vr = self.recall_vectors_raw(query, limit, filter).await?;
1553 (kw, vr)
1554 }
1555 _ => {
1556 let vr = self.recall_vectors_raw(query, limit, filter).await?;
1557 (Vec::new(), vr)
1558 }
1559 };
1560 Ok(results)
1561 }
1562
1563 #[cfg_attr(
1575 feature = "profiling",
1576 tracing::instrument(name = "memory.recall", skip_all, fields(query_len = %query.len(), result_count = tracing::field::Empty))
1577 )]
1578 pub async fn recall_routed(
1579 &self,
1580 query: &str,
1581 limit: usize,
1582 filter: Option<SearchFilter>,
1583 router: &dyn crate::router::MemoryRouter,
1584 goal_entity_id: Option<i64>,
1585 ) -> Result<Vec<RecalledMessage>, MemoryError> {
1586 let route = router.route(query);
1587 tracing::debug!(?route, query_len = query.len(), "memory routing decision");
1588
1589 let (keyword_results, vector_results) =
1590 self.recall_by_route(route, query, limit, filter).await?;
1591
1592 tracing::debug!(
1593 keyword_count = keyword_results.len(),
1594 vector_count = vector_results.len(),
1595 "recall: routed search results"
1596 );
1597
1598 self.recall_merge_and_rank(keyword_results, vector_results, limit, goal_entity_id)
1599 .await
1600 }
1601
1602 #[cfg_attr(
1616 feature = "profiling",
1617 tracing::instrument(name = "memory.recall", skip_all, fields(query_len = %query.len(), result_count = tracing::field::Empty))
1618 )]
1619 pub async fn recall_routed_async(
1620 &self,
1621 query: &str,
1622 limit: usize,
1623 filter: Option<crate::embedding_store::SearchFilter>,
1624 router: &dyn crate::router::AsyncMemoryRouter,
1625 goal_entity_id: Option<i64>,
1626 ) -> Result<Vec<RecalledMessage>, MemoryError> {
1627 let decision = router.route_async(query).await;
1628 let route = decision.route;
1629 tracing::debug!(
1630 ?route,
1631 confidence = decision.confidence,
1632 query_len = query.len(),
1633 "memory routing decision (async)"
1634 );
1635
1636 let (keyword_results, vector_results) =
1637 self.recall_by_route(route, query, limit, filter).await?;
1638
1639 tracing::debug!(
1640 keyword_count = keyword_results.len(),
1641 vector_count = vector_results.len(),
1642 "recall: routed search results (async)"
1643 );
1644
1645 self.recall_merge_and_rank(keyword_results, vector_results, limit, goal_entity_id)
1646 .await
1647 }
1648
1649 #[cfg_attr(
1663 feature = "profiling",
1664 tracing::instrument(name = "memory.recall_graph", skip_all, fields(result_count = tracing::field::Empty))
1665 )]
1666 pub async fn recall_graph(
1667 &self,
1668 query: &str,
1669 limit: usize,
1670 max_hops: u32,
1671 at_timestamp: Option<&str>,
1672 temporal_decay_rate: f64,
1673 edge_types: &[crate::graph::EdgeType],
1674 ) -> Result<Vec<crate::graph::types::GraphFact>, MemoryError> {
1675 let Some(store) = &self.graph_store else {
1676 return Ok(Vec::new());
1677 };
1678
1679 tracing::debug!(
1680 query_len = query.len(),
1681 limit,
1682 max_hops,
1683 "graph: starting recall"
1684 );
1685
1686 let results = crate::graph::retrieval::graph_recall(
1687 store,
1688 self.qdrant.as_deref(),
1689 &self.provider,
1690 query,
1691 limit,
1692 max_hops,
1693 at_timestamp,
1694 temporal_decay_rate,
1695 edge_types,
1696 self.hebbian_reinforcement.is_enabled(),
1697 self.hebbian_lr,
1698 self.embed_timeout,
1699 )
1700 .await?;
1701
1702 tracing::debug!(result_count = results.len(), "graph: recall complete");
1703 #[cfg(feature = "profiling")]
1704 tracing::Span::current().record("result_count", results.len());
1705
1706 Ok(results)
1707 }
1708
1709 #[cfg_attr(
1718 feature = "profiling",
1719 tracing::instrument(name = "memory.recall_graph", skip_all, fields(result_count = tracing::field::Empty))
1720 )]
1721 pub async fn recall_graph_activated(
1722 &self,
1723 query: &str,
1724 limit: usize,
1725 params: crate::graph::SpreadingActivationParams,
1726 edge_types: &[crate::graph::EdgeType],
1727 ) -> Result<Vec<crate::graph::activation::ActivatedFact>, MemoryError> {
1728 let Some(store) = &self.graph_store else {
1729 return Ok(Vec::new());
1730 };
1731
1732 tracing::debug!(
1733 query_len = query.len(),
1734 limit,
1735 "spreading activation: starting graph recall"
1736 );
1737
1738 let embeddings = self.qdrant.as_deref();
1739 let results = crate::graph::retrieval::graph_recall_activated(
1740 store,
1741 embeddings,
1742 &self.provider,
1743 query,
1744 limit,
1745 params,
1746 edge_types,
1747 self.hebbian_reinforcement.is_enabled(),
1748 self.hebbian_lr,
1749 self.embed_timeout,
1750 )
1751 .await?;
1752
1753 tracing::debug!(
1754 result_count = results.len(),
1755 "spreading activation: graph recall complete"
1756 );
1757
1758 Ok(results)
1759 }
1760
1761 #[allow(clippy::too_many_arguments, clippy::too_many_lines)] #[cfg_attr(
1794 feature = "profiling",
1795 tracing::instrument(
1796 name = "memory.recall.graph_view",
1797 skip_all,
1798 fields(view = ?view, result_count = tracing::field::Empty)
1799 )
1800 )]
1801 pub async fn recall_graph_view(
1802 &self,
1803 query: &str,
1804 limit: usize,
1805 view: crate::recall_view::RecallView,
1806 neighbor_cap: usize,
1807 bfs_max_hops: u32,
1808 temporal_decay_rate: f64,
1809 edge_types: &[crate::graph::EdgeType],
1810 sa_params: Option<crate::graph::SpreadingActivationParams>,
1811 ) -> Result<Vec<crate::recall_view::RecalledFact>, MemoryError> {
1812 use crate::recall_view::RecalledFact;
1813
1814 let recalled: Vec<RecalledFact> = if let Some(params) = sa_params {
1816 let activated = self
1817 .recall_graph_activated(query, limit, params, edge_types)
1818 .await?;
1819 activated
1820 .into_iter()
1821 .map(RecalledFact::from_activated_fact)
1822 .collect()
1823 } else {
1824 let facts = self
1825 .recall_graph(
1826 query,
1827 limit,
1828 bfs_max_hops,
1829 None,
1830 temporal_decay_rate,
1831 edge_types,
1832 )
1833 .await?;
1834 facts
1835 .into_iter()
1836 .map(RecalledFact::from_graph_fact)
1837 .collect()
1838 };
1839
1840 let enriched = self
1841 .enrich_recall_view(recalled, view, neighbor_cap, limit, edge_types)
1842 .await?;
1843
1844 #[cfg(feature = "profiling")]
1845 tracing::Span::current().record("result_count", enriched.len());
1846 Ok(enriched)
1847 }
1848
1849 #[allow(clippy::too_many_lines)] pub async fn enrich_recall_view(
1883 &self,
1884 mut recalled: Vec<crate::recall_view::RecalledFact>,
1885 view: crate::recall_view::RecallView,
1886 neighbor_cap: usize,
1887 limit: usize,
1888 edge_types: &[crate::graph::EdgeType],
1889 ) -> Result<Vec<crate::recall_view::RecalledFact>, MemoryError> {
1890 use crate::recall_view::RecallView;
1891
1892 if view == RecallView::Head {
1894 return Ok(recalled);
1895 }
1896
1897 if matches!(view, RecallView::ZoomIn | RecallView::ZoomOut) {
1899 let edge_ids: Vec<i64> = recalled.iter().filter_map(|r| r.fact.edge_id).collect();
1900
1901 if !edge_ids.is_empty()
1902 && let Some(ref store) = self.graph_store
1903 {
1904 const MAX_IDS: usize = 490;
1906 let mut edge_to_msg: std::collections::HashMap<i64, MessageId> =
1907 std::collections::HashMap::new();
1908 for chunk in edge_ids.chunks(MAX_IDS) {
1909 match store.source_message_ids_for_edges(chunk).await {
1910 Ok(pairs) => {
1911 for (eid, mid) in pairs {
1912 edge_to_msg.insert(eid, mid);
1913 }
1914 }
1915 Err(e) => {
1916 tracing::warn!(error = %e, "enrich_recall_view: provenance fetch failed");
1917 }
1918 }
1919 }
1920
1921 for rf in &mut recalled {
1923 if rf.provenance_message_id.is_none()
1924 && let Some(eid) = rf.fact.edge_id
1925 {
1926 rf.provenance_message_id = edge_to_msg.get(&eid).copied();
1927 }
1928 }
1929
1930 let msg_ids: Vec<MessageId> = recalled
1932 .iter()
1933 .filter_map(|r| r.provenance_message_id)
1934 .collect::<std::collections::HashSet<_>>()
1935 .into_iter()
1936 .collect();
1937
1938 if !msg_ids.is_empty() {
1939 match self.sqlite.messages_by_ids(&msg_ids).await {
1940 Ok(messages) => {
1941 let mut mid_to_snippet: std::collections::HashMap<MessageId, String> =
1942 messages
1943 .into_iter()
1944 .map(|(id, msg)| {
1945 let raw = &msg.content;
1946 let scrubbed: String = raw
1947 .chars()
1948 .map(|c| match c {
1949 '\n' | '\r' | '<' | '>' => ' ',
1950 other => other,
1951 })
1952 .take(200)
1953 .collect();
1954 (id, scrubbed)
1955 })
1956 .collect();
1957 for rf in &mut recalled {
1958 if let Some(mid) = rf.provenance_message_id {
1959 rf.provenance_snippet = mid_to_snippet.remove(&mid);
1960 }
1961 }
1962 }
1963 Err(e) => {
1964 tracing::warn!(error = %e, "enrich_recall_view: message snippet fetch failed");
1965 }
1966 }
1967 }
1968 }
1969 }
1970
1971 if view == RecallView::ZoomOut
1973 && let Some(ref store) = self.graph_store
1974 {
1975 type DedupeKey = (String, String, String, crate::graph::EdgeType);
1979 let make_key = |f: &crate::graph::types::GraphFact| -> DedupeKey {
1980 if f.entity_name.is_empty() || f.target_name.is_empty() {
1981 (
1982 f.fact.clone(),
1983 f.relation.clone(),
1984 String::new(),
1985 f.edge_type,
1986 )
1987 } else {
1988 (
1989 f.entity_name.clone(),
1990 f.relation.clone(),
1991 f.target_name.clone(),
1992 f.edge_type,
1993 )
1994 }
1995 };
1996 let mut seen: std::collections::HashSet<DedupeKey> =
1997 recalled.iter().map(|r| make_key(&r.fact)).collect();
1998
1999 let total_neighbor_cap = limit * neighbor_cap;
2000 let mut total_neighbors = 0usize;
2001
2002 for rf in &mut recalled {
2003 if total_neighbors >= total_neighbor_cap {
2004 break;
2005 }
2006 let source_entity_id = match rf.fact.edge_id {
2009 Some(eid) => match store.source_entity_id_for_edge(eid).await {
2010 Ok(Some(id)) => id,
2011 _ => continue,
2012 },
2013 None => continue,
2014 };
2015
2016 let neighbors = match store
2017 .bfs_edges_at_depth(source_entity_id, 1, edge_types)
2018 .await
2019 {
2020 Ok(edges) => edges,
2021 Err(e) => {
2022 tracing::warn!(error = %e, "enrich_recall_view: zoom_out bfs failed");
2023 continue;
2024 }
2025 };
2026
2027 let mut added = 0usize;
2028 for n_edge in neighbors {
2029 if added >= neighbor_cap || total_neighbors >= total_neighbor_cap {
2030 break;
2031 }
2032 let key = make_key(&n_edge.fact);
2033 if seen.insert(key) {
2034 rf.neighbors.push(n_edge.fact);
2035 added += 1;
2036 total_neighbors += 1;
2037 }
2038 }
2039 }
2040 }
2041
2042 Ok(recalled)
2043 }
2044
2045 pub async fn recall_graph_astar(
2053 &self,
2054 query: &str,
2055 limit: usize,
2056 max_hops: u32,
2057 temporal_decay_rate: f64,
2058 edge_types: &[crate::graph::EdgeType],
2059 ) -> Result<Vec<crate::graph::types::GraphFact>, MemoryError> {
2060 let Some(store) = &self.graph_store else {
2061 return Ok(Vec::new());
2062 };
2063 crate::graph::retrieval_astar::graph_recall_astar(
2064 store,
2065 self.qdrant.as_deref(),
2066 &self.provider,
2067 query,
2068 limit,
2069 max_hops,
2070 edge_types,
2071 temporal_decay_rate,
2072 self.hebbian_reinforcement.is_enabled(),
2073 self.hebbian_lr,
2074 self.query_sensitive_cost,
2075 self.embed_timeout,
2076 )
2077 .await
2078 }
2079
2080 pub async fn recall_graph_watercircles(
2088 &self,
2089 query: &str,
2090 limit: usize,
2091 max_hops: u32,
2092 ring_limit: usize,
2093 temporal_decay_rate: f64,
2094 edge_types: &[crate::graph::EdgeType],
2095 ) -> Result<Vec<crate::graph::types::GraphFact>, MemoryError> {
2096 let Some(store) = &self.graph_store else {
2097 return Ok(Vec::new());
2098 };
2099 crate::graph::retrieval_watercircles::graph_recall_watercircles(
2100 store,
2101 self.qdrant.as_deref(),
2102 &self.provider,
2103 query,
2104 limit,
2105 max_hops,
2106 ring_limit,
2107 edge_types,
2108 temporal_decay_rate,
2109 self.hebbian_reinforcement.is_enabled(),
2110 self.hebbian_lr,
2111 self.embed_timeout,
2112 )
2113 .await
2114 }
2115
2116 pub async fn recall_graph_beam(
2124 &self,
2125 query: &str,
2126 limit: usize,
2127 beam_width: usize,
2128 max_hops: u32,
2129 temporal_decay_rate: f64,
2130 edge_types: &[crate::graph::EdgeType],
2131 ) -> Result<Vec<crate::graph::types::GraphFact>, MemoryError> {
2132 let Some(store) = &self.graph_store else {
2133 return Ok(Vec::new());
2134 };
2135 crate::graph::retrieval_beam::graph_recall_beam(
2136 store,
2137 self.qdrant.as_deref(),
2138 &self.provider,
2139 query,
2140 limit,
2141 beam_width,
2142 max_hops,
2143 edge_types,
2144 temporal_decay_rate,
2145 self.hebbian_reinforcement.is_enabled(),
2146 self.hebbian_lr,
2147 self.embed_timeout,
2148 )
2149 .await
2150 }
2151
2152 pub async fn classify_graph_strategy(&self, query: &str) -> String {
2157 crate::graph::strategy_classifier::classify_retrieval_strategy(&self.provider, query).await
2158 }
2159
2160 #[cfg_attr(
2174 feature = "profiling",
2175 tracing::instrument(
2176 name = "memory.recall_graph_hela",
2177 skip_all,
2178 fields(result_count = tracing::field::Empty)
2179 )
2180 )]
2181 pub async fn recall_graph_hela(
2182 &self,
2183 query: &str,
2184 limit: usize,
2185 params: crate::graph::HelaSpreadParams,
2186 ) -> Result<Vec<crate::graph::HelaFact>, MemoryError> {
2187 let Some(store) = &self.graph_store else {
2188 return Ok(Vec::new());
2189 };
2190 let Some(embeddings) = &self.qdrant else {
2191 return Ok(Vec::new());
2192 };
2193
2194 let store = Arc::clone(store);
2195 let embeddings = Arc::clone(embeddings);
2196 let provider = self.provider.clone();
2197 let hebbian_enabled = self.hebbian_reinforcement.is_enabled();
2198 let hebbian_lr = self.hebbian_lr;
2199
2200 let outer_timeout = hela_outer_timeout(¶ms);
2205
2206 let results = tokio::time::timeout(
2207 outer_timeout,
2208 crate::graph::hela_spreading_recall(
2209 &store,
2210 &embeddings,
2211 &provider,
2212 query,
2213 limit,
2214 ¶ms,
2215 hebbian_enabled,
2216 hebbian_lr,
2217 ),
2218 )
2219 .await
2220 .unwrap_or_else(|_| {
2221 tracing::warn!(
2222 outer_timeout_ms = outer_timeout.as_millis(),
2223 "memory.recall_graph_hela: outer timeout exceeded"
2224 );
2225 Ok(Vec::new())
2226 })?;
2227
2228 #[cfg(feature = "profiling")]
2229 tracing::Span::current().record("result_count", results.len());
2230
2231 Ok(results)
2232 }
2233
2234 async fn batch_increment_access_count(
2242 &self,
2243 message_ids: Vec<MessageId>,
2244 ) -> Result<(), MemoryError> {
2245 if message_ids.is_empty() {
2246 return Ok(());
2247 }
2248 self.sqlite.increment_access_counts(&message_ids).await
2249 }
2250
2251 pub async fn has_embedding(&self, message_id: MessageId) -> Result<bool, MemoryError> {
2257 match &self.qdrant {
2258 Some(qdrant) => qdrant.has_embedding(message_id).await,
2259 None => Ok(false),
2260 }
2261 }
2262
2263 pub async fn embed_missing(
2279 &self,
2280 progress_tx: Option<tokio::sync::watch::Sender<Option<super::BackfillProgress>>>,
2281 ) -> Result<usize, MemoryError> {
2282 if self.qdrant.is_none() || !self.effective_embed_provider().supports_embeddings() {
2283 return Ok(0);
2284 }
2285
2286 let total = self.sqlite.count_unembedded_messages().await?;
2287 if total == 0 {
2288 return Ok(0);
2289 }
2290
2291 if let Some(tx) = &progress_tx {
2292 let _ = tx.send(Some(super::BackfillProgress { done: 0, total }));
2293 }
2294
2295 let mut done = 0usize;
2296 let mut succeeded = 0usize;
2297
2298 loop {
2299 const BATCH_SIZE: usize = 32;
2300 const BATCH_SIZE_I64: i64 = 32;
2301 let rows: Vec<_> = self
2302 .sqlite
2303 .stream_unembedded_messages(BATCH_SIZE_I64)
2304 .try_collect()
2305 .await?;
2306
2307 if rows.is_empty() {
2308 break;
2309 }
2310
2311 let batch_len = rows.len();
2312
2313 let results: Vec<bool> = futures::stream::iter(rows)
2314 .map(|(msg_id, conv_id, role, content)| async move {
2315 self.embed_and_store_regular(msg_id, conv_id, &role, &content, None)
2319 })
2320 .buffer_unordered(4)
2321 .collect()
2322 .await;
2323
2324 for ok in &results {
2325 done += 1;
2326 if *ok {
2327 succeeded += 1;
2328 }
2329 if let Some(tx) = &progress_tx {
2330 let _ = tx.send(Some(super::BackfillProgress { done, total }));
2331 }
2332 }
2333
2334 let batch_succeeded = results.iter().filter(|&&b| b).count();
2335 if batch_succeeded > 0 {
2336 tracing::debug!("Backfill batch: {batch_succeeded}/{batch_len} embedded");
2337 }
2338
2339 if batch_len < BATCH_SIZE {
2340 break;
2341 }
2342 }
2343
2344 if let Some(tx) = &progress_tx {
2345 let _ = tx.send(None);
2346 }
2347
2348 if done > 0 {
2349 tracing::info!("Embedded {succeeded}/{total} missing messages");
2350 }
2351 Ok(succeeded)
2352 }
2353}
2354
2355#[cfg(test)]
2356mod tests {
2357 use super::*;
2358
2359 #[test]
2364 fn hela_outer_timeout_scales_with_spread_depth() {
2365 let step_budget = std::time::Duration::from_millis(80);
2366 let embed_timeout = std::time::Duration::from_secs(5);
2367 let margin = std::time::Duration::from_millis(250);
2368
2369 for depth in 1..=6u32 {
2370 let params = crate::graph::HelaSpreadParams {
2371 spread_depth: depth,
2372 step_budget: Some(step_budget),
2373 embed_timeout: Some(embed_timeout),
2374 ..Default::default()
2375 };
2376 let expected = embed_timeout + step_budget * (depth + 2) + margin;
2377 assert_eq!(
2378 hela_outer_timeout(¶ms),
2379 expected,
2380 "outer timeout must scale with spread_depth={depth}"
2381 );
2382 }
2383 }
2384
2385 #[test]
2389 fn hela_outer_timeout_clamps_spread_depth_above_six() {
2390 let step_budget = std::time::Duration::from_millis(80);
2391 let embed_timeout = std::time::Duration::from_secs(5);
2392 let params_over = crate::graph::HelaSpreadParams {
2393 spread_depth: 50,
2394 step_budget: Some(step_budget),
2395 embed_timeout: Some(embed_timeout),
2396 ..Default::default()
2397 };
2398 let params_clamped = crate::graph::HelaSpreadParams {
2399 spread_depth: 6,
2400 step_budget: Some(step_budget),
2401 embed_timeout: Some(embed_timeout),
2402 ..Default::default()
2403 };
2404 assert_eq!(
2405 hela_outer_timeout(¶ms_over),
2406 hela_outer_timeout(¶ms_clamped),
2407 "spread_depth above 6 must clamp identically to the algorithm's own [1, 6] bound"
2408 );
2409 }
2410
2411 #[test]
2415 fn hela_outer_timeout_falls_back_when_params_disabled() {
2416 let params = crate::graph::HelaSpreadParams {
2417 spread_depth: 2,
2418 step_budget: None,
2419 embed_timeout: None,
2420 ..Default::default()
2421 };
2422 let expected = std::time::Duration::from_secs(5)
2423 + std::time::Duration::from_millis(80) * 4
2424 + std::time::Duration::from_millis(250);
2425 assert_eq!(hela_outer_timeout(¶ms), expected);
2426 }
2427
2428 #[test]
2429 fn embed_context_default_all_none() {
2430 let ctx = EmbedContext::default();
2431 assert!(ctx.tool_name.is_none());
2432 assert!(ctx.exit_code.is_none());
2433 assert!(ctx.timestamp.is_none());
2434 }
2435
2436 #[test]
2437 fn embed_context_fields_set_correctly() {
2438 let ctx = EmbedContext {
2439 tool_name: Some("shell".to_string()),
2440 exit_code: Some(0),
2441 timestamp: Some("2026-04-04T00:00:00Z".to_string()),
2442 };
2443 assert_eq!(ctx.tool_name.as_deref(), Some("shell"));
2444 assert_eq!(ctx.exit_code, Some(0));
2445 assert_eq!(ctx.timestamp.as_deref(), Some("2026-04-04T00:00:00Z"));
2446 }
2447
2448 #[test]
2449 fn embed_context_non_zero_exit_code() {
2450 let ctx = EmbedContext {
2451 tool_name: Some("shell".to_string()),
2452 exit_code: Some(1),
2453 timestamp: None,
2454 };
2455 assert_eq!(ctx.exit_code, Some(1));
2456 assert!(ctx.timestamp.is_none());
2457 }
2458
2459 async fn make_semantic_memory() -> crate::semantic::SemanticMemory {
2460 let sqlite = crate::store::SqliteStore::new(":memory:").await.unwrap();
2461 make_semantic_memory_with_sqlite(sqlite)
2462 }
2463
2464 fn make_semantic_memory_with_sqlite(
2472 sqlite: crate::store::SqliteStore,
2473 ) -> crate::semantic::SemanticMemory {
2474 use std::sync::Arc;
2475 use std::sync::atomic::AtomicU64;
2476 use zeph_llm::any::AnyProvider;
2477 use zeph_llm::mock::MockProvider;
2478
2479 let provider = AnyProvider::Mock(MockProvider::default());
2480 crate::semantic::SemanticMemory {
2481 sqlite,
2482 qdrant: None,
2483 provider,
2484 embed_provider: None,
2485 embedding_model: "test-model".into(),
2486 vector_weight: 0.7,
2487 keyword_weight: 0.3,
2488 temporal_decay: crate::semantic::TemporalDecay::Disabled,
2489 temporal_decay_half_life_days: 30,
2490 mmr_reranking: crate::semantic::MmrReranking::Disabled,
2491 mmr_lambda: 0.7,
2492 importance_scoring: crate::semantic::ImportanceScoring::Disabled,
2493 importance_weight: 0.15,
2494 token_counter: Arc::new(crate::token_counter::TokenCounter::new()),
2495 graph_store: None,
2496 experience: None,
2497 community_detection_failures: Arc::new(AtomicU64::new(0)),
2498 graph_extraction_count: Arc::new(AtomicU64::new(0)),
2499 graph_extraction_failures: Arc::new(AtomicU64::new(0)),
2500 last_qdrant_warn: Arc::new(AtomicU64::new(0)),
2501 tier_boost_semantic: 1.3,
2502 admission_control: None,
2503 quality_gate: None,
2504 key_facts_dedup_threshold: 0.95,
2505 embed_tasks: std::sync::Mutex::new(tokio::task::JoinSet::new()),
2506 retrieval_depth: 0,
2507 search_prompt_template: String::new(),
2508 depth_below_limit_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2509 missing_placeholder_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2510 reasoning: None,
2511 query_bias_correction: crate::semantic::QueryBiasCorrection::Disabled,
2512 query_bias_profile_weight: 0.25,
2513 profile_centroid: tokio::sync::RwLock::new(None),
2514 profile_centroid_ttl_secs: 300,
2515 hebbian_reinforcement: crate::semantic::HebbianReinforcement::Disabled,
2516 hebbian_lr: 0.1,
2517 hebbian_spread: crate::HelaSpreadRuntime::default(),
2518 retrieval_failure_logger: None,
2519 summarization_llm_timeout_secs: 60,
2520 query_sensitive_cost: false,
2521 five_signal: None,
2522 embed_timeout: std::time::Duration::from_secs(5),
2523 graph_cancel: std::sync::Mutex::new(Vec::new()),
2524 }
2525 }
2526
2527 #[tokio::test]
2528 async fn spawn_embed_bg_returns_true_when_capacity_available() {
2529 let memory = make_semantic_memory().await;
2530 let dispatched = memory.spawn_embed_bg(std::future::ready(()));
2531 assert!(
2532 dispatched,
2533 "spawn_embed_bg must return true when a task was successfully spawned"
2534 );
2535 }
2536
2537 #[tokio::test]
2538 async fn spawn_embed_bg_returns_false_at_capacity() {
2539 let memory = make_semantic_memory().await;
2540
2541 {
2543 let mut tasks = memory.embed_tasks.lock().unwrap();
2544 for _ in 0..MAX_EMBED_BG_TASKS {
2545 tasks.spawn(std::future::pending::<()>());
2546 }
2547 }
2548
2549 let dispatched = memory.spawn_embed_bg(std::future::ready(()));
2550 assert!(
2551 !dispatched,
2552 "spawn_embed_bg must return false when the task limit is reached"
2553 );
2554 }
2555
2556 #[test]
2557 fn qdrant_warn_rate_limit_suppresses_within_window() {
2558 assert!(
2560 should_emit_qdrant_warn(0, 100, 10),
2561 "first call must not be suppressed"
2562 );
2563
2564 assert!(
2566 !should_emit_qdrant_warn(100, 105, 10),
2567 "call within 10s window must be suppressed"
2568 );
2569
2570 assert!(
2572 should_emit_qdrant_warn(100, 110, 10),
2573 "call after window expiry must not be suppressed"
2574 );
2575 }
2576
2577 #[cfg(feature = "test-utils")]
2588 #[tokio::test]
2589 #[ignore = "requires Docker"]
2590 async fn apply_five_signal_scoring_decodes_created_at_on_postgres() {
2591 use std::sync::Arc;
2592 use testcontainers::runners::AsyncRunner as _;
2593 use testcontainers_modules::postgres::Postgres;
2594 use zeph_config::memory::FiveSignalConfig;
2595
2596 let image = Postgres::default();
2597 let container = image.start().await.expect("docker must be available");
2598 let host = container.get_host().await.unwrap();
2599 let port = container.get_host_port_ipv4(5432).await.unwrap();
2600 let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");
2601 let pool = zeph_db::DbConfig { url, pool_size: 5 }
2602 .connect()
2603 .await
2604 .expect("failed to connect to PG");
2605
2606 let pg_store = crate::store::SqliteStore::from_pool(pool.clone())
2607 .await
2608 .unwrap();
2609 let cid = pg_store.create_conversation().await.unwrap();
2610
2611 let session_start = 1_700_000_000_i64;
2616 let fresh = pg_store.save_message(cid, "user", "fresh").await.unwrap();
2617 let stale = pg_store.save_message(cid, "user", "stale").await.unwrap();
2618
2619 for (id, epoch) in [(fresh, session_start), (stale, session_start + 20 * 86_400)] {
2620 #[expect(clippy::cast_precision_loss)]
2621 let epoch_f = epoch as f64;
2622 sqlx::query(zeph_db::sql!(
2623 "UPDATE messages SET created_at = to_timestamp(?) WHERE id = ?"
2624 ))
2625 .bind(epoch_f)
2626 .bind(id)
2627 .execute(&pool)
2628 .await
2629 .unwrap();
2630 }
2631
2632 let graph_store = Arc::new(crate::graph::GraphStore::new(pool.clone()));
2633 let config = FiveSignalConfig {
2634 w_recency: 0.0,
2635 w_relevance: 0.0,
2636 w_frequency: 0.0,
2637 w_causal: 0.0,
2638 w_novelty: 1.0,
2639 ..FiveSignalConfig::default()
2640 };
2641 let fs = crate::five_signal::FiveSignalRuntime::new(
2642 config,
2643 pool,
2644 graph_store,
2645 None,
2646 session_start,
2647 "sess-novelty-test",
2648 );
2649
2650 let memory = make_semantic_memory_with_sqlite(pg_store);
2656 let mut ranked = vec![(fresh, 0.0), (stale, 0.0)];
2657 memory
2658 .apply_five_signal_scoring(&mut ranked, &fs, None)
2659 .await;
2660
2661 assert_eq!(
2662 ranked[0].0, fresh,
2663 "fresher message (created_at == session_start) must rank first by novelty"
2664 );
2665 assert!(
2666 (ranked[0].1 - 1.0).abs() < 1e-6,
2667 "message created at session_start must have novelty ~1.0, got {}",
2668 ranked[0].1
2669 );
2670 assert!(
2671 ranked[1].1 < ranked[0].1,
2672 "message created 20 days later must have strictly lower novelty"
2673 );
2674 }
2675
2676 #[test]
2677 fn qdrant_warn_rate_limit_shared_across_concurrent_sites() {
2678 let shared = Arc::new(AtomicU64::new(0));
2683
2684 let site_a = Arc::clone(&shared);
2685 let site_b = Arc::clone(&shared);
2686
2687 let now_a = 100u64;
2688 let last_a = site_a.load(Ordering::Relaxed);
2689 if should_emit_qdrant_warn(last_a, now_a, QDRANT_WARN_WINDOW_SECS) {
2690 site_a.store(now_a, Ordering::Relaxed);
2691 }
2692
2693 let now_b = 105u64;
2694 let last_b = site_b.load(Ordering::Relaxed);
2695 let warn_b = should_emit_qdrant_warn(last_b, now_b, QDRANT_WARN_WINDOW_SECS);
2696 assert!(
2697 !warn_b,
2698 "site B must be suppressed because site A already warned within the window"
2699 );
2700 }
2701
2702 #[test]
2703 fn warn_qdrant_ensure_failure_updates_shared_atomic_once() {
2704 let shared = Arc::new(AtomicU64::new(0));
2705 let err = MemoryError::InvalidInput("boom".into());
2706
2707 warn_qdrant_ensure_failure(&shared, "site A", &err);
2708 let after_first = shared.load(Ordering::Relaxed);
2709 assert!(after_first > 0, "first call must record a warn timestamp");
2710
2711 warn_qdrant_ensure_failure(&shared, "site B", &err);
2714 assert_eq!(
2715 shared.load(Ordering::Relaxed),
2716 after_first,
2717 "suppressed call must not overwrite the shared warn timestamp"
2718 );
2719 }
2720}