Skip to main content

stt_core/
directory_page.rs

1//! Paged-directory container (Wave 2 — COPC/GeoParquet-1.1 steal).
2//!
3//! The v5 directory ([`crate::directory`]) is a single whole-load blob: a cold
4//! reader must fetch and decode *every* entry before it can request one tile.
5//! This module wraps that codec in a **paged container** so a reader fetches a
6//! tiny root page plus only the leaf pages its viewport/time-window actually
7//! touches — cold start proportional to the query footprint, not dataset size.
8//!
9//! ## Container layout
10//!
11//! ```text
12//! .sttd  =  [root page frame][leaf 0 frame][leaf 1 frame] ...
13//! ```
14//!
15//! - A **leaf page** is the *unchanged* v5 codec ([`encode_directory`]) over a
16//!   contiguous slice of directory order `(zoom, hilbert, time_start)`. Slicing
17//!   resets delta state and splits RLE runs at boundaries — the only cost of
18//!   paging (sim-measured +6–19% at-rest, paid once by the immutable CDN object,
19//!   not per session). Reusing the proven codec for leaves means the only new
20//!   bytes are the root + framing.
21//! - The **root page** is a fixed-width table of [`PageDescriptor`]s — one per
22//!   leaf — carrying each leaf's byte range and its pruning bounds: a
23//!   **geographic bbox** (lon/lat × 1e7), a **zoom range**, and a **temporal
24//!   `[t_min, t_max]`** (with `t_min` taken from `cover_t_min` when present).
25//!   A reader prunes whole leaves by `zoom ∈ [min,max]` ∧ bbox∩viewport ∧
26//!   `[t_min,t_max]`∩window — *without fetching them*. (The geo-bbox descriptor
27//!   was frozen over the Hilbert-key-range alternative by the step-0 A/B sim;
28//!   it is zoom-correct and needs no Hilbert port in the TS reader.)
29//!
30//! Each page (root and leaves) is an **independent frame** so it is fetchable in
31//! isolation: when `zstd` framing is on (the writer default) every frame is its
32//! own zstd frame; raw framing stores the codec bytes directly. There is no
33//! shared dictionary, so the fzstd TS path decodes every page.
34//!
35//! Offsets in the root are **relative to the end of the root page**
36//! (`absolute = root_length + rel_offset`), so the root can be encoded without
37//! knowing its own at-rest length first.
38//!
39//! This module is pure (no I/O). [`encode_paged_directory`] maps
40//! `&[TileEntry] → bytes + root_length`; [`decode_paged_directory`] is the
41//! load-all inverse (root + every leaf) used by the local Rust reader and the
42//! round-trip tests. The HTTP reader (TS) decodes the root and fetches leaves on
43//! demand — that is where the cold-start win is realized.
44
45use crate::directory::TileEntry;
46use crate::compression;
47use crate::directory::{decode_directory, encode_directory};
48use crate::error::{Error, Result};
49use crate::projection::tile_geo_bounds;
50
51/// Root container version (first byte of the decoded root page). Bumped
52/// independently of the leaf codec's [`crate::directory::DIRECTORY_VERSION`].
53pub const PAGED_ROOT_VERSION: u8 = 1;
54
55/// Descriptor-kind tag: geographic bbox + zoom range + temporal bounds.
56pub const DESCRIPTOR_GEO_BBOX: u8 = 0;
57
58/// Default entries per leaf page — the 1024–4096 sweet spot (sim-validated:
59/// ~60–130 KB zstd pages, the range coalescer's comfort zone).
60pub const DEFAULT_PAGE_ENTRIES: usize = 4096;
61
62/// Fixed-width root header (bytes): version, kind, reserved u16, page_count u32,
63/// page_entries u32.
64const ROOT_HEADER_LEN: usize = 12;
65/// Fixed-width per-page descriptor (bytes). See [`PageDescriptor`] field order.
66const DESCRIPTOR_LEN: usize = 52;
67
68/// One leaf page's pruning descriptor, stored fixed-width in the root page.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct PageDescriptor {
71    /// Byte offset of the leaf frame **relative to the end of the root page**
72    /// (i.e. relative to `root_length`). Absolute = `root_length + rel_offset`.
73    /// Relative so the root encodes without knowing its own at-rest length.
74    pub rel_offset: u64,
75    /// At-rest (framed) byte length of the leaf.
76    pub length: u32,
77    /// Entries in this leaf (Σ over pages == N).
78    pub entry_count: u32,
79    /// Inclusive zoom range of the leaf's entries.
80    pub min_zoom: u8,
81    pub max_zoom: u8,
82    /// Geographic bbox of the leaf's tiles, lon/lat × 1e7. Mins are floored and
83    /// maxes ceiled so the integer bbox never under-covers the true bbox (no
84    /// false-negative page prune from rounding).
85    pub min_lon_e7: i32,
86    pub min_lat_e7: i32,
87    pub max_lon_e7: i32,
88    pub max_lat_e7: i32,
89    /// Subtree temporal bounds: `min(cover_t_min ?? time_start)` ..
90    /// `max(time_end)` — the COPC-temporal page-pointer prune.
91    pub t_min: i64,
92    pub t_max: i64,
93}
94
95impl PageDescriptor {
96    /// Does this leaf overlap a viewport query? `zoom` membership ∧ geo-bbox
97    /// overlap ∧ temporal overlap. Query bbox is lon/lat × 1e7 (same fixed
98    /// point as the descriptor). Mirrors the TS reader's page selection exactly.
99    #[allow(clippy::too_many_arguments)]
100    pub fn overlaps(
101        &self,
102        zoom: u8,
103        q_min_lon_e7: i32,
104        q_min_lat_e7: i32,
105        q_max_lon_e7: i32,
106        q_max_lat_e7: i32,
107        t_start: i64,
108        t_end: i64,
109    ) -> bool {
110        zoom >= self.min_zoom
111            && zoom <= self.max_zoom
112            && self.min_lon_e7 <= q_max_lon_e7
113            && q_min_lon_e7 <= self.max_lon_e7
114            && self.min_lat_e7 <= q_max_lat_e7
115            && q_min_lat_e7 <= self.max_lat_e7
116            && self.t_max >= t_start
117            && self.t_min <= t_end
118    }
119}
120
121/// The decoded root page: the descriptor table plus its header fields.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct PagedRoot {
124    pub descriptor_kind: u8,
125    /// Nominal entries-per-page used at build (informational).
126    pub page_entries: u32,
127    pub pages: Vec<PageDescriptor>,
128}
129
130/// Output of [`encode_paged_directory`]: the whole `.sttd` bytes plus the root
131/// length (→ manifest `directory.rootLength`) and page bookkeeping.
132pub struct EncodedPagedDirectory {
133    /// `[root frame][leaf 0 frame] ...` — the content-addressed `.sttd` bytes.
134    pub bytes: Vec<u8>,
135    /// At-rest length of the root frame (manifest `directory.rootLength`).
136    pub root_length: u64,
137    pub page_count: usize,
138    pub page_entries: usize,
139}
140
141#[inline]
142fn floor_e7(deg: f64) -> i32 {
143    (deg * 1e7).floor().clamp(i32::MIN as f64, i32::MAX as f64) as i32
144}
145
146#[inline]
147fn ceil_e7(deg: f64) -> i32 {
148    (deg * 1e7).ceil().clamp(i32::MIN as f64, i32::MAX as f64) as i32
149}
150
151fn put_u16(buf: &mut Vec<u8>, v: u16) {
152    buf.extend_from_slice(&v.to_le_bytes());
153}
154fn put_u32(buf: &mut Vec<u8>, v: u32) {
155    buf.extend_from_slice(&v.to_le_bytes());
156}
157fn put_u64(buf: &mut Vec<u8>, v: u64) {
158    buf.extend_from_slice(&v.to_le_bytes());
159}
160fn put_i32(buf: &mut Vec<u8>, v: i32) {
161    buf.extend_from_slice(&v.to_le_bytes());
162}
163fn put_i64(buf: &mut Vec<u8>, v: i64) {
164    buf.extend_from_slice(&v.to_le_bytes());
165}
166
167/// Encode the root page (descriptor table) to its raw (pre-framing) bytes.
168pub fn encode_root(page_entries: u32, descriptors: &[PageDescriptor]) -> Vec<u8> {
169    let mut buf = Vec::with_capacity(ROOT_HEADER_LEN + descriptors.len() * DESCRIPTOR_LEN);
170    buf.push(PAGED_ROOT_VERSION);
171    buf.push(DESCRIPTOR_GEO_BBOX);
172    put_u16(&mut buf, 0); // reserved
173    put_u32(&mut buf, descriptors.len() as u32);
174    put_u32(&mut buf, page_entries);
175    for d in descriptors {
176        put_u64(&mut buf, d.rel_offset);
177        put_u32(&mut buf, d.length);
178        put_u32(&mut buf, d.entry_count);
179        buf.push(d.min_zoom);
180        buf.push(d.max_zoom);
181        put_u16(&mut buf, 0); // reserved
182        put_i32(&mut buf, d.min_lon_e7);
183        put_i32(&mut buf, d.min_lat_e7);
184        put_i32(&mut buf, d.max_lon_e7);
185        put_i32(&mut buf, d.max_lat_e7);
186        put_i64(&mut buf, d.t_min);
187        put_i64(&mut buf, d.t_max);
188    }
189    buf
190}
191
192struct Reader<'a> {
193    bytes: &'a [u8],
194    pos: usize,
195}
196impl<'a> Reader<'a> {
197    fn take(&mut self, n: usize) -> Result<&'a [u8]> {
198        let end = self.pos + n;
199        let slice = self
200            .bytes
201            .get(self.pos..end)
202            .ok_or_else(|| Error::InvalidArchive("paged root: truncated".into()))?;
203        self.pos = end;
204        Ok(slice)
205    }
206    fn u16(&mut self) -> Result<u16> {
207        Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
208    }
209    fn u32(&mut self) -> Result<u32> {
210        Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
211    }
212    fn u64(&mut self) -> Result<u64> {
213        Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap()))
214    }
215    fn i32(&mut self) -> Result<i32> {
216        Ok(i32::from_le_bytes(self.take(4)?.try_into().unwrap()))
217    }
218    fn i64(&mut self) -> Result<i64> {
219        Ok(i64::from_le_bytes(self.take(8)?.try_into().unwrap()))
220    }
221}
222
223/// Decode the root page from its raw (already-unframed) bytes.
224pub fn decode_root(bytes: &[u8]) -> Result<PagedRoot> {
225    let mut r = Reader { bytes, pos: 0 };
226    let version = *bytes
227        .first()
228        .ok_or_else(|| Error::InvalidArchive("paged root: empty".into()))?;
229    r.pos = 1;
230    if version != PAGED_ROOT_VERSION {
231        return Err(Error::InvalidArchive(format!(
232            "paged root: unsupported version {version} (expected {PAGED_ROOT_VERSION})"
233        )));
234    }
235    let descriptor_kind = *bytes.get(1).unwrap();
236    r.pos = 2;
237    if descriptor_kind != DESCRIPTOR_GEO_BBOX {
238        return Err(Error::InvalidArchive(format!(
239            "paged root: unsupported descriptor kind {descriptor_kind}"
240        )));
241    }
242    let _reserved = r.u16()?;
243    let page_count = r.u32()? as usize;
244    let page_entries = r.u32()?;
245    let mut pages = Vec::with_capacity(page_count);
246    for _ in 0..page_count {
247        let rel_offset = r.u64()?;
248        let length = r.u32()?;
249        let entry_count = r.u32()?;
250        let min_zoom = r.take(1)?[0];
251        let max_zoom = r.take(1)?[0];
252        let _res = r.u16()?;
253        let min_lon_e7 = r.i32()?;
254        let min_lat_e7 = r.i32()?;
255        let max_lon_e7 = r.i32()?;
256        let max_lat_e7 = r.i32()?;
257        let t_min = r.i64()?;
258        let t_max = r.i64()?;
259        pages.push(PageDescriptor {
260            rel_offset,
261            length,
262            entry_count,
263            min_zoom,
264            max_zoom,
265            min_lon_e7,
266            min_lat_e7,
267            max_lon_e7,
268            max_lat_e7,
269            t_min,
270            t_max,
271        });
272    }
273    Ok(PagedRoot {
274        descriptor_kind,
275        page_entries,
276        pages,
277    })
278}
279
280fn frame(raw: Vec<u8>, zstd: bool, level: i32) -> Result<Vec<u8>> {
281    if zstd {
282        compression::compress_zstd_with_dict_level(&raw, None, level)
283    } else {
284        Ok(raw)
285    }
286}
287
288fn unframe(frame: &[u8], zstd: bool) -> Result<Vec<u8>> {
289    if zstd {
290        compression::decompress_zstd_with_dict(frame, None)
291    } else {
292        Ok(frame.to_vec())
293    }
294}
295
296/// Encode tile entries into the paged container.
297///
298/// `entries` need not be pre-sorted; they are sorted into directory order
299/// `(zoom, hilbert, time_start, temporal_bucket_ms)` — matching the writer and
300/// the v5 codec — then sliced into pages of `page_entries`. `zstd` selects
301/// per-page zstd framing (the writer default) vs raw codec bytes.
302///
303/// The covering section is decided **globally**: if any entry lacks
304/// `cover_t_min`, it is stripped from every leaf, so a paged directory decodes
305/// byte-for-identical-entries to a whole-load v5 directory of the same corpus
306/// (the per-page codec would otherwise emit a cover section for an all-`Some`
307/// page even when the corpus is mixed).
308pub fn encode_paged_directory(
309    entries: &[TileEntry],
310    page_entries: usize,
311    zstd: bool,
312) -> Result<EncodedPagedDirectory> {
313    encode_paged_directory_level(entries, page_entries, zstd, compression::ZSTD_LEVEL)
314}
315
316/// [`encode_paged_directory`] at an explicit zstd `level` (ignored when
317/// `zstd` is false). The directory sits on the cold-start critical path, so a
318/// publish build can spend a higher level here too — decode is level-independent.
319pub fn encode_paged_directory_level(
320    entries: &[TileEntry],
321    page_entries: usize,
322    zstd: bool,
323    level: i32,
324) -> Result<EncodedPagedDirectory> {
325    let page_entries = page_entries.max(1);
326    let mut sorted: Vec<&TileEntry> = entries.iter().collect();
327    sorted.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
328
329    // Global cover decision (mirror the whole-load codec's all-or-nothing rule).
330    let all_cover = !sorted.is_empty() && sorted.iter().all(|e| e.cover_t_min.is_some());
331
332    let mut descriptors: Vec<PageDescriptor> = Vec::new();
333    let mut leaf_frames: Vec<Vec<u8>> = Vec::new();
334    let mut rel_offset = 0u64;
335
336    for chunk in sorted.chunks(page_entries) {
337        // Materialize the slice for encode_directory, applying the global cover
338        // decision so per-page emission matches whole-load.
339        let owned: Vec<TileEntry> = chunk
340            .iter()
341            .map(|&e| {
342                let mut c = e.clone();
343                if !all_cover {
344                    c.cover_t_min = None;
345                }
346                c
347            })
348            .collect();
349        let raw = encode_directory(&owned);
350        let leaf = frame(raw, zstd, level)?;
351
352        let mut geo = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
353        let (mut zmin, mut zmax) = (u8::MAX, 0u8);
354        let (mut tmin, mut tmax) = (i64::MAX, i64::MIN);
355        for &e in chunk {
356            let b = tile_geo_bounds(e.zoom, e.x, e.y);
357            geo.0 = geo.0.min(b.0);
358            geo.1 = geo.1.min(b.1);
359            geo.2 = geo.2.max(b.2);
360            geo.3 = geo.3.max(b.3);
361            zmin = zmin.min(e.zoom);
362            zmax = zmax.max(e.zoom);
363            tmin = tmin.min(e.cover_t_min.unwrap_or(e.time_start));
364            tmax = tmax.max(e.time_end);
365        }
366
367        descriptors.push(PageDescriptor {
368            rel_offset,
369            length: leaf.len() as u32,
370            entry_count: chunk.len() as u32,
371            min_zoom: zmin,
372            max_zoom: zmax,
373            min_lon_e7: floor_e7(geo.0),
374            min_lat_e7: floor_e7(geo.1),
375            max_lon_e7: ceil_e7(geo.2),
376            max_lat_e7: ceil_e7(geo.3),
377            t_min: tmin,
378            t_max: tmax,
379        });
380        rel_offset += leaf.len() as u64;
381        leaf_frames.push(leaf);
382    }
383
384    let root_raw = encode_root(page_entries as u32, &descriptors);
385    let root_frame = frame(root_raw, zstd, level)?;
386    let root_length = root_frame.len() as u64;
387    let page_count = descriptors.len();
388
389    let mut bytes = root_frame;
390    for f in &leaf_frames {
391        bytes.extend_from_slice(f);
392    }
393
394    Ok(EncodedPagedDirectory {
395        bytes,
396        root_length,
397        page_count,
398        page_entries,
399    })
400}
401
402/// Decode a whole paged `.sttd` (root + every leaf) back into the full entry
403/// list, in directory order — the load-all inverse of [`encode_paged_directory`].
404///
405/// `root_length` is the manifest's `directory.rootLength`; `zstd` the per-page
406/// framing flag (`directory.encoding == "zstd"`). Used by the local Rust reader
407/// (no cold-start cost on a mmap'd file) and the round-trip tests. The HTTP
408/// reader decodes only the root and fetches leaves on demand instead.
409pub fn decode_paged_directory(
410    bytes: &[u8],
411    root_length: u64,
412    zstd: bool,
413) -> Result<Vec<TileEntry>> {
414    let rl = root_length as usize;
415    if rl > bytes.len() {
416        return Err(Error::InvalidArchive(format!(
417            "paged directory: rootLength {rl} exceeds object size {}",
418            bytes.len()
419        )));
420    }
421    let root_raw = unframe(&bytes[..rl], zstd)?;
422    let root = decode_root(&root_raw)?;
423    let mut entries = Vec::new();
424    for d in &root.pages {
425        let start = rl + d.rel_offset as usize;
426        let end = start + d.length as usize;
427        let frame = bytes.get(start..end).ok_or_else(|| {
428            Error::InvalidArchive(format!(
429                "paged directory: leaf range {start}..{end} exceeds object size {}",
430                bytes.len()
431            ))
432        })?;
433        let raw = unframe(frame, zstd)?;
434        let mut page = decode_directory(&raw)?;
435        if page.len() != d.entry_count as usize {
436            return Err(Error::InvalidArchive(format!(
437                "paged directory: leaf declared {} entries, decoded {}",
438                d.entry_count,
439                page.len()
440            )));
441        }
442        entries.append(&mut page);
443    }
444    Ok(entries)
445}
446
447/// Structural validation of a paged `.sttd`, beyond what plain decode checks.
448///
449/// `decode_paged_directory` already verifies the root frame, leaf byte-ranges
450/// and per-leaf entry counts. This adds the paged-specific invariants a
451/// validator wants:
452/// - each page descriptor's **bounds cover** every entry in its leaf (geo-bbox,
453///   zoom range, temporal `[t_min,t_max]`) — so a reader's prune never drops a
454///   matching tile;
455/// - **cross-page key order** is monotonic in `(zoom, hilbert, time_start)` — the
456///   leaves are contiguous slices of one global sort;
457/// - declared vs decoded entry totals agree.
458///
459/// Returns the list of violations (empty ⇒ clean). Issue output is capped so a
460/// badly-corrupt directory can't flood the report.
461pub fn verify_paged_structure(bytes: &[u8], root_length: u64, zstd: bool) -> Result<Vec<String>> {
462    const MAX_ISSUES: usize = 25;
463    let mut issues: Vec<String> = Vec::new();
464    let push = |issues: &mut Vec<String>, msg: String| {
465        if issues.len() < MAX_ISSUES {
466            issues.push(msg);
467        } else if issues.len() == MAX_ISSUES {
468            issues.push("… (further paged-structure issues truncated)".into());
469        }
470    };
471
472    let rl = root_length as usize;
473    if rl > bytes.len() {
474        return Ok(vec![format!(
475            "paged: rootLength {rl} exceeds object size {}",
476            bytes.len()
477        )]);
478    }
479    let root = decode_root(&unframe(&bytes[..rl], zstd)?)?;
480
481    let mut decoded_total = 0usize;
482    let mut declared_total = 0usize;
483    let mut prev_last: Option<(u8, u64, i64)> = None;
484    for (pi, d) in root.pages.iter().enumerate() {
485        declared_total += d.entry_count as usize;
486        let start = rl + d.rel_offset as usize;
487        let end = start + d.length as usize;
488        let frame = match bytes.get(start..end) {
489            Some(f) => f,
490            None => {
491                push(
492                    &mut issues,
493                    format!("page {pi}: leaf range {start}..{end} exceeds object size {}", bytes.len()),
494                );
495                continue;
496            }
497        };
498        let leaf = match unframe(frame, zstd).and_then(|raw| decode_directory(&raw)) {
499            Ok(l) => l,
500            Err(e) => {
501                push(&mut issues, format!("page {pi}: leaf decode failed: {e}"));
502                continue;
503            }
504        };
505        if leaf.len() != d.entry_count as usize {
506            push(
507                &mut issues,
508                format!("page {pi}: descriptor declares {} entries, leaf decoded {}", d.entry_count, leaf.len()),
509            );
510        }
511        decoded_total += leaf.len();
512
513        for e in &leaf {
514            if e.zoom < d.min_zoom || e.zoom > d.max_zoom {
515                push(
516                    &mut issues,
517                    format!("page {pi}: entry zoom {} outside descriptor [{}, {}]", e.zoom, d.min_zoom, d.max_zoom),
518                );
519            }
520            let b = tile_geo_bounds(e.zoom, e.x, e.y);
521            if floor_e7(b.0) < d.min_lon_e7
522                || floor_e7(b.1) < d.min_lat_e7
523                || ceil_e7(b.2) > d.max_lon_e7
524                || ceil_e7(b.3) > d.max_lat_e7
525            {
526                push(
527                    &mut issues,
528                    format!("page {pi}: descriptor bbox does not cover tile {}/{}/{}", e.zoom, e.x, e.y),
529                );
530            }
531            let lo = e.cover_t_min.unwrap_or(e.time_start);
532            if lo < d.t_min || e.time_end > d.t_max {
533                push(
534                    &mut issues,
535                    format!("page {pi}: descriptor t-bounds [{}, {}] do not cover entry [{}, {}]", d.t_min, d.t_max, lo, e.time_end),
536                );
537            }
538        }
539
540        // Cross-page (and within-page) directory-order monotonicity.
541        if let (Some(first), Some(last)) = (leaf.first(), leaf.last()) {
542            let fk = (first.zoom, first.hilbert, first.time_start);
543            if let Some(pl) = prev_last {
544                if pl > fk {
545                    push(
546                        &mut issues,
547                        format!("page {pi}: first key {fk:?} precedes previous page's last key {pl:?} (directory order violated)"),
548                    );
549                }
550            }
551            prev_last = Some((last.zoom, last.hilbert, last.time_start));
552        }
553    }
554
555    if decoded_total != declared_total {
556        push(
557            &mut issues,
558            format!("paged: descriptors declare {declared_total} entries, leaves decoded {decoded_total}"),
559        );
560    }
561    Ok(issues)
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    fn entry(z: u8, x: u32, y: u32, ts: i64, te: i64, cover: Option<i64>) -> TileEntry {
569        TileEntry {
570            zoom: z,
571            x,
572            y,
573            time_start: ts,
574            time_end: te,
575            pack_id: 0,
576            offset: (x as u64) * 64,
577            length: 50 + x,
578            uncompressed_size: 100 + x,
579            feature_count: x,
580            hilbert: crate::tile::TileId::new(z, x, y, 0).hilbert_index(),
581            crc32c: 0x1000 + x,
582            temporal_bucket_ms: Some(3_600_000),
583            cover_t_min: cover,
584        }
585    }
586
587    /// A spread-out synthetic corpus across zooms / cells / time buckets.
588    fn corpus(all_cover: bool) -> Vec<TileEntry> {
589        let mut v = Vec::new();
590        for z in [4u8, 8, 12] {
591            let n = 1u32 << z;
592            for i in 0..40u32 {
593                let x = (i * 7) % n;
594                let y = (i * 13) % n;
595                for b in 0..3i64 {
596                    let ts = b * 3_600_000 + i as i64 * 1000;
597                    let cover = if all_cover { Some(ts - 500) } else { None };
598                    v.push(entry(z, x, y, ts, ts + 3_599_000, cover));
599                }
600            }
601        }
602        v
603    }
604
605    /// The whole-load decode of a corpus (the ground truth paged must match).
606    fn whole_load(entries: &[TileEntry]) -> Vec<TileEntry> {
607        decode_directory(&encode_directory(entries)).unwrap()
608    }
609
610    #[test]
611    fn paged_decode_equals_whole_load_all_cover() {
612        let c = corpus(true);
613        let want = whole_load(&c);
614        for pe in [1usize, 2, 7, 50, 4096, 100_000] {
615            for zstd in [true, false] {
616                let enc = encode_paged_directory(&c, pe, zstd).unwrap();
617                let got = decode_paged_directory(&enc.bytes, enc.root_length, zstd).unwrap();
618                assert_eq!(got, want, "page_entries={pe} zstd={zstd}");
619            }
620        }
621    }
622
623    #[test]
624    fn paged_decode_equals_whole_load_mixed_cover() {
625        // A MIXED corpus: whole-load drops the cover section entirely (all
626        // None). Paged must match — its per-page codec must NOT emit cover for
627        // an accidentally-all-Some page.
628        let mut c = corpus(true);
629        c[0].cover_t_min = None; // make it mixed
630        let want = whole_load(&c);
631        assert!(want.iter().all(|e| e.cover_t_min.is_none()));
632        for pe in [1usize, 3, 4096] {
633            let enc = encode_paged_directory(&c, pe, true).unwrap();
634            let got = decode_paged_directory(&enc.bytes, enc.root_length, true).unwrap();
635            assert_eq!(got, want, "page_entries={pe}");
636            assert!(got.iter().all(|e| e.cover_t_min.is_none()));
637        }
638    }
639
640    #[test]
641    fn empty_roundtrips() {
642        let enc = encode_paged_directory(&[], 4096, true).unwrap();
643        assert_eq!(enc.page_count, 0);
644        let got = decode_paged_directory(&enc.bytes, enc.root_length, true).unwrap();
645        assert!(got.is_empty());
646    }
647
648    #[test]
649    fn small_dataset_is_one_page() {
650        let c = corpus(true);
651        let enc = encode_paged_directory(&c, 100_000, true).unwrap();
652        assert_eq!(enc.page_count, 1);
653    }
654
655    #[test]
656    fn page_count_matches_chunking() {
657        let c = corpus(true); // 3 zooms × 40 × 3 = 360 entries
658        assert_eq!(c.len(), 360);
659        let enc = encode_paged_directory(&c, 100, true).unwrap();
660        assert_eq!(enc.page_count, 4); // ceil(360/100)
661    }
662
663    #[test]
664    fn descriptor_bounds_cover_their_entries() {
665        let c = corpus(true);
666        let enc = encode_paged_directory(&c, 37, true).unwrap();
667        let root = decode_root(&unframe(&enc.bytes[..enc.root_length as usize], true).unwrap())
668            .unwrap();
669        // Re-derive the sorted slices the encoder used.
670        let mut sorted = c.clone();
671        sorted.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
672        let mut total = 0u32;
673        for (page, chunk) in root.pages.iter().zip(sorted.chunks(37)) {
674            assert_eq!(page.entry_count as usize, chunk.len());
675            total += page.entry_count;
676            for e in chunk {
677                let b = tile_geo_bounds(e.zoom, e.x, e.y);
678                assert!(e.zoom >= page.min_zoom && e.zoom <= page.max_zoom);
679                // The entry's true bbox must sit inside the (floored/ceiled)
680                // fixed-point descriptor bbox.
681                assert!(floor_e7(b.0) >= page.min_lon_e7, "min_lon under-covered");
682                assert!(floor_e7(b.1) >= page.min_lat_e7, "min_lat under-covered");
683                assert!(ceil_e7(b.2) <= page.max_lon_e7, "max_lon under-covered");
684                assert!(ceil_e7(b.3) <= page.max_lat_e7, "max_lat under-covered");
685                let lo = e.cover_t_min.unwrap_or(e.time_start);
686                assert!(lo >= page.t_min && e.time_end <= page.t_max);
687            }
688        }
689        assert_eq!(total as usize, c.len());
690    }
691
692    #[test]
693    fn cross_page_key_order_is_monotonic() {
694        let c = corpus(true);
695        let got = decode_paged_directory(
696            &encode_paged_directory(&c, 29, true).unwrap().bytes,
697            encode_paged_directory(&c, 29, true).unwrap().root_length,
698            true,
699        )
700        .unwrap();
701        // Decoded entries must be globally non-decreasing in (zoom, hilbert, t).
702        for w in got.windows(2) {
703            let a = (w[0].zoom, w[0].hilbert, w[0].time_start);
704            let b = (w[1].zoom, w[1].hilbert, w[1].time_start);
705            assert!(a <= b, "directory order violated across pages: {a:?} > {b:?}");
706        }
707    }
708
709    #[test]
710    fn root_encode_decode_roundtrips() {
711        let descs = vec![
712            PageDescriptor {
713                rel_offset: 0,
714                length: 1234,
715                entry_count: 4096,
716                min_zoom: 8,
717                max_zoom: 9,
718                min_lon_e7: -740_000_000,
719                min_lat_e7: 400_000_000,
720                max_lon_e7: -730_000_000,
721                max_lat_e7: 410_000_000,
722                t_min: 1_000,
723                t_max: 9_999,
724            },
725            PageDescriptor {
726                rel_offset: 1234,
727                length: 5678,
728                entry_count: 12,
729                min_zoom: 0,
730                max_zoom: 14,
731                min_lon_e7: -1_800_000_000,
732                min_lat_e7: -850_000_000,
733                max_lon_e7: 1_800_000_000,
734                max_lat_e7: 850_000_000,
735                t_min: i64::MIN + 1,
736                t_max: i64::MAX - 1,
737            },
738        ];
739        let raw = encode_root(4096, &descs);
740        let root = decode_root(&raw).unwrap();
741        assert_eq!(root.page_entries, 4096);
742        assert_eq!(root.pages, descs);
743    }
744
745    #[test]
746    fn overlaps_selects_the_right_pages() {
747        // Two well-separated pages; a query over page A must not select page B.
748        let a = PageDescriptor {
749            rel_offset: 0,
750            length: 1,
751            entry_count: 1,
752            min_zoom: 10,
753            max_zoom: 10,
754            min_lon_e7: -740_000_000,
755            min_lat_e7: 400_000_000,
756            max_lon_e7: -730_000_000,
757            max_lat_e7: 410_000_000,
758            t_min: 0,
759            t_max: 1000,
760        };
761        let b = PageDescriptor {
762            min_lon_e7: 1_000_000_000,
763            max_lon_e7: 1_010_000_000,
764            min_lat_e7: -100_000_000,
765            max_lat_e7: -90_000_000,
766            t_min: 5000,
767            t_max: 6000,
768            ..a.clone()
769        };
770        // Query box over A's region, A's time.
771        assert!(a.overlaps(10, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 0, 1000));
772        assert!(!b.overlaps(10, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 0, 1000));
773        // Wrong zoom prunes A.
774        assert!(!a.overlaps(9, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 0, 1000));
775        // Disjoint time prunes A.
776        assert!(!a.overlaps(10, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 2000, 3000));
777    }
778
779    #[test]
780    fn verify_paged_structure_clean_then_detects_corruption() {
781        let c = corpus(true);
782        let enc = encode_paged_directory(&c, 19, true).unwrap();
783        // A faithful build verifies clean.
784        let issues = verify_paged_structure(&enc.bytes, enc.root_length, true).unwrap();
785        assert!(issues.is_empty(), "clean build had issues: {issues:?}");
786
787        // Corrupt the root: shrink page 0's bbox so it no longer covers its
788        // entries (decode the root, tighten a bound, re-encode the root frame in
789        // place). Build a fresh paged object with a deliberately-too-small bbox.
790        let mut sorted = c.clone();
791        sorted.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
792        let root_raw = unframe(&enc.bytes[..enc.root_length as usize], true).unwrap();
793        let mut root = decode_root(&root_raw).unwrap();
794        // Make page 0 claim an empty bbox far from its tiles → cover violations.
795        root.pages[0].min_lon_e7 = 1_790_000_000;
796        root.pages[0].max_lon_e7 = 1_800_000_000;
797        // Also break temporal coverage on page 1.
798        root.pages[1].t_max = i64::MIN;
799        let bad_root = encode_root(root.page_entries, &root.pages);
800        let bad_root_frame = frame(bad_root, true, compression::ZSTD_LEVEL).unwrap();
801        // Reassemble: bad root frame + original leaves. rootLength changes.
802        let mut bad = bad_root_frame.clone();
803        bad.extend_from_slice(&enc.bytes[enc.root_length as usize..]);
804        let bad_issues =
805            verify_paged_structure(&bad, bad_root_frame.len() as u64, true).unwrap();
806        assert!(
807            bad_issues.iter().any(|s| s.contains("bbox does not cover")),
808            "expected a bbox-cover violation, got {bad_issues:?}"
809        );
810        assert!(
811            bad_issues.iter().any(|s| s.contains("t-bounds")),
812            "expected a t-bounds violation, got {bad_issues:?}"
813        );
814    }
815
816    #[test]
817    fn truncated_root_errors() {
818        let c = corpus(true);
819        let enc = encode_paged_directory(&c, 50, false).unwrap();
820        // rootLength claims more than the object holds.
821        assert!(decode_paged_directory(&enc.bytes, enc.bytes.len() as u64 + 1, false).is_err());
822        // A corrupt root version.
823        let mut raw = encode_root(50, &[]);
824        raw[0] = 99;
825        assert!(decode_root(&raw).is_err());
826    }
827}