Skip to main content

scirs2_interpolate/gpu_kdtree/
tree.rs

1//! CPU k-d tree for arbitrary dimension, with Rayon parallel batch queries.
2//!
3//! The tree uses variance-based axis selection at each split and stores
4//! multiple point indices in leaf nodes (standard k-d tree leaf-bucket design).
5
6use crate::error::{InterpolateError, InterpolateResult};
7use scirs2_core::parallel_ops::*;
8use std::collections::BinaryHeap;
9
10// ---------------------------------------------------------------------------
11// Internal node type
12// ---------------------------------------------------------------------------
13
14/// A node in the k-d tree.
15#[derive(Debug)]
16enum KdNode {
17    /// Leaf node holding one or more point indices.
18    Leaf { point_indices: Vec<usize> },
19    /// Interior split node.
20    Split {
21        axis: usize,
22        split_val: f64,
23        left: Box<KdNode>,
24        right: Box<KdNode>,
25    },
26}
27
28// ---------------------------------------------------------------------------
29// Priority queue entry (max-heap by distance, so we can evict the farthest)
30// ---------------------------------------------------------------------------
31
32/// Priority-queue entry: `(dist_sq, point_idx)`.
33///
34/// The `BinaryHeap` in Rust is a max-heap; we want the *farthest* element at
35/// the top so we can evict it when a closer point is found.
36#[derive(PartialEq)]
37struct HeapEntry(f64, usize);
38
39impl Eq for HeapEntry {}
40
41impl PartialOrd for HeapEntry {
42    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
43        Some(self.cmp(other))
44    }
45}
46
47impl Ord for HeapEntry {
48    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
49        // We want a max-heap by distance (largest dist at the top),
50        // so that we can pop the farthest element when we find something closer.
51        self.0
52            .partial_cmp(&other.0)
53            .unwrap_or(std::cmp::Ordering::Equal)
54            .then(other.1.cmp(&self.1))
55    }
56}
57
58// ---------------------------------------------------------------------------
59// Public API types (re-exported via mod.rs)
60// ---------------------------------------------------------------------------
61
62/// Result of a single k-NN query.
63#[derive(Debug, Clone)]
64pub struct KdQueryResult {
65    /// Indices into the original point array (sorted nearest-first).
66    pub indices: Vec<usize>,
67    /// Squared Euclidean distances (same order as `indices`).
68    pub distances_sq: Vec<f64>,
69}
70
71// ---------------------------------------------------------------------------
72// GpuKdTree
73// ---------------------------------------------------------------------------
74
75/// K-d tree for N-dimensional nearest-neighbor queries.
76///
77/// Optimised for pure-f64 coordinates.  Batch queries are executed in
78/// parallel using Rayon when the `parallel` feature of *scirs2-core* is
79/// enabled.
80///
81/// # Notes
82///
83/// This type is intentionally named `GpuKdTree` to distinguish it from the
84/// pre-existing `spatial::kdtree::KdTree<F>` generic tree.  Both types are
85/// available in `scirs2-interpolate`; `GpuKdTree` adds the GPU-dispatch
86/// overlay for large scattered-data queries.
87///
88/// # Example
89///
90/// ```rust
91/// use scirs2_interpolate::gpu_kdtree::GpuKdTree;
92///
93/// let pts = vec![
94///     vec![0.0f64, 0.0],
95///     vec![1.0, 0.0],
96///     vec![0.0, 1.0],
97///     vec![1.0, 1.0],
98///     vec![0.5, 0.5],
99/// ];
100/// let tree = GpuKdTree::new(pts).expect("build");
101/// let res = tree.knn(&[0.6, 0.6], 1).expect("query");
102/// assert_eq!(res.indices[0], 4); // (0.5, 0.5)
103/// ```
104pub struct GpuKdTree {
105    /// Flat storage of all point coordinates.
106    points: Vec<Vec<f64>>,
107    /// Dimensionality (number of coordinates per point).
108    dim: usize,
109    /// Root node, `None` only for an empty tree.
110    root: Option<KdNode>,
111    /// Maximum number of points stored per leaf node.
112    leaf_size: usize,
113}
114
115impl GpuKdTree {
116    // -----------------------------------------------------------------------
117    // Construction
118    // -----------------------------------------------------------------------
119
120    /// Build a k-d tree from `points`.
121    ///
122    /// Each point must have the same number of coordinates.  Returns an error
123    /// if any point has a different dimension from the first.
124    pub fn new(points: Vec<Vec<f64>>) -> InterpolateResult<Self> {
125        Self::with_leaf_size(points, 16)
126    }
127
128    /// Build a k-d tree with a custom leaf-bucket size.
129    ///
130    /// Larger `leaf_size` reduces tree depth at the cost of more linear scans
131    /// inside leaves.  The default of 16 is a good general-purpose value.
132    pub fn with_leaf_size(points: Vec<Vec<f64>>, leaf_size: usize) -> InterpolateResult<Self> {
133        let leaf_size = leaf_size.max(1);
134
135        if points.is_empty() {
136            return Ok(Self {
137                points: Vec::new(),
138                dim: 0,
139                root: None,
140                leaf_size,
141            });
142        }
143
144        let dim = points[0].len();
145        for (i, p) in points.iter().enumerate() {
146            if p.len() != dim {
147                return Err(InterpolateError::InvalidInput {
148                    message: format!("Point {i} has dimension {} but expected {dim}", p.len()),
149                });
150            }
151        }
152
153        let indices: Vec<usize> = (0..points.len()).collect();
154        let root = Some(build_node(&points, indices, dim, leaf_size));
155
156        Ok(Self {
157            points,
158            dim,
159            root,
160            leaf_size,
161        })
162    }
163
164    // -----------------------------------------------------------------------
165    // Queries
166    // -----------------------------------------------------------------------
167
168    /// Find the `k` nearest neighbors to `query`.
169    ///
170    /// Returns a [`KdQueryResult`] with indices and squared distances sorted
171    /// nearest-first.  If `k > n_points()`, all points are returned.
172    pub fn knn(&self, query: &[f64], k: usize) -> InterpolateResult<KdQueryResult> {
173        if query.len() != self.dim {
174            return Err(InterpolateError::InvalidInput {
175                message: format!(
176                    "Query has dimension {} but tree has dimension {}",
177                    query.len(),
178                    self.dim
179                ),
180            });
181        }
182
183        if self.points.is_empty() || self.root.is_none() {
184            return Ok(KdQueryResult {
185                indices: Vec::new(),
186                distances_sq: Vec::new(),
187            });
188        }
189
190        let k_effective = k.min(self.points.len());
191        let mut heap: BinaryHeap<HeapEntry> = BinaryHeap::with_capacity(k_effective + 1);
192
193        if let Some(root) = &self.root {
194            search_knn(root, &self.points, query, k_effective, &mut heap);
195        }
196
197        // Convert heap (max-heap by distance) to sorted nearest-first vec.
198        let mut results: Vec<(f64, usize)> = heap.into_iter().map(|e| (e.0, e.1)).collect();
199        results.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
200
201        Ok(KdQueryResult {
202            indices: results.iter().map(|(_, i)| *i).collect(),
203            distances_sq: results.iter().map(|(d, _)| *d).collect(),
204        })
205    }
206
207    /// Parallel batch k-NN for many query points.
208    ///
209    /// Each query is processed independently; results are in the same order as
210    /// `queries`.  Uses Rayon parallel iteration when the `parallel` feature
211    /// of *scirs2-core* is active; otherwise falls back to sequential.
212    pub fn knn_batch(
213        &self,
214        queries: &[Vec<f64>],
215        k: usize,
216    ) -> InterpolateResult<Vec<KdQueryResult>> {
217        queries.into_par_iter().map(|q| self.knn(q, k)).collect()
218    }
219
220    // -----------------------------------------------------------------------
221    // Accessors
222    // -----------------------------------------------------------------------
223
224    /// Number of points in the tree.
225    pub fn n_points(&self) -> usize {
226        self.points.len()
227    }
228
229    /// Dimensionality of the points in the tree.
230    pub fn dim(&self) -> usize {
231        self.dim
232    }
233}
234
235// ---------------------------------------------------------------------------
236// Tree construction helpers
237// ---------------------------------------------------------------------------
238
239fn build_node(points: &[Vec<f64>], indices: Vec<usize>, dim: usize, leaf_size: usize) -> KdNode {
240    if indices.len() <= leaf_size {
241        return KdNode::Leaf {
242            point_indices: indices,
243        };
244    }
245
246    // Choose the split axis as the dimension with the largest variance.
247    let axis = (0..dim)
248        .max_by(|&a, &b| {
249            let va = variance_along(points, &indices, a);
250            let vb = variance_along(points, &indices, b);
251            va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
252        })
253        .unwrap_or(0);
254
255    // Sort by the chosen axis and split at the median.
256    let mut sorted = indices;
257    sorted.sort_unstable_by(|&i, &j| {
258        points[i][axis]
259            .partial_cmp(&points[j][axis])
260            .unwrap_or(std::cmp::Ordering::Equal)
261    });
262
263    let mid = sorted.len() / 2;
264    let split_val = points[sorted[mid]][axis];
265
266    let right_indices = sorted.split_off(mid);
267    let left_indices = sorted;
268
269    KdNode::Split {
270        axis,
271        split_val,
272        left: Box::new(build_node(points, left_indices, dim, leaf_size)),
273        right: Box::new(build_node(points, right_indices, dim, leaf_size)),
274    }
275}
276
277fn variance_along(points: &[Vec<f64>], indices: &[usize], axis: usize) -> f64 {
278    let n = indices.len() as f64;
279    if n < 2.0 {
280        return 0.0;
281    }
282    let mean = indices.iter().map(|&i| points[i][axis]).sum::<f64>() / n;
283    indices
284        .iter()
285        .map(|&i| (points[i][axis] - mean).powi(2))
286        .sum::<f64>()
287        / n
288}
289
290// ---------------------------------------------------------------------------
291// k-NN search
292// ---------------------------------------------------------------------------
293
294fn dist_sq(a: &[f64], b: &[f64]) -> f64 {
295    a.iter().zip(b.iter()).map(|(x, y)| (x - y).powi(2)).sum()
296}
297
298fn search_knn(
299    node: &KdNode,
300    points: &[Vec<f64>],
301    query: &[f64],
302    k: usize,
303    heap: &mut BinaryHeap<HeapEntry>,
304) {
305    match node {
306        KdNode::Leaf { point_indices } => {
307            for &idx in point_indices {
308                let d = dist_sq(query, &points[idx]);
309                maybe_push(heap, d, idx, k);
310            }
311        }
312        KdNode::Split {
313            axis,
314            split_val,
315            left,
316            right,
317        } => {
318            let diff = query[*axis] - split_val;
319            let (near, far) = if diff <= 0.0 {
320                (left.as_ref(), right.as_ref())
321            } else {
322                (right.as_ref(), left.as_ref())
323            };
324
325            search_knn(near, points, query, k, heap);
326
327            // Check if the far half-space could contain a closer point.
328            let worst_sq = heap.peek().map(|e| e.0).unwrap_or(f64::INFINITY);
329            if diff * diff < worst_sq || heap.len() < k {
330                search_knn(far, points, query, k, heap);
331            }
332        }
333    }
334}
335
336/// Insert `(dist_sq, idx)` into the max-heap if it improves the current k-set.
337fn maybe_push(heap: &mut BinaryHeap<HeapEntry>, d: f64, idx: usize, k: usize) {
338    if heap.len() < k {
339        heap.push(HeapEntry(d, idx));
340    } else if let Some(top) = heap.peek() {
341        if d < top.0 {
342            heap.pop();
343            heap.push(HeapEntry(d, idx));
344        }
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Tests
350// ---------------------------------------------------------------------------
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    // Brute-force k-NN reference implementation for comparison.
357    fn brute_force_knn(points: &[Vec<f64>], query: &[f64], k: usize) -> Vec<usize> {
358        let mut dists: Vec<(f64, usize)> = points
359            .iter()
360            .enumerate()
361            .map(|(i, p)| {
362                let d = p.iter().zip(query).map(|(a, b)| (a - b).powi(2)).sum();
363                (d, i)
364            })
365            .collect();
366        dists.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
367        dists[..k.min(dists.len())]
368            .iter()
369            .map(|(_, i)| *i)
370            .collect()
371    }
372
373    #[test]
374    fn test_kdtree_empty_returns_empty() {
375        let tree = GpuKdTree::new(vec![]).expect("build empty");
376        let res = tree.knn(&[], 1).expect("query empty");
377        assert!(res.indices.is_empty());
378        assert!(res.distances_sq.is_empty());
379    }
380
381    #[test]
382    fn test_kdtree_single_point() {
383        let pts = vec![vec![3.0_f64, 4.0]];
384        let tree = GpuKdTree::new(pts).expect("build single");
385        let res = tree.knn(&[0.0, 0.0], 1).expect("query");
386        assert_eq!(res.indices.len(), 1);
387        assert_eq!(res.indices[0], 0);
388        // dist² = 9 + 16 = 25
389        let expected_d = 25.0_f64;
390        assert!((res.distances_sq[0] - expected_d).abs() < 1e-12);
391    }
392
393    #[test]
394    fn test_kdtree_1d_finds_nearest() {
395        let pts: Vec<Vec<f64>> = vec![vec![0.0], vec![1.0], vec![2.0], vec![3.0], vec![4.0]];
396        let tree = GpuKdTree::new(pts.clone()).expect("build 1d");
397        let res = tree.knn(&[2.5], 1).expect("query 1d");
398        // Nearest to 2.5 should be 2 (dist 0.25) or 3 (dist 0.25) — accept either
399        assert!(
400            res.indices[0] == 2 || res.indices[0] == 3,
401            "expected index 2 or 3, got {}",
402            res.indices[0]
403        );
404        assert!((res.distances_sq[0] - 0.25).abs() < 1e-12);
405    }
406
407    #[test]
408    fn test_kdtree_2d_knn_k3() {
409        // 3×3 grid: indices 0..8
410        // (0,0),(1,0),(2,0),(0,1),(1,1),(2,1),(0,2),(1,2),(2,2)
411        let pts: Vec<Vec<f64>> = (0..3)
412            .flat_map(|r: i32| (0..3).map(move |c: i32| vec![c as f64, r as f64]))
413            .collect();
414        let tree = GpuKdTree::new(pts.clone()).expect("build 2d grid");
415
416        // Query at center (1,1) — index 4
417        let res = tree.knn(&[1.0, 1.0], 3).expect("knn 3 at center");
418        assert_eq!(res.indices.len(), 3);
419        // Nearest should be itself (dist 0) — index 4
420        assert_eq!(res.indices[0], 4);
421        // Next two must all have dist² = 1.0 (orthogonal neighbors)
422        assert!(
423            (res.distances_sq[1] - 1.0).abs() < 1e-12 || (res.distances_sq[2] - 1.0).abs() < 1e-12,
424            "distances: {:?}",
425            res.distances_sq
426        );
427    }
428
429    #[test]
430    fn test_kdtree_knn_batch_matches_brute_force() {
431        use scirs2_core::random::{rngs::StdRng, RngExt, SeedableRng};
432        let mut rng = StdRng::seed_from_u64(42);
433
434        let n = 50_usize;
435        let dim = 3_usize;
436        let pts: Vec<Vec<f64>> = (0..n)
437            .map(|_| (0..dim).map(|_| rng.random::<f64>()).collect())
438            .collect();
439
440        let tree = GpuKdTree::new(pts.clone()).expect("build 3d");
441
442        let queries: Vec<Vec<f64>> = (0..20)
443            .map(|_| (0..dim).map(|_| rng.random::<f64>()).collect())
444            .collect();
445
446        let k = 5;
447        let batch = tree.knn_batch(&queries, k).expect("batch knn");
448        assert_eq!(batch.len(), queries.len());
449
450        for (q_idx, (res, q)) in batch.iter().zip(queries.iter()).enumerate() {
451            let expected = brute_force_knn(&pts, q, k);
452            // Compare sorted index sets (both should agree on the same k points)
453            let mut got = res.indices.clone();
454            let mut exp = expected.clone();
455            got.sort_unstable();
456            exp.sort_unstable();
457            assert_eq!(got, exp, "query {q_idx}: tree={got:?} brute={exp:?}");
458        }
459    }
460
461    #[test]
462    fn test_kdtree_dimension_mismatch_errors() {
463        let pts = vec![vec![1.0_f64, 2.0], vec![3.0, 4.0]];
464        let tree = GpuKdTree::new(pts).expect("build");
465        let err = tree.knn(&[0.0], 1);
466        assert!(err.is_err(), "should error on dimension mismatch");
467    }
468
469    #[test]
470    fn test_kdtree_dimension_mismatch_on_build() {
471        let pts = vec![vec![1.0_f64, 2.0], vec![3.0, 4.0, 5.0]];
472        let err = GpuKdTree::new(pts);
473        assert!(err.is_err(), "should error when points have different dims");
474    }
475
476    #[test]
477    fn test_kdtree_high_dim_correct() {
478        use scirs2_core::random::{rngs::StdRng, RngExt, SeedableRng};
479        let mut rng = StdRng::seed_from_u64(99);
480
481        let n = 100_usize;
482        let dim = 10_usize;
483        let pts: Vec<Vec<f64>> = (0..n)
484            .map(|_| (0..dim).map(|_| rng.random::<f64>()).collect())
485            .collect();
486
487        let tree = GpuKdTree::new(pts.clone()).expect("build 10d");
488        let q: Vec<f64> = (0..dim).map(|_| rng.random::<f64>()).collect();
489        let k = 7;
490
491        let tree_res = tree.knn(&q, k).expect("knn 10d");
492        let brute_res = brute_force_knn(&pts, &q, k);
493
494        let mut got = tree_res.indices.clone();
495        let mut exp = brute_res;
496        got.sort_unstable();
497        exp.sort_unstable();
498        assert_eq!(got, exp, "10-D: tree={got:?} brute={exp:?}");
499    }
500
501    #[test]
502    fn test_kdtree_k_larger_than_n() {
503        let pts = vec![vec![0.0_f64], vec![1.0], vec![2.0]];
504        let tree = GpuKdTree::new(pts).expect("build");
505        let res = tree.knn(&[1.5], 100).expect("k > n");
506        // Should return all 3 points
507        assert_eq!(res.indices.len(), 3);
508    }
509
510    #[test]
511    fn test_kdtree_distances_are_sorted_ascending() {
512        use scirs2_core::random::{rngs::StdRng, RngExt, SeedableRng};
513        let mut rng = StdRng::seed_from_u64(7);
514
515        let pts: Vec<Vec<f64>> = (0..30)
516            .map(|_| vec![rng.random::<f64>(), rng.random::<f64>()])
517            .collect();
518        let tree = GpuKdTree::new(pts).expect("build");
519        let q = vec![0.5, 0.5];
520        let res = tree.knn(&q, 10).expect("knn 10");
521        for w in res.distances_sq.windows(2) {
522            assert!(w[0] <= w[1], "distances not sorted: {:?}", res.distances_sq);
523        }
524    }
525}