1use std::cmp::Ordering;
12use std::collections::{BinaryHeap, HashMap};
13use std::sync::Arc;
14use std::time::Duration;
15
16use bincode::Options;
17use futures::stream::{FuturesUnordered, StreamExt};
18use mongreldb_core::query::{Rerank, Retriever, SearchRequest};
19use mongreldb_core::{CancellationReason, ExecutionControl, RowId, Value};
20use mongreldb_types::ids::{QueryId, TabletId};
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct LocalCandidate {
26 pub tablet_id: TabletId,
28 pub row_id: RowId,
30 pub score: f64,
32 pub local_rank: u32,
34 pub rls_visible: bool,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum FusionMethod {
41 Rrf {
43 k: u32,
45 },
46 MaxScore,
48}
49
50impl Default for FusionMethod {
51 fn default() -> Self {
52 Self::Rrf { k: 60 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct AiWorkBudget {
59 pub candidate_ceiling: usize,
61 pub max_local_candidates: usize,
63 pub memory_bytes: u64,
65 pub deadline_ms: u64,
67}
68
69impl Default for AiWorkBudget {
70 fn default() -> Self {
71 Self {
72 candidate_ceiling: 100,
73 max_local_candidates: 1_000,
74 memory_bytes: 64 * 1024 * 1024,
75 deadline_ms: 5_000,
76 }
77 }
78}
79
80pub fn adaptive_local_k(global_k: usize, overfetch_factor: f64, active_tablets: usize) -> usize {
84 let tablets = active_tablets.max(1) as f64;
85 let raw = (global_k as f64) * overfetch_factor / tablets;
86 raw.ceil().max(1.0) as usize
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
91pub enum AiRetrievalError {
92 #[error("RLS hygiene violation: tablet {tablet_id} emitted hidden row {row_id}")]
94 RlsHygiene {
95 tablet_id: TabletId,
97 row_id: u64,
99 },
100 #[error("AI work budget exceeded: {0}")]
102 BudgetExceeded(String),
103 #[error("distributed AI request cancelled: {0:?}")]
105 Cancelled(CancellationReason),
106 #[error("distributed AI transport failed: {0}")]
108 Transport(String),
109 #[error("distributed AI protocol error: {0}")]
111 Protocol(String),
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct MergedCandidate {
117 pub tablet_id: TabletId,
120 pub row_id: RowId,
122 pub final_score: f64,
124 pub raw_score: f64,
126}
127
128#[derive(Debug, Clone)]
129struct HeapKey {
130 final_score: f64,
131 tablet_id: TabletId,
132 row_id: RowId,
133}
134
135impl PartialEq for HeapKey {
136 fn eq(&self, other: &Self) -> bool {
137 self.cmp(other) == Ordering::Equal
138 }
139}
140impl Eq for HeapKey {}
141impl PartialOrd for HeapKey {
142 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
143 Some(self.cmp(other))
144 }
145}
146impl Ord for HeapKey {
147 fn cmp(&self, other: &Self) -> Ordering {
148 match self
150 .final_score
151 .partial_cmp(&other.final_score)
152 .unwrap_or(Ordering::Equal)
153 {
154 Ordering::Equal => other
155 .tablet_id
156 .cmp(&self.tablet_id)
157 .then_with(|| other.row_id.cmp(&self.row_id)),
158 ord => ord,
159 }
160 }
161}
162
163pub fn merge_candidates(
168 locals: &[LocalCandidate],
169 method: FusionMethod,
170 budget: &AiWorkBudget,
171) -> Result<Vec<MergedCandidate>, AiRetrievalError> {
172 if locals.len() > budget.max_local_candidates {
173 return Err(AiRetrievalError::BudgetExceeded(format!(
174 "{} local candidates > max {}",
175 locals.len(),
176 budget.max_local_candidates
177 )));
178 }
179 for c in locals {
180 if !c.rls_visible {
181 return Err(AiRetrievalError::RlsHygiene {
182 tablet_id: c.tablet_id,
183 row_id: c.row_id.0,
184 });
185 }
186 }
187
188 let mut by_key: HashMap<(TabletId, RowId), LocalCandidate> = HashMap::new();
190 for c in locals {
191 by_key
192 .entry((c.tablet_id, c.row_id))
193 .and_modify(|existing| {
194 if c.score > existing.score {
195 *existing = c.clone();
196 }
197 })
198 .or_insert_with(|| c.clone());
199 }
200
201 let fused: Vec<MergedCandidate> = match method {
202 FusionMethod::Rrf { k } => {
203 by_key
205 .into_values()
206 .map(|c| {
207 let rrf = 1.0 / (f64::from(k) + f64::from(c.local_rank));
208 MergedCandidate {
209 tablet_id: c.tablet_id,
210 row_id: c.row_id,
211 final_score: rrf,
212 raw_score: c.score,
213 }
214 })
215 .collect()
216 }
217 FusionMethod::MaxScore => by_key
218 .into_values()
219 .map(|c| MergedCandidate {
220 tablet_id: c.tablet_id,
221 row_id: c.row_id,
222 final_score: c.score,
223 raw_score: c.score,
224 })
225 .collect(),
226 };
227
228 let mut heap: BinaryHeap<HeapKey> = BinaryHeap::new();
230 let mut map: HashMap<(TabletId, RowId), MergedCandidate> = HashMap::new();
231 for m in fused {
232 let key = (m.tablet_id, m.row_id);
233 heap.push(HeapKey {
234 final_score: m.final_score,
235 tablet_id: m.tablet_id,
236 row_id: m.row_id,
237 });
238 map.insert(key, m);
239 }
240
241 let mut out = Vec::with_capacity(budget.candidate_ceiling.min(map.len()));
242 while out.len() < budget.candidate_ceiling {
243 let Some(hk) = heap.pop() else {
244 break;
245 };
246 if let Some(m) = map.remove(&(hk.tablet_id, hk.row_id)) {
247 out.push(m);
248 }
249 }
250 Ok(out)
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct AiConsistencyAudit {
256 pub read_ts: String,
258 pub replica_applied_ts: String,
260 pub staleness_micros: u64,
262 pub index_applied_ts: String,
264 pub model_version: Option<String>,
266 pub preprocessing_version: Option<String>,
268}
269
270pub const REMOTE_AI_SERVICE_ID: u32 = 2;
276pub const REMOTE_AI_PROTOCOL_VERSION: u16 = 1;
278pub const DEFAULT_REMOTE_AI_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
280pub const DEFAULT_REMOTE_AI_EXECUTIONS: usize = 256;
282pub const MAX_AI_AUTHORIZATION_CONTEXT_BYTES: usize = 64 * 1024;
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct AiTabletQuery {
293 pub query_id: QueryId,
295 pub tablet_id: TabletId,
297 pub table: String,
299 pub search: SearchRequest,
302 pub authorization_context: Vec<u8>,
304 pub deadline_ms: Option<u64>,
306 pub budget: AiWorkBudget,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct AiTabletHit {
313 pub candidate: LocalCandidate,
315 pub cells: Vec<(u16, Value)>,
317 pub exact_rerank_score: Option<f32>,
319 pub consistency: Option<AiConsistencyAudit>,
321}
322
323#[async_trait::async_trait]
325pub trait AiTabletExecutor: Send + Sync {
326 async fn retrieve(
329 &self,
330 request: &AiTabletQuery,
331 control: ExecutionControl,
332 ) -> Result<Vec<AiTabletHit>, AiRetrievalError>;
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336struct RemoteAiEnvelope {
337 version: u16,
338 request: RemoteAiRequest,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342enum RemoteAiRequest {
343 Retrieve(Box<AiTabletQuery>),
344 Cancel {
345 query_id: QueryId,
346 tablet_id: TabletId,
347 },
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
351struct RemoteAiResponseEnvelope {
352 version: u16,
353 response: RemoteAiResponse,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
357enum RemoteAiResponse {
358 Hits(Vec<AiTabletHit>),
359 Cancelled,
360 Error(String),
361}
362
363type AiExecutionKey = (QueryId, TabletId);
364
365pub struct RemoteAiEndpoint {
367 executor: Arc<dyn AiTabletExecutor>,
368 controls: parking_lot::Mutex<HashMap<AiExecutionKey, ExecutionControl>>,
369 max_executions: usize,
370 max_message_bytes: usize,
371}
372
373impl RemoteAiEndpoint {
374 pub fn new(executor: Arc<dyn AiTabletExecutor>) -> Self {
376 Self::with_limits(
377 executor,
378 DEFAULT_REMOTE_AI_EXECUTIONS,
379 DEFAULT_REMOTE_AI_MESSAGE_BYTES,
380 )
381 }
382
383 pub fn with_limits(
385 executor: Arc<dyn AiTabletExecutor>,
386 max_executions: usize,
387 max_message_bytes: usize,
388 ) -> Self {
389 Self {
390 executor,
391 controls: parking_lot::Mutex::new(HashMap::new()),
392 max_executions: max_executions.max(1),
393 max_message_bytes: max_message_bytes.max(1),
394 }
395 }
396
397 pub fn active_executions(&self) -> usize {
399 self.controls.lock().len()
400 }
401
402 pub async fn handle(&self, bytes: &[u8]) -> Result<Vec<u8>, AiRetrievalError> {
404 let envelope: RemoteAiEnvelope = decode_ai_wire(bytes, self.max_message_bytes)?;
405 if envelope.version != REMOTE_AI_PROTOCOL_VERSION {
406 return Err(AiRetrievalError::Protocol(format!(
407 "unsupported distributed-AI protocol version {}; supported version is {}",
408 envelope.version, REMOTE_AI_PROTOCOL_VERSION
409 )));
410 }
411 let response = match self.handle_request(envelope.request).await {
412 Ok(response) => response,
413 Err(error) => RemoteAiResponse::Error(error.to_string()),
414 };
415 encode_ai_wire(
416 &RemoteAiResponseEnvelope {
417 version: REMOTE_AI_PROTOCOL_VERSION,
418 response,
419 },
420 self.max_message_bytes,
421 )
422 }
423
424 async fn handle_request(
425 &self,
426 request: RemoteAiRequest,
427 ) -> Result<RemoteAiResponse, AiRetrievalError> {
428 match request {
429 RemoteAiRequest::Retrieve(request) => {
430 if request.authorization_context.len() > MAX_AI_AUTHORIZATION_CONTEXT_BYTES {
431 return Err(AiRetrievalError::Protocol(format!(
432 "authorization context is {} bytes; limit is {}",
433 request.authorization_context.len(),
434 MAX_AI_AUTHORIZATION_CONTEXT_BYTES
435 )));
436 }
437 let key = (request.query_id, request.tablet_id);
438 let control = request.deadline_ms.map_or_else(
439 || ExecutionControl::new(None),
440 |milliseconds| {
441 ExecutionControl::with_timeout(Duration::from_millis(milliseconds))
442 },
443 );
444 {
445 let mut controls = self.controls.lock();
446 if controls.contains_key(&key) {
447 return Err(AiRetrievalError::Protocol(format!(
448 "query {} is already retrieving tablet {}",
449 request.query_id, request.tablet_id
450 )));
451 }
452 if controls.len() >= self.max_executions {
453 return Err(AiRetrievalError::BudgetExceeded(format!(
454 "worker holds {} AI requests; limit is {}",
455 controls.len(),
456 self.max_executions
457 )));
458 }
459 controls.insert(key, control.clone());
460 }
461 let result = self.executor.retrieve(&request, control.clone()).await;
462 let was_active = self.controls.lock().remove(&key).is_some();
463 if !was_active || control.checkpoint().is_err() {
464 return Err(AiRetrievalError::Cancelled(control.reason()));
465 }
466 let hits = result?;
467 for hit in &hits {
468 if hit.candidate.tablet_id != request.tablet_id {
469 return Err(AiRetrievalError::Protocol(format!(
470 "tablet {} returned candidate labeled {}",
471 request.tablet_id, hit.candidate.tablet_id
472 )));
473 }
474 if !hit.candidate.rls_visible {
475 return Err(AiRetrievalError::RlsHygiene {
476 tablet_id: hit.candidate.tablet_id,
477 row_id: hit.candidate.row_id.0,
478 });
479 }
480 }
481 Ok(RemoteAiResponse::Hits(hits))
482 }
483 RemoteAiRequest::Cancel {
484 query_id,
485 tablet_id,
486 } => {
487 if let Some(control) = self.controls.lock().get(&(query_id, tablet_id)).cloned() {
488 control.cancel(CancellationReason::ClientRequest);
489 }
490 Ok(RemoteAiResponse::Cancelled)
491 }
492 }
493 }
494}
495
496#[async_trait::async_trait]
498pub trait AiRpcClient: Send + Sync {
499 async fn call(&self, request: Vec<u8>) -> Result<Vec<u8>, AiRetrievalError>;
501}
502
503pub struct LoopbackAiRpcClient {
505 endpoint: Arc<RemoteAiEndpoint>,
506}
507
508impl LoopbackAiRpcClient {
509 pub fn new(endpoint: Arc<RemoteAiEndpoint>) -> Self {
511 Self { endpoint }
512 }
513}
514
515#[async_trait::async_trait]
516impl AiRpcClient for LoopbackAiRpcClient {
517 async fn call(&self, request: Vec<u8>) -> Result<Vec<u8>, AiRetrievalError> {
518 self.endpoint.handle(&request).await
519 }
520}
521
522#[derive(Debug, Clone)]
524pub struct DistributedAiResult {
525 pub candidates: Vec<MergedCandidate>,
527 pub hits: Vec<AiTabletHit>,
529}
530
531pub struct AiFanoutRequest<'a> {
533 pub query_id: QueryId,
535 pub tablets: &'a [TabletId],
537 pub table: &'a str,
539 pub search: &'a SearchRequest,
541 pub authorization_context: &'a [u8],
543 pub fusion: FusionMethod,
545 pub overfetch_factor: f64,
547 pub budget: &'a AiWorkBudget,
549 pub control: &'a ExecutionControl,
551}
552
553pub struct RemoteAiTransport {
555 default_client: Option<Arc<dyn AiRpcClient>>,
556 clients: parking_lot::RwLock<HashMap<TabletId, Arc<dyn AiRpcClient>>>,
557 max_message_bytes: usize,
558}
559
560impl RemoteAiTransport {
561 pub fn new(default_client: Arc<dyn AiRpcClient>) -> Self {
563 Self {
564 default_client: Some(default_client),
565 clients: parking_lot::RwLock::new(HashMap::new()),
566 max_message_bytes: DEFAULT_REMOTE_AI_MESSAGE_BYTES,
567 }
568 }
569
570 pub fn routed() -> Self {
572 Self {
573 default_client: None,
574 clients: parking_lot::RwLock::new(HashMap::new()),
575 max_message_bytes: DEFAULT_REMOTE_AI_MESSAGE_BYTES,
576 }
577 }
578
579 pub fn with_client(self, tablet: TabletId, client: Arc<dyn AiRpcClient>) -> Self {
581 self.clients.write().insert(tablet, client);
582 self
583 }
584
585 fn client_for(&self, tablet: TabletId) -> Result<Arc<dyn AiRpcClient>, AiRetrievalError> {
586 self.clients
587 .read()
588 .get(&tablet)
589 .cloned()
590 .or_else(|| self.default_client.as_ref().map(Arc::clone))
591 .ok_or_else(|| {
592 AiRetrievalError::Transport(format!(
593 "no authenticated AI route for tablet {tablet}"
594 ))
595 })
596 }
597
598 pub async fn retrieve(
601 &self,
602 request: AiFanoutRequest<'_>,
603 ) -> Result<DistributedAiResult, AiRetrievalError> {
604 let AiFanoutRequest {
605 query_id,
606 tablets,
607 table,
608 search,
609 authorization_context,
610 fusion,
611 overfetch_factor,
612 budget,
613 control,
614 } = request;
615 if tablets.is_empty() {
616 return Ok(DistributedAiResult {
617 candidates: Vec::new(),
618 hits: Vec::new(),
619 });
620 }
621 if authorization_context.len() > MAX_AI_AUTHORIZATION_CONTEXT_BYTES {
622 return Err(AiRetrievalError::Protocol(format!(
623 "authorization context is {} bytes; limit is {}",
624 authorization_context.len(),
625 MAX_AI_AUTHORIZATION_CONTEXT_BYTES
626 )));
627 }
628 if !overfetch_factor.is_finite() || overfetch_factor <= 0.0 {
629 return Err(AiRetrievalError::Protocol(
630 "overfetch factor must be finite and positive".to_owned(),
631 ));
632 }
633 let request_control = control.child_with_timeout(Duration::from_millis(budget.deadline_ms));
634 request_control
635 .checkpoint()
636 .map_err(|_| AiRetrievalError::Cancelled(request_control.reason()))?;
637 let local_k = adaptive_local_k(budget.candidate_ceiling, overfetch_factor, tablets.len());
638 let total_requested = local_k
639 .checked_mul(tablets.len())
640 .ok_or_else(|| AiRetrievalError::BudgetExceeded("candidate count overflow".into()))?;
641 if total_requested > budget.max_local_candidates {
642 return Err(AiRetrievalError::BudgetExceeded(format!(
643 "{total_requested} requested local candidates > max {}",
644 budget.max_local_candidates
645 )));
646 }
647 let mut local_search = search.clone();
648 local_search.limit = local_k;
649 for named in &mut local_search.retrievers {
650 match &mut named.retriever {
651 Retriever::Ann { k, .. }
652 | Retriever::Sparse { k, .. }
653 | Retriever::MinHash { k, .. } => *k = local_k,
654 }
655 }
656 if let Some(Rerank::ExactVector {
657 candidate_limit, ..
658 }) = &mut local_search.rerank
659 {
660 *candidate_limit = (*candidate_limit).max(local_k);
661 }
662
663 let mut calls = FuturesUnordered::new();
664 for &tablet_id in tablets {
665 let client = self.client_for(tablet_id)?;
666 let request = AiTabletQuery {
667 query_id,
668 tablet_id,
669 table: table.to_owned(),
670 search: local_search.clone(),
671 authorization_context: authorization_context.to_vec(),
672 deadline_ms: request_control
673 .remaining_duration()
674 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64),
675 budget: budget.clone(),
676 };
677 let max_message_bytes = self.max_message_bytes;
678 calls.push(async move {
679 let response = ai_remote_call(
680 &client,
681 RemoteAiRequest::Retrieve(Box::new(request)),
682 max_message_bytes,
683 )
684 .await;
685 (tablet_id, client, response)
686 });
687 }
688 let mut hits = Vec::new();
689 let mut retained_bytes = 0u64;
690 while !calls.is_empty() {
691 tokio::select! {
692 _ = request_control.cancelled() => {
693 for &tablet_id in tablets {
694 let client = self.client_for(tablet_id)?;
695 let max_message_bytes = self.max_message_bytes;
696 tokio::spawn(async move {
697 let _ = ai_remote_call(
698 &client,
699 RemoteAiRequest::Cancel { query_id, tablet_id },
700 max_message_bytes,
701 ).await;
702 });
703 }
704 return Err(AiRetrievalError::Cancelled(request_control.reason()));
705 }
706 result = calls.next() => {
707 let Some((tablet_id, _client, response)) = result else {
708 break;
709 };
710 let response = match response {
711 Ok(response) => response,
712 Err(error) => {
713 self.cancel_tablets(query_id, tablets);
714 return Err(error);
715 }
716 };
717 match response {
718 RemoteAiResponse::Hits(mut tablet_hits) => {
719 if tablet_hits.len() > local_k {
720 self.cancel_tablets(query_id, tablets);
721 return Err(AiRetrievalError::Protocol(format!(
722 "tablet {tablet_id} returned {} candidates; local limit is {local_k}",
723 tablet_hits.len()
724 )));
725 }
726 let bytes = ai_wire_options()
727 .serialized_size(&tablet_hits)
728 .map_err(|error| AiRetrievalError::Protocol(error.to_string()))?;
729 retained_bytes = retained_bytes.saturating_add(bytes);
730 if retained_bytes > budget.memory_bytes {
731 self.cancel_tablets(query_id, tablets);
732 return Err(AiRetrievalError::BudgetExceeded(format!(
733 "{retained_bytes} retained result bytes > memory budget {}",
734 budget.memory_bytes
735 )));
736 }
737 hits.append(&mut tablet_hits);
738 }
739 other => {
740 self.cancel_tablets(query_id, tablets);
741 return Err(AiRetrievalError::Protocol(format!(
742 "expected AI Hits response, got {other:?}"
743 )));
744 }
745 }
746 }
747 }
748 }
749 if hits.len() > budget.max_local_candidates {
750 return Err(AiRetrievalError::BudgetExceeded(format!(
751 "{} returned local candidates > max {}",
752 hits.len(),
753 budget.max_local_candidates
754 )));
755 }
756 let locals = hits
757 .iter()
758 .map(|hit| hit.candidate.clone())
759 .collect::<Vec<_>>();
760 let candidates = merge_candidates(&locals, fusion, budget)?;
761 let by_key = hits
762 .into_iter()
763 .map(|hit| ((hit.candidate.tablet_id, hit.candidate.row_id), hit))
764 .collect::<HashMap<_, _>>();
765 let winner_hits = candidates
766 .iter()
767 .map(|candidate| {
768 by_key
769 .get(&(candidate.tablet_id, candidate.row_id))
770 .cloned()
771 .ok_or_else(|| {
772 AiRetrievalError::Protocol(format!(
773 "winner {} from tablet {} has no payload",
774 candidate.row_id, candidate.tablet_id
775 ))
776 })
777 })
778 .collect::<Result<Vec<_>, _>>()?;
779 Ok(DistributedAiResult {
780 candidates,
781 hits: winner_hits,
782 })
783 }
784
785 fn cancel_tablets(&self, query_id: QueryId, tablets: &[TabletId]) {
786 for &tablet_id in tablets {
787 let Ok(client) = self.client_for(tablet_id) else {
788 continue;
789 };
790 let max_message_bytes = self.max_message_bytes;
791 tokio::spawn(async move {
792 let _ = ai_remote_call(
793 &client,
794 RemoteAiRequest::Cancel {
795 query_id,
796 tablet_id,
797 },
798 max_message_bytes,
799 )
800 .await;
801 });
802 }
803 }
804}
805
806async fn ai_remote_call(
807 client: &Arc<dyn AiRpcClient>,
808 request: RemoteAiRequest,
809 max_message_bytes: usize,
810) -> Result<RemoteAiResponse, AiRetrievalError> {
811 let bytes = encode_ai_wire(
812 &RemoteAiEnvelope {
813 version: REMOTE_AI_PROTOCOL_VERSION,
814 request,
815 },
816 max_message_bytes,
817 )?;
818 let bytes = client.call(bytes).await?;
819 let response: RemoteAiResponseEnvelope = decode_ai_wire(&bytes, max_message_bytes)?;
820 if response.version != REMOTE_AI_PROTOCOL_VERSION {
821 return Err(AiRetrievalError::Protocol(format!(
822 "peer answered with distributed-AI protocol version {}; supported version is {}",
823 response.version, REMOTE_AI_PROTOCOL_VERSION
824 )));
825 }
826 match response.response {
827 RemoteAiResponse::Error(message) => Err(AiRetrievalError::Transport(message)),
828 response => Ok(response),
829 }
830}
831
832fn ai_wire_options() -> impl Options {
833 bincode::DefaultOptions::new()
834 .with_fixint_encoding()
835 .reject_trailing_bytes()
836}
837
838fn encode_ai_wire<T: Serialize>(
839 value: &T,
840 max_message_bytes: usize,
841) -> Result<Vec<u8>, AiRetrievalError> {
842 let bytes = ai_wire_options()
843 .serialize(value)
844 .map_err(|error| AiRetrievalError::Protocol(error.to_string()))?;
845 if bytes.len() > max_message_bytes {
846 return Err(AiRetrievalError::Protocol(format!(
847 "encoded distributed-AI message is {} bytes; limit is {max_message_bytes}",
848 bytes.len()
849 )));
850 }
851 Ok(bytes)
852}
853
854fn decode_ai_wire<T: for<'de> Deserialize<'de>>(
855 bytes: &[u8],
856 max_message_bytes: usize,
857) -> Result<T, AiRetrievalError> {
858 if bytes.len() > max_message_bytes {
859 return Err(AiRetrievalError::Protocol(format!(
860 "distributed-AI message is {} bytes; limit is {max_message_bytes}",
861 bytes.len()
862 )));
863 }
864 ai_wire_options()
865 .with_limit(max_message_bytes as u64)
866 .deserialize(bytes)
867 .map_err(|error| AiRetrievalError::Protocol(error.to_string()))
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use mongreldb_core::query::Fusion;
874
875 fn tid(n: u8) -> TabletId {
876 TabletId::from_bytes({
877 let mut b = [0u8; 16];
878 b[15] = n;
879 b
880 })
881 }
882
883 fn cand(tablet: u8, row: u64, score: f64, rank: u32) -> LocalCandidate {
884 LocalCandidate {
885 tablet_id: tid(tablet),
886 row_id: RowId(row),
887 score,
888 local_rank: rank,
889 rls_visible: true,
890 }
891 }
892
893 #[test]
894 fn merge_is_deterministic() {
895 let locals = vec![
896 cand(2, 10, 0.9, 1),
897 cand(1, 20, 0.8, 1),
898 cand(2, 30, 0.7, 2),
899 cand(1, 10, 0.95, 2),
900 ];
901 let budget = AiWorkBudget {
902 candidate_ceiling: 10,
903 ..AiWorkBudget::default()
904 };
905 let a = merge_candidates(&locals, FusionMethod::Rrf { k: 60 }, &budget).unwrap();
906 let b = merge_candidates(&locals, FusionMethod::Rrf { k: 60 }, &budget).unwrap();
907 assert_eq!(a, b);
908 assert!(a[0].final_score >= a[1].final_score);
910 }
911
912 #[test]
913 fn rls_hidden_row_fails_closed() {
914 let mut c = cand(1, 1, 1.0, 1);
915 c.rls_visible = false;
916 let err =
917 merge_candidates(&[c], FusionMethod::default(), &AiWorkBudget::default()).unwrap_err();
918 assert!(matches!(err, AiRetrievalError::RlsHygiene { .. }));
919 }
920
921 #[test]
922 fn adaptive_local_k_scales() {
923 assert_eq!(adaptive_local_k(10, 2.0, 5), 4); assert_eq!(adaptive_local_k(10, 2.0, 1), 20);
925 assert_eq!(adaptive_local_k(10, 2.0, 0), 20); }
927
928 #[test]
929 fn tie_break_tablet_then_row() {
930 let locals = vec![cand(2, 5, 1.0, 1), cand(1, 9, 1.0, 1)];
932 let out = merge_candidates(
933 &locals,
934 FusionMethod::Rrf { k: 60 },
935 &AiWorkBudget {
936 candidate_ceiling: 2,
937 ..AiWorkBudget::default()
938 },
939 )
940 .unwrap();
941 assert_eq!(out.len(), 2);
942 assert_eq!(out[0].tablet_id, tid(1));
944 assert_eq!(out[1].tablet_id, tid(2));
945 }
946
947 struct StaticExecutor;
948
949 #[async_trait::async_trait]
950 impl AiTabletExecutor for StaticExecutor {
951 async fn retrieve(
952 &self,
953 request: &AiTabletQuery,
954 control: ExecutionControl,
955 ) -> Result<Vec<AiTabletHit>, AiRetrievalError> {
956 control
957 .checkpoint()
958 .map_err(|_| AiRetrievalError::Cancelled(control.reason()))?;
959 assert_eq!(request.authorization_context, b"signed-user");
960 Ok(vec![AiTabletHit {
961 candidate: LocalCandidate {
962 tablet_id: request.tablet_id,
963 row_id: RowId(u64::from(request.tablet_id.as_bytes()[15])),
964 score: 0.9,
965 local_rank: 1,
966 rls_visible: true,
967 },
968 cells: vec![(1, Value::Int64(7))],
969 exact_rerank_score: Some(0.99),
970 consistency: None,
971 }])
972 }
973 }
974
975 #[tokio::test]
976 async fn remote_ai_fanout_merges_and_preserves_exact_rerank_payload() {
977 let endpoint = Arc::new(RemoteAiEndpoint::new(Arc::new(StaticExecutor)));
978 let client: Arc<dyn AiRpcClient> =
979 Arc::new(LoopbackAiRpcClient::new(Arc::clone(&endpoint)));
980 let transport = RemoteAiTransport::new(client);
981 let budget = AiWorkBudget {
982 candidate_ceiling: 2,
983 max_local_candidates: 8,
984 ..AiWorkBudget::default()
985 };
986 let search = SearchRequest {
987 must: Vec::new(),
988 retrievers: Vec::new(),
989 fusion: Fusion::ReciprocalRank { constant: 60 },
990 rerank: None,
991 limit: 2,
992 projection: Some(vec![1]),
993 };
994 let result = transport
995 .retrieve(AiFanoutRequest {
996 query_id: QueryId::new_random(),
997 tablets: &[tid(2), tid(1)],
998 table: "items",
999 search: &search,
1000 authorization_context: b"signed-user",
1001 fusion: FusionMethod::default(),
1002 overfetch_factor: 2.0,
1003 budget: &budget,
1004 control: &ExecutionControl::new(None),
1005 })
1006 .await
1007 .unwrap();
1008 assert_eq!(result.candidates.len(), 2);
1009 assert_eq!(result.candidates[0].tablet_id, tid(1));
1010 assert_eq!(result.hits[0].exact_rerank_score, Some(0.99));
1011 assert_eq!(result.hits[0].cells, vec![(1, Value::Int64(7))]);
1012 assert_eq!(endpoint.active_executions(), 0);
1013 }
1014
1015 #[tokio::test]
1016 async fn remote_ai_enforces_deadline_and_retained_memory_budget() {
1017 let endpoint = Arc::new(RemoteAiEndpoint::new(Arc::new(StaticExecutor)));
1018 let client: Arc<dyn AiRpcClient> =
1019 Arc::new(LoopbackAiRpcClient::new(Arc::clone(&endpoint)));
1020 let transport = RemoteAiTransport::new(client);
1021 let search = SearchRequest {
1022 must: Vec::new(),
1023 retrievers: Vec::new(),
1024 fusion: Fusion::ReciprocalRank { constant: 60 },
1025 rerank: None,
1026 limit: 1,
1027 projection: Some(vec![1]),
1028 };
1029 let deadline = transport
1030 .retrieve(AiFanoutRequest {
1031 query_id: QueryId::new_random(),
1032 tablets: &[tid(1)],
1033 table: "items",
1034 search: &search,
1035 authorization_context: b"signed-user",
1036 fusion: FusionMethod::default(),
1037 overfetch_factor: 1.0,
1038 budget: &AiWorkBudget {
1039 deadline_ms: 0,
1040 ..AiWorkBudget::default()
1041 },
1042 control: &ExecutionControl::new(None),
1043 })
1044 .await
1045 .unwrap_err();
1046 assert_eq!(
1047 deadline,
1048 AiRetrievalError::Cancelled(CancellationReason::Deadline)
1049 );
1050
1051 let memory = transport
1052 .retrieve(AiFanoutRequest {
1053 query_id: QueryId::new_random(),
1054 tablets: &[tid(1)],
1055 table: "items",
1056 search: &search,
1057 authorization_context: b"signed-user",
1058 fusion: FusionMethod::default(),
1059 overfetch_factor: 1.0,
1060 budget: &AiWorkBudget {
1061 memory_bytes: 1,
1062 ..AiWorkBudget::default()
1063 },
1064 control: &ExecutionControl::new(None),
1065 })
1066 .await
1067 .unwrap_err();
1068 assert!(matches!(memory, AiRetrievalError::BudgetExceeded(_)));
1069 assert_eq!(endpoint.active_executions(), 0);
1070 }
1071}