1use std::cmp::Ordering;
10use std::collections::{BinaryHeap, HashMap};
11
12use mongreldb_core::RowId;
13use mongreldb_types::ids::TabletId;
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct LocalCandidate {
19 pub tablet_id: TabletId,
21 pub row_id: RowId,
23 pub score: f64,
25 pub local_rank: u32,
27 pub rls_visible: bool,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum FusionMethod {
34 Rrf {
36 k: u32,
38 },
39 MaxScore,
41}
42
43impl Default for FusionMethod {
44 fn default() -> Self {
45 Self::Rrf { k: 60 }
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct AiWorkBudget {
52 pub candidate_ceiling: usize,
54 pub max_local_candidates: usize,
56 pub memory_bytes: u64,
58 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
73pub 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
84pub enum AiRetrievalError {
85 #[error("RLS hygiene violation: tablet {tablet_id} emitted hidden row {row_id}")]
87 RlsHygiene {
88 tablet_id: TabletId,
90 row_id: u64,
92 },
93 #[error("AI work budget exceeded: {0}")]
95 BudgetExceeded(String),
96}
97
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct MergedCandidate {
101 pub tablet_id: TabletId,
104 pub row_id: RowId,
106 pub final_score: f64,
108 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 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
147pub 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct AiConsistencyAudit {
240 pub read_ts: String,
242 pub replica_applied_ts: String,
244 pub staleness_micros: u64,
246 pub index_applied_ts: String,
248 pub model_version: Option<String>,
250 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 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); assert_eq!(adaptive_local_k(10, 2.0, 1), 20);
308 assert_eq!(adaptive_local_k(10, 2.0, 0), 20); }
310
311 #[test]
312 fn tie_break_tablet_then_row() {
313 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 assert_eq!(out[0].tablet_id, tid(1));
327 assert_eq!(out[1].tablet_id, tid(2));
328 }
329}