Skip to main content

sochdb_vector/query/
controller.rs

1//! Adaptive recall controller.
2//!
3//! Monitors query confidence signals and adjusts search parameters dynamically.
4
5use crate::config::QueryConfig;
6use crate::types::*;
7
8/// Adaptive controller for recall optimization
9pub struct AdaptiveController {
10    config: QueryConfig,
11}
12
13impl AdaptiveController {
14    /// Create a new adaptive controller
15    pub fn new(config: QueryConfig) -> Self {
16        Self { config }
17    }
18
19    /// Compute confidence score based on query results
20    pub fn compute_confidence(&self, results: &[ScoredCandidate], k: usize) -> ConfidenceSignals {
21        if results.is_empty() {
22            return ConfidenceSignals {
23                score_gap: 0.0,
24                entropy: 0.0,
25                coverage: 0.0,
26                confidence: 0.0,
27            };
28        }
29
30        // Score gap: difference between k-th and 2k-th result
31        let score_gap = if results.len() > k {
32            let k_score = results.get(k - 1).map(|c| c.score).unwrap_or(0.0);
33            let two_k_score = results.get(2 * k - 1).map(|c| c.score).unwrap_or(0.0);
34            (k_score - two_k_score).abs()
35        } else {
36            // Not enough results, low confidence
37            0.0
38        };
39
40        // Score entropy (flatness of score distribution)
41        let entropy = self.compute_entropy(&results[..k.min(results.len())]);
42
43        // Coverage: did we find enough candidates?
44        let coverage = (results.len() as f32) / (k as f32).max(1.0);
45
46        // Combined confidence
47        let confidence = self.combine_signals(score_gap, entropy, coverage);
48
49        ConfidenceSignals {
50            score_gap,
51            entropy,
52            coverage,
53            confidence,
54        }
55    }
56
57    /// Compute entropy of score distribution
58    fn compute_entropy(&self, results: &[ScoredCandidate]) -> f32 {
59        if results.is_empty() {
60            return 0.0;
61        }
62
63        let scores: Vec<f32> = results.iter().map(|c| c.score.max(0.001)).collect();
64        let sum: f32 = scores.iter().sum();
65
66        if sum <= 0.0 {
67            return 0.0;
68        }
69
70        let probs: Vec<f32> = scores.iter().map(|s| s / sum).collect();
71        let entropy: f32 = probs
72            .iter()
73            .filter(|&&p| p > 0.0)
74            .map(|&p| -p * p.ln())
75            .sum();
76
77        // Normalize by max entropy
78        let max_entropy = (results.len() as f32).ln();
79        if max_entropy > 0.0 {
80            entropy / max_entropy
81        } else {
82            0.0
83        }
84    }
85
86    /// Combine signals into overall confidence
87    fn combine_signals(&self, score_gap: f32, entropy: f32, coverage: f32) -> f32 {
88        // High score gap = high confidence (clear separation)
89        let gap_signal = (score_gap / self.config.score_gap_threshold).min(1.0);
90
91        // Low entropy = high confidence (peaked distribution)
92        let entropy_signal = 1.0 - entropy;
93
94        // High coverage = high confidence
95        let coverage_signal = coverage.min(1.0);
96
97        // Weighted combination
98        0.4 * gap_signal + 0.3 * entropy_signal + 0.3 * coverage_signal
99    }
100
101    /// Determine if widening is needed
102    pub fn should_widen(&self, confidence: f32) -> bool {
103        confidence < 0.5 // Threshold for widening
104    }
105
106    /// Compute widening parameters
107    pub fn compute_widening(
108        &self,
109        signals: &ConfidenceSignals,
110        _params: &QueryParams,
111    ) -> WideningParams {
112        if signals.confidence >= 0.5 {
113            return WideningParams::none();
114        }
115
116        let factor = self.config.widening_factor;
117
118        // Progressive widening strategy
119        if signals.confidence < 0.2 {
120            // Very low confidence: widen everything
121            WideningParams {
122                l_a_factor: factor * 2.0,
123                l_b_factor: factor * 2.0,
124                r_factor: factor,
125                router_probes_factor: 2.0,
126            }
127        } else if signals.confidence < 0.35 {
128            // Low confidence: widen BPS first (cheaper)
129            WideningParams {
130                l_a_factor: factor,
131                l_b_factor: factor * 1.5,
132                r_factor: 1.0,
133                router_probes_factor: 1.5,
134            }
135        } else {
136            // Moderate confidence: slight widening
137            WideningParams {
138                l_a_factor: 1.0,
139                l_b_factor: factor,
140                r_factor: 1.0,
141                router_probes_factor: 1.0,
142            }
143        }
144    }
145
146    /// Apply filter-aware widening
147    pub fn apply_filter_widening(
148        &self,
149        params: &mut QueryParams,
150        selectivity: f32,
151        max_factor: f32,
152    ) {
153        if selectivity >= 1.0 || selectivity <= 0.0 {
154            return;
155        }
156
157        let factor = (1.0 / selectivity).min(max_factor);
158        params.l_a = ((params.l_a as f32) * factor) as usize;
159        params.l_b = ((params.l_b as f32) * factor) as usize;
160    }
161}
162
163/// Confidence signals from query execution
164#[derive(Debug, Clone)]
165pub struct ConfidenceSignals {
166    /// Score gap between k and 2k results
167    pub score_gap: f32,
168    /// Entropy/flatness of score distribution
169    pub entropy: f32,
170    /// Coverage (fraction of requested candidates found)
171    pub coverage: f32,
172    /// Combined confidence score (0-1)
173    pub confidence: f32,
174}
175
176/// Widening parameters
177#[derive(Debug, Clone)]
178pub struct WideningParams {
179    pub l_a_factor: f32,
180    pub l_b_factor: f32,
181    pub r_factor: f32,
182    pub router_probes_factor: f32,
183}
184
185impl WideningParams {
186    pub fn none() -> Self {
187        Self {
188            l_a_factor: 1.0,
189            l_b_factor: 1.0,
190            r_factor: 1.0,
191            router_probes_factor: 1.0,
192        }
193    }
194
195    pub fn is_identity(&self) -> bool {
196        self.l_a_factor == 1.0
197            && self.l_b_factor == 1.0
198            && self.r_factor == 1.0
199            && self.router_probes_factor == 1.0
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_confidence_high() {
209        let config = QueryConfig::default();
210        let controller = AdaptiveController::new(config);
211
212        // Clear separation between results
213        let results: Vec<ScoredCandidate> = (0..20)
214            .map(|i| ScoredCandidate {
215                id: i as u32,
216                score: 1.0 - (i as f32) * 0.05,
217            })
218            .collect();
219
220        let signals = controller.compute_confidence(&results, 10);
221        assert!(
222            signals.confidence > 0.5,
223            "Expected high confidence: {}",
224            signals.confidence
225        );
226        assert!(!controller.should_widen(signals.confidence));
227    }
228
229    #[test]
230    fn test_confidence_low() {
231        let config = QueryConfig::default();
232        let controller = AdaptiveController::new(config);
233
234        // Flat score distribution
235        let results: Vec<ScoredCandidate> = (0..20)
236            .map(|i| ScoredCandidate {
237                id: i as u32,
238                score: 1.0, // All same score
239            })
240            .collect();
241
242        let signals = controller.compute_confidence(&results, 10);
243        // Should have high entropy, lower confidence
244        assert!(
245            signals.entropy > 0.8,
246            "Expected high entropy: {}",
247            signals.entropy
248        );
249    }
250
251    #[test]
252    fn test_widening_params() {
253        let config = QueryConfig::default();
254        let controller = AdaptiveController::new(config);
255        let params = QueryParams::default();
256
257        let low_confidence = ConfidenceSignals {
258            score_gap: 0.01,
259            entropy: 0.9,
260            coverage: 0.5,
261            confidence: 0.2,
262        };
263
264        let widening = controller.compute_widening(&low_confidence, &params);
265        assert!(widening.l_b_factor > 1.0);
266        assert!(widening.l_a_factor > 1.0);
267    }
268
269    #[test]
270    fn test_filter_widening() {
271        let config = QueryConfig::default();
272        let controller = AdaptiveController::new(config);
273
274        let mut params = QueryParams {
275            k: 10,
276            l_a: 1000,
277            l_b: 2000,
278            ..Default::default()
279        };
280
281        // 10% selectivity should ~10x widen (capped)
282        controller.apply_filter_widening(&mut params, 0.1, 5.0);
283        assert_eq!(params.l_a, 5000);
284        assert_eq!(params.l_b, 10000);
285    }
286}