stt-core 0.1.0

Core library for spatiotemporal tile format
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Space-time blob ordering for the v4 writer.
//!
//! The on-disk *byte order* of tile blobs (independent of the directory index,
//! which must stay `(zoom, hilbert, time_start)` for the lookup codec) decides
//! how few HTTP range requests a client makes: a query box coalesces into one
//! request only when its tiles are byte-contiguous. The best order depends on
//! the dataset's space-vs-time aspect ratio, so the writer takes a
//! [`BlobOrdering`] knob. See `crates/stt-core/examples/simulate_layout.rs` for
//! the measurements that motivate the default ([`BlobOrdering::Hilbert3`]).
//!
//! All five orderings keep **zoom as the primary key** — the directory codec
//! requires zoom-major, and a reorder pass only permutes blobs *within* a zoom.

use std::fmt;
use std::str::FromStr;

/// Cube-side cap (in bits) for the 3D curves: `3 * side` must fit in a u64.
pub const CURVE_BITS_CAP: u32 = 21;

/// Cardinality margin (in bits) within which the space and time axes count as
/// "balanced" for [`BlobOrdering::choose`]. Time must dominate space by MORE
/// than this many bits before the heuristic switches away from the 3D-Hilbert
/// generalist.
pub const ORDERING_BALANCE_BITS: u32 = 3;

/// How tile blobs are ordered on disk by the buffered writer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BlobOrdering {
    /// `(zoom, hilbert, time_start)` — 2D spatial Hilbert, time secondary. Keeps
    /// a cell's whole timeline contiguous; best for pure playback/scrub.
    SpatialMajor,
    /// `(zoom, time_bucket, hilbert)` — all of one time slice contiguous; best
    /// for wide-space-at-one-instant (pan) on time-deep data.
    TimeMajor,
    /// 3D Hilbert curve over `(x, y, time_bucket)` at native per-axis
    /// resolution. The best generalist — no catastrophic query — and the
    /// measured winner on the widest range of datasets. This is the enum
    /// `Default` (the chosen order when reordering is requested), but note the
    /// `stt-build` CLI defaults to `eager` (no reorder); a non-eager
    /// `--blob-ordering` opts into this.
    #[default]
    Hilbert3,
    /// 3D Morton / Z-order over `(x, y, time_bucket)`. Cheaper to compute than
    /// Hilbert but its long jumps hurt locality; rarely the best choice.
    Morton3,
    /// Resolve the concrete ordering at finalize time from the dataset's
    /// space-vs-time cardinality via [`BlobOrdering::choose`]. A "resolve
    /// later" marker, never a key itself — the writer maps it to a concrete
    /// order before sorting blobs, so [`space_time_key`] never sees it.
    Auto,
}

impl BlobOrdering {
    /// Pick a concrete ordering from the dataset's space-vs-time shape.
    ///
    /// `zoom_bits` is the spatial side (≈ max zoom); `time_bits` is
    /// `ceil(log2(#time_buckets))`. The rule is **measured, not from first
    /// principles** (and is the opposite of the naive "prefix the dominant
    /// axis"):
    ///
    /// - **time dominates** by more than [`ORDERING_BALANCE_BITS`]
    ///   (e.g. drifters: 2⁴ cells × 2281 buckets) → [`SpatialMajor`]. The
    ///   dominant access on time-deep data is *playback* (scrub time at a fixed
    ///   viewport), which wants each cell's whole timeline byte-contiguous.
    ///   Measured: spatial-major beats 3D-Hilbert ~3× and time-major ~5× there.
    /// - **balanced or space-dominant** (e.g. flights: 2¹⁰ × 24 buckets) →
    ///   [`Hilbert3`], the robust generalist with no catastrophic query.
    ///
    /// This is the default a single-pass build uses for `--blob-ordering auto`;
    /// the build-time simulator's empirical per-dataset pick (which tries all
    /// orders and measures) is strictly better and overrides it when available.
    ///
    /// [`SpatialMajor`]: BlobOrdering::SpatialMajor
    /// [`Hilbert3`]: BlobOrdering::Hilbert3
    pub fn choose(zoom_bits: u32, time_bits: u32) -> BlobOrdering {
        if time_bits > zoom_bits + ORDERING_BALANCE_BITS {
            BlobOrdering::SpatialMajor
        } else {
            BlobOrdering::Hilbert3
        }
    }
}

