Skip to main content

mongreldb_query/
ai_retrieval.rs

1//! Distributed AI retrieval (spec section 13.4, Stage 4D).
2//!
3//! Per-tablet retrievers apply RLS **before** local top-k, return bounded
4//! candidates with raw scores, and the coordinator merges with deterministic
5//! RRF (or configured fusion). Tie-break: final score desc, tablet id asc,
6//! RowId asc. Adaptive ANN candidate counts with global work budget,
7//! candidate ceiling, memory, and deadline. [`RemoteAiTransport`] fans typed
8//! searches over authenticated node-internal RPC routes; workers validate the
9//! forwarded authorization envelope and apply RLS before local top-k.
10
11use 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/// One candidate from a tablet-local retriever (post-RLS).
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct LocalCandidate {
26    /// Source tablet.
27    pub tablet_id: TabletId,
28    /// Row identity.
29    pub row_id: RowId,
30    /// Raw local score (higher is better).
31    pub score: f64,
32    /// Local rank (1-based) after RLS filtering.
33    pub local_rank: u32,
34    /// Whether this row was visible after RLS (must be true when emitted).
35    pub rls_visible: bool,
36}
37
38/// Fusion method for coordinator merge.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum FusionMethod {
41    /// Reciprocal rank fusion with constant `k` (default 60).
42    Rrf {
43        /// RRF constant k.
44        k: u32,
45    },
46    /// Max of raw scores (deterministic with tie-breaks).
47    MaxScore,
48}
49
50impl Default for FusionMethod {
51    fn default() -> Self {
52        Self::Rrf { k: 60 }
53    }
54}
55
56/// Global work budget for a distributed AI request.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct AiWorkBudget {
59    /// Maximum candidates retained globally after merge.
60    pub candidate_ceiling: usize,
61    /// Maximum total local candidates fetched across tablets.
62    pub max_local_candidates: usize,
63    /// Memory reservation bytes (advisory for the governor).
64    pub memory_bytes: u64,
65    /// Wall-clock deadline remaining in milliseconds.
66    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
80/// Adaptive per-tablet candidate count.
81///
82/// `ceil(global_k * overfetch_factor / active_tablet_count)`.
83pub 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/// Errors of distributed AI retrieval.
90#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
91pub enum AiRetrievalError {
92    /// A tablet returned an RLS-hidden row as a candidate (hygiene violation).
93    #[error("RLS hygiene violation: tablet {tablet_id} emitted hidden row {row_id}")]
94    RlsHygiene {
95        /// Offending tablet.
96        tablet_id: TabletId,
97        /// Hidden row.
98        row_id: u64,
99    },
100    /// Work budget exceeded.
101    #[error("AI work budget exceeded: {0}")]
102    BudgetExceeded(String),
103    /// The request was cancelled locally or remotely.
104    #[error("distributed AI request cancelled: {0:?}")]
105    Cancelled(CancellationReason),
106    /// Authenticated node-to-node transport failed.
107    #[error("distributed AI transport failed: {0}")]
108    Transport(String),
109    /// A peer violated the versioned distributed-AI protocol.
110    #[error("distributed AI protocol error: {0}")]
111    Protocol(String),
112}
113
114/// One merged result row.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct MergedCandidate {
117    /// Tablet of origin (lowest tablet id if fused across; for RRF we keep
118    /// the tablet of the best local rank contribution for tie-break).
119    pub tablet_id: TabletId,
120    /// Row id.
121    pub row_id: RowId,
122    /// Fused final score.
123    pub final_score: f64,
124    /// Best raw local score observed.
125    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        // Max-heap: higher score first; then lower tablet id; then lower row id.
149        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
163/// Deterministic coordinator merge (spec §13.4).
164///
165/// RLS hygiene: any candidate with `rls_visible == false` is a hard error
166/// (hidden rows must never influence ranking).
167pub 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    // Group by (tablet, row) — a row should appear once per tablet.
189    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            // RRF score = sum 1/(k + rank) across appearances (here one per tablet).
204            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    // Sort with deterministic tie-breaks, take ceiling.
229    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/// Audit metadata returned with AI/analytics results (spec §13.6).
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct AiConsistencyAudit {
256    /// Requested read timestamp (string form of HLC).
257    pub read_ts: String,
258    /// Replica applied timestamp.
259    pub replica_applied_ts: String,
260    /// Measured staleness in microseconds (0 if caught up).
261    pub staleness_micros: u64,
262    /// Index applied_through timestamp.
263    pub index_applied_ts: String,
264    /// Model / preprocessing version.
265    pub model_version: Option<String>,
266    /// Preprocessing version.
267    pub preprocessing_version: Option<String>,
268}
269
270// ---------------------------------------------------------------------------
271// Authenticated remote tablet retrieval
272// ---------------------------------------------------------------------------
273
274/// Stable service discriminator inside the cluster internal-RPC multiplexer.
275pub const REMOTE_AI_SERVICE_ID: u32 = 2;
276/// Current private distributed-AI wire generation.
277pub const REMOTE_AI_PROTOCOL_VERSION: u16 = 1;
278/// Default maximum request or response body.
279pub const DEFAULT_REMOTE_AI_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
280/// Default maximum concurrent tablet retrievals held by one worker.
281pub const DEFAULT_REMOTE_AI_EXECUTIONS: usize = 256;
282/// Maximum forwarded authorization-context bytes.
283pub const MAX_AI_AUTHORIZATION_CONTEXT_BYTES: usize = 64 * 1024;
284
285/// One typed tablet-local search request.
286///
287/// `authorization_context` is an opaque, bounded server-issued identity
288/// envelope. The worker executor must validate it and apply column grants,
289/// RLS, and masks before returning candidates. The transport never treats
290/// node mTLS alone as user authorization.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct AiTabletQuery {
293    /// Global query identity.
294    pub query_id: QueryId,
295    /// Tablet whose replica must execute this request.
296    pub tablet_id: TabletId,
297    /// Table name inside that tablet.
298    pub table: String,
299    /// Core hybrid-search request, bounded to tablet-local `k` by the
300    /// coordinator.
301    pub search: SearchRequest,
302    /// Server-issued authorization envelope.
303    pub authorization_context: Vec<u8>,
304    /// Remaining deadline propagated to the worker.
305    pub deadline_ms: Option<u64>,
306    /// Global work budget.
307    pub budget: AiWorkBudget,
308}
309
310/// One tablet result after authorization and optional exact rerank.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct AiTabletHit {
313    /// Ranked candidate used by the deterministic global merge.
314    pub candidate: LocalCandidate,
315    /// Projected, already-masked cells.
316    pub cells: Vec<(u16, Value)>,
317    /// Exact-vector rerank score when requested.
318    pub exact_rerank_score: Option<f32>,
319    /// Per-replica consistency evidence.
320    pub consistency: Option<AiConsistencyAudit>,
321}
322
323/// Worker implementation for tablet-local AI search.
324#[async_trait::async_trait]
325pub trait AiTabletExecutor: Send + Sync {
326    /// Executes one request. Implementations must validate
327    /// `authorization_context` and enforce RLS before local top-k.
328    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
365/// Worker-side endpoint for the distributed-AI protocol.
366pub 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    /// Creates a bounded endpoint.
375    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    /// Creates an endpoint with explicit concurrency and message bounds.
384    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    /// Number of running tablet requests.
398    pub fn active_executions(&self) -> usize {
399        self.controls.lock().len()
400    }
401
402    /// Handles one authenticated internal RPC body.
403    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/// One authenticated request/response carrier for distributed AI.
497#[async_trait::async_trait]
498pub trait AiRpcClient: Send + Sync {
499    /// Performs one bounded internal RPC.
500    async fn call(&self, request: Vec<u8>) -> Result<Vec<u8>, AiRetrievalError>;
501}
502
503/// In-process carrier for endpoint tests.
504pub struct LoopbackAiRpcClient {
505    endpoint: Arc<RemoteAiEndpoint>,
506}
507
508impl LoopbackAiRpcClient {
509    /// Wraps one worker endpoint.
510    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/// Fully merged result plus authorized tablet payloads for the winners.
523#[derive(Debug, Clone)]
524pub struct DistributedAiResult {
525    /// Deterministic global winners.
526    pub candidates: Vec<MergedCandidate>,
527    /// Winner payloads in the same order as `candidates`.
528    pub hits: Vec<AiTabletHit>,
529}
530
531/// Borrowed inputs for one coordinator-side distributed-AI fan-out.
532pub struct AiFanoutRequest<'a> {
533    /// Stable query identity propagated to every tablet.
534    pub query_id: QueryId,
535    /// Tablets participating in the fan-out.
536    pub tablets: &'a [TabletId],
537    /// Authoritative table name.
538    pub table: &'a str,
539    /// Scored-search request.
540    pub search: &'a SearchRequest,
541    /// Server-issued authorization envelope.
542    pub authorization_context: &'a [u8],
543    /// Deterministic global fusion policy.
544    pub fusion: FusionMethod,
545    /// Tablet-local candidate overfetch multiplier.
546    pub overfetch_factor: f64,
547    /// Global deadline, work, candidate, and memory limits.
548    pub budget: &'a AiWorkBudget,
549    /// Parent cancellation and deadline control.
550    pub control: &'a ExecutionControl,
551}
552
553/// Coordinator-side distributed-AI fan-out.
554pub 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    /// Creates a transport whose tablets use `default_client`.
562    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    /// Creates a fail-closed transport with no fallback tablet route.
571    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    /// Routes one tablet to a specific peer.
580    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    /// Fans out a typed search, enforces the global budget, and merges the
599    /// authorized tablet-local top-k results.
600    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        // Score desc: rank-1 entries outrank rank-2 for same k.
909        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); // ceil(4.0)
924        assert_eq!(adaptive_local_k(10, 2.0, 1), 20);
925        assert_eq!(adaptive_local_k(10, 2.0, 0), 20); // treat 0 tablets as 1
926    }
927
928    #[test]
929    fn tie_break_tablet_then_row() {
930        // Equal RRF ranks → lower tablet id first among equal scores.
931        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        // Same rank → same RRF score; tablet 1 before tablet 2.
943        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}