Skip to main content

rvf_types/
quality.rs

1//! Quality envelope types for ADR-033 progressive indexing hardening.
2//!
3//! Defines the mandatory outer return type (`QualityEnvelope`) for all query
4//! APIs, along with retrieval-level and response-level quality signals,
5//! budget reporting, and degradation diagnostics.
6
7/// Quality confidence for a single retrieval candidate.
8///
9/// Attached per-candidate during the search pipeline. Internal use only;
10/// consumers see `ResponseQuality` via the `QualityEnvelope`.
11#[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 index traversed, high confidence in candidate set.
16    Full = 0x00,
17    /// Partial index (Layer A+B), good confidence.
18    Partial = 0x01,
19    /// Layer A only, moderate confidence.
20    LayerAOnly = 0x02,
21    /// Degenerate distribution detected, low confidence.
22    DegenerateDetected = 0x03,
23    /// Brute-force fallback used within budget, exact over scanned region.
24    BruteForceBudgeted = 0x04,
25}
26
27/// Response-level quality signal returned to the caller at the API boundary.
28///
29/// This is the field that consumers (RAG pipelines, agent tool chains,
30/// MCP clients) **must** inspect before using results.
31#[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    /// All results from full index. Trust fully.
36    Verified = 0x00,
37    /// Results from partial index. Usable but may miss neighbors.
38    Usable = 0x01,
39    /// Degraded retrieval detected. Results are best-effort.
40    Degraded = 0x02,
41    /// Insufficient candidates found. Results are unreliable.
42    Unreliable = 0x03,
43}
44
45/// Caller hint for quality vs latency trade-off.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48#[repr(u8)]
49pub enum QualityPreference {
50    /// Runtime decides. Default. Fastest path that meets internal thresholds.
51    Auto = 0x00,
52    /// Caller prefers quality over latency. Runtime may widen n_probe,
53    /// extend budgets up to 4x, and block until Layer B loads.
54    PreferQuality = 0x01,
55    /// Caller prefers latency over quality. Runtime may skip safety net,
56    /// reduce n_probe. ResponseQuality honestly reports what it gets.
57    PreferLatency = 0x02,
58    /// Caller explicitly accepts degraded results. Required to proceed
59    /// when ResponseQuality would be Degraded or Unreliable under Auto.
60    AcceptDegraded = 0x03,
61}
62
63impl Default for QualityPreference {
64    fn default() -> Self {
65        Self::Auto
66    }
67}
68
69/// Which index layers were available and used during a query.
70#[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/// Evidence chain: what index state was actually used for a query.
80#[derive(Clone, Debug, PartialEq)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82pub struct SearchEvidenceSummary {
83    /// Which index layers were available and used.
84    pub layers_used: IndexLayersUsed,
85    /// Effective n_probe (after any adaptive widening).
86    pub n_probe_effective: u32,
87    /// Whether degenerate distribution was detected.
88    pub degenerate_detected: bool,
89    /// Coefficient of variation of top-K centroid distances.
90    pub centroid_distance_cv: f32,
91    /// Number of candidates found by HNSW before safety net.
92    pub hnsw_candidate_count: u32,
93    /// Number of candidates added by safety net scan.
94    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/// Resource consumption report for a single query.
111#[derive(Clone, Debug, Default, PartialEq)]
112#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
113pub struct BudgetReport {
114    /// Wall-clock time for centroid routing (microseconds).
115    pub centroid_routing_us: u64,
116    /// Wall-clock time for HNSW traversal (microseconds).
117    pub hnsw_traversal_us: u64,
118    /// Wall-clock time for safety net scan (microseconds).
119    pub safety_net_scan_us: u64,
120    /// Wall-clock time for reranking (microseconds).
121    pub reranking_us: u64,
122    /// Total wall-clock time (microseconds).
123    pub total_us: u64,
124    /// Distance evaluations performed.
125    pub distance_ops: u64,
126    /// Distance evaluations budget.
127    pub distance_ops_budget: u64,
128    /// Bytes read from storage.
129    pub bytes_read: u64,
130    /// Candidates scanned in safety net.
131    pub linear_scan_count: u64,
132    /// Candidate scan budget.
133    pub linear_scan_budget: u64,
134}
135
136/// Which fallback path was chosen during query execution.
137#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
138#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
139#[repr(u8)]
140pub enum FallbackPath {
141    /// Normal HNSW traversal, no fallback needed.
142    None = 0x00,
143    /// Adaptive n_probe widening due to epoch drift.
144    NProbeWidened = 0x01,
145    /// Adaptive n_probe widening due to degenerate distribution.
146    DegenerateWidened = 0x02,
147    /// Selective safety net scan on hot cache.
148    SafetyNetSelective = 0x03,
149    /// Safety net budget exhausted before completion.
150    SafetyNetBudgetExhausted = 0x04,
151}
152
153/// Structured reason for quality degradation.
154#[derive(Clone, Copy, Debug, PartialEq)]
155#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
156pub enum DegradationReason {
157    /// Centroid epoch drift exceeded threshold.
158    CentroidDrift {
159        epoch_drift: u32,
160        max_drift: u32,
161    },
162    /// Degenerate distance distribution detected.
163    DegenerateDistribution {
164        cv: f32,
165        threshold: f32,
166    },
167    /// Budget exhausted during safety net scan.
168    BudgetExhausted {
169        scanned: u64,
170        total: u64,
171        budget_type: BudgetType,
172    },
173    /// Index layer not yet loaded.
174    IndexNotLoaded {
175        available: IndexLayersUsed,
176    },
177}
178
179/// Which budget cap was hit.
180#[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/// Why quality is degraded — full diagnostic report.
190#[derive(Clone, Debug, PartialEq)]
191#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
192pub struct DegradationReport {
193    /// Which fallback path was chosen.
194    pub fallback_path: FallbackPath,
195    /// Why it was chosen (structured, not prose).
196    pub reason: DegradationReason,
197    /// What guarantee is lost relative to Full quality.
198    pub guarantee_lost: &'static str,
199}
200
201/// Budget caps for the brute-force safety net.
202///
203/// All three are enforced simultaneously. The scan stops at whichever hits
204/// first. These are runtime limits, not caller-adjustable above the defaults
205/// (unless `QualityPreference::PreferQuality`, which extends to 4x).
206#[derive(Clone, Copy, Debug, PartialEq, Eq)]
207#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
208pub struct SafetyNetBudget {
209    /// Maximum wall-clock time for the safety net scan (microseconds).
210    pub max_scan_time_us: u64,
211    /// Maximum number of candidate vectors to scan.
212    pub max_scan_candidates: u64,
213    /// Maximum number of distance evaluations.
214    pub max_distance_ops: u64,
215}
216
217impl SafetyNetBudget {
218    /// Layer A only defaults: tight budget for instant first query.
219    pub const LAYER_A: Self = Self {
220        max_scan_time_us: 2_000,     // 2 ms
221        max_scan_candidates: 10_000,
222        max_distance_ops: 10_000,
223    };
224
225    /// Partial index defaults: moderate budget.
226    pub const PARTIAL: Self = Self {
227        max_scan_time_us: 5_000,     // 5 ms
228        max_scan_candidates: 50_000,
229        max_distance_ops: 50_000,
230    };
231
232    /// Full index: generous budget.
233    pub const FULL: Self = Self {
234        max_scan_time_us: 10_000,    // 10 ms
235        max_scan_candidates: 100_000,
236        max_distance_ops: 100_000,
237    };
238
239    /// Disabled: all zeros. Safety net will not scan anything.
240    pub const DISABLED: Self = Self {
241        max_scan_time_us: 0,
242        max_scan_candidates: 0,
243        max_distance_ops: 0,
244    };
245
246    /// Extend all budgets by 4x for PreferQuality mode.
247    /// Uses saturating arithmetic to prevent overflow.
248    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    /// Check if all budgets are zero (disabled).
257    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
270/// Derive `ResponseQuality` from the worst `RetrievalQuality` in the result set.
271///
272/// Empty input returns `Unreliable` — zero results means zero confidence.
273pub 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}