impl BlobOrdering {
    pub fn as_str(self) -> &'static str {
        match self {
            BlobOrdering::SpatialMajor => "spatial",
            BlobOrdering::TimeMajor => "time-major",
            BlobOrdering::Hilbert3 => "hilbert3",
            BlobOrdering::Morton3 => "morton3",
            BlobOrdering::Auto => "auto",
        }
    }
}

impl fmt::Display for BlobOrdering {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for BlobOrdering {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.trim().to_ascii_lowercase().as_str() {
            "spatial" | "spatial-major" | "hilbert" | "hilbert2" => Ok(BlobOrdering::SpatialMajor),
            "time" | "time-major" | "timemajor" => Ok(BlobOrdering::TimeMajor),
            "hilbert3" | "h3" | "3d-hilbert" => Ok(BlobOrdering::Hilbert3),
            "morton3" | "morton" | "z-order" | "zorder" => Ok(BlobOrdering::Morton3),
            "auto" => Ok(BlobOrdering::Auto),
            other => Err(format!(
                "unknown blob ordering '{other}' (want: spatial|time-major|hilbert3|morton3|auto)"
            )),
        }
    }
}

/// Bits needed to represent values `0..n-1`.
pub fn bits_for(n: u64) -> u32 {
    if n <= 1 {
        0
    } else {
        64 - (n - 1).leading_zeros()
    }
}

/// 3D Morton / Z-order: interleave the low `bits` of x,y,t.
/// bit i of x -> 3i, y -> 3i+1, t -> 3i+2.
pub fn morton3(x: u32, y: u32, t: u32, bits: u32) -> u64 {
    if bits == 0 {
        return 0; // single cell -> index 0
    }
    let mut key: u64 = 0;
    for i in 0..bits {
        key |= (((x >> i) & 1) as u64) << (3 * i)
            | (((y >> i) & 1) as u64) << (3 * i + 1)
            | (((t >> i) & 1) as u64) << (3 * i + 2);
    }
    key
}

/// 3D Hilbert distance via Skilling's transpose algorithm (n=3).
/// Input axes must already be in `[0, 2^bits)`. Returns the scalar Hilbert index.
pub fn hilbert3(x: u32, y: u32, t: u32, bits: u32) -> u64 {
    if bits == 0 {
        return 0; // single cell -> index 0 (guards the `1 << (bits-1)` underflow)
    }
    let n = 3usize;
    let mut coords = [x, y, t];
    // --- inverse undo / transpose (Skilling) ---
    let mut q: u32 = 1 << (bits - 1);
    while q > 1 {
        let p = q - 1;
        for i in 0..n {
            if coords[i] & q != 0 {
                coords[0] ^= p; // invert
            } else {
                let s = (coords[0] ^ coords[i]) & p;
                coords[0] ^= s;
                coords[i] ^= s; // exchange
            }
        }
        q >>= 1;
    }
    // --- Gray encode ---
    for i in 1..n {
        coords[i] ^= coords[i - 1];
    }
    let mut t_acc: u32 = 0;
    let mut q2: u32 = 1 << (bits - 1);
    while q2 > 1 {
        if coords[n - 1] & q2 != 0 {
            t_acc ^= q2 - 1;
        }
        q2 >>= 1;
    }
    for c in coords.iter_mut() {
        *c ^= t_acc;
    }
    // --- interleave transpose words, MSB-first, axis-major, into the distance ---
    let mut key: u64 = 0;
    let mut bit = bits;
    while bit > 0 {
        bit -= 1;
        for i in 0..n {
            key = (key << 1) | (((coords[i] >> bit) & 1) as u64);
        }
    }
    key
}

/// Cube side (in bits) for the 3D curve at a given zoom: each axis at NATIVE
/// resolution — `zoom` bits of space vs `ceil(log2(#buckets))` bits of time —
/// so the curve interleaves space and time bit-for-bit at matching scales.
/// Space dominates the high bits when spatial extent exceeds temporal (and vice
/// versa). Capped so `3*side` fits in a u64.
pub fn curve_bits(zoom: u8, tb_span: i64) -> u32 {
    let tbits = bits_for((tb_span.max(0) + 1) as u64);
    (zoom as u32).max(tbits).min(CURVE_BITS_CAP)
}

