Skip to main content

sochdb_vector/
guarantee_ladder.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Affero General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU Affero General Public License for more details.
14//
15// You should have received a copy of the GNU Affero General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18//! # Guarantee Ladder with Explicit Semantics (Task 2)
19//!
20//! This module provides three guarantee modes with well-defined correctness contracts:
21//!
22//! 1. **Fast Approximate** (`Approximate`): Top-k under proxy score
23//!    - Fastest, ignores quantization error
24//!    - No recall guarantees
25//!
26//! 2. **Calibrated High-Recall** (`Calibrated`): Probabilistic guarantees
27//!    - Uses quantile-bounded error envelopes
28//!    - P(recall ≥ ρ) ≥ 1-δ
29//!
30//! 3. **Certified** (`Certified`): Deterministic exact via LB/UB envelopes
31//!    - Guaranteed correct results
32//!    - Uses rerank to verify all candidates
33//!
34//! ## Math/Algorithm
35//!
36//! Let true score be s(x), proxy be ŝ(x) = s(x) + ε(x).
37//!
38//! - Mode 1: ε ignored (ranking by ŝ)
39//! - Mode 2: ε bounded in probability (use quantiles)
40//! - Mode 3: ε bounded deterministically (use LB/UB comparisons)
41
42use std::time::Duration;
43
44// ============================================================================
45// Guarantee Modes
46// ============================================================================
47
48/// Guarantee mode for search correctness
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub enum GuaranteeMode {
51    /// Fast approximate search - ignores quantization error
52    ///
53    /// Returns top-k by proxy score (e.g., PQ/ADC distance).
54    /// Fastest but may miss true nearest neighbors.
55    Approximate,
56
57    /// Calibrated high-recall search - probabilistic guarantees
58    ///
59    /// Uses learned error envelopes to bound approximation error.
60    /// Guarantees P(recall@k ≥ ρ) ≥ 1-δ with configurable ρ and δ.
61    Calibrated {
62        /// Target recall (ρ)
63        recall_target: f32,
64        /// Confidence level (1-δ)
65        confidence: f32,
66    },
67
68    /// Certified search - deterministic correctness
69    ///
70    /// Uses lower/upper bound envelopes for all candidates.
71    /// Guarantees exact top-k results (same as brute force).
72    Certified,
73}
74
75impl Default for GuaranteeMode {
76    fn default() -> Self {
77        Self::Calibrated {
78            recall_target: 0.95,
79            confidence: 0.99,
80        }
81    }
82}
83
84impl GuaranteeMode {
85    /// Create calibrated mode with given recall target
86    pub fn calibrated(recall_target: f32, confidence: f32) -> Self {
87        Self::Calibrated {
88            recall_target,
89            confidence,
90        }
91    }
92
93    /// Returns true if this mode requires rerank verification
94    pub fn requires_rerank(&self) -> bool {
95        matches!(self, GuaranteeMode::Certified)
96    }
97
98    /// Returns true if this mode uses error envelopes
99    pub fn uses_error_envelopes(&self) -> bool {
100        !matches!(self, GuaranteeMode::Approximate)
101    }
102
103    /// Get the error quantile to use for this mode
104    pub fn error_quantile(&self) -> Option<f32> {
105        match self {
106            GuaranteeMode::Approximate => None,
107            GuaranteeMode::Calibrated { confidence, .. } => Some(*confidence),
108            GuaranteeMode::Certified => Some(1.0), // Use max error bound
109        }
110    }
111}
112
113// ============================================================================
114// Stopping Rules
115// ============================================================================
116
117/// Stopping rule that matches guarantee mode semantics
118#[derive(Debug, Clone)]
119pub enum StoppingRule {
120    /// Stop after scanning fixed number of lists/probes
121    FixedProbes { n_probes: u32 },
122
123    /// Stop when kth best score exceeds best list bound
124    ///
125    /// For approximate mode: compare proxy scores directly
126    BoundBased {
127        /// Minimum number of probes before considering early stop
128        min_probes: u32,
129        /// Maximum probes as safety limit
130        max_probes: u32,
131    },
132
133    /// Stop when probability of finding better candidates drops below threshold
134    ///
135    /// For calibrated mode: uses error envelopes to estimate probability
136    ProbabilisticBound {
137        /// Probability threshold for stopping
138        probability_threshold: f32,
139        /// Error envelope quantile
140        error_quantile: f32,
141        /// Minimum probes
142        min_probes: u32,
143        /// Maximum probes
144        max_probes: u32,
145    },
146
147    /// Stop when all candidates with possible better true scores are checked
148    ///
149    /// For certified mode: uses deterministic LB/UB comparisons
150    DeterministicBound {
151        /// Maximum error bound (guaranteed upper bound on ε)
152        max_error: f32,
153    },
154
155    /// Combined budget and bound stopping
156    BudgetConstrained {
157        inner: Box<StoppingRule>,
158        max_ram_bytes: u64,
159        max_latency: Duration,
160    },
161}
162
163impl StoppingRule {
164    /// Create stopping rule appropriate for guarantee mode
165    pub fn for_mode(mode: &GuaranteeMode, default_probes: u32) -> Self {
166        match mode {
167            GuaranteeMode::Approximate => Self::FixedProbes {
168                n_probes: default_probes,
169            },
170            GuaranteeMode::Calibrated { confidence, .. } => Self::ProbabilisticBound {
171                probability_threshold: 0.01, // Stop when <1% chance of improvement
172                error_quantile: *confidence,
173                min_probes: default_probes / 4,
174                max_probes: default_probes * 4,
175            },
176            GuaranteeMode::Certified => Self::DeterministicBound {
177                max_error: 0.0, // Must be set from calibration data
178            },
179        }
180    }
181
182    /// Wrap with budget constraints
183    pub fn with_budget(self, max_ram_bytes: u64, max_latency: Duration) -> Self {
184        Self::BudgetConstrained {
185            inner: Box::new(self),
186            max_ram_bytes,
187            max_latency,
188        }
189    }
190}
191
192// ============================================================================
193// Stop Decision
194// ============================================================================
195
196/// Decision about whether to stop probing
197#[derive(Debug, Clone)]
198pub struct StopDecision {
199    /// Whether to stop
200    pub should_stop: bool,
201    /// Reason for decision
202    pub reason: StopReason,
203    /// Estimated probability of missing better candidates (for calibrated mode)
204    pub miss_probability: Option<f32>,
205    /// Number of candidates that might have better true scores
206    pub uncertain_candidates: u32,
207}
208
209/// Reason for stopping decision
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum StopReason {
212    /// All probes exhausted
213    ProbesExhausted,
214    /// Bound condition satisfied
215    BoundSatisfied,
216    /// Probability threshold reached
217    ProbabilityThreshold,
218    /// Deterministic guarantee achieved
219    DeterministicComplete,
220    /// Budget exhausted (RAM/latency)
221    BudgetExhausted,
222    /// Still searching
223    Continuing,
224}
225
226// ============================================================================
227// Score Envelope
228// ============================================================================
229
230/// Score with error bounds for certified/calibrated modes
231#[derive(Debug, Clone, Copy)]
232pub struct ScoreEnvelope {
233    /// Proxy score (from quantized representation)
234    pub proxy: f32,
235    /// Lower bound on true score
236    pub lower_bound: f32,
237    /// Upper bound on true score
238    pub upper_bound: f32,
239    /// Quantile-based error bound (for calibrated mode)
240    pub quantile_error: f32,
241}
242
243impl ScoreEnvelope {
244    /// Create from proxy score with error bounds
245    pub fn new(proxy: f32, max_error: f32) -> Self {
246        Self {
247            proxy,
248            lower_bound: proxy - max_error,
249            upper_bound: proxy + max_error,
250            quantile_error: max_error,
251        }
252    }
253
254    /// Create from proxy with asymmetric bounds
255    pub fn with_bounds(proxy: f32, lower_bound: f32, upper_bound: f32) -> Self {
256        Self {
257            proxy,
258            lower_bound,
259            upper_bound,
260            quantile_error: (upper_bound - lower_bound) / 2.0,
261        }
262    }
263
264    /// Check if this envelope definitely beats another
265    ///
266    /// Returns true iff lower_bound(self) > upper_bound(other)
267    pub fn definitely_beats(&self, other: &ScoreEnvelope) -> bool {
268        self.lower_bound > other.upper_bound
269    }
270
271    /// Check if this envelope might beat another
272    ///
273    /// Returns true iff upper_bound(self) > lower_bound(other)
274    pub fn might_beat(&self, other: &ScoreEnvelope) -> bool {
275        self.upper_bound > other.lower_bound
276    }
277
278    /// Get true score estimate (center of bounds)
279    pub fn estimated_true(&self) -> f32 {
280        (self.lower_bound + self.upper_bound) / 2.0
281    }
282}
283
284// ============================================================================
285// Stopping Evaluator
286// ============================================================================
287
288/// Evaluator for stopping rules
289pub struct StoppingEvaluator {
290    rule: StoppingRule,
291    probes_done: u32,
292    ram_bytes_used: u64,
293    start_time: std::time::Instant,
294}
295
296impl StoppingEvaluator {
297    /// Create new evaluator
298    pub fn new(rule: StoppingRule) -> Self {
299        Self {
300            rule,
301            probes_done: 0,
302            ram_bytes_used: 0,
303            start_time: std::time::Instant::now(),
304        }
305    }
306
307    /// Record a probe
308    pub fn record_probe(&mut self, ram_bytes: u64) {
309        self.probes_done += 1;
310        self.ram_bytes_used += ram_bytes;
311    }
312
313    /// Evaluate stopping rule
314    ///
315    /// # Arguments
316    /// * `kth_score` - Score envelope of the kth best candidate
317    /// * `best_remaining_bound` - Best upper bound of remaining lists
318    pub fn evaluate(
319        &self,
320        kth_score: Option<&ScoreEnvelope>,
321        best_remaining_bound: Option<f32>,
322    ) -> StopDecision {
323        match &self.rule {
324            StoppingRule::FixedProbes { n_probes } => {
325                if self.probes_done >= *n_probes {
326                    StopDecision {
327                        should_stop: true,
328                        reason: StopReason::ProbesExhausted,
329                        miss_probability: None,
330                        uncertain_candidates: 0,
331                    }
332                } else {
333                    StopDecision {
334                        should_stop: false,
335                        reason: StopReason::Continuing,
336                        miss_probability: None,
337                        uncertain_candidates: 0,
338                    }
339                }
340            }
341
342            StoppingRule::BoundBased {
343                min_probes,
344                max_probes,
345            } => {
346                if self.probes_done >= *max_probes {
347                    return StopDecision {
348                        should_stop: true,
349                        reason: StopReason::ProbesExhausted,
350                        miss_probability: None,
351                        uncertain_candidates: 0,
352                    };
353                }
354
355                if self.probes_done < *min_probes {
356                    return StopDecision {
357                        should_stop: false,
358                        reason: StopReason::Continuing,
359                        miss_probability: None,
360                        uncertain_candidates: 0,
361                    };
362                }
363
364                // Check bound condition: kth.proxy > best_remaining
365                if let (Some(kth), Some(bound)) = (kth_score, best_remaining_bound) {
366                    if kth.proxy > bound {
367                        return StopDecision {
368                            should_stop: true,
369                            reason: StopReason::BoundSatisfied,
370                            miss_probability: None,
371                            uncertain_candidates: 0,
372                        };
373                    }
374                }
375
376                StopDecision {
377                    should_stop: false,
378                    reason: StopReason::Continuing,
379                    miss_probability: None,
380                    uncertain_candidates: 0,
381                }
382            }
383
384            StoppingRule::ProbabilisticBound {
385                probability_threshold,
386                error_quantile: _,
387                min_probes,
388                max_probes,
389            } => {
390                if self.probes_done >= *max_probes {
391                    return StopDecision {
392                        should_stop: true,
393                        reason: StopReason::ProbesExhausted,
394                        miss_probability: Some(0.0),
395                        uncertain_candidates: 0,
396                    };
397                }
398
399                if self.probes_done < *min_probes {
400                    return StopDecision {
401                        should_stop: false,
402                        reason: StopReason::Continuing,
403                        miss_probability: Some(1.0),
404                        uncertain_candidates: 0,
405                    };
406                }
407
408                // Use error envelopes to estimate miss probability
409                if let (Some(kth), Some(bound)) = (kth_score, best_remaining_bound) {
410                    // Estimate: if kth.lower_bound > bound + error, we're confident
411                    let margin = kth.lower_bound - bound;
412                    let error_margin = kth.quantile_error;
413
414                    // Simple probability model: linear decrease
415                    let miss_prob = if margin > error_margin {
416                        0.0
417                    } else if margin < -error_margin {
418                        1.0
419                    } else {
420                        0.5 - (margin / (2.0 * error_margin))
421                    };
422
423                    if miss_prob < *probability_threshold {
424                        return StopDecision {
425                            should_stop: true,
426                            reason: StopReason::ProbabilityThreshold,
427                            miss_probability: Some(miss_prob),
428                            uncertain_candidates: 0,
429                        };
430                    }
431
432                    return StopDecision {
433                        should_stop: false,
434                        reason: StopReason::Continuing,
435                        miss_probability: Some(miss_prob),
436                        uncertain_candidates: 0,
437                    };
438                }
439
440                StopDecision {
441                    should_stop: false,
442                    reason: StopReason::Continuing,
443                    miss_probability: Some(1.0),
444                    uncertain_candidates: 0,
445                }
446            }
447
448            StoppingRule::DeterministicBound { max_error } => {
449                if let (Some(kth), Some(bound)) = (kth_score, best_remaining_bound) {
450                    // Definitely done: kth.lower_bound > best_remaining + max_error
451                    if kth.lower_bound > bound + *max_error {
452                        return StopDecision {
453                            should_stop: true,
454                            reason: StopReason::DeterministicComplete,
455                            miss_probability: Some(0.0),
456                            uncertain_candidates: 0,
457                        };
458                    }
459                }
460
461                StopDecision {
462                    should_stop: false,
463                    reason: StopReason::Continuing,
464                    miss_probability: None,
465                    uncertain_candidates: 0,
466                }
467            }
468
469            StoppingRule::BudgetConstrained {
470                inner,
471                max_ram_bytes,
472                max_latency,
473            } => {
474                // Check budget first
475                if self.ram_bytes_used > *max_ram_bytes {
476                    return StopDecision {
477                        should_stop: true,
478                        reason: StopReason::BudgetExhausted,
479                        miss_probability: None,
480                        uncertain_candidates: 0,
481                    };
482                }
483
484                if self.start_time.elapsed() > *max_latency {
485                    return StopDecision {
486                        should_stop: true,
487                        reason: StopReason::BudgetExhausted,
488                        miss_probability: None,
489                        uncertain_candidates: 0,
490                    };
491                }
492
493                // Delegate to inner rule
494                let inner_eval = StoppingEvaluator {
495                    rule: (**inner).clone(),
496                    probes_done: self.probes_done,
497                    ram_bytes_used: self.ram_bytes_used,
498                    start_time: self.start_time,
499                };
500                inner_eval.evaluate(kth_score, best_remaining_bound)
501            }
502        }
503    }
504}
505
506// ============================================================================
507// Search Contract
508// ============================================================================
509
510/// Complete search contract specifying guarantees
511#[derive(Debug, Clone)]
512pub struct SearchContract {
513    /// Guarantee mode
514    pub mode: GuaranteeMode,
515    /// Number of results requested
516    pub k: usize,
517    /// Stopping rule
518    pub stopping_rule: StoppingRule,
519    /// Whether to include score envelopes in results
520    pub include_envelopes: bool,
521}
522
523impl SearchContract {
524    /// Create contract for approximate search
525    pub fn approximate(k: usize, n_probes: u32) -> Self {
526        Self {
527            mode: GuaranteeMode::Approximate,
528            k,
529            stopping_rule: StoppingRule::FixedProbes { n_probes },
530            include_envelopes: false,
531        }
532    }
533
534    /// Create contract for calibrated search
535    pub fn calibrated(k: usize, recall_target: f32, confidence: f32) -> Self {
536        let mode = GuaranteeMode::calibrated(recall_target, confidence);
537        let stopping_rule = StoppingRule::for_mode(&mode, 16);
538        Self {
539            mode,
540            k,
541            stopping_rule,
542            include_envelopes: true,
543        }
544    }
545
546    /// Create contract for certified search
547    pub fn certified(k: usize) -> Self {
548        Self {
549            mode: GuaranteeMode::Certified,
550            k,
551            stopping_rule: StoppingRule::DeterministicBound { max_error: 0.0 },
552            include_envelopes: true,
553        }
554    }
555
556    /// Add budget constraints
557    pub fn with_budget(mut self, max_ram_bytes: u64, max_latency: Duration) -> Self {
558        self.stopping_rule = self.stopping_rule.with_budget(max_ram_bytes, max_latency);
559        self
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn test_guarantee_modes() {
569        let approx = GuaranteeMode::Approximate;
570        assert!(!approx.requires_rerank());
571        assert!(!approx.uses_error_envelopes());
572
573        let calibrated = GuaranteeMode::calibrated(0.95, 0.99);
574        assert!(!calibrated.requires_rerank());
575        assert!(calibrated.uses_error_envelopes());
576        assert_eq!(calibrated.error_quantile(), Some(0.99));
577
578        let certified = GuaranteeMode::Certified;
579        assert!(certified.requires_rerank());
580        assert!(certified.uses_error_envelopes());
581    }
582
583    #[test]
584    fn test_score_envelope() {
585        let a = ScoreEnvelope::new(0.9, 0.05);
586        let b = ScoreEnvelope::new(0.8, 0.05);
587
588        assert!(!a.definitely_beats(&b)); // a.lower=0.85, b.upper=0.85: not strictly greater
589        assert!(a.might_beat(&b)); // a.proxy=0.9 > b.proxy=0.8
590
591        let c = ScoreEnvelope::new(0.9, 0.02);
592        let d = ScoreEnvelope::new(0.8, 0.02);
593        // c.lower = 0.88, d.upper = 0.82
594        assert!(c.definitely_beats(&d)); // 0.88 > 0.82
595    }
596
597    #[test]
598    fn test_fixed_probes_stopping() {
599        let rule = StoppingRule::FixedProbes { n_probes: 10 };
600        let mut eval = StoppingEvaluator::new(rule);
601
602        for _ in 0..9 {
603            eval.record_probe(1000);
604            let decision = eval.evaluate(None, None);
605            assert!(!decision.should_stop);
606        }
607
608        eval.record_probe(1000);
609        let decision = eval.evaluate(None, None);
610        assert!(decision.should_stop);
611        assert_eq!(decision.reason, StopReason::ProbesExhausted);
612    }
613
614    #[test]
615    fn test_bound_based_stopping() {
616        let rule = StoppingRule::BoundBased {
617            min_probes: 2,
618            max_probes: 100,
619        };
620        let mut eval = StoppingEvaluator::new(rule);
621
622        // Before min_probes, shouldn't stop
623        eval.record_probe(1000);
624        let kth = ScoreEnvelope::new(0.9, 0.01);
625        let decision = eval.evaluate(Some(&kth), Some(0.8));
626        assert!(!decision.should_stop);
627
628        // After min_probes, should stop when kth > bound
629        eval.record_probe(1000);
630        let decision = eval.evaluate(Some(&kth), Some(0.8));
631        assert!(decision.should_stop);
632        assert_eq!(decision.reason, StopReason::BoundSatisfied);
633    }
634}