1use std::{
4 cmp::Ordering,
5 collections::BTreeSet,
6 time::{Duration, Instant},
7};
8
9use thiserror::Error;
10
11use crate::{
12 Abstention, AbstentionReason, RetrievalLimits, RetrievalMatch, RetrievalOutcome,
13 RetrievalRequest, VectorRecord,
14};
15
16#[derive(Clone, Debug, Error, PartialEq)]
18pub enum RetrievalError {
19 #[error("query vector must be nonempty")]
21 EmptyQueryVector,
22
23 #[error("vector dimension {actual} exceeds maximum {maximum}")]
25 DimensionLimitExceeded {
26 actual: usize,
28 maximum: usize,
30 },
31
32 #[error("candidate dimension {found} does not match query dimension {expected}")]
34 DimensionMismatch {
35 expected: usize,
37 found: usize,
39 },
40
41 #[error("vectors must contain only finite values")]
43 NonFiniteVector,
44
45 #[error("vectors must have nonzero magnitude")]
47 ZeroVector,
48
49 #[error("candidate key must be nonempty")]
51 EmptyCandidateKey,
52
53 #[error("duplicate global candidate key")]
55 DuplicateCandidateKey,
56
57 #[error("retrieval limit must be nonzero")]
59 ZeroLimit,
60
61 #[error("retrieval limit {requested} exceeds maximum {maximum}")]
63 ResultLimitExceeded {
64 requested: usize,
66 maximum: usize,
68 },
69
70 #[error("minimum score must be finite and in [-1, 1]")]
72 InvalidMinimumScore,
73
74 #[error("minimum margin must be finite and in [0, 2]")]
76 InvalidMinimumMargin,
77
78 #[error("global candidate budget exceeded: {maximum}")]
80 CandidateBudgetExceeded {
81 maximum: u64,
83 },
84
85 #[error("retrieval execution timed out")]
87 TimedOut,
88}
89
90pub trait RetrievalClock {
92 fn now(&mut self) -> Duration;
94}
95
96#[derive(Debug)]
98pub struct RetrievalSystemClock {
99 origin: Instant,
100}
101
102impl Default for RetrievalSystemClock {
103 fn default() -> Self {
104 Self {
105 origin: Instant::now(),
106 }
107 }
108}
109
110impl RetrievalClock for RetrievalSystemClock {
111 fn now(&mut self) -> Duration {
112 self.origin.elapsed()
113 }
114}
115
116pub fn retrieve(
124 shards: &[&[VectorRecord]],
125 request: &RetrievalRequest,
126 limits: &RetrievalLimits,
127) -> Result<RetrievalOutcome, RetrievalError> {
128 retrieve_with_clock(
129 shards,
130 request,
131 limits,
132 &mut RetrievalSystemClock::default(),
133 )
134}
135
136pub fn retrieve_with_clock(
143 shards: &[&[VectorRecord]],
144 request: &RetrievalRequest,
145 limits: &RetrievalLimits,
146 clock: &mut impl RetrievalClock,
147) -> Result<RetrievalOutcome, RetrievalError> {
148 validate_request(request, limits)?;
149 let query = NormalizedVector::new(&request.query)?;
150 let started = clock.now();
151 let deadline = started.checked_add(limits.timeout).unwrap_or(Duration::MAX);
152 check_timeout(clock, deadline)?;
153
154 let mut keys = BTreeSet::new();
155 let mut ranked = Vec::new();
156 let mut scanned = 0_u64;
157 for shard in shards {
158 for candidate in *shard {
159 check_timeout(clock, deadline)?;
160 if scanned >= limits.max_candidates {
161 return Err(RetrievalError::CandidateBudgetExceeded {
162 maximum: limits.max_candidates,
163 });
164 }
165 scanned = scanned.saturating_add(1);
166 if candidate.key.is_empty() {
167 return Err(RetrievalError::EmptyCandidateKey);
168 }
169 if !keys.insert(candidate.key.clone()) {
170 return Err(RetrievalError::DuplicateCandidateKey);
171 }
172 if candidate.vector.len() != query.len() {
173 return Err(RetrievalError::DimensionMismatch {
174 expected: query.len(),
175 found: candidate.vector.len(),
176 });
177 }
178 let score = query.cosine_with(&candidate.vector)?;
179 ranked.push(RetrievalMatch {
180 key: candidate.key.clone(),
181 score,
182 });
183 }
184 }
185 ranked.sort_by(compare_matches);
186 check_timeout(clock, deadline)?;
187 Ok(apply_abstention(ranked, scanned, request))
188}
189
190fn validate_request(
191 request: &RetrievalRequest,
192 limits: &RetrievalLimits,
193) -> Result<(), RetrievalError> {
194 if request.query.is_empty() {
195 return Err(RetrievalError::EmptyQueryVector);
196 }
197 if request.query.len() > limits.max_dimensions {
198 return Err(RetrievalError::DimensionLimitExceeded {
199 actual: request.query.len(),
200 maximum: limits.max_dimensions,
201 });
202 }
203 if request.limit == 0 {
204 return Err(RetrievalError::ZeroLimit);
205 }
206 if request.limit > limits.max_returned {
207 return Err(RetrievalError::ResultLimitExceeded {
208 requested: request.limit,
209 maximum: limits.max_returned,
210 });
211 }
212 if !request.minimum_score.is_finite() || !(-1.0..=1.0).contains(&request.minimum_score) {
213 return Err(RetrievalError::InvalidMinimumScore);
214 }
215 if !request.minimum_margin.is_finite() || !(0.0..=2.0).contains(&request.minimum_margin) {
216 return Err(RetrievalError::InvalidMinimumMargin);
217 }
218 Ok(())
219}
220
221fn check_timeout(
222 clock: &mut impl RetrievalClock,
223 deadline: Duration,
224) -> Result<(), RetrievalError> {
225 if clock.now() >= deadline {
226 Err(RetrievalError::TimedOut)
227 } else {
228 Ok(())
229 }
230}
231
232fn compare_matches(left: &RetrievalMatch, right: &RetrievalMatch) -> Ordering {
233 right
234 .score
235 .total_cmp(&left.score)
236 .then_with(|| left.key.cmp(&right.key))
237}
238
239fn apply_abstention(
240 ranked: Vec<RetrievalMatch>,
241 scanned: u64,
242 request: &RetrievalRequest,
243) -> RetrievalOutcome {
244 let Some(best) = ranked.first() else {
245 return RetrievalOutcome::Abstained(Abstention {
246 reason: AbstentionReason::NoCandidates,
247 best_score: None,
248 runner_up_score: None,
249 scanned_candidates: scanned,
250 });
251 };
252 let runner_up = ranked.get(1).map(|candidate| candidate.score);
253 if best.score < request.minimum_score {
254 return RetrievalOutcome::Abstained(Abstention {
255 reason: AbstentionReason::BelowThreshold,
256 best_score: Some(best.score),
257 runner_up_score: runner_up,
258 scanned_candidates: scanned,
259 });
260 }
261 if runner_up.is_some_and(|score| best.score - score < request.minimum_margin) {
262 return RetrievalOutcome::Abstained(Abstention {
263 reason: AbstentionReason::Ambiguous,
264 best_score: Some(best.score),
265 runner_up_score: runner_up,
266 scanned_candidates: scanned,
267 });
268 }
269
270 RetrievalOutcome::Matches {
271 matches: ranked.into_iter().take(request.limit).collect(),
272 scanned_candidates: scanned,
273 }
274}
275
276struct NormalizedVector {
277 values: Vec<f64>,
278}
279
280impl NormalizedVector {
281 fn new(values: &[f64]) -> Result<Self, RetrievalError> {
282 let scale = validate_and_scale(values)?;
283 let norm = scaled_norm(values, scale);
284 Ok(Self {
285 values: values.iter().map(|value| (value / scale) / norm).collect(),
286 })
287 }
288
289 fn len(&self) -> usize {
290 self.values.len()
291 }
292
293 fn cosine_with(&self, candidate: &[f64]) -> Result<f64, RetrievalError> {
294 let scale = validate_and_scale(candidate)?;
295 let norm = scaled_norm(candidate, scale);
296 let mut score = 0.0_f64;
297 for (query, candidate) in self.values.iter().zip(candidate) {
298 score += query * ((candidate / scale) / norm);
299 }
300 if !score.is_finite() {
301 return Err(RetrievalError::NonFiniteVector);
302 }
303 Ok(score.clamp(-1.0, 1.0))
304 }
305}
306
307fn validate_and_scale(values: &[f64]) -> Result<f64, RetrievalError> {
308 let mut scale = 0.0_f64;
309 for value in values {
310 if !value.is_finite() {
311 return Err(RetrievalError::NonFiniteVector);
312 }
313 scale = scale.max(value.abs());
314 }
315 if scale == 0.0 {
316 return Err(RetrievalError::ZeroVector);
317 }
318 Ok(scale)
319}
320
321fn scaled_norm(values: &[f64], scale: f64) -> f64 {
322 values
323 .iter()
324 .map(|value| value / scale)
325 .map(|value| value * value)
326 .sum::<f64>()
327 .sqrt()
328}
329
330#[cfg(test)]
331mod tests {
332 use std::time::Duration;
333
334 use super::{RetrievalClock, RetrievalError, retrieve, retrieve_with_clock};
335 use crate::{
336 AbstentionReason, RetrievalLimits, RetrievalOutcome, RetrievalRequest, VectorRecord,
337 };
338
339 fn request(limit: usize) -> RetrievalRequest {
340 RetrievalRequest {
341 query: vec![1.0, 0.0],
342 limit,
343 minimum_score: -1.0,
344 minimum_margin: 0.0,
345 }
346 }
347
348 #[test]
349 fn exact_global_merge_precedes_limit_with_stable_ties() -> Result<(), RetrievalError> {
350 let first = vec![
351 VectorRecord::new(b"a", vec![1.0, 0.0]),
352 VectorRecord::new(b"z", vec![0.0, 1.0]),
353 ];
354 let second = vec![
355 VectorRecord::new(b"b", vec![0.9, 0.1]),
356 VectorRecord::new(b"c", vec![0.8, 0.2]),
357 VectorRecord::new(b"d", vec![0.8, 0.2]),
358 ];
359 let outcome = retrieve(
360 &[first.as_slice(), second.as_slice()],
361 &request(4),
362 &RetrievalLimits::default(),
363 )?;
364 let RetrievalOutcome::Matches { matches, .. } = outcome else {
365 return Err(RetrievalError::TimedOut);
366 };
367 assert_eq!(
368 matches
369 .iter()
370 .map(|candidate| candidate.key.as_slice())
371 .collect::<Vec<_>>(),
372 [
373 b"a".as_slice(),
374 b"b".as_slice(),
375 b"c".as_slice(),
376 b"d".as_slice()
377 ]
378 );
379 Ok(())
380 }
381
382 #[test]
383 fn threshold_and_margin_produce_explicit_abstention() -> Result<(), RetrievalError> {
384 let candidates = vec![
385 VectorRecord::new(b"a", vec![1.0, 0.0]),
386 VectorRecord::new(b"b", vec![0.99, 0.01]),
387 ];
388 let ambiguous = retrieve(
389 &[candidates.as_slice()],
390 &RetrievalRequest {
391 minimum_margin: 0.01,
392 ..request(2)
393 },
394 &RetrievalLimits::default(),
395 )?;
396 assert!(matches!(
397 ambiguous,
398 RetrievalOutcome::Abstained(crate::Abstention {
399 reason: AbstentionReason::Ambiguous,
400 ..
401 })
402 ));
403
404 let weak = vec![VectorRecord::new(b"weak", vec![0.0, 1.0])];
405 let below = retrieve(
406 &[weak.as_slice()],
407 &RetrievalRequest {
408 minimum_score: 0.5,
409 ..request(1)
410 },
411 &RetrievalLimits::default(),
412 )?;
413 assert!(matches!(
414 below,
415 RetrievalOutcome::Abstained(crate::Abstention {
416 reason: AbstentionReason::BelowThreshold,
417 ..
418 })
419 ));
420 Ok(())
421 }
422
423 #[test]
424 fn no_candidates_is_a_normal_abstention() -> Result<(), RetrievalError> {
425 let empty: [VectorRecord; 0] = [];
426 let outcome = retrieve(
427 &[empty.as_slice()],
428 &request(1),
429 &RetrievalLimits::default(),
430 )?;
431 assert!(matches!(
432 outcome,
433 RetrievalOutcome::Abstained(crate::Abstention {
434 reason: AbstentionReason::NoCandidates,
435 ..
436 })
437 ));
438 Ok(())
439 }
440
441 #[test]
442 fn dimensions_nonfinite_zero_and_duplicates_fail_loudly() {
443 let wrong = vec![VectorRecord::new(b"wrong", vec![1.0])];
444 assert_eq!(
445 retrieve(
446 &[wrong.as_slice()],
447 &request(1),
448 &RetrievalLimits::default()
449 ),
450 Err(RetrievalError::DimensionMismatch {
451 expected: 2,
452 found: 1
453 })
454 );
455
456 let nonfinite = vec![VectorRecord::new(b"bad", vec![f64::NAN, 0.0])];
457 assert_eq!(
458 retrieve(
459 &[nonfinite.as_slice()],
460 &request(1),
461 &RetrievalLimits::default()
462 ),
463 Err(RetrievalError::NonFiniteVector)
464 );
465
466 let zero = vec![VectorRecord::new(b"zero", vec![0.0, 0.0])];
467 assert_eq!(
468 retrieve(&[zero.as_slice()], &request(1), &RetrievalLimits::default()),
469 Err(RetrievalError::ZeroVector)
470 );
471
472 let first = vec![VectorRecord::new(b"same", vec![1.0, 0.0])];
473 let second = vec![VectorRecord::new(b"same", vec![0.0, 1.0])];
474 assert_eq!(
475 retrieve(
476 &[first.as_slice(), second.as_slice()],
477 &request(1),
478 &RetrievalLimits::default()
479 ),
480 Err(RetrievalError::DuplicateCandidateKey)
481 );
482 }
483
484 #[test]
485 fn extreme_finite_components_do_not_overflow_cosine() -> Result<(), RetrievalError> {
486 let candidates = vec![VectorRecord::new(b"same", vec![f64::MAX, f64::MAX])];
487 let outcome = retrieve(
488 &[candidates.as_slice()],
489 &RetrievalRequest {
490 query: vec![f64::MAX, f64::MAX],
491 ..request(1)
492 },
493 &RetrievalLimits::default(),
494 )?;
495 let RetrievalOutcome::Matches { matches, .. } = outcome else {
496 return Err(RetrievalError::TimedOut);
497 };
498 assert!((matches[0].score - 1.0).abs() <= 1.0e-12);
499 Ok(())
500 }
501
502 struct StepClock {
503 current: Duration,
504 step: Duration,
505 }
506
507 impl RetrievalClock for StepClock {
508 fn now(&mut self) -> Duration {
509 let current = self.current;
510 self.current = self.current.saturating_add(self.step);
511 current
512 }
513 }
514
515 #[test]
516 fn budgets_and_timeout_are_global() {
517 let candidates = vec![
518 VectorRecord::new(b"a", vec![1.0, 0.0]),
519 VectorRecord::new(b"b", vec![0.0, 1.0]),
520 ];
521 let budget = RetrievalLimits {
522 max_candidates: 1,
523 ..RetrievalLimits::default()
524 };
525 assert_eq!(
526 retrieve(&[candidates.as_slice()], &request(1), &budget),
527 Err(RetrievalError::CandidateBudgetExceeded { maximum: 1 })
528 );
529
530 let timeout = RetrievalLimits {
531 timeout: Duration::from_millis(3),
532 ..RetrievalLimits::default()
533 };
534 let mut clock = StepClock {
535 current: Duration::ZERO,
536 step: Duration::from_millis(1),
537 };
538 assert_eq!(
539 retrieve_with_clock(&[candidates.as_slice()], &request(1), &timeout, &mut clock),
540 Err(RetrievalError::TimedOut)
541 );
542 }
543}