/// Native-resolution axes for the 3D curves: x,y in `[0,2^zoom)`, time bucket
/// (relative to `tb_min`) in `[0,2^tbits)`. Drops low bits only if an axis
/// exceeds the (capped) cube side.
fn scale_axes(zoom: u8, x: u32, y: u32, tb: i64, tb_min: i64, bits: u32) -> (u32, u32, u32) {
    // checked_shr returns None when the shift >= bit-width (only reachable for an
    // absurd zoom >= 53 or pathological bucket count); collapse those axes to 0
    // rather than panic.
    let sdrop = (zoom as u32).saturating_sub(bits);
    let xs = x.checked_shr(sdrop).unwrap_or(0);
    let ys = y.checked_shr(sdrop).unwrap_or(0);
    let rel = (tb - tb_min).max(0) as u64;
    let tbits = bits_for(rel + 1);
    let tdrop = tbits.saturating_sub(bits);
    let qt = rel.checked_shr(tdrop).unwrap_or(0) as u32;
    (xs, ys, qt)
}

/// The 4-tuple sort key that lays a tile's blob down in `ordering` byte order.
/// `tb` is the tile's time-bucket index (`time_start / bucket_ms`), `tb_min` /
/// `tb_span` are the min and span of bucket indices across the whole archive.
/// Zoom is always the leading component (directory codec requirement).
#[allow(clippy::too_many_arguments)]
pub fn space_time_key(
    ordering: BlobOrdering,
    zoom: u8,
    x: u32,
    y: u32,
    hilbert: u64,
    time_start: i64,
    tb: i64,
    tb_min: i64,
    tb_span: i64,
) -> (u8, u64, u64, u64) {
    match ordering {
        BlobOrdering::SpatialMajor => (zoom, hilbert, time_start.max(0) as u64, 0),
        BlobOrdering::TimeMajor => (zoom, tb.max(0) as u64, hilbert, 0),
        BlobOrdering::Hilbert3 => {
            let bits = curve_bits(zoom, tb_span);
            let (xs, ys, qt) = scale_axes(zoom, x, y, tb, tb_min, bits);
            (zoom, hilbert3(xs, ys, qt, bits), 0, 0)
        }
        BlobOrdering::Morton3 => {
            let bits = curve_bits(zoom, tb_span);
            let (xs, ys, qt) = scale_axes(zoom, x, y, tb, tb_min, bits);
            (zoom, morton3(xs, ys, qt, bits), 0, 0)
        }
        // Auto is resolved to a concrete ordering by the writer before sorting;
        // if one ever reaches here, fall back to the Hilbert3 generalist.
        BlobOrdering::Auto => {
            let bits = curve_bits(zoom, tb_span);
            let (xs, ys, qt) = scale_axes(zoom, x, y, tb, tb_min, bits);
            (zoom, hilbert3(xs, ys, qt, bits), 0, 0)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn morton3_roundtrip() {
        fn de(key: u64, bits: u32) -> (u32, u32, u32) {
            let (mut x, mut y, mut t) = (0u32, 0u32, 0u32);
            for i in 0..bits {
                x |= (((key >> (3 * i)) & 1) as u32) << i;
                y |= (((key >> (3 * i + 1)) & 1) as u32) << i;
                t |= (((key >> (3 * i + 2)) & 1) as u32) << i;
            }
            (x, y, t)
        }
        for &(x, y, t) in &[(0u32, 0u32, 0u32), (1, 2, 3), (7, 7, 7), (5, 0, 6)] {
            assert_eq!(de(morton3(x, y, t, 3), 3), (x, y, t));
        }
    }

    #[test]
    fn hilbert3_is_bijection() {
        let mut seen = vec![false; 512];
        for x in 0..8 {
            for y in 0..8 {
                for t in 0..8 {
                    let h = hilbert3(x, y, t, 3) as usize;
                    assert!(h < 512, "index {h} out of range");
                    assert!(!seen[h], "collision at {h}");
                    seen[h] = true;
                }
            }
        }
        assert!(seen.iter().all(|&b| b));
    }

    #[test]
    fn hilbert3_locality() {
        // Hilbert's defining property: consecutive indices are grid neighbours.
        let bits = 4u32;
        let n = 1u32 << bits;
        let mut pts: Vec<(u64, (u32, u32, u32))> = Vec::new();
        for x in 0..n {
            for y in 0..n {
                for t in 0..n {
                    pts.push((hilbert3(x, y, t, bits), (x, y, t)));
                }
            }
        }
        pts.sort_by_key(|p| p.0);
        for w in pts.windows(2) {
            let (a, b) = (w[0].1, w[1].1);
            let d = (a.0 as i64 - b.0 as i64).abs()
                + (a.1 as i64 - b.1 as i64).abs()
                + (a.2 as i64 - b.2 as i64).abs();
            assert_eq!(d, 1, "consecutive Hilbert cells must be adjacent (got {d})");
        }
    }

    #[test]
    fn curve_bits_balances_native_resolution() {
        // flights-like: zoom 10, 24 buckets -> space 10 bits sets the side.
        assert_eq!(curve_bits(10, 23), 10);
        assert_eq!(bits_for(24), 5);
        // drifters-like: zoom 4, 2281 buckets -> time 12 bits sets the side.
        assert_eq!(curve_bits(4, 2280), 12);
        assert_eq!(bits_for(2281), 12);
    }

    #[test]
    fn scale_axes_keeps_native_ranges() {
        let bits = curve_bits(10, 23);
        let (_, _, qt) = scale_axes(10, 512, 300, 10, 0, bits);
        assert!(qt < (1 << bits_for(24)), "time axis must stay native ~5-bit, got {qt}");
        let (xs, ys, _) = scale_axes(10, 777, 123, 0, 0, bits);
        assert_eq!((xs, ys), (777, 123));
    }

    #[test]
    fn bits_zero_is_safe() {
        // zoom 0 + single time bucket -> curve_bits == 0; the curves must return
        // 0, not panic on `1 << (bits-1)` (BUG-1 regression).
        assert_eq!(curve_bits(0, 0), 0);
        assert_eq!(hilbert3(0, 0, 0, 0), 0);
        assert_eq!(morton3(0, 0, 0, 0), 0);
        let k = space_time_key(BlobOrdering::Hilbert3, 0, 0, 0, 0, 0, 0, 0, 0);
        assert_eq!(k, (0, 0, 0, 0));
        // absurd zoom must not overflow-shift either (BUG-2: checked_shr).
        let _ = space_time_key(BlobOrdering::Hilbert3, 60, 1 << 30, 1 << 30, 0, 0, 0, 0, 0);
    }

    #[test]
    fn ordering_parse_roundtrip() {
        for o in [
            BlobOrdering::SpatialMajor,
            BlobOrdering::TimeMajor,
            BlobOrdering::Hilbert3,
            BlobOrdering::Morton3,
            BlobOrdering::Auto,
        ] {
            assert_eq!(o.as_str().parse::<BlobOrdering>().unwrap(), o);
        }
        assert!("garbage".parse::<BlobOrdering>().is_err());
        assert_eq!(BlobOrdering::default(), BlobOrdering::Hilbert3);
    }

    #[test]
    fn choose_matches_measured_winners() {
        // flights: 2^10 spatial cells, ~24 time buckets -> balanced/space-heavy
        // -> the 3D-Hilbert generalist (measured best on flights).
        assert_eq!(
            BlobOrdering::choose(10, bits_for(24)),
            BlobOrdering::Hilbert3
        );
        // drifters: 2^4 spatial cells, 2281 buckets -> time dominates by far
        // -> spatial-major keeps each cell's timeline contiguous for playback
        // (measured ~3x better than hilbert3, ~5x better than time-major).
        assert_eq!(
            BlobOrdering::choose(4, bits_for(2281)),
            BlobOrdering::SpatialMajor
        );
        // Exactly at the balance margin stays on the generalist.
        assert_eq!(
            BlobOrdering::choose(10, 10 + ORDERING_BALANCE_BITS),
            BlobOrdering::Hilbert3
        );
        // One bit past the margin flips to spatial-major.
        assert_eq!(
            BlobOrdering::choose(10, 10 + ORDERING_BALANCE_BITS + 1),
            BlobOrdering::SpatialMajor
        );
    }

    #[test]
    fn keys_distinguish_orderings() {
        // Same cell, two adjacent time buckets: SpatialMajor keeps them adjacent
        // (time is the low key), TimeMajor separates them by the whole space.
        let k0 = space_time_key(BlobOrdering::SpatialMajor, 10, 5, 5, 42, 0, 0, 0, 100);
        let k1 = space_time_key(BlobOrdering::SpatialMajor, 10, 5, 5, 42, 1, 1, 0, 100);
        assert_eq!(k0.0, k1.0);
        assert_eq!(k0.1, k1.1); // same hilbert -> contiguous under spatial-major
        let t0 = space_time_key(BlobOrdering::TimeMajor, 10, 5, 5, 42, 0, 0, 0, 100);
        let t1 = space_time_key(BlobOrdering::TimeMajor, 10, 5, 5, 42, 1, 1, 0, 100);
        assert_ne!(t0.1, t1.1); // different buckets -> separated under time-major
    }
}