Skip to main content

kernel/
spatial.rs

1//! 2i: space as a key discipline (FACT-04, D31).
2//!
3//! A newcomer's map. To index locations in a btree we need a way to turn
4//! two dimensions (lon, lat) into ONE sortable number such that places
5//! near each other in space usually get nearby numbers -- then "everything
6//! in this box" becomes a few contiguous key ranges. A Hilbert curve does
7//! exactly that (better locality than the simpler Z-order: the curve never
8//! makes the big diagonal jumps Z does). We port PostGIS's own Hilbert
9//! implementation -- notable because PostGIS, the reference R-tree system,
10//! uses this very ordering to BUILD its R-trees; curve order is the best
11//! layout the R-tree camp knows, so we store in curve order directly.
12//!
13//! Two fixed levels: FINE cells (16 bits per axis, ~600 m at the equator)
14//! for points and small shapes; COARSE (8 bits per axis) for anything
15//! whose bounding box would need more than MAX_CELLS fine cells. A
16//! geometry posts to at most MAX_CELLS cells -- write cost O(1) per
17//! geometry, ever (Law 2).
18//!
19//! Values carry the geometry's bounding box as four f32s rounded OUTWARD
20//! (also PostGIS's trick): a float box strictly containing the double box
21//! filters candidates without touching the payload; a degenerate box
22//! (xmin==xmax, ymin==ymax) IS a point, so point workloads answer exact
23//! distances straight from the posting -- zero payload reads.
24
25/// Hilbert index of an (x, y) cell on a 2^bits x 2^bits grid -- the
26/// classic level-parameterized xy2d walk (public domain; PostGIS uses the
27/// same curve family via a 32-bit bit-scan for its sorted R-tree builds,
28/// which inspired this keyspace). The bit-scan variant is fixed to 32-bit
29/// grids; our cells live at 8- and 16-bit levels, and a cell's index must
30/// be computed AT ITS LEVEL or aligned squares stop being contiguous runs
31/// -- the oracle test below caught exactly that with a scaled-shift
32/// shortcut. O(bits) per call, index-time only.
33pub fn cell_hilbert(cx: u32, cy: u32, bits: u8) -> u64 {
34    let n: u64 = 1u64 << bits;
35    let (mut x, mut y) = (cx as u64, cy as u64);
36    let mut d: u64 = 0;
37    let mut s: u64 = n / 2;
38    while s > 0 {
39        let rx = if (x & s) > 0 { 1u64 } else { 0 };
40        let ry = if (y & s) > 0 { 1u64 } else { 0 };
41        d += s * s * ((3 * rx) ^ ry);
42        // rotate
43        if ry == 0 {
44            if rx == 1 {
45                x = s - 1 - (x & (s - 1)) | (x & !(2 * s - 1));
46                y = s - 1 - (y & (s - 1)) | (y & !(2 * s - 1));
47            }
48            std::mem::swap(&mut x, &mut y);
49        }
50        s /= 2;
51    }
52    d
53}
54
55pub const LEVEL_FINE: u8 = 12;
56pub const LEVEL_COARSE: u8 = 8;
57/// Geometries too big for MAX_CELLS coarse cells (continent scale) post
58/// ONE entry in the world bucket, which every query also scans. Sound
59/// (never missed), bounded (one posting), cheap (members are rare and
60/// bbox-filtered). The corner-clip shortcut this replaces missed interior
61/// queries -- caught by the coarse-fallback oracle test.
62pub const LEVEL_WORLD: u8 = 0;
63/// A geometry posts to at most this many cells (Law 2's bound).
64pub const MAX_CELLS: usize = 8;
65
66/// Quantize lon in [-180,180], lat in [-90,90] to `bits`-per-axis cells.
67pub fn cell_of(lon: f64, lat: f64, bits: u8) -> (u32, u32) {
68    let n = (1u64 << bits) as f64;
69    let cx = (((lon + 180.0) / 360.0) * n).floor().clamp(0.0, n - 1.0) as u32;
70    let cy = (((lat + 90.0) / 180.0) * n).floor().clamp(0.0, n - 1.0) as u32;
71    (cx, cy)
72}
73
74/// Outward-rounded f32 bounding box -- PostGIS `box2df_from_gbox_p`.
75#[derive(Clone, Copy, Debug, PartialEq)]
76pub struct BoxF {
77    pub xmin: f32, pub xmax: f32, pub ymin: f32, pub ymax: f32,
78}
79fn next_down(v: f64) -> f32 {
80    let f = v as f32;
81    if (f as f64) > v { f32::from_bits(if f > 0.0 { f.to_bits() - 1 } else { f.to_bits() + 1 }) } else { f }
82}
83fn next_up(v: f64) -> f32 {
84    let f = v as f32;
85    if (f as f64) < v { f32::from_bits(if f >= 0.0 { f.to_bits() + 1 } else { f.to_bits() - 1 }) } else { f }
86}
87impl BoxF {
88    pub fn from_f64(xmin: f64, xmax: f64, ymin: f64, ymax: f64) -> BoxF {
89        BoxF { xmin: next_down(xmin), xmax: next_up(xmax),
90               ymin: next_down(ymin), ymax: next_up(ymax) }
91    }
92    pub fn intersects(&self, o: &BoxF) -> bool {
93        self.xmin <= o.xmax && self.xmax >= o.xmin
94            && self.ymin <= o.ymax && self.ymax >= o.ymin
95    }
96    /// Degenerate box = the geometry is a point (its exact coordinates).
97    pub fn as_point(&self) -> Option<(f64, f64)> {
98        if self.xmin == self.xmax && self.ymin == self.ymax {
99            Some((self.xmin as f64, self.ymin as f64))
100        } else { None }
101    }
102    pub fn encode(&self) -> [u8; 16] {
103        let mut b = [0u8; 16];
104        b[0..4].copy_from_slice(&self.xmin.to_le_bytes());
105        b[4..8].copy_from_slice(&self.xmax.to_le_bytes());
106        b[8..12].copy_from_slice(&self.ymin.to_le_bytes());
107        b[12..16].copy_from_slice(&self.ymax.to_le_bytes());
108        b
109    }
110    pub fn decode(b: &[u8]) -> Option<BoxF> {
111        if b.len() < 16 { return None; }
112        Some(BoxF {
113            xmin: f32::from_le_bytes(b[0..4].try_into().unwrap()),
114            xmax: f32::from_le_bytes(b[4..8].try_into().unwrap()),
115            ymin: f32::from_le_bytes(b[8..12].try_into().unwrap()),
116            ymax: f32::from_le_bytes(b[12..16].try_into().unwrap()),
117        })
118    }
119}
120
121/// The cells a bbox covers at `bits` per axis, capped: returns None when
122/// the cover would exceed `max` cells (caller drops to a coarser level).
123pub fn cover_cells(xmin: f64, xmax: f64, ymin: f64, ymax: f64, bits: u8, max: usize)
124    -> Option<Vec<(u32, u32)>>
125{
126    let (x0, y0) = cell_of(xmin, ymin, bits);
127    let (x1, y1) = cell_of(xmax, ymax, bits);
128    let w = (x1 - x0 + 1) as usize;
129    let h = (y1 - y0 + 1) as usize;
130    if w.saturating_mul(h) > max { return None; }
131    let mut out = Vec::with_capacity(w * h);
132    for cy in y0..=y1 {
133        for cx in x0..=x1 {
134            out.push((cx, cy));
135        }
136    }
137    Some(out)
138}
139
140/// Decompose a query bbox into Hilbert key RANGES at one level: a quadtree
141/// refinement over Hilbert quadrants, emitting one [lo, hi] run for every
142/// square fully inside the box, dropping squares fully outside, and
143/// splitting the ones that straddle the box's edge. `max_ranges` caps the
144/// output; the refinement spends that budget where it buys the most.
145///
146/// The budget is spent WASTE-FIRST. Every straddling square carries the
147/// number of its cells that lie outside the box; the square with the most
148/// outside cells is always the next one split, because that split removes
149/// the most cells the scan would otherwise read and throw away. When one
150/// more split could push the count of runs past the budget, every square
151/// still straddling is emitted whole -- a slightly larger scan, filtered
152/// exactly per posting, never a miss. A depth-first descent that checked
153/// its budget against its own stack was the earlier shape of this: three
154/// pending siblings per level ate fifty of sixty-four slots at sixteen
155/// bits, so it stopped refining at the fourth level and read five times the
156/// box for a 50 km radius.
157pub fn cover_ranges(xmin: f64, xmax: f64, ymin: f64, ymax: f64, bits: u8,
158                    max_ranges: usize) -> Vec<(u64, u64)>
159{
160    use std::collections::BinaryHeap;
161    let (qx0, qy0) = cell_of(xmin, ymin, bits);
162    let (qx1, qy1) = cell_of(xmax, ymax, bits);
163    let budget = max_ranges.max(4);
164    // An aligned power-of-two square of side `size` at cell (x, y); its
165    // cells are one contiguous hilbert run starting at the least of its
166    // corners (see `aligned_squares_are_contiguous_hilbert_runs`).
167    #[derive(PartialEq, Eq, PartialOrd, Ord)]
168    struct Straddling { waste: u64, size: u32, x: u32, y: u32 }
169    let run = |x: u32, y: u32, size: u32| -> (u64, u64) {
170        let (x1, y1) = (x + size - 1, y + size - 1);
171        let corners = [
172            cell_hilbert(x, y, bits), cell_hilbert(x1, y, bits),
173            cell_hilbert(x, y1, bits), cell_hilbert(x1, y1, bits),
174        ];
175        let lo = *corners.iter().min().unwrap();
176        (lo, lo + (size as u64) * (size as u64) - 1)
177    };
178    // How many of the square's cells fall inside the box: `None` when
179    // none do, so the square is dropped.
180    let inside = |x: u32, y: u32, size: u32| -> Option<u64> {
181        let (x1, y1) = (x + size - 1, y + size - 1);
182        if x1 < qx0 || x > qx1 || y1 < qy0 || y > qy1 { return None; }
183        let w = (x1.min(qx1) - x.max(qx0) + 1) as u64;
184        let h = (y1.min(qy1) - y.max(qy0) + 1) as u64;
185        Some(w * h)
186    };
187    let mut out: Vec<(u64, u64)> = Vec::new();
188    let mut straddling: BinaryHeap<Straddling> = BinaryHeap::new();
189    let full = 1u32 << bits;
190    let place = |x: u32, y: u32, size: u32, out: &mut Vec<(u64, u64)>,
191                     straddling: &mut BinaryHeap<Straddling>| {
192        if let Some(cells) = inside(x, y, size) {
193            let total = (size as u64) * (size as u64);
194            if cells == total {
195                out.push(run(x, y, size));
196            } else {
197                straddling.push(Straddling { waste: total - cells, size, x, y });
198            }
199        }
200    };
201    place(0, 0, full, &mut out, &mut straddling);
202    // A split replaces one run by at most four, so it is affordable while
203    // three more runs still fit under the budget.
204    while out.len() + straddling.len() + 3 <= budget {
205        let Some(worst) = straddling.pop() else { break };
206        let half = worst.size / 2;
207        for (dx, dy) in [(0, 0), (half, 0), (0, half), (half, half)] {
208            place(worst.x + dx, worst.y + dy, half, &mut out, &mut straddling);
209        }
210    }
211    out.extend(straddling.into_iter().map(|s| run(s.x, s.y, s.size)));
212    // merge adjacent/overlapping runs so the scan count stays small
213    out.sort_unstable();
214    let mut merged: Vec<(u64, u64)> = Vec::new();
215    for (lo, hi) in out {
216        match merged.last_mut() {
217            Some(last) if lo <= last.1 + 1 => last.1 = last.1.max(hi),
218            _ => merged.push((lo, hi)),
219        }
220    }
221    merged
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    /// Hilbert must be a bijection on small grids and neighbours must be
229    /// adjacent along the curve for at least one axis step (the locality
230    /// property Z-order lacks at quadrant seams).
231    #[test]
232    fn hilbert_is_a_bijection_on_an_8x8_grid() {
233        let bits = 3u8;
234        let mut seen = std::collections::HashSet::new();
235        for y in 0..8u32 {
236            for x in 0..8u32 {
237                let h = cell_hilbert(x, y, bits);
238                assert!(seen.insert(h), "collision at ({x},{y})");
239            }
240        }
241        assert_eq!(seen.len(), 64);
242        // consecutive curve positions differ by exactly one grid step
243        let mut by_h: Vec<(u64, (u32, u32))> = (0..8u32)
244            .flat_map(|y| (0..8u32).map(move |x| (cell_hilbert(x, y, 3), (x, y))))
245            .collect();
246        by_h.sort();
247        for w in by_h.windows(2) {
248            let ((_, (x0, y0)), (_, (x1, y1))) = (w[0], w[1]);
249            let d = x0.abs_diff(x1) + y0.abs_diff(y1);
250            assert_eq!(d, 1, "curve jumps from ({x0},{y0}) to ({x1},{y1})");
251        }
252    }
253
254    /// Every cell inside a query rect is covered by some emitted range,
255    /// and no range is unbounded nonsense -- against brute force.
256    #[test]
257    fn cover_ranges_cover_exactly_against_bruteforce() {
258        let bits = 6u8; // 64x64 world: brute force is cheap
259        let cases = [
260            (-180.0, 180.0, -90.0, 90.0),
261            (-1.0, 1.0, -1.0, 1.0),
262            (10.0, 11.5, -20.0, -19.2),
263            (100.0, 179.9, 50.0, 89.9),
264            (-0.001, 0.001, -0.001, 0.001),
265        ];
266        for (xmin, xmax, ymin, ymax) in cases {
267            let ranges = cover_ranges(xmin, xmax, ymin, ymax, bits, 32);
268            assert!(ranges.len() <= 40, "range budget blown: {}", ranges.len());
269            let (qx0, qy0) = cell_of(xmin, ymin, bits);
270            let (qx1, qy1) = cell_of(xmax, ymax, bits);
271            for cy in qy0..=qy1 {
272                for cx in qx0..=qx1 {
273                    let h = cell_hilbert(cx, cy, bits);
274                    assert!(ranges.iter().any(|&(lo, hi)| lo <= h && h <= hi),
275                            "cell ({cx},{cy}) h={h} not covered for box {:?}",
276                            (xmin, xmax, ymin, ymax));
277                }
278            }
279        }
280    }
281
282    /// The cover's over-fetch is bounded by its RANGE budget, not by how deep
283    /// the recursion happens to be when the budget check first trips. A box
284    /// of 262x262 cells (a 50 km radius at 16 bits) under a budget of 64
285    /// ranges must cover at most 1.5x its own cells, and a box of 47x47
286    /// (10 km) and 9x17 (2 km) the same, with never more ranges than the
287    /// budget. Before this was pinned the 50 km cover spent 5.2x its box and
288    /// only 14 of its 64 ranges: the check counted the DFS stack -- three
289    /// pending siblings per level, fifty of the sixty-four -- and emitted
290    /// whole quadrants far larger than the box.
291    #[test]
292    fn cover_over_fetch_is_bounded_by_the_range_budget() {
293        let bits = 16u8;
294        let (lon, lat) = (107.6f64, -6.9f64);
295        for (half_lon, half_lat, budget, ceiling) in
296            [(0.45, 0.45, 64, 1.5), (0.09, 0.09, 64, 1.5), (0.018, 0.018, 64, 1.6)]
297        {
298            let (xmin, xmax, ymin, ymax) = (lon - half_lon, lon + half_lon, lat - half_lat, lat + half_lat);
299            let ranges = cover_ranges(xmin, xmax, ymin, ymax, bits, budget);
300            assert!(ranges.len() <= budget, "{} ranges over a budget of {budget}", ranges.len());
301            let (qx0, qy0) = cell_of(xmin, ymin, bits);
302            let (qx1, qy1) = cell_of(xmax, ymax, bits);
303            let box_cells = (qx1 - qx0 + 1) as u64 * (qy1 - qy0 + 1) as u64;
304            let covered: u64 = ranges.iter().map(|(lo, hi)| hi - lo + 1).sum();
305            assert!(
306                covered as f64 <= ceiling * box_cells as f64,
307                "a {}x{} box was covered with {covered} cells ({:.2}x) by {} ranges",
308                qx1 - qx0 + 1, qy1 - qy0 + 1, covered as f64 / box_cells as f64, ranges.len()
309            );
310        }
311    }
312
313    /// The quadrant-run assumption itself: an aligned power-of-two square
314    /// is one contiguous hilbert interval.
315    #[test]
316    fn aligned_squares_are_contiguous_hilbert_runs() {
317        let bits = 6u8;
318        for size_log in 1..=5u32 {
319            let size = 1u32 << size_log;
320            for qy in (0..64).step_by(size as usize) {
321                for qx in (0..64).step_by(size as usize) {
322                    let mut hs: Vec<u64> = (0..size).flat_map(|dy| {
323                        (0..size).map(move |dx| cell_hilbert(qx + dx, qy + dy, bits))
324                    }).collect();
325                    hs.sort_unstable();
326                    let lo = hs[0];
327                    for (i, h) in hs.iter().enumerate() {
328                        assert_eq!(*h, lo + i as u64,
329                                   "square at ({qx},{qy}) size {size} not contiguous");
330                    }
331                }
332            }
333        }
334    }
335
336    #[test]
337    fn outward_rounding_always_contains_the_double_box() {
338        for &(a, b) in &[(0.1f64, 0.2f64), (-179.99999, 179.99999),
339                          (37.42421356237, 37.42421356238), (-0.0, 0.0)] {
340            let bx = BoxF::from_f64(a, b, a, b);
341            assert!((bx.xmin as f64) <= a && (bx.xmax as f64) >= b);
342            assert!((bx.ymin as f64) <= a && (bx.ymax as f64) >= b);
343        }
344    }
345}
346
347/// Typed geometry -- the kernel's only geometry language. Coordinates are
348/// (lon, lat) pairs in WGS84 degrees (GeoJSON axis order); rings are
349/// implicitly closed. GeoJSON <-> Geom conversion lives above the kernel.
350#[derive(Clone, Debug, PartialEq)]
351pub enum Geom {
352    Point(f64, f64),
353    LineString(Vec<[f64; 2]>),
354    Polygon(Vec<Vec<[f64; 2]>>),
355    MultiPoint(Vec<[f64; 2]>),
356    MultiLineString(Vec<Vec<[f64; 2]>>),
357    MultiPolygon(Vec<Vec<Vec<[f64; 2]>>>),
358}
359
360impl Geom {
361    pub fn bbox(&self) -> Option<(f64, f64, f64, f64)> {
362        let mut b: Option<(f64, f64, f64, f64)> = None;
363        let mut add = |x: f64, y: f64| {
364            b = Some(match b {
365                None => (x, x, y, y),
366                Some((x0, x1, y0, y1)) => (x0.min(x), x1.max(x), y0.min(y), y1.max(y)),
367            });
368        };
369        match self {
370            Geom::Point(x, y) => add(*x, *y),
371            Geom::LineString(c) | Geom::MultiPoint(c) =>
372                c.iter().for_each(|p| add(p[0], p[1])),
373            Geom::Polygon(rs) | Geom::MultiLineString(rs) =>
374                rs.iter().flatten().for_each(|p| add(p[0], p[1])),
375            Geom::MultiPolygon(ps) =>
376                ps.iter().flatten().flatten().for_each(|p| add(p[0], p[1])),
377        }
378        b
379    }
380
381    /// Outer rings as [[lat, lon]] (geomath's internal layout) for PIP.
382    pub fn rings_latlon(&self) -> Vec<Vec<[f64; 2]>> {
383        let flip = |r: &Vec<[f64; 2]>| r.iter().map(|p| [p[1], p[0]]).collect();
384        match self {
385            Geom::Polygon(rs) => rs.iter().take(1).map(flip).collect(),
386            Geom::MultiPolygon(ps) =>
387                ps.iter().filter_map(|rs| rs.first()).map(|r| flip(r)).collect(),
388            _ => Vec::new(),
389        }
390    }
391
392    pub fn encode(&self) -> Vec<u8> {
393        fn coords(v: &mut Vec<u8>, c: &[[f64; 2]]) {
394            v.extend_from_slice(&(c.len() as u32).to_le_bytes());
395            for p in c {
396                v.extend_from_slice(&p[0].to_le_bytes());
397                v.extend_from_slice(&p[1].to_le_bytes());
398            }
399        }
400        fn ringsets(v: &mut Vec<u8>, rs: &[Vec<[f64; 2]>]) {
401            v.extend_from_slice(&(rs.len() as u32).to_le_bytes());
402            for r in rs { coords(v, r); }
403        }
404        let mut v = Vec::new();
405        match self {
406            Geom::Point(x, y) => { v.push(1); v.extend_from_slice(&x.to_le_bytes()); v.extend_from_slice(&y.to_le_bytes()); }
407            Geom::LineString(c) => { v.push(2); coords(&mut v, c); }
408            Geom::Polygon(rs) => { v.push(3); ringsets(&mut v, rs); }
409            Geom::MultiPoint(c) => { v.push(4); coords(&mut v, c); }
410            Geom::MultiLineString(rs) => { v.push(5); ringsets(&mut v, rs); }
411            Geom::MultiPolygon(ps) => {
412                v.push(6);
413                v.extend_from_slice(&(ps.len() as u32).to_le_bytes());
414                for rs in ps { ringsets(&mut v, rs); }
415            }
416        }
417        v
418    }
419
420    pub fn decode(b: &[u8]) -> Option<Geom> {
421        fn f64_at(b: &[u8], p: &mut usize) -> Option<f64> {
422            let v = f64::from_le_bytes(b.get(*p..*p + 8)?.try_into().ok()?);
423            *p += 8; Some(v)
424        }
425        fn u32_at(b: &[u8], p: &mut usize) -> Option<u32> {
426            let v = u32::from_le_bytes(b.get(*p..*p + 4)?.try_into().ok()?);
427            *p += 4; Some(v)
428        }
429        fn coords(b: &[u8], p: &mut usize) -> Option<Vec<[f64; 2]>> {
430            let n = u32_at(b, p)? as usize;
431            if n > b.len() / 16 + 1 { return None; } // bound off disk (L5)
432            let mut c = Vec::with_capacity(n);
433            for _ in 0..n { c.push([f64_at(b, p)?, f64_at(b, p)?]); }
434            Some(c)
435        }
436        fn ringsets(b: &[u8], p: &mut usize) -> Option<Vec<Vec<[f64; 2]>>> {
437            let n = u32_at(b, p)? as usize;
438            if n > b.len() / 4 + 1 { return None; }
439            let mut rs = Vec::with_capacity(n);
440            for _ in 0..n { rs.push(coords(b, p)?); }
441            Some(rs)
442        }
443        let mut p = 1usize;
444        match *b.first()? {
445            1 => Some(Geom::Point(f64_at(b, &mut p)?, f64_at(b, &mut p)?)),
446            2 => Some(Geom::LineString(coords(b, &mut p)?)),
447            3 => Some(Geom::Polygon(ringsets(b, &mut p)?)),
448            4 => Some(Geom::MultiPoint(coords(b, &mut p)?)),
449            5 => Some(Geom::MultiLineString(ringsets(b, &mut p)?)),
450            6 => {
451                let n = u32_at(b, &mut p)? as usize;
452                if n > b.len() / 4 + 1 { return None; }
453                let mut ps = Vec::with_capacity(n);
454                for _ in 0..n { ps.push(ringsets(b, &mut p)?); }
455                Some(Geom::MultiPolygon(ps))
456            }
457            _ => None,
458        }
459    }
460}