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
346 // Decode the per-entry key columns into a scratch buffer; blob fields are
347 // filled in during the run expansion below.
348 struct Key {
349 zoom: u8,
350 hilbert: u64,
351 x: u32,
352 y: u32,
353 time_start: i64,
354 time_end: i64,
355 feature_count: u32,
356 temporal_bucket_ms: Option<u64>,
357 }
358 let mut keys: Vec<Key> = Vec::with_capacity(n);
359 let mut prev_zoom = 0i64;
360 let mut prev_hilbert = 0i64;
361 let mut prev_x = 0i64;
362 let mut prev_y = 0i64;
363 let mut prev_t = 0i64;
364 for _ in 0..n {
365 prev_zoom = prev_zoom.wrapping_add(get_ivarint(bytes, &mut pos)?);
366 prev_hilbert = prev_hilbert.wrapping_add(get_ivarint(bytes, &mut pos)?);
367 prev_x = prev_x.wrapping_add(get_ivarint(bytes, &mut pos)?);
368 prev_y = prev_y.wrapping_add(get_ivarint(bytes, &mut pos)?);
369 prev_t = prev_t.wrapping_add(get_ivarint(bytes, &mut pos)?);
370 let duration = get_ivarint(bytes, &mut pos)?;
371 let feature_count_raw = get_uvarint(bytes, &mut pos)?;
372 let temporal_bucket_ms = if get_uvarint(bytes, &mut pos)? == 0 {
373 None
374 } else {
375 Some(get_uvarint(bytes, &mut pos)?)
376 };
377 // Validate the spatial / feature columns fit their target widths, so a
378 // corrupt (or foreign mis-encoded) directory errors loudly instead of
379 // silently truncating through `as u8` / `as u32`.
380 if !(0..=u8::MAX as i64).contains(&prev_zoom) {
381 return Err(Error::InvalidArchive(format!(
382 "directory: zoom {prev_zoom} out of u8 range"
383 )));
384 }
385 if !(0..=u32::MAX as i64).contains(&prev_x) {
386 return Err(Error::InvalidArchive(format!(
387 "directory: x {prev_x} out of u32 range"
388 )));
389 }
390 if !(0..=u32::MAX as i64).contains(&prev_y) {
391 return Err(Error::InvalidArchive(format!(
392 "directory: y {prev_y} out of u32 range"
393 )));
394 }
395 if feature_count_raw > u32::MAX as u64 {
396 return Err(Error::InvalidArchive(format!(
397 "directory: feature_count {feature_count_raw} out of u32 range"
398 )));
399 }
400 keys.push(Key {
401 zoom: prev_zoom as u8,
402 hilbert: prev_hilbert as u64,
403 x: prev_x as u32,
404 y: prev_y as u32,
405 time_start: prev_t,
406 time_end: prev_t.wrapping_add(duration),
407 feature_count: feature_count_raw as u32,
408 temporal_bucket_ms,
409 });
410 }
411
412 // Expand runs over the keys, assigning each run's shared blob fields. The
413 // pack_id is delta+zig-zag against the previous run, and the offset
414 // contiguity expectation resets to 0 on every pack change — symmetric with
415 // the encoder.
416 let mut entries = Vec::with_capacity(n);
417 let mut cursor = 0usize;
418 let mut expected_offset = 0u64;
419 let mut prev_pack_id = 0i64;
420 for _ in 0..run_count {
421 let run_len = get_uvarint(bytes, &mut pos)? as usize;
422 // v5 carries a Δpack_id column (zig-zag) and resets the offset
423 // contiguity expectation on every pack change. v4 has neither: one
424 // implicit pack (pack_id = 0), whole-file-contiguous offsets.
425 let pid = if has_pack_id {
426 prev_pack_id.wrapping_add(get_ivarint(bytes, &mut pos)?)
427 } else {
428 0
429 };
430 if pid != prev_pack_id {
431 expected_offset = 0;
432 }
433 prev_pack_id = pid;
434 if !(0..=u32::MAX as i64).contains(&pid) {
435 return Err(Error::InvalidArchive(format!(
436 "directory: pack_id {pid} out of u32 range"
437 )));
438 }
439 let pack_id = pid as u32;
440 let offset = if get_uvarint(bytes, &mut pos)? == 0 {
441 expected_offset
442 } else {
443 get_uvarint(bytes, &mut pos)?
444 };
445 let length = get_uvarint(bytes, &mut pos)? as u32;
446 let uncompressed_size = get_uvarint(bytes, &mut pos)? as u32;
447 let crc = u32::from_le_bytes(
448 bytes
449 .get(pos..pos + 4)
450 .ok_or_else(|| Error::InvalidArchive("directory: truncated crc".into()))?
451 .try_into()
452 .unwrap(),
453 );
454 pos += 4;
455
456 if cursor + run_len > n {
457 return Err(Error::InvalidArchive(
458 "directory: run length exceeds entry count".into(),
459 ));
460 }
461 for _ in 0..run_len {
462 let k = &keys[cursor];
463 cursor += 1;
464 entries.push(TileEntry {
465 zoom: k.zoom,
466 x: k.x,
467 y: k.y,
468 time_start: k.time_start,
469 time_end: k.time_end,
470 pack_id,
471 offset,
472 length,
473 uncompressed_size,
474 feature_count: k.feature_count,
475 hilbert: k.hilbert,
476 crc32c: crc,
477 temporal_bucket_ms: k.temporal_bucket_ms,
478 cover_t_min: None,
479 });
480 }
481 expected_offset = offset.wrapping_add(length as u64);
482 }
483
484 if cursor != n {
485 return Err(Error::InvalidArchive(format!(
486 "directory: runs covered {cursor} entries, expected {n}"
487 )));
488 }
489
490 // Optional trailing covering section(s). A pre-covering archive's buffer
491 // ends here; if bytes remain, read tagged sections. Unknown tags stop the
492 // scan (forward-compat) rather than erroring.
493 if pos < bytes.len() {
494 let tag = bytes[pos];
495 pos += 1;
496 if tag == COVER_SECTION_TMIN {
497 for e in entries.iter_mut() {
498 let delta = get_ivarint(bytes, &mut pos)?;
499 e.cover_t_min = Some(e.time_start.wrapping_add(delta));
500 }
501 }
502 }
503
504 Ok(entries)
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 fn entry(
512 zoom: u8,
513 x: u32,
514 y: u32,
515 hilbert: u64,
516 ts: i64,
517 te: i64,
518 offset: u64,
519 length: u32,
520 unc: u32,
521 fc: u32,
522 crc: u32,
523 tb: Option<u64>,
524 ) -> TileEntry {
525 TileEntry {
526 zoom,
527 x,
528 y,
529 time_start: ts,
530 time_end: te,
531 pack_id: 0,
532 offset,
533 length,
534 uncompressed_size: unc,
535 feature_count: fc,
536 hilbert,
537 crc32c: crc,
538 temporal_bucket_ms: tb,
539 cover_t_min: None,
540 }
541 }
542
543 /// Build an entry with an explicit `pack_id` (v5 packed format).
544 #[allow(clippy::too_many_arguments)]
545 fn entry_pack(
546 pack_id: u32,
547 zoom: u8,
548 x: u32,
549 y: u32,
550 hilbert: u64,
551 ts: i64,
552 te: i64,
553 offset: u64,
554 length: u32,
555 unc: u32,
556 fc: u32,
557 crc: u32,
558 tb: Option<u64>,
559 ) -> TileEntry {
560 let mut e = entry(zoom, x, y, hilbert, ts, te, offset, length, unc, fc, crc, tb);
561 e.pack_id = pack_id;
562 e
563 }
564
565 #[test]
566 fn empty_roundtrips() {
567 let bytes = encode_directory(&[]);
568 let back = decode_directory(&bytes).unwrap();
569 assert!(back.is_empty());
570 }
571
572 /// The optional covering section round-trips `cover_t_min` exactly (incl. a
573 /// value BELOW `time_start` — a feature starting before its bucket edge).
574 #[test]
575 fn cover_t_min_section_roundtrips() {
576 let mut entries = Vec::new();
577 for i in 0..20u32 {
578 let mut e = entry(
579 10, i, i, i as u64, (i as i64) * 1000, (i as i64) * 1000 + 900,
580 64 + i as u64 * 50, 50, 100, i, 0x100 + i, Some(1000),
581 );
582 // tight lower bound: usually inside the bucket, but entry 0 starts
583 // 500ms BEFORE its bucket boundary to exercise the signed delta.
584 e.cover_t_min = Some(if i == 0 { -500 } else { (i as i64) * 1000 + 250 });
585 entries.push(e);
586 }
587 let bytes = encode_directory(&entries);
588 let back = decode_directory(&bytes).unwrap();
589 let mut expected = entries.clone();
590 expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
591 assert_eq!(back, expected);
592 assert!(back.iter().all(|e| e.cover_t_min.is_some()));
593 }
594
595 /// A directory with NO covering bounds must produce a buffer byte-identical
596 /// to the pre-covering codec (no trailing section), and decode to `None`.
597 #[test]
598 fn absent_cover_section_is_backward_compatible() {
599 let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 7, None);
600 let bytes = encode_directory(std::slice::from_ref(&e));
601 // No trailing tag byte: the last byte is the run's crc (4 LE bytes),
602 // never a lone COVER_SECTION_TMIN tag.
603 let back = decode_directory(&bytes).unwrap();
604 assert_eq!(back.len(), 1);
605 assert_eq!(back[0].cover_t_min, None);
606 }
607
608 #[test]
609 fn single_entry_roundtrips() {
610 let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 0xDEAD_BEEF, Some(3_600_000));
611 let bytes = encode_directory(std::slice::from_ref(&e));
612 let back = decode_directory(&bytes).unwrap();
613 assert_eq!(back, vec![e]);
614 }
615
616 /// Distinct contiguous blobs: the offset column should ride the contiguity
617 /// sentinel, and every field must round-trip exactly.
618 #[test]
619 fn contiguous_distinct_blobs_roundtrip() {
620 let mut entries = Vec::new();
621 let mut offset = 64u64;
622 for i in 0..50u32 {
623 let len = 100 + i;
624 entries.push(entry(
625 12,
626 i,
627 i,
628 i as u64, // hilbert monotonic → already in directory order
629 (i as i64) * 1000,
630 (i as i64) * 1000 + 500,
631 offset,
632 len,
633 len * 2,
634 i,
635 0x1000 + i,
636 None,
637 ));
638 offset += len as u64;
639 }
640 let bytes = encode_directory(&entries);
641 let back = decode_directory(&bytes).unwrap();
642 assert_eq!(back, entries);
643 }
644
645 /// Temporal RLE: one spatial cell whose content is identical across many
646 /// time buckets (same blob → same offset/length/crc). The encoding must
647 /// collapse these into a single run yet decode back to every entry.
648 #[test]
649 fn identical_across_time_collapses_to_one_run_and_roundtrips() {
650 let crc = 0xABCD_1234u32;
651 let mut entries = Vec::new();
652 // 100 consecutive hourly buckets of one static cell, all the same blob.
653 for b in 0..100u64 {
654 entries.push(entry(
655 9,
656 3,
657 4,
658 77, // same hilbert (same cell)
659 (b as i64) * 3_600_000,
660 (b as i64) * 3_600_000 + 3_599_999,
661 4096, // same offset (deduped blob)
662 512, // same length
663 1024, // same uncompressed
664 64,
665 crc, // same crc
666 Some(3_600_000),
667 ));
668 }
669 let bytes = encode_directory(&entries);
670 let back = decode_directory(&bytes).unwrap();
671 assert_eq!(back, entries);
672
673 // The headline RLE win is on the per-blob columns (offset/length/
674 // uncompressed/crc): the static run stores them once instead of 100×.
675 // Compare against the same corpus with *distinct* blobs, where every
676 // entry needs its own run.
677 let mut distinct = entries.clone();
678 for (i, e) in distinct.iter_mut().enumerate() {
679 e.offset = 4096 + i as u64 * 512;
680 e.crc32c = 0x9000 + i as u32;
681 }
682 let distinct_bytes = encode_directory(&distinct);
683 eprintln!(
684 "static-cell RLE directory: {} bytes vs distinct-blob: {} bytes",
685 bytes.len(),
686 distinct_bytes.len()
687 );
688 // The blob columns are ~10 bytes/run. Collapsing 100 runs → 1 must save
689 // close to the full 99 × 10 bytes the distinct encoding spends on them.
690 assert!(
691 distinct_bytes.len() >= bytes.len() + 99 * 8,
692 "RLE should reclaim the per-blob columns: rle={}, distinct={}",
693 bytes.len(),
694 distinct_bytes.len()
695 );
696 }
697
698 /// A mixed corpus across several zooms, cells, times, with some shared
699 /// blobs and some unsorted input — the codec must sort, RLE, and round-trip.
700 #[test]
701 fn mixed_unsorted_corpus_roundtrips() {
702 let mut entries = Vec::new();
703 let mut off = 64u64;
704 for zoom in [4u8, 8, 12] {
705 for cell in 0..20u64 {
706 for t in 0..5i64 {
707 let len = 80 + (cell as u32 % 7);
708 // Every 3rd (cell,t) reuses the previous blob to exercise RLE.
709 let shared = t > 0 && t % 3 != 0;
710 let (offset, crc) = if shared {
711 (off, 0x5555)
712 } else {
713 off += len as u64;
714 (off, 0x6000 + cell as u32 + t as u32)
715 };
716 entries.push(entry(
717 zoom,
718 cell as u32,
719 (cell * 2) as u32,
720 cell * 10 + zoom as u64, // arbitrary but stable hilbert
721 t * 1000 + cell as i64,
722 t * 1000 + cell as i64 + 250,
723 offset,
724 len,
725 len * 3,
726 (cell + t as u64) as u32,
727 crc,
728 if zoom == 4 { Some(86_400_000) } else { None },
729 ));
730 }
731 }
732 }
733 // Shuffle deterministically so encode must sort.
734 entries.reverse();
735
736 let bytes = encode_directory(&entries);
737 let back = decode_directory(&bytes).unwrap();
738
739 // Expected = the same entries in directory order.
740 let mut expected = entries.clone();
741 expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
742 assert_eq!(back, expected);
743 }
744
745 #[test]
746 fn negative_and_extreme_times_roundtrip() {
747 let entries = vec![
748 entry(0, 0, 0, 0, i64::MIN + 1, i64::MIN + 10, 64, 8, 16, 1, 1, None),
749 entry(0, 0, 0, 0, -5000, -1000, 72, 8, 16, 1, 2, Some(1)),
750 entry(0, 0, 0, 0, 0, 0, 80, 8, 16, 1, 3, None),
751 entry(0, 0, 0, 0, i64::MAX - 10, i64::MAX, 88, 8, 16, 1, 4, None),
752 ];
753 let bytes = encode_directory(&entries);
754 let back = decode_directory(&bytes).unwrap();
755 assert_eq!(back, entries);
756 }
757
758 #[test]
759 fn truncated_buffer_errors() {
760 let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 7, None);
761 let bytes = encode_directory(std::slice::from_ref(&e));
762 // Lop off the trailing crc bytes.
763 let truncated = &bytes[..bytes.len() - 2];
764 assert!(decode_directory(truncated).is_err());
765 }
766
767 #[test]
768 fn wrong_version_errors() {
769 let mut bytes = encode_directory(&[]);
770 bytes[0] = 99;
771 assert!(decode_directory(&bytes).is_err());
772 }
773
774 /// A foreign / corrupt directory whose zoom delta overflows u8 must error
775 /// rather than silently truncate via `as u8`.
776 #[test]
777 fn decode_rejects_out_of_range_columns() {
778 let mut buf = Vec::new();
779 buf.push(DIRECTORY_VERSION);
780 put_uvarint(&mut buf, 1); // N
781 put_uvarint(&mut buf, 1); // R
782 put_ivarint(&mut buf, 300); // Δzoom (out of u8 range)
783 put_ivarint(&mut buf, 0); // Δhilbert
784 put_ivarint(&mut buf, 0); // Δx
785 put_ivarint(&mut buf, 0); // Δy
786 put_ivarint(&mut buf, 0); // Δtime_start
787 put_ivarint(&mut buf, 0); // duration
788 put_uvarint(&mut buf, 0); // feature_count
789 put_uvarint(&mut buf, 0); // bucket present = 0
790 put_uvarint(&mut buf, 1); // run_len
791 put_ivarint(&mut buf, 0); // Δpack_id (v5)
792 put_uvarint(&mut buf, 0); // offset flag (contiguous)
793 put_uvarint(&mut buf, 0); // length
794 put_uvarint(&mut buf, 0); // uncompressed
795 buf.extend_from_slice(&0u32.to_le_bytes()); // crc
796 assert!(decode_directory(&buf).is_err());
797 }
798
799 /// Boundary: u64::MAX must round-trip for both the blob offset and the
800 /// temporal bucket — neither sentinel may collide with a real value.
801 #[test]
802 fn u64_max_offset_and_bucket_roundtrip() {
803 let entries = vec![
804 entry(5, 1, 1, 10, 0, 100, u64::MAX, 8, 16, 1, 7, Some(u64::MAX)),
805 entry(5, 2, 2, 11, 0, 100, 64, 8, 16, 1, 8, Some(3_600_000)),
806 entry(5, 3, 3, 12, 0, 100, 0, 8, 16, 1, 9, None),
807 ];
808 let bytes = encode_directory(&entries);
809 let back = decode_directory(&bytes).unwrap();
810 assert_eq!(back, entries);
811 }
812
813 /// v5: entries spread across multiple packs round-trip with the correct
814 /// `pack_id`, and each pack's offsets are pack-relative (every pack starts
815 /// from a small offset, riding the contiguity sentinel reset).
816 #[test]
817 fn multi_pack_offsets_are_pack_relative_and_roundtrip() {
818 let mut entries = Vec::new();
819 // Three packs, each with a fresh offset run starting at 0. Distinct
820 // blobs within a pack are contiguous (offset += length).
821 for pack_id in 0..3u32 {
822 let mut off = 0u64;
823 for i in 0..6u32 {
824 let hil = (pack_id as u64) * 100 + i as u64; // monotone in dir order
825 let len = 40 + i;
826 entries.push(entry_pack(
827 pack_id,
828 9,
829 pack_id * 10 + i,
830 i,
831 hil,
832 (i as i64) * 1000,
833 (i as i64) * 1000 + 500,
834 off,
835 len,
836 len * 2,
837 i,
838 0x2000 + pack_id * 100 + i,
839 Some(3_600_000),
840 ));
841 off += len as u64;
842 }
843 }
844 let bytes = encode_directory(&entries);
845 let back = decode_directory(&bytes).unwrap();
846 let mut expected = entries.clone();
847 expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
848 assert_eq!(back, expected);
849 // Every pack contributes entries, and the first run of each pack has
850 // offset 0 (pack-relative reset). pack_ids observed are exactly {0,1,2}.
851 let packs: std::collections::BTreeSet<u32> = back.iter().map(|e| e.pack_id).collect();
852 assert_eq!(packs, [0u32, 1, 2].into_iter().collect());
853 for p in 0..3u32 {
854 assert!(back.iter().any(|e| e.pack_id == p && e.offset == 0));
855 }
856 }
857
858 /// v5: RLE within a pack still collapses a static cell across time, but two
859 /// blobs that are byte-identical in *different* packs must NOT collapse
860 /// (pack_id is part of the run identity), and both decode to their own pack.
861 #[test]
862 fn rle_collapses_within_pack_but_not_across_packs() {
863 let crc = 0x55AA_55AAu32;
864 let mut entries = Vec::new();
865 // Pack 0: one static cell across 50 hourly buckets — one run.
866 for b in 0..50u64 {
867 entries.push(entry_pack(
868 0, 9, 3, 4, 77,
869 (b as i64) * 3_600_000,
870 (b as i64) * 3_600_000 + 3_599_999,
871 4096, 512, 1024, 64, crc, Some(3_600_000),
872 ));
873 }
874 // Pack 1: same blob fields (offset/len/unc/crc) but a different pack —
875 // a separate physical object, so it must stay its own run.
876 for b in 0..50u64 {
877 entries.push(entry_pack(
878 1, 9, 5, 6, 99,
879 (b as i64) * 3_600_000,
880 (b as i64) * 3_600_000 + 3_599_999,
881 4096, 512, 1024, 64, crc, Some(3_600_000),
882 ));
883 }
884 let bytes = encode_directory(&entries);
885 let back = decode_directory(&bytes).unwrap();
886 let mut expected = entries.clone();
887 expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
888 assert_eq!(back, expected);
889 assert!(back.iter().any(|e| e.pack_id == 0));
890 assert!(back.iter().any(|e| e.pack_id == 1));
891 }
892
893 /// v5: the cover section still round-trips alongside multi-pack pack_ids.
894 #[test]
895 fn cover_section_roundtrips_with_pack_ids() {
896 let mut entries = Vec::new();
897 for i in 0..12u32 {
898 let pack_id = i / 4; // packs 0,1,2
899 let mut e = entry_pack(
900 pack_id, 10, i, i, i as u64,
901 (i as i64) * 1000, (i as i64) * 1000 + 900,
902 (i % 4) as u64 * 50, 50, 100, i, 0x300 + i, Some(1000),
903 );
904 e.cover_t_min = Some((i as i64) * 1000 + 250);
905 entries.push(e);
906 }
907 let bytes = encode_directory(&entries);
908 let back = decode_directory(&bytes).unwrap();
909 let mut expected = entries.clone();
910 expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
911 assert_eq!(back, expected);
912 assert!(back.iter().all(|e| e.cover_t_min.is_some()));
913 let packs: std::collections::BTreeSet<u32> = back.iter().map(|e| e.pack_id).collect();
914 assert_eq!(packs, [0u32, 1, 2].into_iter().collect());
915 }
916}