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 (P0.8-T4 parent budget).
57///
58/// One coordinator budget covers tablet RPCs, local candidates, retained
59/// contributions, adaptive refill rounds, payload cells, exact gathers,
60/// rerank dimensions, and serialization.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct AiWorkBudget {
63    /// Maximum candidates retained globally after merge.
64    pub candidate_ceiling: usize,
65    /// Maximum total local candidates fetched across tablets.
66    pub max_local_candidates: usize,
67    /// Memory reservation bytes (advisory for the governor).
68    pub memory_bytes: u64,
69    /// Wall-clock deadline remaining in milliseconds.
70    pub deadline_ms: u64,
71    /// Maximum tablet RPCs issued by one coordinator fan-out (including refill).
72    #[serde(default = "default_max_tablet_rpcs")]
73    pub max_tablet_rpcs: usize,
74    /// Maximum retained hybrid contributions across all tablets/retrievers.
75    #[serde(default = "default_max_retained_contributions")]
76    pub max_retained_contributions: usize,
77    /// Maximum adaptive hybrid refill rounds.
78    #[serde(default = "default_max_refill_rounds")]
79    pub max_refill_rounds: usize,
80    /// Maximum projected payload cells retained from tablet hits.
81    #[serde(default = "default_max_payload_cells")]
82    pub max_payload_cells: usize,
83    /// Maximum exact-vector gather rows for global rerank.
84    #[serde(default = "default_max_exact_gathers")]
85    pub max_exact_gathers: usize,
86    /// Maximum dimensions (or tokens) for exact rerank payloads.
87    #[serde(default = "default_max_rerank_dimensions")]
88    pub max_rerank_dimensions: usize,
89    /// Maximum serialized request/response bytes retained by the coordinator.
90    #[serde(default = "default_max_serialization_bytes")]
91    pub max_serialization_bytes: usize,
92    /// When set, every tablet hit must report this model generation (fail-closed).
93    #[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
138/// Adaptive per-tablet candidate count.
139///
140/// `ceil(global_k * overfetch_factor / active_tablet_count)`.
141pub 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/// Errors of distributed AI retrieval.
148#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
149pub enum AiRetrievalError {
150    /// A tablet returned an RLS-hidden row as a candidate (hygiene violation).
151    #[error("RLS hygiene violation: tablet {tablet_id} emitted hidden row {row_id}")]
152    RlsHygiene {
153        /// Offending tablet.
154        tablet_id: TabletId,
155        /// Hidden row.
156        row_id: u64,
157    },
158    /// Work budget exceeded.
159    #[error("AI work budget exceeded: {0}")]
160    BudgetExceeded(String),
161    /// The request was cancelled locally or remotely.
162    #[error("distributed AI request cancelled: {0:?}")]
163    Cancelled(CancellationReason),
164    /// Authenticated node-to-node transport failed.
165    #[error("distributed AI transport failed: {0}")]
166    Transport(String),
167    /// A peer violated the versioned distributed-AI protocol.
168    #[error("distributed AI protocol error: {0}")]
169    Protocol(String),
170}
171
172/// One merged result row.
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174pub struct MergedCandidate {
175    /// Tablet of origin (lowest tablet id if fused across; for RRF we keep
176    /// the tablet of the best local rank contribution for tie-break).
177    pub tablet_id: TabletId,
178    /// Row id.
179    pub row_id: RowId,
180    /// Fused final score.
181    pub final_score: f64,
182    /// Best raw local score observed.
183    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        // Max-heap: higher score first; then lower tablet id; then lower row id.
207        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
221/// Deterministic coordinator merge (spec §13.4).
222///
223/// RLS hygiene: any candidate with `rls_visible == false` is a hard error
224/// (hidden rows must never influence ranking).
225pub 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    // Group by (tablet, row) — a row should appear once per tablet.
247    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            // RRF score = sum 1/(k + rank) across appearances (here one per tablet).
262            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    // Sort with deterministic tie-breaks, take ceiling.
287    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// ---------------------------------------------------------------------------
312// Global hybrid fusion (P0.8): per-retriever contributions + coordinator RRF
313// ---------------------------------------------------------------------------
314
315/// One tablet-local hit from a single named retriever (post-RLS).
316///
317/// Multiple contributions for the same `(row_id, retriever_id)` may arrive
318/// from different tablets; the coordinator keeps one contribution per named
319/// retriever per global row before fusing.
320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321pub struct LocalRetrieverContribution {
322    /// Source tablet.
323    pub tablet_id: TabletId,
324    /// Row identity (globally unique within the table).
325    pub row_id: RowId,
326    /// Named retriever identity (`dense`, `sparse`, `minhash`, …).
327    pub retriever_id: String,
328    /// Optional retriever kind tag for protocol diagnostics.
329    pub retriever_kind: Option<String>,
330    /// Local 1-based rank within this retriever on this tablet (after RLS).
331    pub local_rank: u32,
332    /// Raw local score (higher is better in the contribution protocol).
333    pub raw_score: f64,
334    /// Upper bound on the score of any *unseen* local hit for this
335    /// retriever after `local_rank` (`None` when the tablet is exhausted for
336    /// this retriever).
337    pub upper_bound_after: Option<f64>,
338    /// Whether this row was visible after RLS (must be true when emitted).
339    pub rls_visible: bool,
340    /// Retriever weight used by RRF (`weight / (k + rank)`). Defaults to 1.0
341    /// when omitted at the merge call site.
342    pub weight: f64,
343}
344
345impl LocalRetrieverContribution {
346    /// Builds a contribution with weight 1.0 and no kind/bound metadata.
347    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/// Per-retriever component of a fused hybrid hit.
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370pub struct HybridComponentScore {
371    /// Named retriever.
372    pub retriever_id: String,
373    /// Optional kind tag.
374    pub retriever_kind: Option<String>,
375    /// Tablet that contributed this component.
376    pub tablet_id: TabletId,
377    /// 1-based local rank used for fusion.
378    pub rank: u32,
379    /// Raw local score.
380    pub raw_score: f64,
381    /// Contribution to the fused score (`weight / (k + rank)` for RRF).
382    pub contribution: f64,
383    /// Weight applied for this retriever.
384    pub weight: f64,
385}
386
387/// One globally fused hybrid candidate with component provenance.
388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
389pub struct HybridMergedCandidate {
390    /// Tie-break tablet (lowest tablet id among the row's contributions).
391    pub tablet_id: TabletId,
392    /// Row identity.
393    pub row_id: RowId,
394    /// Fused final score.
395    pub final_score: f64,
396    /// Best raw local score across components.
397    pub raw_score: f64,
398    /// Per-retriever component scores (sorted by retriever_id for stability).
399    pub components: Vec<HybridComponentScore>,
400}
401
402/// Exhaustion / bound metadata for one `(tablet, retriever)` stream.
403#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
404pub struct RetrieverTabletBound {
405    /// Tablet of the stream.
406    pub tablet_id: TabletId,
407    /// Named retriever.
408    pub retriever_id: String,
409    /// Next 1-based rank that would be assigned to an unseen hit (`None` if
410    /// exhausted).
411    pub next_rank: Option<u32>,
412    /// Upper bound on the raw score of any unseen hit (`None` if exhausted
413    /// or unknown).
414    pub unseen_score_bound: Option<f64>,
415    /// True when the tablet has no further hits for this retriever.
416    pub exhausted: bool,
417    /// Weight applied to this retriever during fusion.
418    pub weight: f64,
419}
420
421impl RetrieverTabletBound {
422    /// Convenience constructor for an exhausted stream.
423    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    /// Convenience constructor for a live stream with a next rank bound.
435    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/// Outcome of one global hybrid merge step.
454#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455pub struct HybridMergeResult {
456    /// Winners under the candidate ceiling, best first.
457    pub candidates: Vec<HybridMergedCandidate>,
458    /// `(tablet, retriever)` streams that still need refill before the top-k
459    /// is provably exact under the declared bounds.
460    pub refill: Vec<(TabletId, String)>,
461}
462
463/// Deterministic global hybrid fusion over per-retriever contributions
464/// (P0.8 / audit §11.4).
465///
466/// Algorithm:
467/// 1. Reject RLS-hidden contributions (fail-closed).
468/// 2. Deduplicate to one contribution per `(row_id, retriever_id)` — keep the
469///    best rank (then higher raw score, then lower tablet id).
470/// 3. Group by `row_id`, compute RRF (`Σ weight/(k+rank)`) or max-score fusion,
471///    and retain component provenance.
472/// 4. Sort by fused score desc, tablet id asc, row id asc; apply the ceiling.
473/// 5. Report adaptive-refill targets from `bounds` when provided.
474pub 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    // Dedup: one contribution per (row, retriever).
507    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    // Group by row.
520    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
610/// True when `a` is a better contribution than `b` for the same
611/// `(row, retriever)` key.
612fn 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
619/// Ranking order: fused score desc, tablet id asc, row id asc.
620fn 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
628/// Adaptive refill API: which `(tablet, retriever)` streams could still
629/// contribute a global top-`k` winner given the current merge result and the
630/// per-stream unseen bounds.
631///
632/// A stream is refilled when it is not exhausted and either:
633/// * fewer than `k` winners are known, or
634/// * the optimistic single-retriever contribution of its next unseen hit
635///   (`weight / (k_rrf + next_rank)` for RRF, or `weight * unseen_score_bound`
636///   for max-score) is not strictly worse than the current `k`-th winner.
637pub 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
685/// Drives [`merge_hybrid_contributions`] with adaptive refill until the result
686/// is exact under the provided bounds (or the work budget is exhausted).
687///
688/// `refill_batch(tablet, retriever)` must return the next batch of local
689/// contributions for that stream plus an updated bound. Returning an empty
690/// batch with an unchanged non-exhausted bound is a protocol error.
691pub 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        // Intermediate merge keeps all fused rows so refill decisions see the
726        // true k-th threshold; the final return is truncated to global_k.
727        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            // Recompute refill against the truncated winner list.
733            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/// Audit metadata returned with AI/analytics results (spec §13.6).
775#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
776pub struct AiConsistencyAudit {
777    /// Requested read timestamp (string form of HLC).
778    pub read_ts: String,
779    /// Replica applied timestamp.
780    pub replica_applied_ts: String,
781    /// Measured staleness in microseconds (0 if caught up).
782    pub staleness_micros: u64,
783    /// Index applied_through timestamp.
784    pub index_applied_ts: String,
785    /// Model / preprocessing version.
786    pub model_version: Option<String>,
787    /// Preprocessing version.
788    pub preprocessing_version: Option<String>,
789}
790
791// ---------------------------------------------------------------------------
792// Authenticated remote tablet retrieval
793// ---------------------------------------------------------------------------
794
795/// Stable service discriminator inside the cluster internal-RPC multiplexer.
796pub const REMOTE_AI_SERVICE_ID: u32 = 2;
797/// Current private distributed-AI wire generation.
798pub const REMOTE_AI_PROTOCOL_VERSION: u16 = 1;
799/// Default maximum request or response body.
800pub const DEFAULT_REMOTE_AI_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
801/// Default maximum concurrent tablet retrievals held by one worker.
802pub const DEFAULT_REMOTE_AI_EXECUTIONS: usize = 256;
803/// Maximum forwarded authorization-context bytes.
804pub const MAX_AI_AUTHORIZATION_CONTEXT_BYTES: usize = 64 * 1024;
805
806/// One typed tablet-local search request.
807///
808/// `authorization_context` is an opaque, bounded server-issued identity
809/// envelope. The worker executor must validate it and apply column grants,
810/// RLS, and masks before returning candidates. The transport never treats
811/// node mTLS alone as user authorization.
812#[derive(Debug, Clone, Serialize, Deserialize)]
813pub struct AiTabletQuery {
814    /// Global query identity.
815    pub query_id: QueryId,
816    /// Tablet whose replica must execute this request.
817    pub tablet_id: TabletId,
818    /// Table name inside that tablet.
819    pub table: String,
820    /// Core hybrid-search request, bounded to tablet-local `k` by the
821    /// coordinator.
822    pub search: SearchRequest,
823    /// Server-issued authorization envelope.
824    pub authorization_context: Vec<u8>,
825    /// Remaining deadline propagated to the worker.
826    pub deadline_ms: Option<u64>,
827    /// Global work budget.
828    pub budget: AiWorkBudget,
829}
830
831/// Tablet response metadata for distributed AI (P0.8-T2).
832///
833/// Carries retriever exhaustion / unseen bounds, index and read HLCs, and the
834/// model generation that produced the hit. The coordinator fail-closes when
835/// model generations disagree across tablets or with
836/// [`AiWorkBudget::expected_model_generation`].
837#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
838pub struct TabletAiResponseMetadata {
839    /// True when every named local retriever stream on this tablet is exhausted.
840    #[serde(default)]
841    pub retriever_exhausted: bool,
842    /// Upper bound on the score of any unseen local hit (`None` if exhausted
843    /// or unknown). Mirrors the tightest `upper_bound_after` across streams.
844    #[serde(default)]
845    pub unseen_score_bound: Option<f64>,
846    /// Index `applied_through` HLC on the serving replica (`physical.logical.node`).
847    #[serde(default)]
848    pub index_applied_hlc: Option<String>,
849    /// Read HLC used for this tablet-local search.
850    #[serde(default)]
851    pub read_hlc: Option<String>,
852    /// Embedding / ANN model generation id for this tablet's index.
853    #[serde(default)]
854    pub model_generation: Option<u64>,
855}
856
857/// One tablet result after authorization and optional exact rerank.
858#[derive(Debug, Clone, Serialize, Deserialize)]
859pub struct AiTabletHit {
860    /// Ranked candidate used by the deterministic global merge.
861    pub candidate: LocalCandidate,
862    /// Projected, already-masked cells.
863    pub cells: Vec<(u16, Value)>,
864    /// Exact-vector rerank score when requested.
865    pub exact_rerank_score: Option<f32>,
866    /// Per-replica consistency evidence.
867    pub consistency: Option<AiConsistencyAudit>,
868    /// Per-retriever contributions for global hybrid fusion (P0.8).
869    ///
870    /// When non-empty (or the request names multiple retrievers), the
871    /// coordinator uses [`merge_hybrid_contributions`] instead of
872    /// single-score [`merge_candidates`]. Empty means a single local-rank
873    /// contribution derived from [`Self::candidate`].
874    #[serde(default)]
875    pub contributions: Vec<LocalRetrieverContribution>,
876    /// Tablet response metadata (exhaustion, HLC, model generation) — P0.8-T2.
877    #[serde(default)]
878    pub metadata: TabletAiResponseMetadata,
879}
880
881/// Worker implementation for tablet-local AI search.
882#[async_trait::async_trait]
883pub trait AiTabletExecutor: Send + Sync {
884    /// Executes one request. Implementations must validate
885    /// `authorization_context` and enforce RLS before local top-k.
886    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
923/// Worker-side endpoint for the distributed-AI protocol.
924pub 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    /// Creates a bounded endpoint.
933    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    /// Creates an endpoint with explicit concurrency and message bounds.
942    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    /// Number of running tablet requests.
956    pub fn active_executions(&self) -> usize {
957        self.controls.lock().len()
958    }
959
960    /// Handles one authenticated internal RPC body.
961    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/// One authenticated request/response carrier for distributed AI.
1055#[async_trait::async_trait]
1056pub trait AiRpcClient: Send + Sync {
1057    /// Performs one bounded internal RPC.
1058    async fn call(&self, request: Vec<u8>) -> Result<Vec<u8>, AiRetrievalError>;
1059}
1060
1061/// In-process carrier for endpoint tests.
1062pub struct LoopbackAiRpcClient {
1063    endpoint: Arc<RemoteAiEndpoint>,
1064}
1065
1066impl LoopbackAiRpcClient {
1067    /// Wraps one worker endpoint.
1068    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/// Fully merged result plus authorized tablet payloads for the winners.
1081#[derive(Debug, Clone)]
1082pub struct DistributedAiResult {
1083    /// Deterministic global winners.
1084    pub candidates: Vec<MergedCandidate>,
1085    /// Winner payloads in the same order as `candidates`.
1086    pub hits: Vec<AiTabletHit>,
1087}
1088
1089/// Production coordinator fusion for distributed AI / Kit search (P0.8).
1090///
1091/// When the request names multiple retrievers **or** any tablet hit carries
1092/// per-retriever [`LocalRetrieverContribution`]s, this uses
1093/// [`merge_hybrid_contributions`] so dense+sparse(+MinHash) RRF is global.
1094/// Otherwise it falls back to single-score [`merge_candidates`].
1095///
1096/// This is the function the product path must call — unit tests of
1097/// `merge_hybrid_contributions` alone do not exercise production wiring.
1098pub 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
1138/// Exact global rerank (P0.8-T3 / X7): blend fused score with tablet-supplied
1139/// exact-vector scores and re-order winners.
1140///
1141/// When the search requests [`Rerank::ExactVector`] or hits already carry
1142/// `exact_rerank_score`, final score becomes `fused + weight * exact` (weight
1143/// defaults to 1.0 when only payload scores are present). Candidates without
1144/// an exact score keep their fused score. Ordering is final_score desc,
1145/// tablet id asc, row id asc — matching the local search path.
1146fn 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        // Tablet workers already applied exact scoring; re-order globally.
1170        _ => (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    // Prefer exact key match; fall back to row_id when hybrid merge picks a
1183    // different tablet_id among multi-tablet contributions for the same row.
1184    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
1232/// Fail-closed validation of tablet metadata (model generation, payload cells).
1233fn 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            // Coordinator requested a generation but tablet omitted it.
1260            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
1274/// Flattens tablet hits into per-retriever contributions for global hybrid merge.
1275fn 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            // Tablet already fused or single-retriever payload: one synthetic
1288            // contribution so hybrid merge still runs for multi-retriever
1289            // requests (prefer real components when workers provide them).
1290            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
1331/// Borrowed inputs for one coordinator-side distributed-AI fan-out.
1332pub struct AiFanoutRequest<'a> {
1333    /// Stable query identity propagated to every tablet.
1334    pub query_id: QueryId,
1335    /// Tablets participating in the fan-out.
1336    pub tablets: &'a [TabletId],
1337    /// Authoritative table name.
1338    pub table: &'a str,
1339    /// Scored-search request.
1340    pub search: &'a SearchRequest,
1341    /// Server-issued authorization envelope.
1342    pub authorization_context: &'a [u8],
1343    /// Deterministic global fusion policy.
1344    pub fusion: FusionMethod,
1345    /// Tablet-local candidate overfetch multiplier.
1346    pub overfetch_factor: f64,
1347    /// Global deadline, work, candidate, and memory limits.
1348    pub budget: &'a AiWorkBudget,
1349    /// Parent cancellation and deadline control.
1350    pub control: &'a ExecutionControl,
1351}
1352
1353/// Coordinator-side distributed-AI fan-out.
1354pub 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    /// Creates a transport whose tablets use `default_client`.
1362    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    /// Creates a fail-closed transport with no fallback tablet route.
1371    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    /// Routes one tablet to a specific peer.
1380    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    /// Fans out a typed search, enforces the global budget, and merges the
1399    /// authorized tablet-local top-k results.
1400    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        // P0.8 production path: multi-retriever / contribution-bearing hits
1564        // fuse via merge_hybrid_contributions (not single local_rank RRF).
1565        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                        // Hybrid merge may pick the lowest tablet id among
1578                        // contributions; match by row_id when the exact
1579                        // (tablet, row) key is missing.
1580                        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        // Score desc: rank-1 entries outrank rank-2 for same k.
1725        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); // ceil(4.0)
1740        assert_eq!(adaptive_local_k(10, 2.0, 1), 20);
1741        assert_eq!(adaptive_local_k(10, 2.0, 0), 20); // treat 0 tablets as 1
1742    }
1743
1744    #[test]
1745    fn tie_break_tablet_then_row() {
1746        // Equal RRF ranks → lower tablet id first among equal scores.
1747        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        // Same rank → same RRF score; tablet 1 before tablet 2.
1759        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    /// Multi-retriever hybrid executor: each tablet returns per-retriever
1794    /// contributions so the production path must call merge_hybrid_contributions.
1795    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            // Tablet 1: dense ranks row 10 first; sparse ranks row 20 first.
1809            // Tablet 2: dense ranks row 20 first; sparse ranks row 10 first.
1810            // Global hybrid RRF should prefer row 10 (dense#1@t1 + sparse#1@t2).
1811            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        // Production path (not unit-only merge_hybrid_contributions): when
1916        // multiple named retrievers are present, fuse_distributed_hits must
1917        // sum per-retriever RRF contributions globally.
1918        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, // wrong if used alone
1925                    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        // Row 10 has two rank-1 components → 2/61; row 20 has two rank-2 → 2/62.
1990        assert_eq!(fused[0].row_id, RowId(10));
1991        assert!((fused[0].final_score - 2.0 / 61.0).abs() < 1e-12);
1992        // Single-score merge_candidates would have preferred row 20 (local_rank 1).
1993        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                // ceil(2 * 2.0 / 2) = 2 local hits per tablet.
2057                overfetch_factor: 2.0,
2058                budget: &budget,
2059                control: &ExecutionControl::new(None),
2060            })
2061            .await
2062            .unwrap();
2063        assert!(!result.candidates.is_empty());
2064        // Global hybrid prefers row 10 (dense@t1 rank1 + sparse@t2 rank1).
2065        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    // -----------------------------------------------------------------------
2127    // Global hybrid fusion (P0.8)
2128    // -----------------------------------------------------------------------
2129
2130    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        // Row 10: dense rank 1 + sparse rank 2 → 1/61 + 1/62
2143        // Row 20: dense rank 2 only → 1/62
2144        // Row 30: sparse rank 1 only → 1/61
2145        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        // Highest fused: row 10 (two components).
2160        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        // Component scores present and named.
2165        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        // Rows 20 and 30 share the same single-component RRF (rank 2 dense vs
2172        // rank 1 sparse differ): rank-1 sparse (1/61) > rank-2 dense (1/62).
2173        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), // better rank wins
2183            contrib(2, 7, "dense", 3, 0.4), // worse
2184        ];
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    // ID: P0.8-X6 Adaptive refill recovers a global winner.
2228    #[test]
2229    fn hybrid_adaptive_refill_recovers_global_winner() {
2230        // Initial: tablet 1 only has a rank-2 dense hit (RRF 1/62). Tablet 2 is
2231        // not exhausted and can still emit a rank-1 hit (RRF 1/61) that must
2232        // displace the provisional winner after refill.
2233        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    // ID: P0.8-X7 Exact global rerank changes order correctly.
2275    #[test]
2276    fn exact_global_rerank_changes_order_correctly() {
2277        // Fused RRF alone prefers row 1 (rank 1). Exact scores invert: row 2
2278        // has a much higher exact score, so global rerank must place it first.
2279        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        // final = RRF(2) + 0.95 = 1/62 + 0.95  >  RRF(1) + 0.1 = 1/61 + 0.1
2346        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            // next rank 1000 → contribution ~1/1060 << 1/61 → no refill
2361            RetrieverTabletBound::open(tid(2), "dense", 1000, Some(0.1), 1.0),
2362            // next rank 1 → contribution 1/61 == threshold → refill
2363            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), // mismatch
2432                ..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        // expected_model_generation on budget also fail-closes.
2464        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    // P0.8-X2: Dense + Sparse + MinHash global RRF matches centralized baseline.
2491    #[test]
2492    fn p08_x2_dense_sparse_minhash_global_rrf_matches_baseline() {
2493        // Centralized baseline: one contribution per named retriever per row.
2494        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        // Row 10: rank1 dense + rank2 sparse + rank1 minhash = 1/61 + 1/62 + 1/61
2510        // Row 20: rank2 dense + rank1 sparse + rank2 minhash = 1/62 + 1/61 + 1/62
2511        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        // Production fuse path with MinHash retriever kind present.
2523        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    // P0.8-X5: RLS-hidden rows never contribute (candidate + contribution paths).
2581    #[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        // Endpoint path also fails closed on rls_visible=false hits.
2601        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    // P0.8-X9: model/index generation mismatch fails closed.
2632    #[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    /// Failing executor for one tablet — used by P0.8-X10.
2698    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    // P0.8-X10: one tablet failure is fail-closed (no partial winners).
2736    #[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}