Skip to main content

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