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.
8
9use std::cmp::Ordering;
10use std::collections::{BinaryHeap, HashMap};
11
12use mongreldb_core::RowId;
13use mongreldb_types::ids::TabletId;
14use serde::{Deserialize, Serialize};
15
16/// One candidate from a tablet-local retriever (post-RLS).
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct LocalCandidate {
19    /// Source tablet.
20    pub tablet_id: TabletId,
21    /// Row identity.
22    pub row_id: RowId,
23    /// Raw local score (higher is better).
24    pub score: f64,
25    /// Local rank (1-based) after RLS filtering.
26    pub local_rank: u32,
27    /// Whether this row was visible after RLS (must be true when emitted).
28    pub rls_visible: bool,
29}
30
31/// Fusion method for coordinator merge.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum FusionMethod {
34    /// Reciprocal rank fusion with constant `k` (default 60).
35    Rrf {
36        /// RRF constant k.
37        k: u32,
38    },
39    /// Max of raw scores (deterministic with tie-breaks).
40    MaxScore,
41}
42
43impl Default for FusionMethod {
44    fn default() -> Self {
45        Self::Rrf { k: 60 }
46    }
47}
48
49/// Global work budget for a distributed AI request.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct AiWorkBudget {
52    /// Maximum candidates retained globally after merge.
53    pub candidate_ceiling: usize,
54    /// Maximum total local candidates fetched across tablets.
55    pub max_local_candidates: usize,
56    /// Memory reservation bytes (advisory for the governor).
57    pub memory_bytes: u64,
58    /// Wall-clock deadline remaining in milliseconds.
59    pub deadline_ms: u64,
60}
61
62impl Default for AiWorkBudget {
63    fn default() -> Self {
64        Self {
65            candidate_ceiling: 100,
66            max_local_candidates: 1_000,
67            memory_bytes: 64 * 1024 * 1024,
68            deadline_ms: 5_000,
69        }
70    }
71}
72
73/// Adaptive per-tablet candidate count.
74///
75/// `ceil(global_k * overfetch_factor / active_tablet_count)`.
76pub fn adaptive_local_k(global_k: usize, overfetch_factor: f64, active_tablets: usize) -> usize {
77    let tablets = active_tablets.max(1) as f64;
78    let raw = (global_k as f64) * overfetch_factor / tablets;
79    raw.ceil().max(1.0) as usize
80}
81
82/// Errors of distributed AI retrieval.
83#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
84pub enum AiRetrievalError {
85    /// A tablet returned an RLS-hidden row as a candidate (hygiene violation).
86    #[error("RLS hygiene violation: tablet {tablet_id} emitted hidden row {row_id}")]
87    RlsHygiene {
88        /// Offending tablet.
89        tablet_id: TabletId,
90        /// Hidden row.
91        row_id: u64,
92    },
93    /// Work budget exceeded.
94    #[error("AI work budget exceeded: {0}")]
95    BudgetExceeded(String),
96}
97
98/// One merged result row.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct MergedCandidate {
101    /// Tablet of origin (lowest tablet id if fused across; for RRF we keep
102    /// the tablet of the best local rank contribution for tie-break).
103    pub tablet_id: TabletId,
104    /// Row id.
105    pub row_id: RowId,
106    /// Fused final score.
107    pub final_score: f64,
108    /// Best raw local score observed.
109    pub raw_score: f64,
110}
111
112#[derive(Debug, Clone)]
113struct HeapKey {
114    final_score: f64,
115    tablet_id: TabletId,
116    row_id: RowId,
117}
118
119impl PartialEq for HeapKey {
120    fn eq(&self, other: &Self) -> bool {
121        self.cmp(other) == Ordering::Equal
122    }
123}
124impl Eq for HeapKey {}
125impl PartialOrd for HeapKey {
126    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
127        Some(self.cmp(other))
128    }
129}
130impl Ord for HeapKey {
131    fn cmp(&self, other: &Self) -> Ordering {
132        // Max-heap: higher score first; then lower tablet id; then lower row id.
133        match self
134            .final_score
135            .partial_cmp(&other.final_score)
136            .unwrap_or(Ordering::Equal)
137        {
138            Ordering::Equal => other
139                .tablet_id
140                .cmp(&self.tablet_id)
141                .then_with(|| other.row_id.cmp(&self.row_id)),
142            ord => ord,
143        }
144    }
145}
146
147/// Deterministic coordinator merge (spec §13.4).
148///
149/// RLS hygiene: any candidate with `rls_visible == false` is a hard error
150/// (hidden rows must never influence ranking).
151pub fn merge_candidates(
152    locals: &[LocalCandidate],
153    method: FusionMethod,
154    budget: &AiWorkBudget,
155) -> Result<Vec<MergedCandidate>, AiRetrievalError> {
156    if locals.len() > budget.max_local_candidates {
157        return Err(AiRetrievalError::BudgetExceeded(format!(
158            "{} local candidates > max {}",
159            locals.len(),
160            budget.max_local_candidates
161        )));
162    }
163    for c in locals {
164        if !c.rls_visible {
165            return Err(AiRetrievalError::RlsHygiene {
166                tablet_id: c.tablet_id,
167                row_id: c.row_id.0,
168            });
169        }
170    }
171
172    // Group by (tablet, row) — a row should appear once per tablet.
173    let mut by_key: HashMap<(TabletId, RowId), LocalCandidate> = HashMap::new();
174    for c in locals {
175        by_key
176            .entry((c.tablet_id, c.row_id))
177            .and_modify(|existing| {
178                if c.score > existing.score {
179                    *existing = c.clone();
180                }
181            })
182            .or_insert_with(|| c.clone());
183    }
184
185    let fused: Vec<MergedCandidate> = match method {
186        FusionMethod::Rrf { k } => {
187            // RRF score = sum 1/(k + rank) across appearances (here one per tablet).
188            by_key
189                .into_values()
190                .map(|c| {
191                    let rrf = 1.0 / (f64::from(k) + f64::from(c.local_rank));
192                    MergedCandidate {
193                        tablet_id: c.tablet_id,
194                        row_id: c.row_id,
195                        final_score: rrf,
196                        raw_score: c.score,
197                    }
198                })
199                .collect()
200        }
201        FusionMethod::MaxScore => by_key
202            .into_values()
203            .map(|c| MergedCandidate {
204                tablet_id: c.tablet_id,
205                row_id: c.row_id,
206                final_score: c.score,
207                raw_score: c.score,
208            })
209            .collect(),
210    };
211
212    // Sort with deterministic tie-breaks, take ceiling.
213    let mut heap: BinaryHeap<HeapKey> = BinaryHeap::new();
214    let mut map: HashMap<(TabletId, RowId), MergedCandidate> = HashMap::new();
215    for m in fused {
216        let key = (m.tablet_id, m.row_id);
217        heap.push(HeapKey {
218            final_score: m.final_score,
219            tablet_id: m.tablet_id,
220            row_id: m.row_id,
221        });
222        map.insert(key, m);
223    }
224
225    let mut out = Vec::with_capacity(budget.candidate_ceiling.min(map.len()));
226    while out.len() < budget.candidate_ceiling {
227        let Some(hk) = heap.pop() else {
228            break;
229        };
230        if let Some(m) = map.remove(&(hk.tablet_id, hk.row_id)) {
231            out.push(m);
232        }
233    }
234    Ok(out)
235}
236
237/// Audit metadata returned with AI/analytics results (spec §13.6).
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct AiConsistencyAudit {
240    /// Requested read timestamp (string form of HLC).
241    pub read_ts: String,
242    /// Replica applied timestamp.
243    pub replica_applied_ts: String,
244    /// Measured staleness in microseconds (0 if caught up).
245    pub staleness_micros: u64,
246    /// Index applied_through timestamp.
247    pub index_applied_ts: String,
248    /// Model / preprocessing version.
249    pub model_version: Option<String>,
250    /// Preprocessing version.
251    pub preprocessing_version: Option<String>,
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn tid(n: u8) -> TabletId {
259        TabletId::from_bytes({
260            let mut b = [0u8; 16];
261            b[15] = n;
262            b
263        })
264    }
265
266    fn cand(tablet: u8, row: u64, score: f64, rank: u32) -> LocalCandidate {
267        LocalCandidate {
268            tablet_id: tid(tablet),
269            row_id: RowId(row),
270            score,
271            local_rank: rank,
272            rls_visible: true,
273        }
274    }
275
276    #[test]
277    fn merge_is_deterministic() {
278        let locals = vec![
279            cand(2, 10, 0.9, 1),
280            cand(1, 20, 0.8, 1),
281            cand(2, 30, 0.7, 2),
282            cand(1, 10, 0.95, 2),
283        ];
284        let budget = AiWorkBudget {
285            candidate_ceiling: 10,
286            ..AiWorkBudget::default()
287        };
288        let a = merge_candidates(&locals, FusionMethod::Rrf { k: 60 }, &budget).unwrap();
289        let b = merge_candidates(&locals, FusionMethod::Rrf { k: 60 }, &budget).unwrap();
290        assert_eq!(a, b);
291        // Score desc: rank-1 entries outrank rank-2 for same k.
292        assert!(a[0].final_score >= a[1].final_score);
293    }
294
295    #[test]
296    fn rls_hidden_row_fails_closed() {
297        let mut c = cand(1, 1, 1.0, 1);
298        c.rls_visible = false;
299        let err =
300            merge_candidates(&[c], FusionMethod::default(), &AiWorkBudget::default()).unwrap_err();
301        assert!(matches!(err, AiRetrievalError::RlsHygiene { .. }));
302    }
303
304    #[test]
305    fn adaptive_local_k_scales() {
306        assert_eq!(adaptive_local_k(10, 2.0, 5), 4); // ceil(4.0)
307        assert_eq!(adaptive_local_k(10, 2.0, 1), 20);
308        assert_eq!(adaptive_local_k(10, 2.0, 0), 20); // treat 0 tablets as 1
309    }
310
311    #[test]
312    fn tie_break_tablet_then_row() {
313        // Equal RRF ranks → lower tablet id first among equal scores.
314        let locals = vec![cand(2, 5, 1.0, 1), cand(1, 9, 1.0, 1)];
315        let out = merge_candidates(
316            &locals,
317            FusionMethod::Rrf { k: 60 },
318            &AiWorkBudget {
319                candidate_ceiling: 2,
320                ..AiWorkBudget::default()
321            },
322        )
323        .unwrap();
324        assert_eq!(out.len(), 2);
325        // Same rank → same RRF score; tablet 1 before tablet 2.
326        assert_eq!(out[0].tablet_id, tid(1));
327        assert_eq!(out[1].tablet_id, tid(2));
328    }
329}