stt_core/directory.rs
1//! STT v5 directory — a compact, range-request-friendly tile index.
2//!
3//! Replaces the v2/v3 Arrow-IPC index (fixed-width columns + IPC framing) with
4//! a columnar binary encoding inspired by PMTiles v3:
5//!
6//! - **Columnar + delta + zig-zag varints.** Entries are sorted by
7//! `(zoom, hilbert, time_start)`, so each column (zoom, hilbert, x, y,
8//! time_start) is near-monotonic and delta-codes to ~1 byte per entry.
9//! - **Blob-run RLE.** Consecutive entries that point at the *same physical
10//! blob* (a spatial cell whose content is identical across consecutive time
11//! buckets — the temporal analogue of PMTiles' ocean tiles) collapse into one
12//! run. The heavy per-blob columns (offset/length/uncompressed/crc) are then
13//! stored once per *run* instead of once per *entry*.
14//! - **Per-run pack id (v5).** Each run carries a `pack_id` (which packed-format
15//! object holds the blob), delta+zig-zag coded against the previous run's
16//! pack id — packs are near-monotonic in directory order, so ~1 byte/run. A
17//! single-file archive has `pack_id == 0` on every run.
18//! - **Pack-relative offset contiguity sentinel.** A run whose blob immediately
19//! follows the previous run's blob *in the same pack* stores offset `0`;
20//! otherwise a `1` flag + the raw offset. The contiguity expectation resets to
21//! `0` whenever the pack id changes between consecutive runs, so the first run
22//! of every pack still hits the cheap `0` sentinel. Sequential archives (the
23//! common case) cost ~1 byte for the whole offset column.
24//!
25//! The directory is self-describing (leading version byte + entry/run counts)
26//! and decodes to exactly the `TileEntry` list that was encoded, provided the
27//! input was already (or is internally re-)sorted into directory order.
28//!
29//! This module is pure (no I/O): `encode_directory` / `decode_directory` map
30//! `&[TileEntry] ⇆ Vec<u8>`. The archive writer/reader own where the buffer
31//! lives in the file (single-file v4) or object (packed `index/<hash>.sttd`).
32//!
33//! ## v5 wire format (per-run columns, in order)
34//!
35//! Each run, in directory order, writes:
36//! 1. `run_len` — uvarint
37//! 2. `Δpack_id` — ivarint (zig-zag of `pack_id - prev_pack_id`)
38//! 3. offset sentinel — uvarint `0` (== `expected_offset`) **or** `1` then a
39//! uvarint raw offset. `expected_offset` is reset to `0` before this run when
40//! `pack_id != prev_pack_id`.
41//! 4. `length` — uvarint
42//! 5. `uncompressed` — uvarint
43//! 6. `crc32c` — 4 raw little-endian bytes
44//!
45//! The `Δpack_id` column is **new in v5** and sits immediately after `run_len`,
46//! before the offset sentinel. Per-entry key columns and the trailing
47//! `COVER_SECTION_TMIN` section are byte-identical to v4.
48
49use crate::error::{Error, Result};
50use crate::tile::TileId;
51
52/// One directory entry: where a tile lives and what it covers.
53#[derive(Debug, Clone, PartialEq)]
54pub struct TileEntry {
55 /// Zoom level.
56 pub zoom: u8,
57 /// Tile X coordinate.
58 pub x: u32,
59 /// Tile Y coordinate.
60 pub y: u32,
61 /// Inclusive temporal start (Unix ms).
62 pub time_start: i64,
63 /// Inclusive temporal end (Unix ms).
64 pub time_end: i64,
65 /// Index of the pack object holding this tile's blob (packed format).
66 ///
67 /// This selects `manifest.packs[pack_id]` and [`offset`](Self::offset) is
68 /// **pack-relative**. A per-blob (per-run) property, not a per-entry key —
69 /// entries RLE-collapse only within one pack.
70 pub pack_id: u32,
71 /// Byte offset of the compressed blob, relative to `packs[pack_id]`.
72 pub offset: u64,
73 /// Compressed blob length in bytes.
74 pub length: u32,
75 /// Uncompressed payload length in bytes.
76 pub uncompressed_size: u32,
77 /// Total feature count across the tile's layers.
78 pub feature_count: u32,
79 /// Hilbert index of `(zoom, x, y)` — directory sort key.
80 pub hilbert: u64,
81 /// CRC32C integrity tag of the compressed blob.
82 pub crc32c: u32,
83 /// Temporal bucket size in milliseconds this tile occupies.
84 ///
85 /// Base tiles record the archive's base bucket size; temporal-LOD tiles
86 /// record their coarser bucket. `None` when the archive carries no LOD
87 /// pyramid (readers fall back to `Metadata::temporal_bucket_ms`).
88 pub temporal_bucket_ms: Option<u64>,
89 /// **Tight lower covering bound** — the minimum feature *start* time actually
90 /// present in the tile. `time_start` is the addressable *bucket boundary*
91 /// (kept loose so `(z,x,y,bucket)` lookup works); `cover_t_min` is the real
92 /// earliest data, which a client uses to prune a tile whose features all lie
93 /// after a query window (`time_end` already gives the tight upper bound).
94 /// `None` when not computed (pre-covering builds), in which case clients
95 /// fall back to `time_start`.
96 pub cover_t_min: Option<i64>,
97}
98
99impl TileEntry {
100 /// The tile's identity.
101 pub fn tile_id(&self) -> TileId {
102 TileId::new(self.zoom, self.x, self.y, self.time_start.max(0) as u64)
103 }
104}
105
106/// Directory format tag (first byte of the buffer). Bumped independently of the
107/// archive `FORMAT_VERSION` so the directory codec can evolve on its own.
108///
109/// v5 adds the per-run `pack_id` column and makes the offset contiguity sentinel
110/// pack-relative (reset on every pack change). See the module docs.
111pub const DIRECTORY_VERSION: u8 = 5;
112
113/// Tag for the optional trailing **covering** section: one signed varint per
114/// entry (in directory order) giving `cover_t_min - time_start`, the tight
115/// lower temporal bound. Backward-compatible — a pre-covering archive's buffer
116/// simply ends after the per-run blob columns, and the decoder leaves
117/// `cover_t_min = None`. Forward-compatible — a decoder that doesn't recognise a
118/// trailing tag stops reading it. See [`TileEntry::cover_t_min`].
119const COVER_SECTION_TMIN: u8 = 1;
120
121// ----------------------------------------------------------------------------
122// LEB128 varints
123// ----------------------------------------------------------------------------
124
125fn put_uvarint(buf: &mut Vec<u8>, mut v: u64) {
126 loop {
127 let byte = (v & 0x7f) as u8;
128 v >>= 7;
129 if v != 0 {
130 buf.push(byte | 0x80);
131 } else {
132 buf.push(byte);
133 break;
134 }
135 }
136}
137
138fn get_uvarint(buf: &[u8], pos: &mut usize) -> Result<u64> {
139 let mut result = 0u64;
140 let mut shift = 0u32;
141 loop {
142 let byte = *buf
143 .get(*pos)
144 .ok_or_else(|| Error::InvalidArchive("directory: truncated varint".into()))?;
145 *pos += 1;
146 result |= ((byte & 0x7f) as u64) << shift;
147 if byte & 0x80 == 0 {
148 break;
149 }
150 shift += 7;
151 if shift >= 64 {
152 return Err(Error::InvalidArchive("directory: varint exceeds 64 bits".into()));
153 }
154 }
155 Ok(result)
156}
157
158#[inline]
159fn zigzag(v: i64) -> u64 {
160 ((v << 1) ^ (v >> 63)) as u64
161}
162
163#[inline]
164fn unzigzag(v: u64) -> i64 {
165 ((v >> 1) as i64) ^ -((v & 1) as i64)
166}
167
168fn put_ivarint(buf: &mut Vec<u8>, v: i64) {
169 put_uvarint(buf, zigzag(v));
170}
171
172fn get_ivarint(buf: &[u8], pos: &mut usize) -> Result<i64> {
173 Ok(unzigzag(get_uvarint(buf, pos)?))
174}
175
176// ----------------------------------------------------------------------------
177// Encode
178// ----------------------------------------------------------------------------
179
180/// Encode tile entries into the v5 directory buffer.
181///
182/// Entries are sorted into directory order `(zoom, hilbert, time_start)` first,
183/// so the caller need not pre-sort. Two entries are considered to share a blob
184/// (and so RLE-collapse) when their `(pack_id, offset, length,
185/// uncompressed_size, crc32c)` all match — which is exactly what the
186/// dedup-on-write path produces for byte-identical tiles within one pack. The
187/// `pack_id` is part of the run identity (v5): two entries collapse only when
188/// they live in the same pack *and* point at the same blob.
189pub fn encode_directory(entries: &[TileEntry]) -> Vec<u8> {
190 let mut sorted: Vec<&TileEntry> = entries.iter().collect();
191 sorted.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
192 let n = sorted.len();
193
194 // Compute blob runs up front so we can write the run count into the header.
195 // A run is a maximal stretch of consecutive entries pointing at one blob in
196 // one pack (pack_id is part of the run identity in v5).
197 let mut runs: Vec<(usize, u32, u64, u32, u32, u32)> = Vec::new();
198 let mut i = 0;
199 while i < n {
200 let head = sorted[i];
201 let crc = head.crc32c;
202 let mut j = i + 1;
203 while j < n {
204 let e = sorted[j];
205 if e.pack_id == head.pack_id
206 && e.offset == head.offset
207 && e.length == head.length
208 && e.uncompressed_size == head.uncompressed_size
209 && e.crc32c == crc
210 {
211 j += 1;
212 } else {
213 break;
214 }
215 }
216 runs.push((
217 j - i,
218 head.pack_id,
219 head.offset,
220 head.length,
221 head.uncompressed_size,
222 crc,
223 ));
224 i = j;
225 }
226
227 let mut buf = Vec::with_capacity(n * 8 + runs.len() * 8 + 16);
228 buf.push(DIRECTORY_VERSION);
229 put_uvarint(&mut buf, n as u64);
230 put_uvarint(&mut buf, runs.len() as u64);
231
232 // Per-entry key columns (delta / zig-zag coded against the previous entry).
233 let mut prev_zoom = 0i64;
234 let mut prev_hilbert = 0i64;
235 let mut prev_x = 0i64;
236 let mut prev_y = 0i64;
237 let mut prev_t = 0i64;
238 for e in &sorted {
239 put_ivarint(&mut buf, (e.zoom as i64).wrapping_sub(prev_zoom));
240 prev_zoom = e.zoom as i64;
241 put_ivarint(&mut buf, (e.hilbert as i64).wrapping_sub(prev_hilbert));
242 prev_hilbert = e.hilbert as i64;
243 put_ivarint(&mut buf, (e.x as i64).wrapping_sub(prev_x));
244 prev_x = e.x as i64;
245 put_ivarint(&mut buf, (e.y as i64).wrapping_sub(prev_y));
246 prev_y = e.y as i64;
247 put_ivarint(&mut buf, e.time_start.wrapping_sub(prev_t));
248 prev_t = e.time_start;
249 // duration may legitimately be 0; store signed so end<start round-trips too.
250 put_ivarint(&mut buf, e.time_end.wrapping_sub(e.time_start));
251 put_uvarint(&mut buf, e.feature_count as u64);
252 // temporal_bucket_ms: a presence flag (0 = None, 1 = Some) followed by
253 // the raw value when present — so every u64 (incl. u64::MAX) round-trips
254 // without colliding with the None sentinel.
255 match e.temporal_bucket_ms {
256 Some(v) => {
257 put_uvarint(&mut buf, 1);
258 put_uvarint(&mut buf, v);
259 }
260 None => put_uvarint(&mut buf, 0),
261 }
262 }
263
264 // Per-run blob columns: run_len, Δpack_id, offset (pack-relative
265 // contiguity), length, uncompressed, crc. The pack_id is delta+zig-zag coded
266 // against the previous run (packs are near-monotonic → ~1 byte/run), and the
267 // offset contiguity expectation resets to 0 whenever the pack changes so the
268 // first run of each pack hits the cheap `0` sentinel.
269 let mut expected_offset = 0u64;
270 let mut prev_pack_id = 0i64;
271 for (run_len, pack_id, offset, length, uncompressed, crc) in &runs {
272 put_uvarint(&mut buf, *run_len as u64);
273 // Δpack_id (zig-zag). When the pack changes, reset the offset contiguity
274 // expectation so this run's first blob is "contiguous from 0".
275 let pid = *pack_id as i64;
276 if pid != prev_pack_id {
277 expected_offset = 0;
278 }
279 put_ivarint(&mut buf, pid.wrapping_sub(prev_pack_id));
280 prev_pack_id = pid;
281 // Offset: 0 = contiguous (== expected); else a `1` flag followed by the
282 // raw offset, so a real u64::MAX offset can't collide with the
283 // contiguity sentinel.
284 if *offset == expected_offset {
285 put_uvarint(&mut buf, 0);
286 } else {
287 put_uvarint(&mut buf, 1);
288 put_uvarint(&mut buf, *offset);
289 }
290 put_uvarint(&mut buf, *length as u64);
291 put_uvarint(&mut buf, *uncompressed as u64);
292 buf.extend_from_slice(&crc.to_le_bytes());
293 expected_offset = offset.wrapping_add(*length as u64);
294 }
295
296 // Optional trailing covering section. Emitted only when EVERY entry carries
297 // a tight lower bound, so it's exactly N signed varints indexable 1:1 with
298 // the key rows. A mixed or all-`None` corpus writes nothing (the common case
299 // for repacked/transcoded archives), keeping the buffer byte-identical to a
300 // pre-covering directory.
301 if n > 0 && sorted.iter().all(|e| e.cover_t_min.is_some()) {
302 buf.push(COVER_SECTION_TMIN);
303 for e in &sorted {
304 // cover_t_min - time_start; signed because a feature can start
305 // before its bucket boundary. Small magnitude → ~1-2 bytes.
306 let delta = e.cover_t_min.unwrap().wrapping_sub(e.time_start);
307 put_ivarint(&mut buf, delta);
308 }
309 }
310
311 buf
312}
313
314// ----------------------------------------------------------------------------
315// Decode
316// ----------------------------------------------------------------------------
317
318/// Lowest directory version this decoder accepts. v4 (single-file archives) has
319/// no per-run `pack_id` column and whole-file offsets — decoded as `pack_id = 0`
320/// with no pack-relative reset. v5 adds the `pack_id` column. The encoder always
321/// writes [`DIRECTORY_VERSION`] (v5); v4 read support keeps the v4 single-file
322/// `ArchiveReader` working as the transcode input.
323const MIN_DIRECTORY_VERSION: u8 = 4;
324
325/// Decode a v4/v5 directory buffer back into tile entries (in directory order).
326///
327/// Both versions share the per-entry key columns and the trailing cover section.
328/// v5 prepends a `Δpack_id` (zig-zag) varint to each run's columns and resets
329/// the offset contiguity expectation on every pack change; v4 omits the column
330/// and never resets (one implicit pack, `pack_id = 0`).
331pub fn decode_directory(bytes: &[u8]) -> Result<Vec<TileEntry>> {
332 let mut pos = 0usize;
333 let version = *bytes
334 .first()
335 .ok_or_else(|| Error::InvalidArchive("directory: empty buffer".into()))?;
336 pos += 1;
337 if !(MIN_DIRECTORY_VERSION..=DIRECTORY_VERSION).contains(&version) {
338 return Err(Error::InvalidArchive(format!(
339 "directory: unsupported version {version} (expected {MIN_DIRECTORY_VERSION}..={DIRECTORY_VERSION})"
340 )));
341 }
342 let has_pack_id = version >= 5;
343 let n = get_uvarint(bytes, &mut pos)? as usize;
344 let run_count = get_uvarint(bytes, &mut pos)? as usize;
345 // Adversarial-input guard: every entry costs ≥ 8 wire bytes (8 one-byte-
346 // minimum varints) and every run ≥ 8 (v4) / 9 (v5, with the Δpack_id
347 // column), and entries + runs share this one buffer — so
348 // 8·(n + run_count) ≤ bytes.len() must hold for any decodable input
349 // (/8 blanket keeps degenerate zero-length-run v4 buffers decodable).
350 // Rejecting BEFORE the `with_capacity` calls below keeps a doctored
351 // header from forcing a huge allocation: the scratch `Key` is 56 B/entry,
352 // so the un-divided `n > bytes.len()` form of this guard still allowed a
353 // 56× amplification (a 100 MB object claiming n = 100 M entries forced a
354 // 5.6 GB allocation). Post-division the worst case is 56·(len/8) = 7×
355 // the buffer — the same as a maximally compact legitimate directory,
356 // i.e. tight without parsing (guarded by tests/adversarial_decode.rs).
357 if n.saturating_add(run_count) > bytes.len() / 8 {
358 return Err(Error::InvalidArchive(format!(
359 "directory: header claims {n} entries + {run_count} runs, more than the \
360 {}-byte buffer can hold at ≥8 bytes per record",
361 bytes.len()
362 )));
363 }
364
365 // Decode the per-entry key columns into a scratch buffer; blob fields are
366 // filled in during the run expansion below.
367 struct Key {
368 zoom: u8,
369 hilbert: u64,
370 x: u32,
371 y: u32,
372 time_start: i64,
373 time_end: i64,
374 feature_count: u32,
375 temporal_bucket_ms: Option<u64>,
376 }
377 let mut keys: Vec<Key> = Vec::with_capacity(n);
378 let mut prev_zoom = 0i64;
379 let mut prev_hilbert = 0i64;
380 let mut prev_x = 0i64;
381 let mut prev_y = 0i64;
382 let mut prev_t = 0i64;
383 for _ in 0..n {
384 prev_zoom = prev_zoom.wrapping_add(get_ivarint(bytes, &mut pos)?);
385 prev_hilbert = prev_hilbert.wrapping_add(get_ivarint(bytes, &mut pos)?);
386 prev_x = prev_x.wrapping_add(get_ivarint(bytes, &mut pos)?);
387 prev_y = prev_y.wrapping_add(get_ivarint(bytes, &mut pos)?);
388 prev_t = prev_t.wrapping_add(get_ivarint(bytes, &mut pos)?);
389 let duration = get_ivarint(bytes, &mut pos)?;
390 let feature_count_raw = get_uvarint(bytes, &mut pos)?;
391 let temporal_bucket_ms = if get_uvarint(bytes, &mut pos)? == 0 {
392 None
393 } else {
394 Some(get_uvarint(bytes, &mut pos)?)
395 };
396 // Validate the spatial / feature columns fit their target widths, so a
397 // corrupt (or foreign mis-encoded) directory errors loudly instead of
398 // silently truncating through `as u8` / `as u32`.
399 if !(0..=u8::MAX as i64).contains(&prev_zoom) {
400 return Err(Error::InvalidArchive(format!(
401 "directory: zoom {prev_zoom} out of u8 range"
402 )));
403 }
404 if !(0..=u32::MAX as i64).contains(&prev_x) {
405 return Err(Error::InvalidArchive(format!(
406 "directory: x {prev_x} out of u32 range"
407 )));
408 }
409 if !(0..=u32::MAX as i64).contains(&prev_y) {
410 return Err(Error::InvalidArchive(format!(
411 "directory: y {prev_y} out of u32 range"
412 )));
413 }
414 if feature_count_raw > u32::MAX as u64 {
415 return Err(Error::InvalidArchive(format!(
416 "directory: feature_count {feature_count_raw} out of u32 range"
417 )));
418 }
419 keys.push(Key {
420 zoom: prev_zoom as u8,
421 hilbert: prev_hilbert as u64,
422 x: prev_x as u32,
423 y: prev_y as u32,
424 time_start: prev_t,
425 time_end: prev_t.wrapping_add(duration),
426 feature_count: feature_count_raw as u32,
427 temporal_bucket_ms,
428 });
429 }
430
431 // Expand runs over the keys, assigning each run's shared blob fields. The
432 // pack_id is delta+zig-zag against the previous run, and the offset
433 // contiguity expectation resets to 0 on every pack change — symmetric with
434 // the encoder.
435 let mut entries = Vec::with_capacity(n);
436 let mut cursor = 0usize;
437 let mut expected_offset = 0u64;
438 let mut prev_pack_id = 0i64;
439 for _ in 0..run_count {
440 let run_len = get_uvarint(bytes, &mut pos)? as usize;
441 // v5 carries a Δpack_id column (zig-zag) and resets the offset
442 // contiguity expectation on every pack change. v4 has neither: one
443 // implicit pack (pack_id = 0), whole-file-contiguous offsets.
444 let pid = if has_pack_id {
445 prev_pack_id.wrapping_add(get_ivarint(bytes, &mut pos)?)
446 } else {
447 0
448 };
449 if pid != prev_pack_id {
450 expected_offset = 0;
451 }
452 prev_pack_id = pid;
453 if !(0..=u32::MAX as i64).contains(&pid) {
454 return Err(Error::InvalidArchive(format!(
455 "directory: pack_id {pid} out of u32 range"
456 )));
457 }
458 let pack_id = pid as u32;
459 let offset = if get_uvarint(bytes, &mut pos)? == 0 {
460 expected_offset
461 } else {
462 get_uvarint(bytes, &mut pos)?
463 };
464 let length = get_uvarint(bytes, &mut pos)? as u32;
465 let uncompressed_size = get_uvarint(bytes, &mut pos)? as u32;
466 let crc = u32::from_le_bytes(
467 bytes
468 .get(pos..pos + 4)
469 .ok_or_else(|| Error::InvalidArchive("directory: truncated crc".into()))?
470 .try_into()
471 .unwrap(),
472 );
473 pos += 4;
474
475 // Phrased as a subtraction (cursor ≤ n is a loop invariant) so a
476 // doctored run_len near u64::MAX can't overflow `cursor + run_len`.
477 if run_len > n - cursor {
478 return Err(Error::InvalidArchive(
479 "directory: run length exceeds entry count".into(),
480 ));
481 }
482 for _ in 0..run_len {
483 let k = &keys[cursor];
484 cursor += 1;
485 entries.push(TileEntry {
486 zoom: k.zoom,
487 x: k.x,
488 y: k.y,
489 time_start: k.time_start,
490 time_end: k.time_end,
491 pack_id,
492 offset,
493 length,
494 uncompressed_size,
495 feature_count: k.feature_count,
496 hilbert: k.hilbert,
497 crc32c: crc,
498 temporal_bucket_ms: k.temporal_bucket_ms,
499 cover_t_min: None,
500 });
501 }
502 expected_offset = offset.wrapping_add(length as u64);
503 }
504
505 if cursor != n {
506 return Err(Error::InvalidArchive(format!(
507 "directory: runs covered {cursor} entries, expected {n}"
508 )));
509 }
510
511 // Optional trailing covering section(s). A pre-covering archive's buffer
512 // ends here; if bytes remain, read tagged sections. Unknown tags stop the
513 // scan (forward-compat) rather than erroring.
514 if pos < bytes.len() {
515 let tag = bytes[pos];
516 pos += 1;
517 if tag == COVER_SECTION_TMIN {
518 for e in entries.iter_mut() {
519 let delta = get_ivarint(bytes, &mut pos)?;
520 e.cover_t_min = Some(e.time_start.wrapping_add(delta));
521 }
522 }
523 }
524
525 Ok(entries)
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 fn entry(
533 zoom: u8,
534 x: u32,
535 y: u32,
536 hilbert: u64,
537 ts: i64,
538 te: i64,
539 offset: u64,
540 length: u32,
541 unc: u32,
542 fc: u32,
543 crc: u32,
544 tb: Option<u64>,
545 ) -> TileEntry {
546 TileEntry {
547 zoom,
548 x,
549 y,
550 time_start: ts,
551 time_end: te,
552 pack_id: 0,
553 offset,
554 length,
555 uncompressed_size: unc,
556 feature_count: fc,
557 hilbert,
558 crc32c: crc,
559 temporal_bucket_ms: tb,
560 cover_t_min: None,
561 }
562 }
563
564 /// Build an entry with an explicit `pack_id` (v5 packed format).
565 #[allow(clippy::too_many_arguments)]
566 fn entry_pack(
567 pack_id: u32,
568 zoom: u8,
569 x: u32,
570 y: u32,
571 hilbert: u64,
572 ts: i64,
573 te: i64,
574 offset: u64,
575 length: u32,
576 unc: u32,
577 fc: u32,
578 crc: u32,
579 tb: Option<u64>,
580 ) -> TileEntry {
581 let mut e = entry(zoom, x, y, hilbert, ts, te, offset, length, unc, fc, crc, tb);
582 e.pack_id = pack_id;
583 e
584 }
585
586 #[test]
587 fn empty_roundtrips() {
588 let bytes = encode_directory(&[]);
589 let back = decode_directory(&bytes).unwrap();
590 assert!(back.is_empty());
591 }
592
593 /// The optional covering section round-trips `cover_t_min` exactly (incl. a
594 /// value BELOW `time_start` — a feature starting before its bucket edge).
595 #[test]
596 fn cover_t_min_section_roundtrips() {
597 let mut entries = Vec::new();
598 for i in 0..20u32 {
599 let mut e = entry(
600 10, i, i, i as u64, (i as i64) * 1000, (i as i64) * 1000 + 900,
601 64 + i as u64 * 50, 50, 100, i, 0x100 + i, Some(1000),
602 );
603 // tight lower bound: usually inside the bucket, but entry 0 starts
604 // 500ms BEFORE its bucket boundary to exercise the signed delta.
605 e.cover_t_min = Some(if i == 0 { -500 } else { (i as i64) * 1000 + 250 });
606 entries.push(e);
607 }
608 let bytes = encode_directory(&entries);
609 let back = decode_directory(&bytes).unwrap();
610 let mut expected = entries.clone();
611 expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
612 assert_eq!(back, expected);
613 assert!(back.iter().all(|e| e.cover_t_min.is_some()));
614 }
615
616 /// A directory with NO covering bounds must produce a buffer byte-identical
617 /// to the pre-covering codec (no trailing section), and decode to `None`.
618 #[test]
619 fn absent_cover_section_is_backward_compatible() {
620 let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 7, None);
621 let bytes = encode_directory(std::slice::from_ref(&e));
622 // No trailing tag byte: the last byte is the run's crc (4 LE bytes),
623 // never a lone COVER_SECTION_TMIN tag.
624 let back = decode_directory(&bytes).unwrap();
625 assert_eq!(back.len(), 1);
626 assert_eq!(back[0].cover_t_min, None);
627 }
628
629 #[test]
630 fn single_entry_roundtrips() {
631 let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 0xDEAD_BEEF, Some(3_600_000));
632 let bytes = encode_directory(std::slice::from_ref(&e));
633 let back = decode_directory(&bytes).unwrap();
634 assert_eq!(back, vec![e]);
635 }
636
637 /// Distinct contiguous blobs: the offset column should ride the contiguity
638 /// sentinel, and every field must round-trip exactly.
639 #[test]
640 fn contiguous_distinct_blobs_roundtrip() {
641 let mut entries = Vec::new();
642 let mut offset = 64u64;
643 for i in 0..50u32 {
644 let len = 100 + i;
645 entries.push(entry(
646 12,
647 i,
648 i,
649 i as u64, // hilbert monotonic → already in directory order
650 (i as i64) * 1000,
651 (i as i64) * 1000 + 500,
652 offset,
653 len,
654 len * 2,
655 i,
656 0x1000 + i,
657 None,
658 ));
659 offset += len as u64;
660 }
661 let bytes = encode_directory(&entries);
662 let back = decode_directory(&bytes).unwrap();
663 assert_eq!(back, entries);
664 }
665
666 /// Temporal RLE: one spatial cell whose content is identical across many
667 /// time buckets (same blob → same offset/length/crc). The encoding must
668 /// collapse these into a single run yet decode back to every entry.
669 #[test]
670 fn identical_across_time_collapses_to_one_run_and_roundtrips() {
671 let crc = 0xABCD_1234u32;
672 let mut entries = Vec::new();
673 // 100 consecutive hourly buckets of one static cell, all the same blob.
674 for b in 0..100u64 {
675 entries.push(entry(
676 9,
677 3,
678 4,
679 77, // same hilbert (same cell)
680 (b as i64) * 3_600_000,
681 (b as i64) * 3_600_000 + 3_599_999,
682 4096, // same offset (deduped blob)
683 512, // same length
684 1024, // same uncompressed
685 64,
686 crc, // same crc
687 Some(3_600_000),
688 ));
689 }
690 let bytes = encode_directory(&entries);
691 let back = decode_directory(&bytes).unwrap();
692 assert_eq!(back, entries);
693
694 // The headline RLE win is on the per-blob columns (offset/length/
695 // uncompressed/crc): the static run stores them once instead of 100×.
696 // Compare against the same corpus with *distinct* blobs, where every
697 // entry needs its own run.
698 let mut distinct = entries.clone();
699 for (i, e) in distinct.iter_mut().enumerate() {
700 e.offset = 4096 + i as u64 * 512;
701 e.crc32c = 0x9000 + i as u32;
702 }
703 let distinct_bytes = encode_directory(&distinct);
704 eprintln!(
705 "static-cell RLE directory: {} bytes vs distinct-blob: {} bytes",
706 bytes.len(),
707 distinct_bytes.len()
708 );
709 // The blob columns are ~10 bytes/run. Collapsing 100 runs → 1 must save
710 // close to the full 99 × 10 bytes the distinct encoding spends on them.
711 assert!(
712 distinct_bytes.len() >= bytes.len() + 99 * 8,
713 "RLE should reclaim the per-blob columns: rle={}, distinct={}",
714 bytes.len(),
715 distinct_bytes.len()
716 );
717 }
718
719 /// A mixed corpus across several zooms, cells, times, with some shared
720 /// blobs and some unsorted input — the codec must sort, RLE, and round-trip.
721 #[test]
722 fn mixed_unsorted_corpus_roundtrips() {
723 let mut entries = Vec::new();
724 let mut off = 64u64;
725 for zoom in [4u8, 8, 12] {
726 for cell in 0..20u64 {
727 for t in 0..5i64 {
728 let len = 80 + (cell as u32 % 7);
729 // Every 3rd (cell,t) reuses the previous blob to exercise RLE.
730 let shared = t > 0 && t % 3 != 0;
731 let (offset, crc) = if shared {
732 (off, 0x5555)
733 } else {
734 off += len as u64;
735 (off, 0x6000 + cell as u32 + t as u32)
736 };
737 entries.push(entry(
738 zoom,
739 cell as u32,
740 (cell * 2) as u32,
741 cell * 10 + zoom as u64, // arbitrary but stable hilbert
742 t * 1000 + cell as i64,
743 t * 1000 + cell as i64 + 250,
744 offset,
745 len,
746 len * 3,
747 (cell + t as u64) as u32,
748 crc,
749 if zoom == 4 { Some(86_400_000) } else { None },
750 ));
751 }
752 }
753 }
754 // Shuffle deterministically so encode must sort.
755 entries.reverse();
756
757 let bytes = encode_directory(&entries);
758 let back = decode_directory(&bytes).unwrap();
759
760 // Expected = the same entries in directory order.
761 let mut expected = entries.clone();
762 expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
763 assert_eq!(back, expected);
764 }
765
766 #[test]
767 fn negative_and_extreme_times_roundtrip() {
768 let entries = vec![
769 entry(0, 0, 0, 0, i64::MIN + 1, i64::MIN + 10, 64, 8, 16, 1, 1, None),
770 entry(0, 0, 0, 0, -5000, -1000, 72, 8, 16, 1, 2, Some(1)),
771 entry(0, 0, 0, 0, 0, 0, 80, 8, 16, 1, 3, None),
772 entry(0, 0, 0, 0, i64::MAX - 10, i64::MAX, 88, 8, 16, 1, 4, None),
773 ];
774 let bytes = encode_directory(&entries);
775 let back = decode_directory(&bytes).unwrap();
776 assert_eq!(back, entries);
777 }
778
779 #[test]
780 fn truncated_buffer_errors() {
781 let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 7, None);
782 let bytes = encode_directory(std::slice::from_ref(&e));
783 // Lop off the trailing crc bytes.
784 let truncated = &bytes[..bytes.len() - 2];
785 assert!(decode_directory(truncated).is_err());
786 }
787
788 #[test]
789 fn wrong_version_errors() {
790 let mut bytes = encode_directory(&[]);
791 bytes[0] = 99;
792 assert!(decode_directory(&bytes).is_err());
793 }
794
795 /// A foreign / corrupt directory whose zoom delta overflows u8 must error
796 /// rather than silently truncate via `as u8`.
797 #[test]
798 fn decode_rejects_out_of_range_columns() {
799 let mut buf = Vec::new();
800 buf.push(DIRECTORY_VERSION);
801 put_uvarint(&mut buf, 1); // N
802 put_uvarint(&mut buf, 1); // R
803 put_ivarint(&mut buf, 300); // Δzoom (out of u8 range)
804 put_ivarint(&mut buf, 0); // Δhilbert
805 put_ivarint(&mut buf, 0); // Δx
806 put_ivarint(&mut buf, 0); // Δy
807 put_ivarint(&mut buf, 0); // Δtime_start
808 put_ivarint(&mut buf, 0); // duration
809 put_uvarint(&mut buf, 0); // feature_count
810 put_uvarint(&mut buf, 0); // bucket present = 0
811 put_uvarint(&mut buf, 1); // run_len
812 put_ivarint(&mut buf, 0); // Δpack_id (v5)
813 put_uvarint(&mut buf, 0); // offset flag (contiguous)
814 put_uvarint(&mut buf, 0); // length
815 put_uvarint(&mut buf, 0); // uncompressed
816 buf.extend_from_slice(&0u32.to_le_bytes()); // crc
817 assert!(decode_directory(&buf).is_err());
818 }
819
820 /// Boundary: u64::MAX must round-trip for both the blob offset and the
821 /// temporal bucket — neither sentinel may collide with a real value.
822 #[test]
823 fn u64_max_offset_and_bucket_roundtrip() {
824 let entries = vec![
825 entry(5, 1, 1, 10, 0, 100, u64::MAX, 8, 16, 1, 7, Some(u64::MAX)),
826 entry(5, 2, 2, 11, 0, 100, 64, 8, 16, 1, 8, Some(3_600_000)),
827 entry(5, 3, 3, 12, 0, 100, 0, 8, 16, 1, 9, None),
828 ];
829 let bytes = encode_directory(&entries);
830 let back = decode_directory(&bytes).unwrap();
831 assert_eq!(back, entries);
832 }
833
834 /// v5: entries spread across multiple packs round-trip with the correct
835 /// `pack_id`, and each pack's offsets are pack-relative (every pack starts
836 /// from a small offset, riding the contiguity sentinel reset).
837 #[test]
838 fn multi_pack_offsets_are_pack_relative_and_roundtrip() {
839 let mut entries = Vec::new();
840 // Three packs, each with a fresh offset run starting at 0. Distinct
841 // blobs within a pack are contiguous (offset += length).
842 for pack_id in 0..3u32 {
843 let mut off = 0u64;
844 for i in 0..6u32 {
845 let hil = (pack_id as u64) * 100 + i as u64; // monotone in dir order
846 let len = 40 + i;
847 entries.push(entry_pack(
848 pack_id,
849 9,
850 pack_id * 10 + i,
851 i,
852 hil,
853 (i as i64) * 1000,
854 (i as i64) * 1000 + 500,
855 off,
856 len,
857 len * 2,
858 i,
859 0x2000 + pack_id * 100 + i,
860 Some(3_600_000),
861 ));
862 off += len as u64;
863 }
864 }
865 let bytes = encode_directory(&entries);
866 let back = decode_directory(&bytes).unwrap();
867 let mut expected = entries.clone();
868 expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
869 assert_eq!(back, expected);
870 // Every pack contributes entries, and the first run of each pack has
871 // offset 0 (pack-relative reset). pack_ids observed are exactly {0,1,2}.
872 let packs: std::collections::BTreeSet<u32> = back.iter().map(|e| e.pack_id).collect();
873 assert_eq!(packs, [0u32, 1, 2].into_iter().collect());
874 for p in 0..3u32 {
875 assert!(back.iter().any(|e| e.pack_id == p && e.offset == 0));
876 }
877 }
878
879 /// v5: RLE within a pack still collapses a static cell across time, but two
880 /// blobs that are byte-identical in *different* packs must NOT collapse
881 /// (pack_id is part of the run identity), and both decode to their own pack.
882 #[test]
883 fn rle_collapses_within_pack_but_not_across_packs() {
884 let crc = 0x55AA_55AAu32;
885 let mut entries = Vec::new();
886 // Pack 0: one static cell across 50 hourly buckets — one run.
887 for b in 0..50u64 {
888 entries.push(entry_pack(
889 0, 9, 3, 4, 77,
890 (b as i64) * 3_600_000,
891 (b as i64) * 3_600_000 + 3_599_999,
892 4096, 512, 1024, 64, crc, Some(3_600_000),
893 ));
894 }
895 // Pack 1: same blob fields (offset/len/unc/crc) but a different pack —
896 // a separate physical object, so it must stay its own run.
897 for b in 0..50u64 {
898 entries.push(entry_pack(
899 1, 9, 5, 6, 99,
900 (b as i64) * 3_600_000,
901 (b as i64) * 3_600_000 + 3_599_999,
902 4096, 512, 1024, 64, crc, Some(3_600_000),
903 ));
904 }
905 let bytes = encode_directory(&entries);
906 let back = decode_directory(&bytes).unwrap();
907 let mut expected = entries.clone();
908 expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
909 assert_eq!(back, expected);
910 assert!(back.iter().any(|e| e.pack_id == 0));
911 assert!(back.iter().any(|e| e.pack_id == 1));
912 }
913
914 /// v5: the cover section still round-trips alongside multi-pack pack_ids.
915 #[test]
916 fn cover_section_roundtrips_with_pack_ids() {
917 let mut entries = Vec::new();
918 for i in 0..12u32 {
919 let pack_id = i / 4; // packs 0,1,2
920 let mut e = entry_pack(
921 pack_id, 10, i, i, i as u64,
922 (i as i64) * 1000, (i as i64) * 1000 + 900,
923 (i % 4) as u64 * 50, 50, 100, i, 0x300 + i, Some(1000),
924 );
925 e.cover_t_min = Some((i as i64) * 1000 + 250);
926 entries.push(e);
927 }
928 let bytes = encode_directory(&entries);
929 let back = decode_directory(&bytes).unwrap();
930 let mut expected = entries.clone();
931 expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
932 assert_eq!(back, expected);
933 assert!(back.iter().all(|e| e.cover_t_min.is_some()));
934 let packs: std::collections::BTreeSet<u32> = back.iter().map(|e| e.pack_id).collect();
935 assert_eq!(packs, [0u32, 1, 2].into_iter().collect());
936 }
937}