1#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13#[repr(u8)]
14pub enum RetrievalQuality {
15 Full = 0x00,
17 Partial = 0x01,
19 LayerAOnly = 0x02,
21 DegenerateDetected = 0x03,
23 BruteForceBudgeted = 0x04,
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33#[repr(u8)]
34pub enum ResponseQuality {
35 Verified = 0x00,
37 Usable = 0x01,
39 Degraded = 0x02,
41 Unreliable = 0x03,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48#[repr(u8)]
49pub enum QualityPreference {
50 Auto = 0x00,
52 PreferQuality = 0x01,
55 PreferLatency = 0x02,
58 AcceptDegraded = 0x03,
61}
62
63impl Default for QualityPreference {
64 fn default() -> Self {
65 Self::Auto
66 }
67}
68
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72pub struct IndexLayersUsed {
73 pub layer_a: bool,
74 pub layer_b: bool,
75 pub layer_c: bool,
76 pub hot_cache: bool,
77}
78
79#[derive(Clone, Debug, PartialEq)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82pub struct SearchEvidenceSummary {
83 pub layers_used: IndexLayersUsed,
85 pub n_probe_effective: u32,
87 pub degenerate_detected: bool,
89 pub centroid_distance_cv: f32,
91 pub hnsw_candidate_count: u32,
93 pub safety_net_candidate_count: u32,
95}
96
97impl Default for SearchEvidenceSummary {
98 fn default() -> Self {
99 Self {
100 layers_used: IndexLayersUsed::default(),
101 n_probe_effective: 0,
102 degenerate_detected: false,
103 centroid_distance_cv: 0.0,
104 hnsw_candidate_count: 0,
105 safety_net_candidate_count: 0,
106 }
107 }
108}
109
110#[derive(Clone, Debug, Default, PartialEq)]
112#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
113pub struct BudgetReport {
114 pub centroid_routing_us: u64,
116 pub hnsw_traversal_us: u64,
118 pub safety_net_scan_us: u64,
120 pub reranking_us: u64,
122 pub total_us: u64,
124 pub distance_ops: u64,
126 pub distance_ops_budget: u64,
128 pub bytes_read: u64,
130 pub linear_scan_count: u64,
132 pub linear_scan_budget: u64,
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
138#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
139#[repr(u8)]
140pub enum FallbackPath {
141 None = 0x00,
143 NProbeWidened = 0x01,
145 DegenerateWidened = 0x02,
147 SafetyNetSelective = 0x03,
149 SafetyNetBudgetExhausted = 0x04,
151}
152
153#[derive(Clone, Copy, Debug, PartialEq)]
155#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
156pub enum DegradationReason {
157 CentroidDrift {
159 epoch_drift: u32,
160 max_drift: u32,
161 },
162 DegenerateDistribution {
164 cv: f32,
165 threshold: f32,
166 },
167 BudgetExhausted {
169 scanned: u64,
170 total: u64,
171 budget_type: BudgetType,
172 },
173 IndexNotLoaded {
175 available: IndexLayersUsed,
176 },
177}
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
181#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
182#[repr(u8)]
183pub enum BudgetType {
184 Time = 0x00,
185 Candidates = 0x01,
186 DistanceOps = 0x02,
187}
188
189#[derive(Clone, Debug, PartialEq)]
191#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
192pub struct DegradationReport {
193 pub fallback_path: FallbackPath,
195 pub reason: DegradationReason,
197 pub guarantee_lost: &'static str,
199}
200
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
207#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
208pub struct SafetyNetBudget {
209 pub max_scan_time_us: u64,
211 pub max_scan_candidates: u64,
213 pub max_distance_ops: u64,
215}
216
217impl SafetyNetBudget {
218 pub const LAYER_A: Self = Self {
220 max_scan_time_us: 2_000, max_scan_candidates: 10_000,
222 max_distance_ops: 10_000,
223 };
224
225 pub const PARTIAL: Self = Self {
227 max_scan_time_us: 5_000, max_scan_candidates: 50_000,
229 max_distance_ops: 50_000,
230 };
231
232 pub const FULL: Self = Self {
234 max_scan_time_us: 10_000, max_scan_candidates: 100_000,
236 max_distance_ops: 100_000,
237 };
238
239 pub const DISABLED: Self = Self {
241 max_scan_time_us: 0,
242 max_scan_candidates: 0,
243 max_distance_ops: 0,
244 };
245
246 pub const fn extended_4x(&self) -> Self {
249 Self {
250 max_scan_time_us: self.max_scan_time_us.saturating_mul(4),
251 max_scan_candidates: self.max_scan_candidates.saturating_mul(4),
252 max_distance_ops: self.max_distance_ops.saturating_mul(4),
253 }
254 }
255
256 pub const fn is_disabled(&self) -> bool {
258 self.max_scan_time_us == 0
259 && self.max_scan_candidates == 0
260 && self.max_distance_ops == 0
261 }
262}
263
264impl Default for SafetyNetBudget {
265 fn default() -> Self {
266 Self::LAYER_A
267 }
268}
269
270pub fn derive_response_quality(retrieval_qualities: &[RetrievalQuality]) -> ResponseQuality {
274 if retrieval_qualities.is_empty() {
275 return ResponseQuality::Unreliable;
276 }
277
278 let worst = retrieval_qualities
279 .iter()
280 .copied()
281 .max_by_key(|q| *q as u8)
282 .unwrap_or(RetrievalQuality::Full);
283
284 match worst {
285 RetrievalQuality::Full => ResponseQuality::Verified,
286 RetrievalQuality::Partial => ResponseQuality::Usable,
287 RetrievalQuality::LayerAOnly => ResponseQuality::Usable,
288 RetrievalQuality::DegenerateDetected => ResponseQuality::Degraded,
289 RetrievalQuality::BruteForceBudgeted => ResponseQuality::Degraded,
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn retrieval_quality_ordering() {
299 assert!(RetrievalQuality::Full < RetrievalQuality::BruteForceBudgeted);
300 assert!(RetrievalQuality::Partial < RetrievalQuality::DegenerateDetected);
301 }
302
303 #[test]
304 fn response_quality_ordering() {
305 assert!(ResponseQuality::Verified < ResponseQuality::Unreliable);
306 assert!(ResponseQuality::Usable < ResponseQuality::Degraded);
307 }
308
309 #[test]
310 fn derive_quality_full() {
311 let q = derive_response_quality(&[RetrievalQuality::Full, RetrievalQuality::Full]);
312 assert_eq!(q, ResponseQuality::Verified);
313 }
314
315 #[test]
316 fn derive_quality_mixed() {
317 let q = derive_response_quality(&[
318 RetrievalQuality::Full,
319 RetrievalQuality::DegenerateDetected,
320 ]);
321 assert_eq!(q, ResponseQuality::Degraded);
322 }
323
324 #[test]
325 fn derive_quality_empty_is_unreliable() {
326 let q = derive_response_quality(&[]);
327 assert_eq!(q, ResponseQuality::Unreliable);
328 }
329
330 #[test]
331 fn derive_quality_layer_a() {
332 let q = derive_response_quality(&[RetrievalQuality::LayerAOnly]);
333 assert_eq!(q, ResponseQuality::Usable);
334 }
335
336 #[test]
337 fn derive_quality_brute_force() {
338 let q = derive_response_quality(&[RetrievalQuality::BruteForceBudgeted]);
339 assert_eq!(q, ResponseQuality::Degraded);
340 }
341
342 #[test]
343 fn safety_net_budget_layer_a() {
344 let b = SafetyNetBudget::LAYER_A;
345 assert_eq!(b.max_scan_time_us, 2_000);
346 assert_eq!(b.max_scan_candidates, 10_000);
347 assert_eq!(b.max_distance_ops, 10_000);
348 assert!(!b.is_disabled());
349 }
350
351 #[test]
352 fn safety_net_budget_extended() {
353 let b = SafetyNetBudget::LAYER_A.extended_4x();
354 assert_eq!(b.max_scan_time_us, 8_000);
355 assert_eq!(b.max_scan_candidates, 40_000);
356 assert_eq!(b.max_distance_ops, 40_000);
357 }
358
359 #[test]
360 fn safety_net_budget_disabled() {
361 let b = SafetyNetBudget::DISABLED;
362 assert!(b.is_disabled());
363 assert_eq!(b.max_scan_time_us, 0);
364 }
365
366 #[test]
367 fn quality_preference_default_is_auto() {
368 assert_eq!(QualityPreference::default(), QualityPreference::Auto);
369 }
370
371 #[test]
372 fn quality_repr_values() {
373 assert_eq!(RetrievalQuality::Full as u8, 0x00);
374 assert_eq!(RetrievalQuality::BruteForceBudgeted as u8, 0x04);
375 assert_eq!(ResponseQuality::Verified as u8, 0x00);
376 assert_eq!(ResponseQuality::Unreliable as u8, 0x03);
377 assert_eq!(QualityPreference::Auto as u8, 0x00);
378 assert_eq!(QualityPreference::AcceptDegraded as u8, 0x03);
379 }
380
381 #[test]
382 fn fallback_path_repr() {
383 assert_eq!(FallbackPath::None as u8, 0x00);
384 assert_eq!(FallbackPath::SafetyNetBudgetExhausted as u8, 0x04);
385 }
386
387 #[test]
388 fn budget_report_default_is_zero() {
389 let r = BudgetReport::default();
390 assert_eq!(r.total_us, 0);
391 assert_eq!(r.distance_ops, 0);
392 }
393
394 #[test]
395 fn degradation_report_construction() {
396 let report = DegradationReport {
397 fallback_path: FallbackPath::SafetyNetBudgetExhausted,
398 reason: DegradationReason::BudgetExhausted {
399 scanned: 5000,
400 total: 10000,
401 budget_type: BudgetType::DistanceOps,
402 },
403 guarantee_lost: "recall may be below target",
404 };
405 assert_eq!(report.fallback_path, FallbackPath::SafetyNetBudgetExhausted);
406 }
407
408 #[test]
409 fn evidence_summary_default() {
410 let e = SearchEvidenceSummary::default();
411 assert!(!e.degenerate_detected);
412 assert_eq!(e.n_probe_effective, 0);
413 assert_eq!(e.centroid_distance_cv, 0.0);
414 }
415
416 #[test]
417 fn index_layers_default_all_false() {
418 let l = IndexLayersUsed::default();
419 assert!(!l.layer_a);
420 assert!(!l.layer_b);
421 assert!(!l.layer_c);
422 assert!(!l.hot_cache);
423 }
424}