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)]
62pub struct AiWorkBudget {
63 pub candidate_ceiling: usize,
65 pub max_local_candidates: usize,
67 pub memory_bytes: u64,
69 pub deadline_ms: u64,
71 #[serde(default = "default_max_tablet_rpcs")]
73 pub max_tablet_rpcs: usize,
74 #[serde(default = "default_max_retained_contributions")]
76 pub max_retained_contributions: usize,
77 #[serde(default = "default_max_refill_rounds")]
79 pub max_refill_rounds: usize,
80 #[serde(default = "default_max_payload_cells")]
82 pub max_payload_cells: usize,
83 #[serde(default = "default_max_exact_gathers")]
85 pub max_exact_gathers: usize,
86 #[serde(default = "default_max_rerank_dimensions")]
88 pub max_rerank_dimensions: usize,
89 #[serde(default = "default_max_serialization_bytes")]
91 pub max_serialization_bytes: usize,
92 #[serde(default)]
94 pub expected_model_generation: Option<u64>,
95}
96
97fn default_max_tablet_rpcs() -> usize {
98 256
99}
100fn default_max_retained_contributions() -> usize {
101 4_096
102}
103fn default_max_refill_rounds() -> usize {
104 64
105}
106fn default_max_payload_cells() -> usize {
107 65_536
108}
109fn default_max_exact_gathers() -> usize {
110 1_024
111}
112fn default_max_rerank_dimensions() -> usize {
113 4_096
114}
115fn default_max_serialization_bytes() -> usize {
116 16 * 1024 * 1024
117}
118
119impl Default for AiWorkBudget {
120 fn default() -> Self {
121 Self {
122 candidate_ceiling: 100,
123 max_local_candidates: 1_000,
124 memory_bytes: 64 * 1024 * 1024,
125 deadline_ms: 5_000,
126 max_tablet_rpcs: default_max_tablet_rpcs(),
127 max_retained_contributions: default_max_retained_contributions(),
128 max_refill_rounds: default_max_refill_rounds(),
129 max_payload_cells: default_max_payload_cells(),
130 max_exact_gathers: default_max_exact_gathers(),
131 max_rerank_dimensions: default_max_rerank_dimensions(),
132 max_serialization_bytes: default_max_serialization_bytes(),
133 expected_model_generation: None,
134 }
135 }
136}
137
138pub fn adaptive_local_k(global_k: usize, overfetch_factor: f64, active_tablets: usize) -> usize {
142 let tablets = active_tablets.max(1) as f64;
143 let raw = (global_k as f64) * overfetch_factor / tablets;
144 raw.ceil().max(1.0) as usize
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
149pub enum AiRetrievalError {
150 #[error("RLS hygiene violation: tablet {tablet_id} emitted hidden row {row_id}")]
152 RlsHygiene {
153 tablet_id: TabletId,
155 row_id: u64,
157 },
158 #[error("AI work budget exceeded: {0}")]
160 BudgetExceeded(String),
161 #[error("distributed AI request cancelled: {0:?}")]
163 Cancelled(CancellationReason),
164 #[error("distributed AI transport failed: {0}")]
166 Transport(String),
167 #[error("distributed AI protocol error: {0}")]
169 Protocol(String),
170}
171
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174pub struct MergedCandidate {
175 pub tablet_id: TabletId,
178 pub row_id: RowId,
180 pub final_score: f64,
182 pub raw_score: f64,
184}
185
186#[derive(Debug, Clone)]
187struct HeapKey {
188 final_score: f64,
189 tablet_id: TabletId,
190 row_id: RowId,
191}
192
193impl PartialEq for HeapKey {
194 fn eq(&self, other: &Self) -> bool {
195 self.cmp(other) == Ordering::Equal
196 }
197}
198impl Eq for HeapKey {}
199impl PartialOrd for HeapKey {
200 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
201 Some(self.cmp(other))
202 }
203}
204impl Ord for HeapKey {
205 fn cmp(&self, other: &Self) -> Ordering {
206 match self
208 .final_score
209 .partial_cmp(&other.final_score)
210 .unwrap_or(Ordering::Equal)
211 {
212 Ordering::Equal => other
213 .tablet_id
214 .cmp(&self.tablet_id)
215 .then_with(|| other.row_id.cmp(&self.row_id)),
216 ord => ord,
217 }
218 }
219}
220
221pub fn merge_candidates(
226 locals: &[LocalCandidate],
227 method: FusionMethod,
228 budget: &AiWorkBudget,
229) -> Result<Vec<MergedCandidate>, AiRetrievalError> {
230 if locals.len() > budget.max_local_candidates {
231 return Err(AiRetrievalError::BudgetExceeded(format!(
232 "{} local candidates > max {}",
233 locals.len(),
234 budget.max_local_candidates
235 )));
236 }
237 for c in locals {
238 if !c.rls_visible {
239 return Err(AiRetrievalError::RlsHygiene {
240 tablet_id: c.tablet_id,
241 row_id: c.row_id.0,
242 });
243 }
244 }
245
246 let mut by_key: HashMap<(TabletId, RowId), LocalCandidate> = HashMap::new();
248 for c in locals {
249 by_key
250 .entry((c.tablet_id, c.row_id))
251 .and_modify(|existing| {
252 if c.score > existing.score {
253 *existing = c.clone();
254 }
255 })
256 .or_insert_with(|| c.clone());
257 }
258
259 let fused: Vec<MergedCandidate> = match method {
260 FusionMethod::Rrf { k } => {
261 by_key
263 .into_values()
264 .map(|c| {
265 let rrf = 1.0 / (f64::from(k) + f64::from(c.local_rank));
266 MergedCandidate {
267 tablet_id: c.tablet_id,
268 row_id: c.row_id,
269 final_score: rrf,
270 raw_score: c.score,
271 }
272 })
273 .collect()
274 }
275 FusionMethod::MaxScore => by_key
276 .into_values()
277 .map(|c| MergedCandidate {
278 tablet_id: c.tablet_id,
279 row_id: c.row_id,
280 final_score: c.score,
281 raw_score: c.score,
282 })
283 .collect(),
284 };
285
286 let mut heap: BinaryHeap<HeapKey> = BinaryHeap::new();
288 let mut map: HashMap<(TabletId, RowId), MergedCandidate> = HashMap::new();
289 for m in fused {
290 let key = (m.tablet_id, m.row_id);
291 heap.push(HeapKey {
292 final_score: m.final_score,
293 tablet_id: m.tablet_id,
294 row_id: m.row_id,
295 });
296 map.insert(key, m);
297 }
298
299 let mut out = Vec::with_capacity(budget.candidate_ceiling.min(map.len()));
300 while out.len() < budget.candidate_ceiling {
301 let Some(hk) = heap.pop() else {
302 break;
303 };
304 if let Some(m) = map.remove(&(hk.tablet_id, hk.row_id)) {
305 out.push(m);
306 }
307 }
308 Ok(out)
309}
310
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321pub struct LocalRetrieverContribution {
322 pub tablet_id: TabletId,
324 pub row_id: RowId,
326 pub retriever_id: String,
328 pub retriever_kind: Option<String>,
330 pub local_rank: u32,
332 pub raw_score: f64,
334 pub upper_bound_after: Option<f64>,
338 pub rls_visible: bool,
340 pub weight: f64,
343}
344
345impl LocalRetrieverContribution {
346 pub fn new(
348 tablet_id: TabletId,
349 row_id: RowId,
350 retriever_id: impl Into<String>,
351 local_rank: u32,
352 raw_score: f64,
353 ) -> Self {
354 Self {
355 tablet_id,
356 row_id,
357 retriever_id: retriever_id.into(),
358 retriever_kind: None,
359 local_rank,
360 raw_score,
361 upper_bound_after: None,
362 rls_visible: true,
363 weight: 1.0,
364 }
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370pub struct HybridComponentScore {
371 pub retriever_id: String,
373 pub retriever_kind: Option<String>,
375 pub tablet_id: TabletId,
377 pub rank: u32,
379 pub raw_score: f64,
381 pub contribution: f64,
383 pub weight: f64,
385}
386
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
389pub struct HybridMergedCandidate {
390 pub tablet_id: TabletId,
392 pub row_id: RowId,
394 pub final_score: f64,
396 pub raw_score: f64,
398 pub components: Vec<HybridComponentScore>,
400}
401
402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
404pub struct RetrieverTabletBound {
405 pub tablet_id: TabletId,
407 pub retriever_id: String,
409 pub next_rank: Option<u32>,
412 pub unseen_score_bound: Option<f64>,
415 pub exhausted: bool,
417 pub weight: f64,
419}
420
421impl RetrieverTabletBound {
422 pub fn exhausted(tablet_id: TabletId, retriever_id: impl Into<String>) -> Self {
424 Self {
425 tablet_id,
426 retriever_id: retriever_id.into(),
427 next_rank: None,
428 unseen_score_bound: None,
429 exhausted: true,
430 weight: 1.0,
431 }
432 }
433
434 pub fn open(
436 tablet_id: TabletId,
437 retriever_id: impl Into<String>,
438 next_rank: u32,
439 unseen_score_bound: Option<f64>,
440 weight: f64,
441 ) -> Self {
442 Self {
443 tablet_id,
444 retriever_id: retriever_id.into(),
445 next_rank: Some(next_rank.max(1)),
446 unseen_score_bound,
447 exhausted: false,
448 weight,
449 }
450 }
451}
452
453#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455pub struct HybridMergeResult {
456 pub candidates: Vec<HybridMergedCandidate>,
458 pub refill: Vec<(TabletId, String)>,
461}
462
463pub fn merge_hybrid_contributions(
475 contributions: &[LocalRetrieverContribution],
476 method: FusionMethod,
477 budget: &AiWorkBudget,
478 bounds: &[RetrieverTabletBound],
479) -> Result<HybridMergeResult, AiRetrievalError> {
480 if contributions.len() > budget.max_local_candidates {
481 return Err(AiRetrievalError::BudgetExceeded(format!(
482 "{} hybrid contributions > max {}",
483 contributions.len(),
484 budget.max_local_candidates
485 )));
486 }
487 for c in contributions {
488 if !c.rls_visible {
489 return Err(AiRetrievalError::RlsHygiene {
490 tablet_id: c.tablet_id,
491 row_id: c.row_id.0,
492 });
493 }
494 if c.local_rank == 0 {
495 return Err(AiRetrievalError::Protocol(
496 "local_rank must be 1-based (got 0)".to_owned(),
497 ));
498 }
499 if !c.raw_score.is_finite() || !c.weight.is_finite() {
500 return Err(AiRetrievalError::Protocol(
501 "contribution scores and weights must be finite".to_owned(),
502 ));
503 }
504 }
505
506 let mut best: HashMap<(RowId, String), LocalRetrieverContribution> = HashMap::new();
508 for c in contributions {
509 let key = (c.row_id, c.retriever_id.clone());
510 best.entry(key)
511 .and_modify(|existing| {
512 if contribution_better(c, existing) {
513 *existing = c.clone();
514 }
515 })
516 .or_insert_with(|| c.clone());
517 }
518
519 let mut by_row: HashMap<RowId, Vec<LocalRetrieverContribution>> = HashMap::new();
521 for c in best.into_values() {
522 by_row.entry(c.row_id).or_default().push(c);
523 }
524
525 let mut fused = Vec::with_capacity(by_row.len());
526 for (row_id, mut comps) in by_row {
527 comps.sort_by(|a, b| {
528 a.retriever_id
529 .cmp(&b.retriever_id)
530 .then_with(|| a.tablet_id.cmp(&b.tablet_id))
531 });
532 let tablet_id = comps
533 .iter()
534 .map(|c| c.tablet_id)
535 .min()
536 .expect("row group is non-empty");
537 let raw_score = comps
538 .iter()
539 .map(|c| c.raw_score)
540 .fold(f64::NEG_INFINITY, f64::max);
541 let (final_score, components) = match method {
542 FusionMethod::Rrf { k } => {
543 let mut components = Vec::with_capacity(comps.len());
544 let mut score = 0.0;
545 for c in &comps {
546 let contribution = c.weight / (f64::from(k) + f64::from(c.local_rank));
547 if !contribution.is_finite() {
548 return Err(AiRetrievalError::Protocol(
549 "RRF contribution must be finite".to_owned(),
550 ));
551 }
552 score += contribution;
553 components.push(HybridComponentScore {
554 retriever_id: c.retriever_id.clone(),
555 retriever_kind: c.retriever_kind.clone(),
556 tablet_id: c.tablet_id,
557 rank: c.local_rank,
558 raw_score: c.raw_score,
559 contribution,
560 weight: c.weight,
561 });
562 }
563 (score, components)
564 }
565 FusionMethod::MaxScore => {
566 let mut components = Vec::with_capacity(comps.len());
567 let mut score = f64::NEG_INFINITY;
568 for c in &comps {
569 let contribution = c.raw_score * c.weight;
570 score = score.max(contribution);
571 components.push(HybridComponentScore {
572 retriever_id: c.retriever_id.clone(),
573 retriever_kind: c.retriever_kind.clone(),
574 tablet_id: c.tablet_id,
575 rank: c.local_rank,
576 raw_score: c.raw_score,
577 contribution,
578 weight: c.weight,
579 });
580 }
581 (score, components)
582 }
583 };
584 if !final_score.is_finite() {
585 return Err(AiRetrievalError::Protocol(
586 "fused score must be finite".to_owned(),
587 ));
588 }
589 fused.push(HybridMergedCandidate {
590 tablet_id,
591 row_id,
592 final_score,
593 raw_score,
594 components,
595 });
596 }
597
598 fused.sort_by(hybrid_candidate_cmp);
599 if fused.len() > budget.candidate_ceiling {
600 fused.truncate(budget.candidate_ceiling);
601 }
602
603 let refill = hybrid_refill_targets(&fused, budget.candidate_ceiling, method, bounds);
604 Ok(HybridMergeResult {
605 candidates: fused,
606 refill,
607 })
608}
609
610fn contribution_better(a: &LocalRetrieverContribution, b: &LocalRetrieverContribution) -> bool {
613 a.local_rank < b.local_rank
614 || (a.local_rank == b.local_rank
615 && (a.raw_score > b.raw_score
616 || (a.raw_score == b.raw_score && a.tablet_id < b.tablet_id)))
617}
618
619fn hybrid_candidate_cmp(a: &HybridMergedCandidate, b: &HybridMergedCandidate) -> Ordering {
621 b.final_score
622 .partial_cmp(&a.final_score)
623 .unwrap_or(Ordering::Equal)
624 .then_with(|| a.tablet_id.cmp(&b.tablet_id))
625 .then_with(|| a.row_id.cmp(&b.row_id))
626}
627
628pub fn hybrid_refill_targets(
638 winners: &[HybridMergedCandidate],
639 k: usize,
640 method: FusionMethod,
641 bounds: &[RetrieverTabletBound],
642) -> Vec<(TabletId, String)> {
643 if k == 0 || bounds.is_empty() {
644 return Vec::new();
645 }
646 let mut refill = Vec::new();
647 let threshold = if winners.len() < k {
648 None
649 } else {
650 Some(winners[k - 1].final_score)
651 };
652 for bound in bounds {
653 if bound.exhausted {
654 continue;
655 }
656 let Some(next_rank) = bound.next_rank.filter(|r| *r > 0) else {
657 continue;
658 };
659 let optimistic = match method {
660 FusionMethod::Rrf { k: rrf_k } => {
661 bound.weight / (f64::from(rrf_k) + f64::from(next_rank))
662 }
663 FusionMethod::MaxScore => bound
664 .unseen_score_bound
665 .map(|s| s * bound.weight)
666 .unwrap_or(f64::INFINITY),
667 };
668 if !optimistic.is_finite() {
669 refill.push((bound.tablet_id, bound.retriever_id.clone()));
670 continue;
671 }
672 match threshold {
673 None => refill.push((bound.tablet_id, bound.retriever_id.clone())),
674 Some(t) if optimistic >= t => {
675 refill.push((bound.tablet_id, bound.retriever_id.clone()));
676 }
677 Some(_) => {}
678 }
679 }
680 refill.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
681 refill.dedup();
682 refill
683}
684
685pub fn exact_hybrid_merge<F>(
692 mut contributions: Vec<LocalRetrieverContribution>,
693 mut bounds: HashMap<(TabletId, String), RetrieverTabletBound>,
694 method: FusionMethod,
695 budget: &AiWorkBudget,
696 global_k: usize,
697 mut refill_batch: F,
698) -> Result<Vec<HybridMergedCandidate>, AiRetrievalError>
699where
700 F: FnMut(
701 TabletId,
702 &str,
703 )
704 -> Result<(Vec<LocalRetrieverContribution>, RetrieverTabletBound), AiRetrievalError>,
705{
706 let mut rounds = 0usize;
707 let max_refill_rounds = budget.max_refill_rounds.max(1);
708 loop {
709 if contributions.len() > budget.max_local_candidates {
710 return Err(AiRetrievalError::BudgetExceeded(format!(
711 "hybrid contributions {} exceed max {}",
712 contributions.len(),
713 budget.max_local_candidates
714 )));
715 }
716 if contributions.len() > budget.max_retained_contributions {
717 return Err(AiRetrievalError::BudgetExceeded(format!(
718 "hybrid contributions {} exceed max_retained_contributions {}",
719 contributions.len(),
720 budget.max_retained_contributions
721 )));
722 }
723 let bound_list: Vec<RetrieverTabletBound> = bounds.values().cloned().collect();
724 let mut step_budget = budget.clone();
725 step_budget.candidate_ceiling = step_budget.candidate_ceiling.max(global_k).max(1);
728 let mut result =
729 merge_hybrid_contributions(&contributions, method, &step_budget, &bound_list)?;
730 if global_k > 0 && result.candidates.len() > global_k {
731 result.candidates.truncate(global_k);
732 result.refill =
734 hybrid_refill_targets(&result.candidates, global_k, method, &bound_list);
735 }
736 if result.refill.is_empty() {
737 if result.candidates.len() > budget.candidate_ceiling {
738 result.candidates.truncate(budget.candidate_ceiling);
739 }
740 return Ok(result.candidates);
741 }
742 rounds += 1;
743 if rounds > max_refill_rounds {
744 return Err(AiRetrievalError::BudgetExceeded(format!(
745 "hybrid adaptive refill exceeded {max_refill_rounds} rounds"
746 )));
747 }
748 for (tablet, retriever) in result.refill {
749 let (batch, new_bound) = refill_batch(tablet, &retriever)?;
750 let key = (tablet, retriever.clone());
751 let previous = bounds.get(&key).cloned();
752 if batch.is_empty()
753 && previous
754 .as_ref()
755 .is_some_and(|p| !p.exhausted && p.next_rank == new_bound.next_rank)
756 {
757 return Err(AiRetrievalError::Protocol(format!(
758 "hybrid refill for tablet {tablet} retriever `{retriever}` made no progress"
759 )));
760 }
761 for c in &batch {
762 if c.tablet_id != tablet || c.retriever_id != retriever {
763 return Err(AiRetrievalError::Protocol(format!(
764 "hybrid refill batch must match tablet {tablet} retriever `{retriever}`"
765 )));
766 }
767 }
768 contributions.extend(batch);
769 bounds.insert(key, new_bound);
770 }
771 }
772}
773
774#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
776pub struct AiConsistencyAudit {
777 pub read_ts: String,
779 pub replica_applied_ts: String,
781 pub staleness_micros: u64,
783 pub index_applied_ts: String,
785 pub model_version: Option<String>,
787 pub preprocessing_version: Option<String>,
789}
790
791pub const REMOTE_AI_SERVICE_ID: u32 = 2;
797pub const REMOTE_AI_PROTOCOL_VERSION: u16 = 1;
799pub const DEFAULT_REMOTE_AI_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
801pub const DEFAULT_REMOTE_AI_EXECUTIONS: usize = 256;
803pub const MAX_AI_AUTHORIZATION_CONTEXT_BYTES: usize = 64 * 1024;
805
806#[derive(Debug, Clone, Serialize, Deserialize)]
813pub struct AiTabletQuery {
814 pub query_id: QueryId,
816 pub tablet_id: TabletId,
818 pub table: String,
820 pub search: SearchRequest,
823 pub authorization_context: Vec<u8>,
825 pub deadline_ms: Option<u64>,
827 pub budget: AiWorkBudget,
829}
830
831#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
838pub struct TabletAiResponseMetadata {
839 #[serde(default)]
841 pub retriever_exhausted: bool,
842 #[serde(default)]
845 pub unseen_score_bound: Option<f64>,
846 #[serde(default)]
848 pub index_applied_hlc: Option<String>,
849 #[serde(default)]
851 pub read_hlc: Option<String>,
852 #[serde(default)]
854 pub model_generation: Option<u64>,
855}
856
857#[derive(Debug, Clone, Serialize, Deserialize)]
859pub struct AiTabletHit {
860 pub candidate: LocalCandidate,
862 pub cells: Vec<(u16, Value)>,
864 pub exact_rerank_score: Option<f32>,
866 pub consistency: Option<AiConsistencyAudit>,
868 #[serde(default)]
875 pub contributions: Vec<LocalRetrieverContribution>,
876 #[serde(default)]
878 pub metadata: TabletAiResponseMetadata,
879}
880
881#[async_trait::async_trait]
883pub trait AiTabletExecutor: Send + Sync {
884 async fn retrieve(
887 &self,
888 request: &AiTabletQuery,
889 control: ExecutionControl,
890 ) -> Result<Vec<AiTabletHit>, AiRetrievalError>;
891}
892
893#[derive(Debug, Clone, Serialize, Deserialize)]
894struct RemoteAiEnvelope {
895 version: u16,
896 request: RemoteAiRequest,
897}
898
899#[derive(Debug, Clone, Serialize, Deserialize)]
900enum RemoteAiRequest {
901 Retrieve(Box<AiTabletQuery>),
902 Cancel {
903 query_id: QueryId,
904 tablet_id: TabletId,
905 },
906}
907
908#[derive(Debug, Clone, Serialize, Deserialize)]
909struct RemoteAiResponseEnvelope {
910 version: u16,
911 response: RemoteAiResponse,
912}
913
914#[derive(Debug, Clone, Serialize, Deserialize)]
915enum RemoteAiResponse {
916 Hits(Vec<AiTabletHit>),
917 Cancelled,
918 Error(String),
919}
920
921type AiExecutionKey = (QueryId, TabletId);
922
923pub struct RemoteAiEndpoint {
925 executor: Arc<dyn AiTabletExecutor>,
926 controls: parking_lot::Mutex<HashMap<AiExecutionKey, ExecutionControl>>,
927 max_executions: usize,
928 max_message_bytes: usize,
929}
930
931impl RemoteAiEndpoint {
932 pub fn new(executor: Arc<dyn AiTabletExecutor>) -> Self {
934 Self::with_limits(
935 executor,
936 DEFAULT_REMOTE_AI_EXECUTIONS,
937 DEFAULT_REMOTE_AI_MESSAGE_BYTES,
938 )
939 }
940
941 pub fn with_limits(
943 executor: Arc<dyn AiTabletExecutor>,
944 max_executions: usize,
945 max_message_bytes: usize,
946 ) -> Self {
947 Self {
948 executor,
949 controls: parking_lot::Mutex::new(HashMap::new()),
950 max_executions: max_executions.max(1),
951 max_message_bytes: max_message_bytes.max(1),
952 }
953 }
954
955 pub fn active_executions(&self) -> usize {
957 self.controls.lock().len()
958 }
959
960 pub async fn handle(&self, bytes: &[u8]) -> Result<Vec<u8>, AiRetrievalError> {
962 let envelope: RemoteAiEnvelope = decode_ai_wire(bytes, self.max_message_bytes)?;
963 if envelope.version != REMOTE_AI_PROTOCOL_VERSION {
964 return Err(AiRetrievalError::Protocol(format!(
965 "unsupported distributed-AI protocol version {}; supported version is {}",
966 envelope.version, REMOTE_AI_PROTOCOL_VERSION
967 )));
968 }
969 let response = match self.handle_request(envelope.request).await {
970 Ok(response) => response,
971 Err(error) => RemoteAiResponse::Error(error.to_string()),
972 };
973 encode_ai_wire(
974 &RemoteAiResponseEnvelope {
975 version: REMOTE_AI_PROTOCOL_VERSION,
976 response,
977 },
978 self.max_message_bytes,
979 )
980 }
981
982 async fn handle_request(
983 &self,
984 request: RemoteAiRequest,
985 ) -> Result<RemoteAiResponse, AiRetrievalError> {
986 match request {
987 RemoteAiRequest::Retrieve(request) => {
988 if request.authorization_context.len() > MAX_AI_AUTHORIZATION_CONTEXT_BYTES {
989 return Err(AiRetrievalError::Protocol(format!(
990 "authorization context is {} bytes; limit is {}",
991 request.authorization_context.len(),
992 MAX_AI_AUTHORIZATION_CONTEXT_BYTES
993 )));
994 }
995 let key = (request.query_id, request.tablet_id);
996 let control = request.deadline_ms.map_or_else(
997 || ExecutionControl::new(None),
998 |milliseconds| {
999 ExecutionControl::with_timeout(Duration::from_millis(milliseconds))
1000 },
1001 );
1002 {
1003 let mut controls = self.controls.lock();
1004 if controls.contains_key(&key) {
1005 return Err(AiRetrievalError::Protocol(format!(
1006 "query {} is already retrieving tablet {}",
1007 request.query_id, request.tablet_id
1008 )));
1009 }
1010 if controls.len() >= self.max_executions {
1011 return Err(AiRetrievalError::BudgetExceeded(format!(
1012 "worker holds {} AI requests; limit is {}",
1013 controls.len(),
1014 self.max_executions
1015 )));
1016 }
1017 controls.insert(key, control.clone());
1018 }
1019 let result = self.executor.retrieve(&request, control.clone()).await;
1020 let was_active = self.controls.lock().remove(&key).is_some();
1021 if !was_active || control.checkpoint().is_err() {
1022 return Err(AiRetrievalError::Cancelled(control.reason()));
1023 }
1024 let hits = result?;
1025 for hit in &hits {
1026 if hit.candidate.tablet_id != request.tablet_id {
1027 return Err(AiRetrievalError::Protocol(format!(
1028 "tablet {} returned candidate labeled {}",
1029 request.tablet_id, hit.candidate.tablet_id
1030 )));
1031 }
1032 if !hit.candidate.rls_visible {
1033 return Err(AiRetrievalError::RlsHygiene {
1034 tablet_id: hit.candidate.tablet_id,
1035 row_id: hit.candidate.row_id.0,
1036 });
1037 }
1038 }
1039 Ok(RemoteAiResponse::Hits(hits))
1040 }
1041 RemoteAiRequest::Cancel {
1042 query_id,
1043 tablet_id,
1044 } => {
1045 if let Some(control) = self.controls.lock().get(&(query_id, tablet_id)).cloned() {
1046 control.cancel(CancellationReason::ClientRequest);
1047 }
1048 Ok(RemoteAiResponse::Cancelled)
1049 }
1050 }
1051 }
1052}
1053
1054#[async_trait::async_trait]
1056pub trait AiRpcClient: Send + Sync {
1057 async fn call(&self, request: Vec<u8>) -> Result<Vec<u8>, AiRetrievalError>;
1059}
1060
1061pub struct LoopbackAiRpcClient {
1063 endpoint: Arc<RemoteAiEndpoint>,
1064}
1065
1066impl LoopbackAiRpcClient {
1067 pub fn new(endpoint: Arc<RemoteAiEndpoint>) -> Self {
1069 Self { endpoint }
1070 }
1071}
1072
1073#[async_trait::async_trait]
1074impl AiRpcClient for LoopbackAiRpcClient {
1075 async fn call(&self, request: Vec<u8>) -> Result<Vec<u8>, AiRetrievalError> {
1076 self.endpoint.handle(&request).await
1077 }
1078}
1079
1080#[derive(Debug, Clone)]
1082pub struct DistributedAiResult {
1083 pub candidates: Vec<MergedCandidate>,
1085 pub hits: Vec<AiTabletHit>,
1087}
1088
1089pub fn fuse_distributed_hits(
1099 hits: &[AiTabletHit],
1100 search: &SearchRequest,
1101 method: FusionMethod,
1102 budget: &AiWorkBudget,
1103) -> Result<Vec<MergedCandidate>, AiRetrievalError> {
1104 validate_hit_metadata(hits, budget)?;
1105 let multi_retriever = search.retrievers.len() > 1;
1106 let has_contributions = hits.iter().any(|hit| !hit.contributions.is_empty());
1107 let mut candidates = if multi_retriever || has_contributions {
1108 let contributions = collect_hit_contributions(hits, search)?;
1109 if contributions.len() > budget.max_retained_contributions {
1110 return Err(AiRetrievalError::BudgetExceeded(format!(
1111 "{} contributions > max_retained_contributions {}",
1112 contributions.len(),
1113 budget.max_retained_contributions
1114 )));
1115 }
1116 let hybrid = merge_hybrid_contributions(&contributions, method, budget, &[])?;
1117 hybrid
1118 .candidates
1119 .into_iter()
1120 .map(|c| MergedCandidate {
1121 tablet_id: c.tablet_id,
1122 row_id: c.row_id,
1123 final_score: c.final_score,
1124 raw_score: c.raw_score,
1125 })
1126 .collect()
1127 } else {
1128 let locals = hits
1129 .iter()
1130 .map(|hit| hit.candidate.clone())
1131 .collect::<Vec<_>>();
1132 merge_candidates(&locals, method, budget)?
1133 };
1134 apply_exact_global_rerank(&mut candidates, hits, search, budget)?;
1135 Ok(candidates)
1136}
1137
1138fn apply_exact_global_rerank(
1147 candidates: &mut Vec<MergedCandidate>,
1148 hits: &[AiTabletHit],
1149 search: &SearchRequest,
1150 budget: &AiWorkBudget,
1151) -> Result<(), AiRetrievalError> {
1152 let has_scores = hits.iter().any(|hit| hit.exact_rerank_score.is_some());
1153 if !has_scores {
1154 return Ok(());
1155 }
1156 let (weight, candidate_limit) = match &search.rerank {
1157 Some(Rerank::ExactVector {
1158 candidate_limit,
1159 weight,
1160 ..
1161 }) => {
1162 if !weight.is_finite() || *weight < 0.0 {
1163 return Err(AiRetrievalError::Protocol(
1164 "exact rerank weight must be finite and non-negative".to_owned(),
1165 ));
1166 }
1167 (*weight, (*candidate_limit).max(1))
1168 }
1169 _ => (1.0, candidates.len().max(1)),
1171 };
1172 if candidates.len() > candidate_limit {
1173 candidates.truncate(candidate_limit);
1174 }
1175 if candidates.len() > budget.max_exact_gathers {
1176 return Err(AiRetrievalError::BudgetExceeded(format!(
1177 "{} exact gather candidates > max_exact_gathers {}",
1178 candidates.len(),
1179 budget.max_exact_gathers
1180 )));
1181 }
1182 let mut by_key: HashMap<(TabletId, RowId), f32> = HashMap::new();
1185 let mut by_row: HashMap<RowId, f32> = HashMap::new();
1186 for hit in hits {
1187 let Some(score) = hit.exact_rerank_score else {
1188 continue;
1189 };
1190 if !score.is_finite() {
1191 return Err(AiRetrievalError::Protocol(
1192 "exact_rerank_score must be finite".to_owned(),
1193 ));
1194 }
1195 by_key.insert((hit.candidate.tablet_id, hit.candidate.row_id), score);
1196 by_row
1197 .entry(hit.candidate.row_id)
1198 .and_modify(|existing| {
1199 if score > *existing {
1200 *existing = score;
1201 }
1202 })
1203 .or_insert(score);
1204 }
1205 for candidate in candidates.iter_mut() {
1206 let exact = by_key
1207 .get(&(candidate.tablet_id, candidate.row_id))
1208 .or_else(|| by_row.get(&candidate.row_id));
1209 if let Some(score) = exact {
1210 let blended = candidate.final_score + weight * f64::from(*score);
1211 if !blended.is_finite() {
1212 return Err(AiRetrievalError::Protocol(
1213 "exact-reranked final_score must be finite".to_owned(),
1214 ));
1215 }
1216 candidate.final_score = blended;
1217 }
1218 }
1219 candidates.sort_by(|a, b| {
1220 b.final_score
1221 .partial_cmp(&a.final_score)
1222 .unwrap_or(Ordering::Equal)
1223 .then_with(|| a.tablet_id.cmp(&b.tablet_id))
1224 .then_with(|| a.row_id.cmp(&b.row_id))
1225 });
1226 if candidates.len() > budget.candidate_ceiling {
1227 candidates.truncate(budget.candidate_ceiling);
1228 }
1229 Ok(())
1230}
1231
1232fn validate_hit_metadata(
1234 hits: &[AiTabletHit],
1235 budget: &AiWorkBudget,
1236) -> Result<(), AiRetrievalError> {
1237 let mut total_cells = 0usize;
1238 let mut observed_generation: Option<u64> = None;
1239 for hit in hits {
1240 total_cells = total_cells.saturating_add(hit.cells.len());
1241 if let Some(gen) = hit.metadata.model_generation {
1242 if let Some(expected) = budget.expected_model_generation {
1243 if gen != expected {
1244 return Err(AiRetrievalError::Protocol(format!(
1245 "tablet model generation {gen} != expected {expected}"
1246 )));
1247 }
1248 }
1249 match observed_generation {
1250 None => observed_generation = Some(gen),
1251 Some(prev) if prev != gen => {
1252 return Err(AiRetrievalError::Protocol(format!(
1253 "model generation mismatch across tablets: {prev} vs {gen}"
1254 )));
1255 }
1256 Some(_) => {}
1257 }
1258 } else if let Some(expected) = budget.expected_model_generation {
1259 return Err(AiRetrievalError::Protocol(format!(
1261 "tablet omitted model_generation; expected {expected}"
1262 )));
1263 }
1264 }
1265 if total_cells > budget.max_payload_cells {
1266 return Err(AiRetrievalError::BudgetExceeded(format!(
1267 "{total_cells} payload cells > max {}",
1268 budget.max_payload_cells
1269 )));
1270 }
1271 Ok(())
1272}
1273
1274fn collect_hit_contributions(
1276 hits: &[AiTabletHit],
1277 search: &SearchRequest,
1278) -> Result<Vec<LocalRetrieverContribution>, AiRetrievalError> {
1279 let weight_by_name: HashMap<&str, f64> = search
1280 .retrievers
1281 .iter()
1282 .map(|named| (named.name.as_str(), named.weight))
1283 .collect();
1284 let mut out = Vec::new();
1285 for hit in hits {
1286 if hit.contributions.is_empty() {
1287 let retriever_id = search
1291 .retrievers
1292 .first()
1293 .map(|named| named.name.clone())
1294 .unwrap_or_else(|| "local".to_owned());
1295 let weight = weight_by_name
1296 .get(retriever_id.as_str())
1297 .copied()
1298 .unwrap_or(1.0);
1299 out.push(LocalRetrieverContribution {
1300 tablet_id: hit.candidate.tablet_id,
1301 row_id: hit.candidate.row_id,
1302 retriever_id,
1303 retriever_kind: None,
1304 local_rank: hit.candidate.local_rank.max(1),
1305 raw_score: hit.candidate.score,
1306 upper_bound_after: None,
1307 rls_visible: hit.candidate.rls_visible,
1308 weight,
1309 });
1310 continue;
1311 }
1312 for mut contribution in hit.contributions.iter().cloned() {
1313 if contribution.tablet_id != hit.candidate.tablet_id {
1314 contribution.tablet_id = hit.candidate.tablet_id;
1315 }
1316 if contribution.row_id != hit.candidate.row_id {
1317 return Err(AiRetrievalError::Protocol(format!(
1318 "contribution row {} does not match hit row {}",
1319 contribution.row_id.0, hit.candidate.row_id.0
1320 )));
1321 }
1322 if let Some(weight) = weight_by_name.get(contribution.retriever_id.as_str()) {
1323 contribution.weight = *weight;
1324 }
1325 out.push(contribution);
1326 }
1327 }
1328 Ok(out)
1329}
1330
1331pub struct AiFanoutRequest<'a> {
1333 pub query_id: QueryId,
1335 pub tablets: &'a [TabletId],
1337 pub table: &'a str,
1339 pub search: &'a SearchRequest,
1341 pub authorization_context: &'a [u8],
1343 pub fusion: FusionMethod,
1345 pub overfetch_factor: f64,
1347 pub budget: &'a AiWorkBudget,
1349 pub control: &'a ExecutionControl,
1351}
1352
1353pub struct RemoteAiTransport {
1355 default_client: Option<Arc<dyn AiRpcClient>>,
1356 clients: parking_lot::RwLock<HashMap<TabletId, Arc<dyn AiRpcClient>>>,
1357 max_message_bytes: usize,
1358}
1359
1360impl RemoteAiTransport {
1361 pub fn new(default_client: Arc<dyn AiRpcClient>) -> Self {
1363 Self {
1364 default_client: Some(default_client),
1365 clients: parking_lot::RwLock::new(HashMap::new()),
1366 max_message_bytes: DEFAULT_REMOTE_AI_MESSAGE_BYTES,
1367 }
1368 }
1369
1370 pub fn routed() -> Self {
1372 Self {
1373 default_client: None,
1374 clients: parking_lot::RwLock::new(HashMap::new()),
1375 max_message_bytes: DEFAULT_REMOTE_AI_MESSAGE_BYTES,
1376 }
1377 }
1378
1379 pub fn with_client(self, tablet: TabletId, client: Arc<dyn AiRpcClient>) -> Self {
1381 self.clients.write().insert(tablet, client);
1382 self
1383 }
1384
1385 fn client_for(&self, tablet: TabletId) -> Result<Arc<dyn AiRpcClient>, AiRetrievalError> {
1386 self.clients
1387 .read()
1388 .get(&tablet)
1389 .cloned()
1390 .or_else(|| self.default_client.as_ref().map(Arc::clone))
1391 .ok_or_else(|| {
1392 AiRetrievalError::Transport(format!(
1393 "no authenticated AI route for tablet {tablet}"
1394 ))
1395 })
1396 }
1397
1398 pub async fn retrieve(
1401 &self,
1402 request: AiFanoutRequest<'_>,
1403 ) -> Result<DistributedAiResult, AiRetrievalError> {
1404 let AiFanoutRequest {
1405 query_id,
1406 tablets,
1407 table,
1408 search,
1409 authorization_context,
1410 fusion,
1411 overfetch_factor,
1412 budget,
1413 control,
1414 } = request;
1415 if tablets.is_empty() {
1416 return Ok(DistributedAiResult {
1417 candidates: Vec::new(),
1418 hits: Vec::new(),
1419 });
1420 }
1421 if tablets.len() > budget.max_tablet_rpcs {
1422 return Err(AiRetrievalError::BudgetExceeded(format!(
1423 "{} tablets > max_tablet_rpcs {}",
1424 tablets.len(),
1425 budget.max_tablet_rpcs
1426 )));
1427 }
1428 if authorization_context.len() > MAX_AI_AUTHORIZATION_CONTEXT_BYTES {
1429 return Err(AiRetrievalError::Protocol(format!(
1430 "authorization context is {} bytes; limit is {}",
1431 authorization_context.len(),
1432 MAX_AI_AUTHORIZATION_CONTEXT_BYTES
1433 )));
1434 }
1435 if !overfetch_factor.is_finite() || overfetch_factor <= 0.0 {
1436 return Err(AiRetrievalError::Protocol(
1437 "overfetch factor must be finite and positive".to_owned(),
1438 ));
1439 }
1440 let request_control = control.child_with_timeout(Duration::from_millis(budget.deadline_ms));
1441 request_control
1442 .checkpoint()
1443 .map_err(|_| AiRetrievalError::Cancelled(request_control.reason()))?;
1444 let local_k = adaptive_local_k(budget.candidate_ceiling, overfetch_factor, tablets.len());
1445 let total_requested = local_k
1446 .checked_mul(tablets.len())
1447 .ok_or_else(|| AiRetrievalError::BudgetExceeded("candidate count overflow".into()))?;
1448 if total_requested > budget.max_local_candidates {
1449 return Err(AiRetrievalError::BudgetExceeded(format!(
1450 "{total_requested} requested local candidates > max {}",
1451 budget.max_local_candidates
1452 )));
1453 }
1454 let mut local_search = search.clone();
1455 local_search.limit = local_k;
1456 for named in &mut local_search.retrievers {
1457 match &mut named.retriever {
1458 Retriever::Ann { k, .. }
1459 | Retriever::Sparse { k, .. }
1460 | Retriever::MinHash { k, .. } => *k = local_k,
1461 }
1462 }
1463 if let Some(Rerank::ExactVector {
1464 candidate_limit, ..
1465 }) = &mut local_search.rerank
1466 {
1467 *candidate_limit = (*candidate_limit).max(local_k);
1468 }
1469
1470 let mut calls = FuturesUnordered::new();
1471 for &tablet_id in tablets {
1472 let client = self.client_for(tablet_id)?;
1473 let request = AiTabletQuery {
1474 query_id,
1475 tablet_id,
1476 table: table.to_owned(),
1477 search: local_search.clone(),
1478 authorization_context: authorization_context.to_vec(),
1479 deadline_ms: request_control
1480 .remaining_duration()
1481 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64),
1482 budget: budget.clone(),
1483 };
1484 let max_message_bytes = self.max_message_bytes;
1485 calls.push(async move {
1486 let response = ai_remote_call(
1487 &client,
1488 RemoteAiRequest::Retrieve(Box::new(request)),
1489 max_message_bytes,
1490 )
1491 .await;
1492 (tablet_id, client, response)
1493 });
1494 }
1495 let mut hits = Vec::new();
1496 let mut retained_bytes = 0u64;
1497 while !calls.is_empty() {
1498 tokio::select! {
1499 _ = request_control.cancelled() => {
1500 for &tablet_id in tablets {
1501 let client = self.client_for(tablet_id)?;
1502 let max_message_bytes = self.max_message_bytes;
1503 tokio::spawn(async move {
1504 let _ = ai_remote_call(
1505 &client,
1506 RemoteAiRequest::Cancel { query_id, tablet_id },
1507 max_message_bytes,
1508 ).await;
1509 });
1510 }
1511 return Err(AiRetrievalError::Cancelled(request_control.reason()));
1512 }
1513 result = calls.next() => {
1514 let Some((tablet_id, _client, response)) = result else {
1515 break;
1516 };
1517 let response = match response {
1518 Ok(response) => response,
1519 Err(error) => {
1520 self.cancel_tablets(query_id, tablets);
1521 return Err(error);
1522 }
1523 };
1524 match response {
1525 RemoteAiResponse::Hits(mut tablet_hits) => {
1526 if tablet_hits.len() > local_k {
1527 self.cancel_tablets(query_id, tablets);
1528 return Err(AiRetrievalError::Protocol(format!(
1529 "tablet {tablet_id} returned {} candidates; local limit is {local_k}",
1530 tablet_hits.len()
1531 )));
1532 }
1533 let bytes = ai_wire_options()
1534 .serialized_size(&tablet_hits)
1535 .map_err(|error| AiRetrievalError::Protocol(error.to_string()))?;
1536 retained_bytes = retained_bytes.saturating_add(bytes);
1537 if retained_bytes > budget.memory_bytes {
1538 self.cancel_tablets(query_id, tablets);
1539 return Err(AiRetrievalError::BudgetExceeded(format!(
1540 "{retained_bytes} retained result bytes > memory budget {}",
1541 budget.memory_bytes
1542 )));
1543 }
1544 hits.append(&mut tablet_hits);
1545 }
1546 other => {
1547 self.cancel_tablets(query_id, tablets);
1548 return Err(AiRetrievalError::Protocol(format!(
1549 "expected AI Hits response, got {other:?}"
1550 )));
1551 }
1552 }
1553 }
1554 }
1555 }
1556 if hits.len() > budget.max_local_candidates {
1557 return Err(AiRetrievalError::BudgetExceeded(format!(
1558 "{} returned local candidates > max {}",
1559 hits.len(),
1560 budget.max_local_candidates
1561 )));
1562 }
1563 let candidates = fuse_distributed_hits(&hits, search, fusion, budget)?;
1566 let by_key = hits
1567 .into_iter()
1568 .map(|hit| ((hit.candidate.tablet_id, hit.candidate.row_id), hit))
1569 .collect::<HashMap<_, _>>();
1570 let winner_hits = candidates
1571 .iter()
1572 .map(|candidate| {
1573 by_key
1574 .get(&(candidate.tablet_id, candidate.row_id))
1575 .cloned()
1576 .or_else(|| {
1577 by_key
1581 .iter()
1582 .find(|((_, row), _)| *row == candidate.row_id)
1583 .map(|(_, hit)| hit.clone())
1584 })
1585 .ok_or_else(|| {
1586 AiRetrievalError::Protocol(format!(
1587 "winner {} from tablet {} has no payload",
1588 candidate.row_id, candidate.tablet_id
1589 ))
1590 })
1591 })
1592 .collect::<Result<Vec<_>, _>>()?;
1593 Ok(DistributedAiResult {
1594 candidates,
1595 hits: winner_hits,
1596 })
1597 }
1598
1599 fn cancel_tablets(&self, query_id: QueryId, tablets: &[TabletId]) {
1600 for &tablet_id in tablets {
1601 let Ok(client) = self.client_for(tablet_id) else {
1602 continue;
1603 };
1604 let max_message_bytes = self.max_message_bytes;
1605 tokio::spawn(async move {
1606 let _ = ai_remote_call(
1607 &client,
1608 RemoteAiRequest::Cancel {
1609 query_id,
1610 tablet_id,
1611 },
1612 max_message_bytes,
1613 )
1614 .await;
1615 });
1616 }
1617 }
1618}
1619
1620async fn ai_remote_call(
1621 client: &Arc<dyn AiRpcClient>,
1622 request: RemoteAiRequest,
1623 max_message_bytes: usize,
1624) -> Result<RemoteAiResponse, AiRetrievalError> {
1625 let bytes = encode_ai_wire(
1626 &RemoteAiEnvelope {
1627 version: REMOTE_AI_PROTOCOL_VERSION,
1628 request,
1629 },
1630 max_message_bytes,
1631 )?;
1632 let bytes = client.call(bytes).await?;
1633 let response: RemoteAiResponseEnvelope = decode_ai_wire(&bytes, max_message_bytes)?;
1634 if response.version != REMOTE_AI_PROTOCOL_VERSION {
1635 return Err(AiRetrievalError::Protocol(format!(
1636 "peer answered with distributed-AI protocol version {}; supported version is {}",
1637 response.version, REMOTE_AI_PROTOCOL_VERSION
1638 )));
1639 }
1640 match response.response {
1641 RemoteAiResponse::Error(message) => Err(AiRetrievalError::Transport(message)),
1642 response => Ok(response),
1643 }
1644}
1645
1646fn ai_wire_options() -> impl Options {
1647 bincode::DefaultOptions::new()
1648 .with_fixint_encoding()
1649 .reject_trailing_bytes()
1650}
1651
1652fn encode_ai_wire<T: Serialize>(
1653 value: &T,
1654 max_message_bytes: usize,
1655) -> Result<Vec<u8>, AiRetrievalError> {
1656 let bytes = ai_wire_options()
1657 .serialize(value)
1658 .map_err(|error| AiRetrievalError::Protocol(error.to_string()))?;
1659 if bytes.len() > max_message_bytes {
1660 return Err(AiRetrievalError::Protocol(format!(
1661 "encoded distributed-AI message is {} bytes; limit is {max_message_bytes}",
1662 bytes.len()
1663 )));
1664 }
1665 Ok(bytes)
1666}
1667
1668fn decode_ai_wire<T: for<'de> Deserialize<'de>>(
1669 bytes: &[u8],
1670 max_message_bytes: usize,
1671) -> Result<T, AiRetrievalError> {
1672 if bytes.len() > max_message_bytes {
1673 return Err(AiRetrievalError::Protocol(format!(
1674 "distributed-AI message is {} bytes; limit is {max_message_bytes}",
1675 bytes.len()
1676 )));
1677 }
1678 ai_wire_options()
1679 .with_limit(max_message_bytes as u64)
1680 .deserialize(bytes)
1681 .map_err(|error| AiRetrievalError::Protocol(error.to_string()))
1682}
1683
1684#[cfg(test)]
1685mod tests {
1686 use super::*;
1687 use std::collections::HashMap;
1688
1689 use mongreldb_core::query::Fusion;
1690
1691 fn tid(n: u8) -> TabletId {
1692 TabletId::from_bytes({
1693 let mut b = [0u8; 16];
1694 b[15] = n;
1695 b
1696 })
1697 }
1698
1699 fn cand(tablet: u8, row: u64, score: f64, rank: u32) -> LocalCandidate {
1700 LocalCandidate {
1701 tablet_id: tid(tablet),
1702 row_id: RowId(row),
1703 score,
1704 local_rank: rank,
1705 rls_visible: true,
1706 }
1707 }
1708
1709 #[test]
1710 fn merge_is_deterministic() {
1711 let locals = vec![
1712 cand(2, 10, 0.9, 1),
1713 cand(1, 20, 0.8, 1),
1714 cand(2, 30, 0.7, 2),
1715 cand(1, 10, 0.95, 2),
1716 ];
1717 let budget = AiWorkBudget {
1718 candidate_ceiling: 10,
1719 ..AiWorkBudget::default()
1720 };
1721 let a = merge_candidates(&locals, FusionMethod::Rrf { k: 60 }, &budget).unwrap();
1722 let b = merge_candidates(&locals, FusionMethod::Rrf { k: 60 }, &budget).unwrap();
1723 assert_eq!(a, b);
1724 assert!(a[0].final_score >= a[1].final_score);
1726 }
1727
1728 #[test]
1729 fn rls_hidden_row_fails_closed() {
1730 let mut c = cand(1, 1, 1.0, 1);
1731 c.rls_visible = false;
1732 let err =
1733 merge_candidates(&[c], FusionMethod::default(), &AiWorkBudget::default()).unwrap_err();
1734 assert!(matches!(err, AiRetrievalError::RlsHygiene { .. }));
1735 }
1736
1737 #[test]
1738 fn adaptive_local_k_scales() {
1739 assert_eq!(adaptive_local_k(10, 2.0, 5), 4); assert_eq!(adaptive_local_k(10, 2.0, 1), 20);
1741 assert_eq!(adaptive_local_k(10, 2.0, 0), 20); }
1743
1744 #[test]
1745 fn tie_break_tablet_then_row() {
1746 let locals = vec![cand(2, 5, 1.0, 1), cand(1, 9, 1.0, 1)];
1748 let out = merge_candidates(
1749 &locals,
1750 FusionMethod::Rrf { k: 60 },
1751 &AiWorkBudget {
1752 candidate_ceiling: 2,
1753 ..AiWorkBudget::default()
1754 },
1755 )
1756 .unwrap();
1757 assert_eq!(out.len(), 2);
1758 assert_eq!(out[0].tablet_id, tid(1));
1760 assert_eq!(out[1].tablet_id, tid(2));
1761 }
1762
1763 struct StaticExecutor;
1764
1765 #[async_trait::async_trait]
1766 impl AiTabletExecutor for StaticExecutor {
1767 async fn retrieve(
1768 &self,
1769 request: &AiTabletQuery,
1770 control: ExecutionControl,
1771 ) -> Result<Vec<AiTabletHit>, AiRetrievalError> {
1772 control
1773 .checkpoint()
1774 .map_err(|_| AiRetrievalError::Cancelled(control.reason()))?;
1775 assert_eq!(request.authorization_context, b"signed-user");
1776 Ok(vec![AiTabletHit {
1777 candidate: LocalCandidate {
1778 tablet_id: request.tablet_id,
1779 row_id: RowId(u64::from(request.tablet_id.as_bytes()[15])),
1780 score: 0.9,
1781 local_rank: 1,
1782 rls_visible: true,
1783 },
1784 cells: vec![(1, Value::Int64(7))],
1785 exact_rerank_score: Some(0.99),
1786 consistency: None,
1787 contributions: Vec::new(),
1788 metadata: TabletAiResponseMetadata::default(),
1789 }])
1790 }
1791 }
1792
1793 struct HybridExecutor;
1796
1797 #[async_trait::async_trait]
1798 impl AiTabletExecutor for HybridExecutor {
1799 async fn retrieve(
1800 &self,
1801 request: &AiTabletQuery,
1802 control: ExecutionControl,
1803 ) -> Result<Vec<AiTabletHit>, AiRetrievalError> {
1804 control
1805 .checkpoint()
1806 .map_err(|_| AiRetrievalError::Cancelled(control.reason()))?;
1807 let tablet = request.tablet_id;
1808 let is_t1 = tablet.as_bytes()[15] == 1;
1812 let (dense_row, sparse_row, dense_rank_10, sparse_rank_10) = if is_t1 {
1813 (10u64, 20u64, 1u32, 2u32)
1814 } else {
1815 (20u64, 10u64, 2u32, 1u32)
1816 };
1817 let mut hits = Vec::new();
1818 for &(row, dense_rank, sparse_rank) in &[
1819 (10u64, dense_rank_10, sparse_rank_10),
1820 (
1821 20u64,
1822 if dense_row == 20 { 1 } else { 2 },
1823 if sparse_row == 20 { 1 } else { 2 },
1824 ),
1825 ] {
1826 let contributions = vec![
1827 LocalRetrieverContribution {
1828 tablet_id: tablet,
1829 row_id: RowId(row),
1830 retriever_id: "dense".into(),
1831 retriever_kind: Some("ann".into()),
1832 local_rank: dense_rank,
1833 raw_score: 1.0 / f64::from(dense_rank),
1834 upper_bound_after: None,
1835 rls_visible: true,
1836 weight: 1.0,
1837 },
1838 LocalRetrieverContribution {
1839 tablet_id: tablet,
1840 row_id: RowId(row),
1841 retriever_id: "sparse".into(),
1842 retriever_kind: Some("sparse".into()),
1843 local_rank: sparse_rank,
1844 raw_score: 1.0 / f64::from(sparse_rank),
1845 upper_bound_after: None,
1846 rls_visible: true,
1847 weight: 1.0,
1848 },
1849 ];
1850 hits.push(AiTabletHit {
1851 candidate: LocalCandidate {
1852 tablet_id: tablet,
1853 row_id: RowId(row),
1854 score: contributions.iter().map(|c| c.raw_score).sum(),
1855 local_rank: dense_rank.min(sparse_rank),
1856 rls_visible: true,
1857 },
1858 cells: vec![(1, Value::Int64(row as i64))],
1859 exact_rerank_score: None,
1860 consistency: None,
1861 contributions,
1862 metadata: TabletAiResponseMetadata {
1863 retriever_exhausted: true,
1864 model_generation: Some(1),
1865 ..TabletAiResponseMetadata::default()
1866 },
1867 });
1868 }
1869 Ok(hits)
1870 }
1871 }
1872
1873 #[tokio::test]
1874 async fn remote_ai_fanout_merges_and_preserves_exact_rerank_payload() {
1875 let endpoint = Arc::new(RemoteAiEndpoint::new(Arc::new(StaticExecutor)));
1876 let client: Arc<dyn AiRpcClient> =
1877 Arc::new(LoopbackAiRpcClient::new(Arc::clone(&endpoint)));
1878 let transport = RemoteAiTransport::new(client);
1879 let budget = AiWorkBudget {
1880 candidate_ceiling: 2,
1881 max_local_candidates: 8,
1882 ..AiWorkBudget::default()
1883 };
1884 let search = SearchRequest {
1885 must: Vec::new(),
1886 retrievers: Vec::new(),
1887 fusion: Fusion::ReciprocalRank { constant: 60 },
1888 rerank: None,
1889 limit: 2,
1890 projection: Some(vec![1]),
1891 };
1892 let result = transport
1893 .retrieve(AiFanoutRequest {
1894 query_id: QueryId::new_random(),
1895 tablets: &[tid(2), tid(1)],
1896 table: "items",
1897 search: &search,
1898 authorization_context: b"signed-user",
1899 fusion: FusionMethod::default(),
1900 overfetch_factor: 2.0,
1901 budget: &budget,
1902 control: &ExecutionControl::new(None),
1903 })
1904 .await
1905 .unwrap();
1906 assert_eq!(result.candidates.len(), 2);
1907 assert_eq!(result.candidates[0].tablet_id, tid(1));
1908 assert_eq!(result.hits[0].exact_rerank_score, Some(0.99));
1909 assert_eq!(result.hits[0].cells, vec![(1, Value::Int64(7))]);
1910 assert_eq!(endpoint.active_executions(), 0);
1911 }
1912
1913 #[test]
1914 fn fuse_distributed_hits_production_path_uses_hybrid_for_multi_retriever() {
1915 let hits = vec![
1919 AiTabletHit {
1920 candidate: LocalCandidate {
1921 tablet_id: tid(1),
1922 row_id: RowId(10),
1923 score: 0.5,
1924 local_rank: 2, rls_visible: true,
1926 },
1927 cells: vec![],
1928 exact_rerank_score: None,
1929 consistency: None,
1930 contributions: vec![
1931 LocalRetrieverContribution::new(tid(1), RowId(10), "dense", 1, 0.9),
1932 LocalRetrieverContribution::new(tid(1), RowId(10), "sparse", 1, 0.8),
1933 ],
1934 metadata: TabletAiResponseMetadata::default(),
1935 },
1936 AiTabletHit {
1937 candidate: LocalCandidate {
1938 tablet_id: tid(2),
1939 row_id: RowId(20),
1940 score: 0.99,
1941 local_rank: 1,
1942 rls_visible: true,
1943 },
1944 cells: vec![],
1945 exact_rerank_score: None,
1946 consistency: None,
1947 contributions: vec![
1948 LocalRetrieverContribution::new(tid(2), RowId(20), "dense", 2, 0.5),
1949 LocalRetrieverContribution::new(tid(2), RowId(20), "sparse", 2, 0.4),
1950 ],
1951 metadata: TabletAiResponseMetadata::default(),
1952 },
1953 ];
1954 let search = SearchRequest {
1955 must: Vec::new(),
1956 retrievers: vec![
1957 mongreldb_core::query::NamedRetriever {
1958 name: "dense".into(),
1959 weight: 1.0,
1960 retriever: Retriever::Ann {
1961 column_id: 1,
1962 query: vec![0.0],
1963 k: 10,
1964 },
1965 },
1966 mongreldb_core::query::NamedRetriever {
1967 name: "sparse".into(),
1968 weight: 1.0,
1969 retriever: Retriever::Sparse {
1970 column_id: 2,
1971 query: vec![],
1972 k: 10,
1973 },
1974 },
1975 ],
1976 fusion: Fusion::ReciprocalRank { constant: 60 },
1977 rerank: None,
1978 limit: 2,
1979 projection: None,
1980 };
1981 let budget = AiWorkBudget {
1982 candidate_ceiling: 2,
1983 max_local_candidates: 16,
1984 ..AiWorkBudget::default()
1985 };
1986 let fused =
1987 fuse_distributed_hits(&hits, &search, FusionMethod::Rrf { k: 60 }, &budget).unwrap();
1988 assert_eq!(fused.len(), 2);
1989 assert_eq!(fused[0].row_id, RowId(10));
1991 assert!((fused[0].final_score - 2.0 / 61.0).abs() < 1e-12);
1992 let single = merge_candidates(
1994 &hits.iter().map(|h| h.candidate.clone()).collect::<Vec<_>>(),
1995 FusionMethod::Rrf { k: 60 },
1996 &budget,
1997 )
1998 .unwrap();
1999 assert_eq!(
2000 single[0].row_id,
2001 RowId(20),
2002 "sanity: single-rank merge prefers wrong winner"
2003 );
2004 assert_ne!(
2005 fused[0].row_id, single[0].row_id,
2006 "production hybrid path must differ from single local_rank RRF"
2007 );
2008 }
2009
2010 #[tokio::test]
2011 async fn remote_ai_transport_production_path_hybrid_fuse_multi_retriever() {
2012 let endpoint = Arc::new(RemoteAiEndpoint::new(Arc::new(HybridExecutor)));
2013 let client: Arc<dyn AiRpcClient> =
2014 Arc::new(LoopbackAiRpcClient::new(Arc::clone(&endpoint)));
2015 let transport = RemoteAiTransport::new(client);
2016 let budget = AiWorkBudget {
2017 candidate_ceiling: 2,
2018 max_local_candidates: 32,
2019 ..AiWorkBudget::default()
2020 };
2021 let search = SearchRequest {
2022 must: Vec::new(),
2023 retrievers: vec![
2024 mongreldb_core::query::NamedRetriever {
2025 name: "dense".into(),
2026 weight: 1.0,
2027 retriever: Retriever::Ann {
2028 column_id: 1,
2029 query: vec![0.0],
2030 k: 10,
2031 },
2032 },
2033 mongreldb_core::query::NamedRetriever {
2034 name: "sparse".into(),
2035 weight: 1.0,
2036 retriever: Retriever::Sparse {
2037 column_id: 2,
2038 query: vec![],
2039 k: 10,
2040 },
2041 },
2042 ],
2043 fusion: Fusion::ReciprocalRank { constant: 60 },
2044 rerank: None,
2045 limit: 2,
2046 projection: Some(vec![1]),
2047 };
2048 let result = transport
2049 .retrieve(AiFanoutRequest {
2050 query_id: QueryId::new_random(),
2051 tablets: &[tid(1), tid(2)],
2052 table: "items",
2053 search: &search,
2054 authorization_context: b"",
2055 fusion: FusionMethod::Rrf { k: 60 },
2056 overfetch_factor: 2.0,
2058 budget: &budget,
2059 control: &ExecutionControl::new(None),
2060 })
2061 .await
2062 .unwrap();
2063 assert!(!result.candidates.is_empty());
2064 assert_eq!(result.candidates[0].row_id, RowId(10));
2066 assert_eq!(endpoint.active_executions(), 0);
2067 }
2068
2069 #[tokio::test]
2070 async fn remote_ai_enforces_deadline_and_retained_memory_budget() {
2071 let endpoint = Arc::new(RemoteAiEndpoint::new(Arc::new(StaticExecutor)));
2072 let client: Arc<dyn AiRpcClient> =
2073 Arc::new(LoopbackAiRpcClient::new(Arc::clone(&endpoint)));
2074 let transport = RemoteAiTransport::new(client);
2075 let search = SearchRequest {
2076 must: Vec::new(),
2077 retrievers: Vec::new(),
2078 fusion: Fusion::ReciprocalRank { constant: 60 },
2079 rerank: None,
2080 limit: 1,
2081 projection: Some(vec![1]),
2082 };
2083 let deadline = transport
2084 .retrieve(AiFanoutRequest {
2085 query_id: QueryId::new_random(),
2086 tablets: &[tid(1)],
2087 table: "items",
2088 search: &search,
2089 authorization_context: b"signed-user",
2090 fusion: FusionMethod::default(),
2091 overfetch_factor: 1.0,
2092 budget: &AiWorkBudget {
2093 deadline_ms: 0,
2094 ..AiWorkBudget::default()
2095 },
2096 control: &ExecutionControl::new(None),
2097 })
2098 .await
2099 .unwrap_err();
2100 assert_eq!(
2101 deadline,
2102 AiRetrievalError::Cancelled(CancellationReason::Deadline)
2103 );
2104
2105 let memory = transport
2106 .retrieve(AiFanoutRequest {
2107 query_id: QueryId::new_random(),
2108 tablets: &[tid(1)],
2109 table: "items",
2110 search: &search,
2111 authorization_context: b"signed-user",
2112 fusion: FusionMethod::default(),
2113 overfetch_factor: 1.0,
2114 budget: &AiWorkBudget {
2115 memory_bytes: 1,
2116 ..AiWorkBudget::default()
2117 },
2118 control: &ExecutionControl::new(None),
2119 })
2120 .await
2121 .unwrap_err();
2122 assert!(matches!(memory, AiRetrievalError::BudgetExceeded(_)));
2123 assert_eq!(endpoint.active_executions(), 0);
2124 }
2125
2126 fn contrib(
2131 tablet: u8,
2132 row: u64,
2133 retriever: &str,
2134 rank: u32,
2135 score: f64,
2136 ) -> LocalRetrieverContribution {
2137 LocalRetrieverContribution::new(tid(tablet), RowId(row), retriever, rank, score)
2138 }
2139
2140 #[test]
2141 fn merge_hybrid_global_rrf_sums_components_and_returns_provenance() {
2142 let contributions = vec![
2146 contrib(1, 10, "dense", 1, 0.99),
2147 contrib(2, 10, "sparse", 2, 0.40),
2148 contrib(1, 20, "dense", 2, 0.80),
2149 contrib(2, 30, "sparse", 1, 0.95),
2150 ];
2151 let budget = AiWorkBudget {
2152 candidate_ceiling: 10,
2153 ..AiWorkBudget::default()
2154 };
2155 let result =
2156 merge_hybrid_contributions(&contributions, FusionMethod::Rrf { k: 60 }, &budget, &[])
2157 .unwrap();
2158 assert_eq!(result.candidates.len(), 3);
2159 assert_eq!(result.candidates[0].row_id, RowId(10));
2161 assert_eq!(result.candidates[0].components.len(), 2);
2162 let expected = 1.0 / 61.0 + 1.0 / 62.0;
2163 assert!((result.candidates[0].final_score - expected).abs() < 1e-12);
2164 let names: Vec<_> = result.candidates[0]
2166 .components
2167 .iter()
2168 .map(|c| c.retriever_id.as_str())
2169 .collect();
2170 assert_eq!(names, vec!["dense", "sparse"]);
2171 assert_eq!(result.candidates[1].row_id, RowId(30));
2174 assert_eq!(result.candidates[2].row_id, RowId(20));
2175 assert!(result.refill.is_empty());
2176 }
2177
2178 #[test]
2179 fn merge_hybrid_dedups_duplicate_retriever_contributions() {
2180 let contributions = vec![
2181 contrib(1, 7, "dense", 2, 0.5),
2182 contrib(1, 7, "dense", 1, 0.9), contrib(2, 7, "dense", 3, 0.4), ];
2185 let out = merge_hybrid_contributions(
2186 &contributions,
2187 FusionMethod::Rrf { k: 60 },
2188 &AiWorkBudget::default(),
2189 &[],
2190 )
2191 .unwrap();
2192 assert_eq!(out.candidates.len(), 1);
2193 assert_eq!(out.candidates[0].components.len(), 1);
2194 assert_eq!(out.candidates[0].components[0].rank, 1);
2195 assert!((out.candidates[0].final_score - 1.0 / 61.0).abs() < 1e-12);
2196 }
2197
2198 #[test]
2199 fn merge_hybrid_retriever_order_does_not_alter_output() {
2200 let a = vec![
2201 contrib(1, 1, "dense", 1, 1.0),
2202 contrib(1, 1, "sparse", 2, 0.5),
2203 contrib(2, 2, "minhash", 1, 0.8),
2204 ];
2205 let mut b = a.clone();
2206 b.reverse();
2207 let budget = AiWorkBudget::default();
2208 let ra = merge_hybrid_contributions(&a, FusionMethod::Rrf { k: 60 }, &budget, &[]).unwrap();
2209 let rb = merge_hybrid_contributions(&b, FusionMethod::Rrf { k: 60 }, &budget, &[]).unwrap();
2210 assert_eq!(ra.candidates, rb.candidates);
2211 }
2212
2213 #[test]
2214 fn merge_hybrid_rls_hidden_fails_closed() {
2215 let mut c = contrib(1, 1, "dense", 1, 1.0);
2216 c.rls_visible = false;
2217 let err = merge_hybrid_contributions(
2218 &[c],
2219 FusionMethod::default(),
2220 &AiWorkBudget::default(),
2221 &[],
2222 )
2223 .unwrap_err();
2224 assert!(matches!(err, AiRetrievalError::RlsHygiene { .. }));
2225 }
2226
2227 #[test]
2229 fn hybrid_adaptive_refill_recovers_global_winner() {
2230 let initial = vec![contrib(1, 1, "dense", 2, 0.5)];
2234 let mut bounds = HashMap::new();
2235 bounds.insert(
2236 (tid(1), "dense".to_owned()),
2237 RetrieverTabletBound::exhausted(tid(1), "dense"),
2238 );
2239 bounds.insert(
2240 (tid(2), "dense".to_owned()),
2241 RetrieverTabletBound::open(tid(2), "dense", 1, Some(1.0), 1.0),
2242 );
2243 let budget = AiWorkBudget {
2244 candidate_ceiling: 1,
2245 max_local_candidates: 100,
2246 ..AiWorkBudget::default()
2247 };
2248 let mut refill_calls = 0u32;
2249 let winners = exact_hybrid_merge(
2250 initial,
2251 bounds,
2252 FusionMethod::Rrf { k: 60 },
2253 &budget,
2254 1,
2255 |tablet, retriever| {
2256 refill_calls += 1;
2257 assert_eq!(tablet, tid(2));
2258 assert_eq!(retriever, "dense");
2259 Ok((
2260 vec![contrib(2, 99, "dense", 1, 0.99)],
2261 RetrieverTabletBound::exhausted(tid(2), "dense"),
2262 ))
2263 },
2264 )
2265 .unwrap();
2266 assert_eq!(refill_calls, 1);
2267 assert_eq!(winners.len(), 1);
2268 assert_eq!(winners[0].row_id, RowId(99));
2269 assert_eq!(winners[0].tablet_id, tid(2));
2270 assert_eq!(winners[0].components.len(), 1);
2271 assert!((winners[0].final_score - 1.0 / 61.0).abs() < 1e-12);
2272 }
2273
2274 #[test]
2276 fn exact_global_rerank_changes_order_correctly() {
2277 let hits = vec![
2280 AiTabletHit {
2281 candidate: cand(1, 1, 0.9, 1),
2282 cells: vec![],
2283 exact_rerank_score: Some(0.1),
2284 consistency: None,
2285 contributions: vec![LocalRetrieverContribution::new(
2286 tid(1),
2287 RowId(1),
2288 "dense",
2289 1,
2290 0.9,
2291 )],
2292 metadata: TabletAiResponseMetadata::default(),
2293 },
2294 AiTabletHit {
2295 candidate: cand(1, 2, 0.5, 2),
2296 cells: vec![],
2297 exact_rerank_score: Some(0.95),
2298 consistency: None,
2299 contributions: vec![LocalRetrieverContribution::new(
2300 tid(1),
2301 RowId(2),
2302 "dense",
2303 2,
2304 0.5,
2305 )],
2306 metadata: TabletAiResponseMetadata::default(),
2307 },
2308 ];
2309 let search = SearchRequest {
2310 must: Vec::new(),
2311 retrievers: vec![mongreldb_core::query::NamedRetriever {
2312 name: "dense".into(),
2313 weight: 1.0,
2314 retriever: Retriever::Ann {
2315 column_id: 1,
2316 query: vec![1.0],
2317 k: 2,
2318 },
2319 }],
2320 fusion: Fusion::ReciprocalRank { constant: 60 },
2321 rerank: Some(Rerank::ExactVector {
2322 embedding_column: 1,
2323 query: vec![1.0],
2324 metric: mongreldb_core::query::VectorMetric::Cosine,
2325 candidate_limit: 2,
2326 weight: 1.0,
2327 }),
2328 limit: 2,
2329 projection: None,
2330 };
2331 let fused = fuse_distributed_hits(
2332 &hits,
2333 &search,
2334 FusionMethod::Rrf { k: 60 },
2335 &AiWorkBudget::default(),
2336 )
2337 .unwrap();
2338 assert_eq!(fused.len(), 2);
2339 assert_eq!(
2340 fused[0].row_id,
2341 RowId(2),
2342 "exact rerank must promote row 2 over fused rank-1: {fused:?}"
2343 );
2344 assert_eq!(fused[1].row_id, RowId(1));
2345 assert!(fused[0].final_score > fused[1].final_score);
2347 }
2348
2349 #[test]
2350 fn hybrid_refill_targets_skips_exhausted_and_dominated_streams() {
2351 let winners = vec![HybridMergedCandidate {
2352 tablet_id: tid(1),
2353 row_id: RowId(1),
2354 final_score: 1.0 / 61.0,
2355 raw_score: 1.0,
2356 components: Vec::new(),
2357 }];
2358 let bounds = vec![
2359 RetrieverTabletBound::exhausted(tid(1), "dense"),
2360 RetrieverTabletBound::open(tid(2), "dense", 1000, Some(0.1), 1.0),
2362 RetrieverTabletBound::open(tid(3), "sparse", 1, Some(1.0), 1.0),
2364 ];
2365 let refill = hybrid_refill_targets(&winners, 1, FusionMethod::Rrf { k: 60 }, &bounds);
2366 assert_eq!(refill, vec![(tid(3), "sparse".to_owned())]);
2367 }
2368
2369 #[test]
2370 fn tablet_ai_response_metadata_round_trips() {
2371 let meta = TabletAiResponseMetadata {
2372 retriever_exhausted: true,
2373 unseen_score_bound: Some(0.42),
2374 index_applied_hlc: Some("100.0.1".into()),
2375 read_hlc: Some("101.2.3".into()),
2376 model_generation: Some(7),
2377 };
2378 let hit = AiTabletHit {
2379 candidate: cand(1, 1, 1.0, 1),
2380 cells: vec![(1, Value::Int64(1))],
2381 exact_rerank_score: None,
2382 consistency: None,
2383 contributions: Vec::new(),
2384 metadata: meta.clone(),
2385 };
2386 let bytes = bincode::serialize(&hit).unwrap();
2387 let restored: AiTabletHit = bincode::deserialize(&bytes).unwrap();
2388 assert_eq!(restored.metadata, meta);
2389 assert!(restored.metadata.retriever_exhausted);
2390 assert_eq!(restored.metadata.unseen_score_bound, Some(0.42));
2391 assert_eq!(restored.metadata.model_generation, Some(7));
2392 assert_eq!(
2393 restored.metadata.index_applied_hlc.as_deref(),
2394 Some("100.0.1")
2395 );
2396 assert_eq!(restored.metadata.read_hlc.as_deref(), Some("101.2.3"));
2397 }
2398
2399 #[test]
2400 fn fuse_fail_closed_on_model_generation_mismatch() {
2401 let mut hit_a = AiTabletHit {
2402 candidate: cand(1, 10, 0.9, 1),
2403 cells: vec![],
2404 exact_rerank_score: None,
2405 consistency: None,
2406 contributions: vec![LocalRetrieverContribution::new(
2407 tid(1),
2408 RowId(10),
2409 "dense",
2410 1,
2411 0.9,
2412 )],
2413 metadata: TabletAiResponseMetadata {
2414 model_generation: Some(1),
2415 ..TabletAiResponseMetadata::default()
2416 },
2417 };
2418 let hit_b = AiTabletHit {
2419 candidate: cand(2, 20, 0.8, 1),
2420 cells: vec![],
2421 exact_rerank_score: None,
2422 consistency: None,
2423 contributions: vec![LocalRetrieverContribution::new(
2424 tid(2),
2425 RowId(20),
2426 "dense",
2427 1,
2428 0.8,
2429 )],
2430 metadata: TabletAiResponseMetadata {
2431 model_generation: Some(2), ..TabletAiResponseMetadata::default()
2433 },
2434 };
2435 let search = SearchRequest {
2436 must: Vec::new(),
2437 retrievers: vec![mongreldb_core::query::NamedRetriever {
2438 name: "dense".into(),
2439 weight: 1.0,
2440 retriever: Retriever::Ann {
2441 column_id: 1,
2442 query: vec![0.0],
2443 k: 10,
2444 },
2445 }],
2446 fusion: Fusion::ReciprocalRank { constant: 60 },
2447 rerank: None,
2448 limit: 2,
2449 projection: None,
2450 };
2451 let err = fuse_distributed_hits(
2452 &[hit_a.clone(), hit_b],
2453 &search,
2454 FusionMethod::Rrf { k: 60 },
2455 &AiWorkBudget::default(),
2456 )
2457 .unwrap_err();
2458 assert!(
2459 matches!(err, AiRetrievalError::Protocol(ref msg) if msg.contains("model generation")),
2460 "unexpected: {err:?}"
2461 );
2462
2463 hit_a.metadata.model_generation = Some(9);
2465 let budget = AiWorkBudget {
2466 expected_model_generation: Some(1),
2467 ..AiWorkBudget::default()
2468 };
2469 let err = fuse_distributed_hits(&[hit_a], &search, FusionMethod::Rrf { k: 60 }, &budget)
2470 .unwrap_err();
2471 assert!(
2472 matches!(err, AiRetrievalError::Protocol(ref msg) if msg.contains("expected 1")),
2473 "unexpected: {err:?}"
2474 );
2475 }
2476
2477 #[test]
2478 fn ai_work_budget_parent_fields_have_defaults() {
2479 let b = AiWorkBudget::default();
2480 assert!(b.max_tablet_rpcs > 0);
2481 assert!(b.max_retained_contributions > 0);
2482 assert!(b.max_refill_rounds > 0);
2483 assert!(b.max_payload_cells > 0);
2484 assert!(b.max_exact_gathers > 0);
2485 assert!(b.max_rerank_dimensions > 0);
2486 assert!(b.max_serialization_bytes > 0);
2487 assert!(b.expected_model_generation.is_none());
2488 }
2489
2490 #[test]
2492 fn p08_x2_dense_sparse_minhash_global_rrf_matches_baseline() {
2493 let contributions = vec![
2495 contrib(1, 10, "dense", 1, 0.99),
2496 contrib(1, 10, "sparse", 2, 0.40),
2497 contrib(2, 10, "minhash", 1, 0.90),
2498 contrib(1, 20, "dense", 2, 0.80),
2499 contrib(2, 20, "sparse", 1, 0.95),
2500 contrib(1, 20, "minhash", 2, 0.50),
2501 ];
2502 let budget = AiWorkBudget {
2503 candidate_ceiling: 10,
2504 ..AiWorkBudget::default()
2505 };
2506 let hybrid =
2507 merge_hybrid_contributions(&contributions, FusionMethod::Rrf { k: 60 }, &budget, &[])
2508 .unwrap();
2509 assert_eq!(hybrid.candidates[0].row_id, RowId(10));
2512 let expected_10 = 1.0 / 61.0 + 1.0 / 62.0 + 1.0 / 61.0;
2513 assert!((hybrid.candidates[0].final_score - expected_10).abs() < 1e-12);
2514 assert_eq!(hybrid.candidates[0].components.len(), 3);
2515 let names: Vec<_> = hybrid.candidates[0]
2516 .components
2517 .iter()
2518 .map(|c| c.retriever_id.as_str())
2519 .collect();
2520 assert_eq!(names, vec!["dense", "minhash", "sparse"]);
2521
2522 let hits = vec![AiTabletHit {
2524 candidate: cand(1, 10, 1.0, 1),
2525 cells: vec![],
2526 exact_rerank_score: None,
2527 consistency: None,
2528 contributions: vec![
2529 LocalRetrieverContribution::new(tid(1), RowId(10), "dense", 1, 0.99),
2530 LocalRetrieverContribution::new(tid(1), RowId(10), "sparse", 2, 0.40),
2531 LocalRetrieverContribution::new(tid(1), RowId(10), "minhash", 1, 0.90),
2532 ],
2533 metadata: TabletAiResponseMetadata {
2534 model_generation: Some(1),
2535 ..TabletAiResponseMetadata::default()
2536 },
2537 }];
2538 let search = SearchRequest {
2539 must: Vec::new(),
2540 retrievers: vec![
2541 mongreldb_core::query::NamedRetriever {
2542 name: "dense".into(),
2543 weight: 1.0,
2544 retriever: Retriever::Ann {
2545 column_id: 1,
2546 query: vec![0.0],
2547 k: 10,
2548 },
2549 },
2550 mongreldb_core::query::NamedRetriever {
2551 name: "sparse".into(),
2552 weight: 1.0,
2553 retriever: Retriever::Sparse {
2554 column_id: 2,
2555 query: vec![],
2556 k: 10,
2557 },
2558 },
2559 mongreldb_core::query::NamedRetriever {
2560 name: "minhash".into(),
2561 weight: 1.0,
2562 retriever: Retriever::MinHash {
2563 column_id: 3,
2564 members: vec![],
2565 k: 10,
2566 },
2567 },
2568 ],
2569 fusion: Fusion::ReciprocalRank { constant: 60 },
2570 rerank: None,
2571 limit: 1,
2572 projection: None,
2573 };
2574 let fused =
2575 fuse_distributed_hits(&hits, &search, FusionMethod::Rrf { k: 60 }, &budget).unwrap();
2576 assert_eq!(fused[0].row_id, RowId(10));
2577 assert!((fused[0].final_score - expected_10).abs() < 1e-12);
2578 }
2579
2580 #[test]
2582 fn p08_x5_rls_hidden_rows_never_contribute() {
2583 let mut c = cand(1, 1, 1.0, 1);
2584 c.rls_visible = false;
2585 let err =
2586 merge_candidates(&[c], FusionMethod::default(), &AiWorkBudget::default()).unwrap_err();
2587 assert!(matches!(err, AiRetrievalError::RlsHygiene { .. }));
2588
2589 let mut contrib_hidden = contrib(1, 1, "dense", 1, 1.0);
2590 contrib_hidden.rls_visible = false;
2591 let err = merge_hybrid_contributions(
2592 &[contrib_hidden],
2593 FusionMethod::default(),
2594 &AiWorkBudget::default(),
2595 &[],
2596 )
2597 .unwrap_err();
2598 assert!(matches!(err, AiRetrievalError::RlsHygiene { .. }));
2599
2600 let hit = AiTabletHit {
2602 candidate: {
2603 let mut c = cand(1, 9, 1.0, 1);
2604 c.rls_visible = false;
2605 c
2606 },
2607 cells: vec![],
2608 exact_rerank_score: None,
2609 consistency: None,
2610 contributions: Vec::new(),
2611 metadata: TabletAiResponseMetadata::default(),
2612 };
2613 let search = SearchRequest {
2614 must: Vec::new(),
2615 retrievers: Vec::new(),
2616 fusion: Fusion::ReciprocalRank { constant: 60 },
2617 rerank: None,
2618 limit: 1,
2619 projection: None,
2620 };
2621 let err = fuse_distributed_hits(
2622 &[hit],
2623 &search,
2624 FusionMethod::Rrf { k: 60 },
2625 &AiWorkBudget::default(),
2626 )
2627 .unwrap_err();
2628 assert!(matches!(err, AiRetrievalError::RlsHygiene { .. }));
2629 }
2630
2631 #[test]
2633 fn p08_x9_model_generation_mismatch_fails() {
2634 let hit_a = AiTabletHit {
2635 candidate: cand(1, 10, 0.9, 1),
2636 cells: vec![],
2637 exact_rerank_score: None,
2638 consistency: None,
2639 contributions: vec![LocalRetrieverContribution::new(
2640 tid(1),
2641 RowId(10),
2642 "dense",
2643 1,
2644 0.9,
2645 )],
2646 metadata: TabletAiResponseMetadata {
2647 model_generation: Some(1),
2648 ..TabletAiResponseMetadata::default()
2649 },
2650 };
2651 let hit_b = AiTabletHit {
2652 candidate: cand(2, 20, 0.8, 1),
2653 cells: vec![],
2654 exact_rerank_score: None,
2655 consistency: None,
2656 contributions: vec![LocalRetrieverContribution::new(
2657 tid(2),
2658 RowId(20),
2659 "dense",
2660 1,
2661 0.8,
2662 )],
2663 metadata: TabletAiResponseMetadata {
2664 model_generation: Some(2),
2665 ..TabletAiResponseMetadata::default()
2666 },
2667 };
2668 let search = SearchRequest {
2669 must: Vec::new(),
2670 retrievers: vec![mongreldb_core::query::NamedRetriever {
2671 name: "dense".into(),
2672 weight: 1.0,
2673 retriever: Retriever::Ann {
2674 column_id: 1,
2675 query: vec![0.0],
2676 k: 10,
2677 },
2678 }],
2679 fusion: Fusion::ReciprocalRank { constant: 60 },
2680 rerank: None,
2681 limit: 2,
2682 projection: None,
2683 };
2684 let err = fuse_distributed_hits(
2685 &[hit_a, hit_b],
2686 &search,
2687 FusionMethod::Rrf { k: 60 },
2688 &AiWorkBudget::default(),
2689 )
2690 .unwrap_err();
2691 assert!(
2692 matches!(err, AiRetrievalError::Protocol(ref msg) if msg.contains("model generation")),
2693 "unexpected: {err:?}"
2694 );
2695 }
2696
2697 struct FailingTabletExecutor {
2699 fail_tablet: u8,
2700 }
2701
2702 #[async_trait::async_trait]
2703 impl AiTabletExecutor for FailingTabletExecutor {
2704 async fn retrieve(
2705 &self,
2706 request: &AiTabletQuery,
2707 control: ExecutionControl,
2708 ) -> Result<Vec<AiTabletHit>, AiRetrievalError> {
2709 control
2710 .checkpoint()
2711 .map_err(|_| AiRetrievalError::Cancelled(control.reason()))?;
2712 let n = request.tablet_id.as_bytes()[15];
2713 if n == self.fail_tablet {
2714 return Err(AiRetrievalError::Transport(format!(
2715 "tablet {n} unavailable"
2716 )));
2717 }
2718 Ok(vec![AiTabletHit {
2719 candidate: LocalCandidate {
2720 tablet_id: request.tablet_id,
2721 row_id: RowId(u64::from(n)),
2722 score: 0.9,
2723 local_rank: 1,
2724 rls_visible: true,
2725 },
2726 cells: vec![],
2727 exact_rerank_score: None,
2728 consistency: None,
2729 contributions: Vec::new(),
2730 metadata: TabletAiResponseMetadata::default(),
2731 }])
2732 }
2733 }
2734
2735 #[tokio::test]
2737 async fn p08_x10_one_tablet_failure_fail_closed() {
2738 let endpoint = Arc::new(RemoteAiEndpoint::new(Arc::new(FailingTabletExecutor {
2739 fail_tablet: 2,
2740 })));
2741 let client: Arc<dyn AiRpcClient> =
2742 Arc::new(LoopbackAiRpcClient::new(Arc::clone(&endpoint)));
2743 let transport = RemoteAiTransport::new(client);
2744 let search = SearchRequest {
2745 must: Vec::new(),
2746 retrievers: Vec::new(),
2747 fusion: Fusion::ReciprocalRank { constant: 60 },
2748 rerank: None,
2749 limit: 2,
2750 projection: None,
2751 };
2752 let err = transport
2753 .retrieve(AiFanoutRequest {
2754 query_id: QueryId::new_random(),
2755 tablets: &[tid(1), tid(2)],
2756 table: "items",
2757 search: &search,
2758 authorization_context: b"",
2759 fusion: FusionMethod::default(),
2760 overfetch_factor: 1.0,
2761 budget: &AiWorkBudget::default(),
2762 control: &ExecutionControl::new(None),
2763 })
2764 .await
2765 .unwrap_err();
2766 assert!(
2767 matches!(err, AiRetrievalError::Transport(ref msg) if msg.contains("unavailable")),
2768 "fail-closed on tablet failure, got: {err:?}"
2769 );
2770 assert_eq!(endpoint.active_executions(), 0);
2771 }
2772}