Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/skade

.nornir/spatial-index-design.md

Spatial index (skade::spatial) — design, guide, and the idiot guide

The first spatial acceleration structure in the warehouse. A dependency-free geohash grid index over point rows: bounding-box, radius, and k-nearest queries in O(log n + hits), built from the Arrow batches the warehouse already reads.

See the ranked backlog it comes from: .nornir/warehouse-capability-gaps.md §3.


1. Why (the gap it closes)

skade already streams OSM / GeoParquet features (ParquetWindows) and korp renders a geo view, but the warehouse could not answer a spatial question without a full table scan. It had columnar storage + ACID + time-travel + bloom point-lookups — but no structure that turns "which points are in this box / within this radius / nearest to here" into anything better than reading every row. geoint / OSM is a named use case; this was the biggest capability-for-effort gap, and it needs no external service and no new dependency.

2. The idea (why a sorted Vec is a spatial index)

A geohash interleaves the bits of latitude and longitude and base-32 encodes them, so it is a prefix code: two points that are close in space share a long geohash prefix, and every point inside a given cell shares that cell's geohash as a prefix. Therefore:

No R-tree, no geo crate, no quadtree pointers — a Vec<(geohash, id, lat, lon)> sorted once is the index. The lat/lon are kept so every candidate is verified against its real coordinates: results are exact, with no false positives (the geohash grid only decides what to look at, never what to return).

insert →  Vec<Entry{ hash, id, lat, lon }>          (unsorted)
build  →  sort by hash                              (contiguous cells)
query  →  pick covering cells → binary-search each → verify coords exactly

3. API

use skade::spatial::GeoIndex;

let mut idx = GeoIndex::new(9);      // precision 9 ≈ 5 m cells (good OSM default)
idx.insert(59.3293, 18.0686, 1);     // (lat, lon, id)
idx.insert(59.9139, 10.7522, 2);
idx.build();                          // sort once; idempotent

idx.query_bbox(58.0, 9.0, 61.0, 19.0);   // Vec<id> inside [minlat,minlon,maxlat,maxlon]
idx.query_radius(59.33, 18.07, 600_000.); // Vec<(id, metres)> within radius (haversine)
idx.nearest(59.33, 18.07, 5);             // Vec<(id, metres)>, closest first (kNN)

The warehouse read seam — build straight off the Arrow batches skade already hands back (read_all / read_columns / ParquetWindows):

let batches = skade::read_all(&table).await?;
let idx = GeoIndex::from_batches(&batches, "lat", "lon", "id", 9)?; // already built

from_batches accepts any float/int lat-lon columns and any int id column, skips rows with a null in any of the three, and errors on a missing/wrong-typed column. This is the same builder shape the future graph / full-text / bitmap indexes will follow (see gaps §11), so it is deliberately the template, not a one-off.

4. Correctness & determinism

5. Complexity & when to use which precision

Op Cost
insert O(1) amortised + one geohash encode
build O(n log n) sort, once
query_bbox O(cells · log n + candidates)
query_radius bbox query + haversine per candidate
nearest(k) radius-doubling until ≥ k found, then sort

Precision picks the cell size (higher = finer = tighter candidate sets, at more distinct hashes): p6 ≈ 1.2 km, p7 ≈ 150 m, p8 ≈ 40 m, p9 ≈ 5 m, p12 ≈ 3.7 cm. For city-scale OSM points p9 is the sweet spot; for country-scale aggregation p6.

6. Tests & bench posture

skade/tests/spatial.rs — 12 tests: prefix-code property, determinism, bbox selection + corner normalisation, brute-force equivalence over 2 000 points, global box, radius great-circle correctness, kNN ordering, haversine vs. a known Stockholm↔Oslo distance, the from_batches read seam (incl. null-skipping and bad-column rejection), and a LIGHT in-process timing guard (light_query_timing_is_sublinear, 50 k points) that catches a future regression from prefix-scan back to full-scan.

Bench posture (nordisk BENCH RULE): the timing check is a light, pure in-process guard only — no heavy runs on this box. A heavy N-sweep (millions of OSM points, geohash-vs-scan crossover) belongs on Loki/Odin and is not run here. This structure is new capability, so "keep only if faster AND byte-identical" does not apply (there is no prior implementation to be byte-identical to); the determinism test is the analogous guarantee.

7. For the absolute beginner ("idiot guide")

You have a table of things with a latitude and a longitude (shops, sensors, OSM nodes). You want "everything near here" without reading the whole table.

  1. Make an index: let mut idx = GeoIndex::new(9);
  2. Put your points in: idx.insert(lat, lon, row_id); (or one call GeoIndex::from_batches(&batches, "lat", "lon", "id", 9)? straight from what skade read for you).
  3. idx.build(); (once, after inserting).
  4. Ask:

    • "what's in this rectangle?" → idx.query_bbox(minlat, minlon, maxlat, maxlon)
    • "what's within 2 km of me?" → idx.query_radius(mylat, mylon, 2000.0)
    • "the 5 closest?" → idx.nearest(mylat, mylon, 5)

Behind the scenes it turns each map cell into a short text code (a "geohash"), sorts them, and jumps straight to the codes near you with a binary search instead of scanning every row. The answers are exact — it double-checks the real coordinates before handing anything back.

8. Next iterations (not this round — see gaps §3 / §11)