Skip to main content

hyphae_retrieval/
engine.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use 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/// Failure to validate or completely execute exact semantic retrieval.
17#[derive(Clone, Debug, Error, PartialEq)]
18pub enum RetrievalError {
19    /// Query vectors must be nonempty.
20    #[error("query vector must be nonempty")]
21    EmptyQueryVector,
22
23    /// Vector dimensions exceed policy.
24    #[error("vector dimension {actual} exceeds maximum {maximum}")]
25    DimensionLimitExceeded {
26        /// Observed dimension.
27        actual: usize,
28        /// Configured maximum.
29        maximum: usize,
30    },
31
32    /// Candidate dimension differs from the query.
33    #[error("candidate dimension {found} does not match query dimension {expected}")]
34    DimensionMismatch {
35        /// Query dimension.
36        expected: usize,
37        /// Candidate dimension.
38        found: usize,
39    },
40
41    /// Query or candidate contains NaN or infinity.
42    #[error("vectors must contain only finite values")]
43    NonFiniteVector,
44
45    /// Cosine similarity is undefined for a zero vector.
46    #[error("vectors must have nonzero magnitude")]
47    ZeroVector,
48
49    /// Candidate keys must be nonempty.
50    #[error("candidate key must be nonempty")]
51    EmptyCandidateKey,
52
53    /// Candidate keys must be globally unique.
54    #[error("duplicate global candidate key")]
55    DuplicateCandidateKey,
56
57    /// At least one result must be requested.
58    #[error("retrieval limit must be nonzero")]
59    ZeroLimit,
60
61    /// Requested result count exceeds policy.
62    #[error("retrieval limit {requested} exceeds maximum {maximum}")]
63    ResultLimitExceeded {
64        /// Requested count.
65        requested: usize,
66        /// Configured maximum.
67        maximum: usize,
68    },
69
70    /// Minimum score is non-finite or outside cosine range.
71    #[error("minimum score must be finite and in [-1, 1]")]
72    InvalidMinimumScore,
73
74    /// Minimum margin is non-finite or outside cosine range width.
75    #[error("minimum margin must be finite and in [0, 2]")]
76    InvalidMinimumMargin,
77
78    /// Global candidate budget was exhausted.
79    #[error("global candidate budget exceeded: {maximum}")]
80    CandidateBudgetExceeded {
81        /// Configured maximum.
82        maximum: u64,
83    },
84
85    /// Cooperative monotonic deadline expired.
86    #[error("retrieval execution timed out")]
87    TimedOut,
88}
89
90/// Injectable monotonic clock for deterministic timeout conformance.
91pub trait RetrievalClock {
92    /// Returns a nondecreasing duration in an arbitrary local epoch.
93    fn now(&mut self) -> Duration;
94}
95
96/// Production retrieval clock backed by [`Instant`].
97#[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
116/// Executes exact global cosine retrieval with the system clock.
117///
118/// # Errors
119///
120/// Returns an error for invalid vectors or policies, duplicate keys, budgets,
121/// dimensions, or timeout. Threshold and ambiguity rejection are successful
122/// abstention outcomes.
123pub 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
136/// Executes exact retrieval with an injectable monotonic clock.
137///
138/// # Errors
139///
140/// Returns an error for invalid vectors or policies, duplicate keys, budgets,
141/// dimensions, or timeout.
142pub 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}