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}