ripvec_core/hybrid.rs
1//! Hybrid semantic + keyword search with Reciprocal Rank Fusion (RRF).
2//!
3//! [`HybridIndex`] wraps a [`SearchIndex`] (dense vector search) and a
4//! [`Bm25Index`] (BM25 keyword search) and fuses their ranked results via
5//! Reciprocal Rank Fusion so that chunks appearing high in either list
6//! bubble to the top of the combined ranking.
7
8use std::collections::HashMap;
9use std::fmt;
10use std::str::FromStr;
11
12use crate::bm25::Bm25Index;
13use crate::chunk::CodeChunk;
14use crate::index::SearchIndex;
15
16/// Controls which retrieval strategy is used during search.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub enum SearchMode {
19 /// Fuse semantic (vector) and keyword (BM25) results via RRF.
20 #[default]
21 Hybrid,
22 /// Dense vector cosine-similarity ranking only.
23 Semantic,
24 /// BM25 keyword ranking only.
25 Keyword,
26}
27
28impl fmt::Display for SearchMode {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 Self::Hybrid => f.write_str("hybrid"),
32 Self::Semantic => f.write_str("semantic"),
33 Self::Keyword => f.write_str("keyword"),
34 }
35 }
36}
37
38/// Error returned when a `SearchMode` string cannot be parsed.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ParseSearchModeError(String);
41
42impl fmt::Display for ParseSearchModeError {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 write!(
45 f,
46 "unknown search mode {:?}; expected hybrid, semantic, or keyword",
47 self.0
48 )
49 }
50}
51
52impl std::error::Error for ParseSearchModeError {}
53
54impl FromStr for SearchMode {
55 type Err = ParseSearchModeError;
56
57 fn from_str(s: &str) -> Result<Self, Self::Err> {
58 match s {
59 "hybrid" => Ok(Self::Hybrid),
60 "semantic" => Ok(Self::Semantic),
61 "keyword" => Ok(Self::Keyword),
62 other => Err(ParseSearchModeError(other.to_string())),
63 }
64 }
65}
66
67/// Combined semantic + keyword search index with RRF fusion.
68///
69/// Build once from chunks and pre-computed embeddings; query repeatedly
70/// via [`search`](Self::search).
71pub struct HybridIndex {
72 /// Semantic (dense vector) search index.
73 pub semantic: SearchIndex,
74 /// BM25 keyword search index.
75 bm25: Bm25Index,
76}
77
78impl HybridIndex {
79 /// Build a `HybridIndex` from raw chunks and their pre-computed embeddings.
80 ///
81 /// Constructs both the [`SearchIndex`] and [`Bm25Index`] in one call.
82 /// `cascade_dim` is forwarded to [`SearchIndex::new`] for optional MRL
83 /// cascade pre-filtering.
84 ///
85 /// # Errors
86 ///
87 /// Returns an error if the BM25 index cannot be built (e.g., tantivy
88 /// schema or writer failure).
89 pub fn new(
90 chunks: Vec<CodeChunk>,
91 embeddings: &[Vec<f32>],
92 cascade_dim: Option<usize>,
93 ) -> crate::Result<Self> {
94 let bm25 = Bm25Index::build(&chunks)?;
95 let semantic = SearchIndex::new(chunks, embeddings, cascade_dim);
96 Ok(Self { semantic, bm25 })
97 }
98
99 /// Assemble a `HybridIndex` from pre-built components.
100 ///
101 /// Useful when the caller has already constructed the sub-indices
102 /// separately (e.g., loaded from a cache).
103 #[must_use]
104 pub fn from_parts(semantic: SearchIndex, bm25: Bm25Index) -> Self {
105 Self { semantic, bm25 }
106 }
107
108 /// Search the index and return `(chunk_index, score)` pairs.
109 ///
110 /// Dispatches based on `mode`:
111 /// - [`SearchMode::Semantic`] — pure dense vector search via
112 /// [`SearchIndex::rank`].
113 /// - [`SearchMode::Keyword`] — pure BM25 keyword search, truncated to
114 /// `top_k`.
115 /// - [`SearchMode::Hybrid`] — retrieves both ranked lists, fuses them
116 /// with [`rrf_fuse`], then truncates to `top_k`.
117 ///
118 /// Scores are min-max normalized to `[0, 1]` regardless of mode, so
119 /// a threshold of 0.5 always means "above midpoint of the score range"
120 /// whether the underlying scores are cosine similarity, BM25, or RRF.
121 #[must_use]
122 pub fn search(
123 &self,
124 query_embedding: &[f32],
125 query_text: &str,
126 top_k: usize,
127 threshold: f32,
128 mode: SearchMode,
129 ) -> Vec<(usize, f32)> {
130 let mut raw = match mode {
131 SearchMode::Semantic => {
132 // Fetch more than top_k so normalization has a meaningful range.
133 self.semantic
134 .rank_turboquant(query_embedding, top_k.max(100), 0.0)
135 }
136 SearchMode::Keyword => self.bm25.search(query_text, top_k.max(100)),
137 SearchMode::Hybrid => {
138 let sem = self
139 .semantic
140 .rank_turboquant(query_embedding, top_k.max(100), 0.0);
141 let kw = self.bm25.search(query_text, top_k.max(100));
142 rrf_fuse(&sem, &kw, 60.0)
143 }
144 };
145
146 // Min-max normalize scores to [0, 1] so threshold is model-agnostic.
147 if let (Some(max), Some(min)) = (raw.first().map(|(_, s)| *s), raw.last().map(|(_, s)| *s))
148 {
149 let range = max - min;
150 if range > f32::EPSILON {
151 for (_, score) in &mut raw {
152 *score = (*score - min) / range;
153 }
154 } else {
155 // All scores identical — normalize to 1.0
156 for (_, score) in &mut raw {
157 *score = 1.0;
158 }
159 }
160 }
161
162 // Apply threshold on normalized scores, then truncate
163 raw.retain(|(_, score)| *score >= threshold);
164 raw.truncate(top_k);
165 raw
166 }
167
168 /// All chunks in the index.
169 #[must_use]
170 pub fn chunks(&self) -> &[CodeChunk] {
171 &self.semantic.chunks
172 }
173}
174
175/// Reciprocal Rank Fusion of two ranked lists.
176///
177/// Each entry in `semantic` and `bm25` is `(chunk_index, _score)`.
178/// The fused score for a chunk is the sum of `1 / (k + rank + 1)` across
179/// every list the chunk appears in, where `rank` is 0-based.
180///
181/// Returns all chunks that appear in either list, sorted descending by
182/// fused RRF score.
183///
184/// `k` should typically be 60.0 — a conventional constant that smooths the
185/// ranking boost for the very top results.
186#[must_use]
187pub fn rrf_fuse(semantic: &[(usize, f32)], bm25: &[(usize, f32)], k: f32) -> Vec<(usize, f32)> {
188 let mut scores: HashMap<usize, f32> = HashMap::new();
189
190 for (rank, &(idx, _)) in semantic.iter().enumerate() {
191 *scores.entry(idx).or_insert(0.0) += 1.0 / (k + rank as f32 + 1.0);
192 }
193 for (rank, &(idx, _)) in bm25.iter().enumerate() {
194 *scores.entry(idx).or_insert(0.0) += 1.0 / (k + rank as f32 + 1.0);
195 }
196
197 let mut results: Vec<(usize, f32)> = scores.into_iter().collect();
198 results.sort_unstable_by(|a, b| {
199 b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)) // stable tie-break by chunk index
200 });
201 results
202}
203
204/// Sigmoid steepness for the PageRank percentile boost. Lower values
205/// produce a sharper transition between "below median" (low boost) and
206/// "above median" (full boost).
207const PAGERANK_SIGMOID_STEEPNESS: f32 = 0.15;
208
209/// Sigmoid-shaped multiplicative boost factor for a single PageRank
210/// **percentile** in the corpus (not the raw rank value).
211///
212/// Returns the multiplier (so the final score is `dense_score * factor`).
213///
214/// ```text
215/// factor = 1 + alpha * sigmoid((percentile - 0.5) / s)
216/// sigmoid(z) = 1 / (1 + exp(-z))
217/// ```
218///
219/// where `s = PAGERANK_SIGMOID_STEEPNESS`.
220///
221/// ## Why this shape, with examples
222///
223/// The first attempt used a logarithmic saturation curve on raw rank
224/// values. That failed because raw ranks in a top-K result set
225/// concentrate in a tiny band (max ≈ 0.028 in Tokio), producing
226/// uniformly tiny boosts. The next attempt added a "presence floor"
227/// for `rank > 0`, which failed because tests also have tiny-but-
228/// positive PR from PageRank's damping term — both impl and test
229/// cleared the floor equally.
230///
231/// Switching the input to **percentile in the corpus** fixes both
232/// pathologies. A test with no inbound edges sits in the bottom decile
233/// of the PR distribution (percentile ≈ 0.05); a typical
234/// implementation file sits above the median. The sigmoid then makes
235/// the transition between "below median" (no boost) and "above median"
236/// (near-full boost) sharp:
237///
238/// | percentile | sigmoid | boost (α=0.5) |
239/// |------------|---------|---------------|
240/// | 0.05 (low test) | 0.04 | 1.02× |
241/// | 0.30 | 0.21 | 1.10× |
242/// | 0.50 (median) | 0.50 | 1.25× |
243/// | 0.70 | 0.79 | 1.40× |
244/// | 0.95 (top impl)| 0.95 | 1.47× |
245///
246/// Ceiling at `1 + α` — with `α = 0.5` that's 1.5×, bounded enough to
247/// keep PageRank a tiebreaker rather than a dominator: an irrelevant
248/// top-PR file with dense score 0.6 gets `0.6 × 1.5 = 0.9` and still
249/// loses to a relevant low-PR file scoring above 0.9.
250///
251/// This matches the two design constraints:
252/// 1. A test (low percentile) should not be lifted above an impl
253/// (high percentile) on similar dense scores. Sigmoid centered at
254/// 0.5 makes "below median" almost-no-boost.
255/// 2. A heavily-imported file shouldn't dominate. The sigmoid plateau
256/// above `percentile > 0.85` means a singularly-popular file gets
257/// barely more boost than a moderately-popular one.
258#[must_use]
259pub fn pagerank_boost_factor(percentile: f32, alpha: f32) -> f32 {
260 if percentile <= 0.0 || alpha <= 0.0 {
261 return 1.0;
262 }
263 let z = (percentile.clamp(0.0, 1.0) - 0.5) / PAGERANK_SIGMOID_STEEPNESS;
264 let sigmoid = 1.0 / (1.0 + (-z).exp());
265 1.0 + alpha * sigmoid
266}
267
268/// Apply a multiplicative PageRank boost to search results.
269///
270/// For each result, looks up the chunk's PageRank percentile and applies
271/// the sigmoid boost from [`pagerank_boost_factor`].
272///
273/// Results are re-sorted after boosting.
274///
275/// `pagerank_by_file` maps relative file paths to their **PageRank
276/// percentile** in the corpus distribution — not the raw rank value.
277/// Build it via [`pagerank_lookup`], which switched to percentile in
278/// service of the sigmoid curve.
279///
280/// `alpha` controls the maximum boost (ceiling = `1 + alpha`). The
281/// `alpha` field from [`RepoGraph`] is recommended (auto-tuned from
282/// graph density).
283pub fn boost_with_pagerank<S: std::hash::BuildHasher>(
284 results: &mut [(usize, f32)],
285 chunks: &[CodeChunk],
286 pagerank_by_file: &HashMap<String, f32, S>,
287 alpha: f32,
288) {
289 // Operates on `&mut [_]` (not `&mut Vec<_>`) so we can't delegate
290 // to `crate::ranking::PageRankBoost::apply` directly (the trait
291 // method takes `&mut Vec` to allow truncation layers). Replicate
292 // the boost loop inline; both paths share `lookup_rank` +
293 // `pagerank_boost_factor` so the curve stays consistent.
294 for (idx, score) in results.iter_mut() {
295 if let Some(chunk) = chunks.get(*idx) {
296 let rank = lookup_rank(pagerank_by_file, &chunk.file_path, &chunk.name);
297 *score *= pagerank_boost_factor(rank, alpha);
298 }
299 }
300 results.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
301}
302
303/// `boost_with_pagerank` variant that operates on `SearchResult` directly,
304/// for callers that don't have the raw `(usize, f32)` pair at hand.
305///
306/// Same boost math as [`boost_with_pagerank`]; re-sorts in place.
307pub fn boost_with_pagerank_results<S: std::hash::BuildHasher>(
308 results: &mut [crate::embed::SearchResult],
309 pagerank_by_file: &HashMap<String, f32, S>,
310 alpha: f32,
311) {
312 // SearchResult shape; inline math like `boost_with_pagerank`.
313 for r in results.iter_mut() {
314 let rank = lookup_rank(pagerank_by_file, &r.chunk.file_path, &r.chunk.name);
315 r.similarity *= pagerank_boost_factor(rank, alpha);
316 }
317 results.sort_unstable_by(|a, b| b.similarity.total_cmp(&a.similarity));
318}
319
320/// Resolve a chunk's PageRank score from a path that may be rooted
321/// differently than the graph keys.
322///
323/// Background: `RepoGraph` stores `FileNode.path` as `path.strip_prefix(root)`
324/// where `root` is the **canonicalized** corpus root. Chunk
325/// `file_path` is `path.display()` where `path` came from the walker —
326/// which uses the caller-supplied root **as-is** (not canonicalized).
327/// When the caller passes `tests/corpus/code/tokio`, chunk paths look
328/// like `tests/corpus/code/tokio/tokio/src/.../foo.rs` while graph
329/// keys look like `tokio/src/.../foo.rs`. Direct lookup never hits.
330///
331/// This function tries: definition-level exact (`"file::name"`),
332/// file-level exact, then walks the chunk path one segment at a time
333/// from the left and retries each suffix. First match wins.
334///
335/// (The proper fix is to normalize chunk paths at chunk-creation time
336/// to be relative to the canonicalized corpus root; that's a larger
337/// refactor planned alongside the `RankingLayer` work. Suffix matching
338/// is the surgical patch that makes PageRank actually function.)
339/// Re-exported under a longer name for use from the
340/// [`crate::ranking`] module. Kept as a `pub(crate)` symbol so it
341/// doesn't leak into the public surface; the canonical access point
342/// is [`crate::ranking::PageRankBoost`].
343pub(crate) fn lookup_rank_for_chunk<S: std::hash::BuildHasher>(
344 pr: &HashMap<String, f32, S>,
345 file_path: &str,
346 name: &str,
347) -> f32 {
348 lookup_rank(pr, file_path, name)
349}
350
351fn lookup_rank<S: std::hash::BuildHasher>(
352 pr: &HashMap<String, f32, S>,
353 file_path: &str,
354 name: &str,
355) -> f32 {
356 let def_key = format!("{file_path}::{name}");
357 if let Some(&r) = pr.get(&def_key) {
358 return r;
359 }
360 if let Some(&r) = pr.get(file_path) {
361 return r;
362 }
363 // Slide a left-edge cursor through the path. For
364 // `a/b/c/d/foo.rs` try `b/c/d/foo.rs`, then `c/d/foo.rs`, etc.
365 // Path components are typically <= 8 levels, so this is cheap.
366 let mut rest = file_path;
367 while let Some(idx) = rest.find('/') {
368 rest = &rest[idx + 1..];
369 if rest.is_empty() {
370 break;
371 }
372 let def_key = format!("{rest}::{name}");
373 if let Some(&r) = pr.get(&def_key) {
374 return r;
375 }
376 if let Some(&r) = pr.get(rest) {
377 return r;
378 }
379 }
380 0.0
381}
382
383/// Build a normalized PageRank lookup table from a [`RepoGraph`].
384///
385/// Returns a map from `"file_path::def_name"` to definition-level PageRank
386/// normalized to `[0, 1]`. Also inserts file-level entries (`"file_path"`)
387/// as aggregated fallback for chunks that don't match a specific definition.
388#[must_use]
389pub fn pagerank_lookup(graph: &crate::repo_map::RepoGraph) -> HashMap<String, f32> {
390 // Switched from `rank / max_rank` (proportional) to percentile in
391 // the corpus distribution. Rationale: a top-K result set typically
392 // contains files whose raw ranks are all in a tiny band near zero
393 // (Tokio: max in top-10 was 0.028 out of 1.0). Proportional
394 // normalization gave uniformly tiny boosts. Percentile separates
395 // "bottom decile (tests, leaves)" from "top half (impls, hubs)"
396 // crisply, and pairs with the sigmoid in `pagerank_boost_factor`
397 // to put the rank-transition where the action is.
398 //
399 // Definition-level and file-level percentiles use independent
400 // distributions: `def_ranks` and `base_ranks`. A file that has no
401 // defs still gets a file-level percentile from `base_ranks`.
402 let def_pct = make_percentile_fn(&graph.def_ranks);
403 let base_pct = make_percentile_fn(&graph.base_ranks);
404 let mut map = HashMap::new();
405 for (file_idx, file) in graph.files.iter().enumerate() {
406 for (def_idx, def) in file.defs.iter().enumerate() {
407 let flat = graph.def_offsets[file_idx] + def_idx;
408 if let Some(&rank) = graph.def_ranks.get(flat) {
409 let key = format!("{}::{}", file.path, def.name);
410 map.insert(key, def_pct(rank));
411 }
412 }
413 if file_idx < graph.base_ranks.len() {
414 map.insert(file.path.clone(), base_pct(graph.base_ranks[file_idx]));
415 }
416 }
417 map
418}
419
420/// Build a `value → percentile` function from a slice of rank values.
421///
422/// Sorts a copy once at build time, then each lookup is a binary search
423/// over the sorted slice. Returns the empirical CDF: the fraction of
424/// values strictly less than the queried value. Handles empty input
425/// and `NaN` defensively.
426fn make_percentile_fn(values: &[f32]) -> impl Fn(f32) -> f32 + '_ {
427 let mut sorted: Vec<f32> = values.iter().copied().filter(|v| v.is_finite()).collect();
428 sorted.sort_unstable_by(f32::total_cmp);
429 move |value: f32| {
430 if sorted.is_empty() {
431 return 0.0;
432 }
433 // partition_point returns the count of elements strictly less
434 // than `value` (because the predicate is `<`).
435 let count_below = sorted.partition_point(|&v| v < value);
436 #[expect(
437 clippy::cast_precision_loss,
438 reason = "rank counts well below f32 precision threshold"
439 )]
440 let pct = count_below as f32 / sorted.len() as f32;
441 pct
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn rrf_union_semantics() {
451 // sem: [0, 1, 2], bm25: [3, 0, 4]
452 // Chunk 0 appears in both lists → highest RRF score.
453 // Chunks 1, 2, 3, 4 appear in exactly one list → all five appear.
454 let sem = vec![(0, 0.9), (1, 0.8), (2, 0.7)];
455 let bm25 = vec![(3, 10.0), (0, 8.0), (4, 6.0)];
456
457 let fused = rrf_fuse(&sem, &bm25, 60.0);
458
459 let indices: Vec<usize> = fused.iter().map(|&(i, _)| i).collect();
460
461 // All 5 unique chunks must appear
462 for expected in [0, 1, 2, 3, 4] {
463 assert!(
464 indices.contains(&expected),
465 "chunk {expected} missing from fused results"
466 );
467 }
468 assert_eq!(fused.len(), 5);
469
470 // Chunk 0 must rank first (double-list bonus)
471 assert_eq!(indices[0], 0, "chunk 0 should rank first");
472 }
473
474 #[test]
475 fn rrf_single_list() {
476 // Only semantic results; BM25 is empty.
477 let sem = vec![(0, 0.9), (1, 0.8)];
478 let bm25: Vec<(usize, f32)> = vec![];
479
480 let fused = rrf_fuse(&sem, &bm25, 60.0);
481
482 assert_eq!(fused.len(), 2);
483 // Chunk 0 ranked first in sem list → higher RRF score than chunk 1
484 assert_eq!(fused[0].0, 0);
485 assert_eq!(fused[1].0, 1);
486 assert!(fused[0].1 > fused[1].1);
487 }
488
489 #[test]
490 fn search_mode_roundtrip() {
491 assert_eq!("hybrid".parse::<SearchMode>().unwrap(), SearchMode::Hybrid);
492 assert_eq!(
493 "semantic".parse::<SearchMode>().unwrap(),
494 SearchMode::Semantic
495 );
496 assert_eq!(
497 "keyword".parse::<SearchMode>().unwrap(),
498 SearchMode::Keyword
499 );
500
501 let err = "invalid".parse::<SearchMode>();
502 assert!(err.is_err(), "expected parse error for 'invalid'");
503 let msg = err.unwrap_err().to_string();
504 assert!(
505 msg.contains("invalid"),
506 "error message should echo the bad input"
507 );
508 }
509
510 #[test]
511 fn search_mode_display() {
512 assert_eq!(SearchMode::Hybrid.to_string(), "hybrid");
513 assert_eq!(SearchMode::Semantic.to_string(), "semantic");
514 assert_eq!(SearchMode::Keyword.to_string(), "keyword");
515 }
516
517 #[test]
518 fn pagerank_boost_amplifies_relevant() {
519 let chunks = vec![
520 CodeChunk {
521 file_path: "important.rs".into(),
522 name: "a".into(),
523 kind: "function".into(),
524 start_line: 1,
525 end_line: 10,
526 content: String::new(),
527 enriched_content: String::new(),
528 },
529 CodeChunk {
530 file_path: "obscure.rs".into(),
531 name: "b".into(),
532 kind: "function".into(),
533 start_line: 1,
534 end_line: 10,
535 content: String::new(),
536 enriched_content: String::new(),
537 },
538 ];
539
540 // Both start with same score; important.rs has high PageRank
541 let mut results = vec![(0, 0.8_f32), (1, 0.8)];
542 let mut pr = HashMap::new();
543 pr.insert("important.rs".to_string(), 1.0); // max PageRank
544 pr.insert("obscure.rs".to_string(), 0.1); // low PageRank
545
546 boost_with_pagerank(&mut results, &chunks, &pr, 0.3);
547
548 // important.rs should now rank higher
549 assert_eq!(
550 results[0].0, 0,
551 "important.rs should rank first after boost"
552 );
553 assert!(results[0].1 > results[1].1);
554
555 // Boost values reflect the sigmoid-on-percentile curve in
556 // `pagerank_boost_factor` (alpha=0.3 here):
557 // - percentile=1.0: sigmoid(3.33) ≈ 0.965, boost ≈ 1.29 → 1.032
558 // - percentile=0.1: sigmoid(-2.67) ≈ 0.065, boost ≈ 1.02 → 0.816
559 assert!(
560 (results[0].1 - 1.032).abs() < 0.01,
561 "rank=1.0 boost: expected ~1.032, got {}",
562 results[0].1
563 );
564 assert!(
565 (results[1].1 - 0.816).abs() < 0.01,
566 "rank=0.1 boost: expected ~0.816, got {}",
567 results[1].1
568 );
569 }
570
571 #[test]
572 fn pagerank_boost_zero_relevance_stays_zero() {
573 let chunks = vec![CodeChunk {
574 file_path: "important.rs".into(),
575 name: "a".into(),
576 kind: "function".into(),
577 start_line: 1,
578 end_line: 10,
579 content: String::new(),
580 enriched_content: String::new(),
581 }];
582
583 let mut results = vec![(0, 0.0_f32)];
584 let mut pr = HashMap::new();
585 pr.insert("important.rs".to_string(), 1.0);
586
587 boost_with_pagerank(&mut results, &chunks, &pr, 0.3);
588
589 // Zero score stays zero regardless of PageRank
590 assert!(results[0].1.abs() < f32::EPSILON);
591 }
592
593 #[test]
594 fn pagerank_boost_unknown_file_no_effect() {
595 let chunks = vec![CodeChunk {
596 file_path: "unknown.rs".into(),
597 name: "a".into(),
598 kind: "function".into(),
599 start_line: 1,
600 end_line: 10,
601 content: String::new(),
602 enriched_content: String::new(),
603 }];
604
605 let mut results = vec![(0, 0.5_f32)];
606 let pr = HashMap::new(); // empty — no PageRank data
607
608 boost_with_pagerank(&mut results, &chunks, &pr, 0.3);
609
610 // No PageRank data → no boost
611 assert!((results[0].1 - 0.5).abs() < f32::EPSILON);
612 }
613}