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    // Adversarial-input guard: descriptors are fixed-width, so a count beyond
246    // what the remaining bytes can hold is corrupt; reject before allocating
247    // the table (guarded by tests/adversarial_decode.rs).
248    if bytes.len().saturating_sub(r.pos) < page_count.saturating_mul(DESCRIPTOR_LEN) {
249        return Err(Error::InvalidArchive(format!(
250            "paged root: header claims {page_count} pages but only {} bytes remain",
251            bytes.len().saturating_sub(r.pos)
252        )));
253    }
254    let mut pages = Vec::with_capacity(page_count);
255    for _ in 0..page_count {
256        let rel_offset = r.u64()?;
257        let length = r.u32()?;
258        let entry_count = r.u32()?;
259        let min_zoom = r.take(1)?[0];
260        let max_zoom = r.take(1)?[0];
261        let _res = r.u16()?;
262        let min_lon_e7 = r.i32()?;
263        let min_lat_e7 = r.i32()?;
264        let max_lon_e7 = r.i32()?;
265        let max_lat_e7 = r.i32()?;
266        let t_min = r.i64()?;
267        let t_max = r.i64()?;
268        pages.push(PageDescriptor {
269            rel_offset,
270            length,
271            entry_count,
272            min_zoom,
273            max_zoom,
274            min_lon_e7,
275            min_lat_e7,
276            max_lon_e7,
277            max_lat_e7,
278            t_min,
279            t_max,
280        });
281    }
282    Ok(PagedRoot {
283        descriptor_kind,
284        page_entries,
285        pages,
286    })
287}
288
289fn frame(raw: Vec<u8>, zstd: bool, level: i32) -> Result<Vec<u8>> {
290    if zstd {
291        compression::compress_zstd_with_dict_level(&raw, None, level)
292    } else {
293        Ok(raw)
294    }
295}
296
297fn unframe(frame: &[u8], zstd: bool) -> Result<Vec<u8>> {
298    if zstd {
299        compression::decompress_zstd_with_dict(frame, None)
300    } else {
301        Ok(frame.to_vec())
302    }
303}
304
305/// Encode tile entries into the paged container.
306///
307/// `entries` need not be pre-sorted; they are sorted into directory order
308/// `(zoom, hilbert, time_start, temporal_bucket_ms)` — matching the writer and
309/// the v5 codec — then sliced into pages of `page_entries`. `zstd` selects
310/// per-page zstd framing (the writer default) vs raw codec bytes.
311///
312/// The covering section is decided **globally**: if any entry lacks
313/// `cover_t_min`, it is stripped from every leaf, so a paged directory decodes
314/// byte-for-identical-entries to a whole-load v5 directory of the same corpus
315/// (the per-page codec would otherwise emit a cover section for an all-`Some`
316/// page even when the corpus is mixed).
317pub fn encode_paged_directory(
318    entries: &[TileEntry],
319    page_entries: usize,
320    zstd: bool,
321) -> Result<EncodedPagedDirectory> {
322    encode_paged_directory_level(entries, page_entries, zstd, compression::ZSTD_LEVEL)
323}
324
325/// [`encode_paged_directory`] at an explicit zstd `level` (ignored when
326/// `zstd` is false). The directory sits on the cold-start critical path, so a
327/// publish build can spend a higher level here too — decode is level-independent.
328pub fn encode_paged_directory_level(
329    entries: &[TileEntry],
330    page_entries: usize,
331    zstd: bool,
332    level: i32,
333) -> Result<EncodedPagedDirectory> {
334    let page_entries = page_entries.max(1);
335    let mut sorted: Vec<&TileEntry> = entries.iter().collect();
336    sorted.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
337
338    // Global cover decision (mirror the whole-load codec's all-or-nothing rule).
339    let all_cover = !sorted.is_empty() && sorted.iter().all(|e| e.cover_t_min.is_some());
340
341    let mut descriptors: Vec<PageDescriptor> = Vec::new();
342    let mut leaf_frames: Vec<Vec<u8>> = Vec::new();
343    let mut rel_offset = 0u64;
344
345    for chunk in sorted.chunks(page_entries) {
346        // Materialize the slice for encode_directory, applying the global cover
347        // decision so per-page emission matches whole-load.
348        let owned: Vec<TileEntry> = chunk
349            .iter()
350            .map(|&e| {
351                let mut c = e.clone();
352                if !all_cover {
353                    c.cover_t_min = None;
354                }
355                c
356            })
357            .collect();
358        let raw = encode_directory(&owned);
359        let leaf = frame(raw, zstd, level)?;
360
361        let mut geo = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
362        let (mut zmin, mut zmax) = (u8::MAX, 0u8);
363        let (mut tmin, mut tmax) = (i64::MAX, i64::MIN);
364        for &e in chunk {
365            let b = tile_geo_bounds(e.zoom, e.x, e.y);
366            geo.0 = geo.0.min(b.0);
367            geo.1 = geo.1.min(b.1);
368            geo.2 = geo.2.max(b.2);
369            geo.3 = geo.3.max(b.3);
370            zmin = zmin.min(e.zoom);
371            zmax = zmax.max(e.zoom);
372            tmin = tmin.min(e.cover_t_min.unwrap_or(e.time_start));
373            tmax = tmax.max(e.time_end);
374        }
375
376        descriptors.push(PageDescriptor {
377            rel_offset,
378            length: leaf.len() as u32,
379            entry_count: chunk.len() as u32,
380            min_zoom: zmin,
381            max_zoom: zmax,
382            min_lon_e7: floor_e7(geo.0),
383            min_lat_e7: floor_e7(geo.1),
384            max_lon_e7: ceil_e7(geo.2),
385            max_lat_e7: ceil_e7(geo.3),
386            t_min: tmin,
387            t_max: tmax,
388        });
389        rel_offset += leaf.len() as u64;
390        leaf_frames.push(leaf);
391    }
392
393    let root_raw = encode_root(page_entries as u32, &descriptors);
394    let root_frame = frame(root_raw, zstd, level)?;
395    let root_length = root_frame.len() as u64;
396    let page_count = descriptors.len();
397
398    let mut bytes = root_frame;
399    for f in &leaf_frames {
400        bytes.extend_from_slice(f);
401    }
402
403    Ok(EncodedPagedDirectory {
404        bytes,
405        root_length,
406        page_count,
407        page_entries,
408    })
409}
410
411/// Decode a whole paged `.sttd` (root + every leaf) back into the full entry
412/// list, in directory order — the load-all inverse of [`encode_paged_directory`].
413///
414/// `root_length` is the manifest's `directory.rootLength`; `zstd` the per-page
415/// framing flag (`directory.encoding == "zstd"`). Used by the local Rust reader
416/// (no cold-start cost on a mmap'd file) and the round-trip tests. The HTTP
417/// reader decodes only the root and fetches leaves on demand instead.
418pub fn decode_paged_directory(
419    bytes: &[u8],
420    root_length: u64,
421    zstd: bool,
422) -> Result<Vec<TileEntry>> {
423    let rl = root_length as usize;
424    if rl > bytes.len() {
425        return Err(Error::InvalidArchive(format!(
426            "paged directory: rootLength {rl} exceeds object size {}",
427            bytes.len()
428        )));
429    }
430    let root_raw = unframe(&bytes[..rl], zstd)?;
431    let root = decode_root(&root_raw)?;
432    let mut entries = Vec::new();
433    for d in &root.pages {
434        // Checked range arithmetic: a corrupt root's rel_offset/length must
435        // error, not overflow (guarded by tests/adversarial_decode.rs).
436        let start = root_length.checked_add(d.rel_offset);
437        let end = start.and_then(|s| s.checked_add(d.length as u64));
438        let frame = match (start, end) {
439            (Some(s), Some(e)) if e <= bytes.len() as u64 => &bytes[s as usize..e as usize],
440            _ => {
441                return Err(Error::InvalidArchive(format!(
442                    "paged directory: leaf range rootLength+{}..+{} exceeds object size {}",
443                    d.rel_offset,
444                    d.length,
445                    bytes.len()
446                )))
447            }
448        };
449        let raw = unframe(frame, zstd)?;
450        let mut page = decode_directory(&raw)?;
451        if page.len() != d.entry_count as usize {
452            return Err(Error::InvalidArchive(format!(
453                "paged directory: leaf declared {} entries, decoded {}",
454                d.entry_count,
455                page.len()
456            )));
457        }
458        entries.append(&mut page);
459    }
460    Ok(entries)
461}
462
463/// Structural validation of a paged `.sttd`, beyond what plain decode checks.
464///
465/// `decode_paged_directory` already verifies the root frame, leaf byte-ranges
466/// and per-leaf entry counts. This adds the paged-specific invariants a
467/// validator wants:
468/// - each page descriptor's **bounds cover** every entry in its leaf (geo-bbox,
469///   zoom range, temporal `[t_min,t_max]`) — so a reader's prune never drops a
470///   matching tile;
471/// - **cross-page key order** is monotonic in `(zoom, hilbert, time_start)` — the
472///   leaves are contiguous slices of one global sort;
473/// - declared vs decoded entry totals agree.
474///
475/// Returns the list of violations (empty ⇒ clean). Issue output is capped so a
476/// badly-corrupt directory can't flood the report.
477pub fn verify_paged_structure(bytes: &[u8], root_length: u64, zstd: bool) -> Result<Vec<String>> {
478    const MAX_ISSUES: usize = 25;
479    let mut issues: Vec<String> = Vec::new();
480    let push = |issues: &mut Vec<String>, msg: String| {
481        if issues.len() < MAX_ISSUES {
482            issues.push(msg);
483        } else if issues.len() == MAX_ISSUES {
484            issues.push("… (further paged-structure issues truncated)".into());
485        }
486    };
487
488    let rl = root_length as usize;
489    if rl > bytes.len() {
490        return Ok(vec![format!(
491            "paged: rootLength {rl} exceeds object size {}",
492            bytes.len()
493        )]);
494    }
495    let root = decode_root(&unframe(&bytes[..rl], zstd)?)?;
496
497    let mut decoded_total = 0usize;
498    let mut declared_total = 0usize;
499    let mut prev_last: Option<(u8, u64, i64)> = None;
500    for (pi, d) in root.pages.iter().enumerate() {
501        declared_total += d.entry_count as usize;
502        // Checked range arithmetic, mirroring `decode_paged_directory`: a
503        // corrupt descriptor must be reported, not overflow.
504        let start = root_length.checked_add(d.rel_offset);
505        let end = start.and_then(|s| s.checked_add(d.length as u64));
506        let frame = match (start, end) {
507            (Some(s), Some(e)) if e <= bytes.len() as u64 => &bytes[s as usize..e as usize],
508            _ => {
509                push(
510                    &mut issues,
511                    format!(
512                        "page {pi}: leaf range rootLength+{}..+{} exceeds object size {}",
513                        d.rel_offset,
514                        d.length,
515                        bytes.len()
516                    ),
517                );
518                continue;
519            }
520        };
521        let leaf = match unframe(frame, zstd).and_then(|raw| decode_directory(&raw)) {
522            Ok(l) => l,
523            Err(e) => {
524                push(&mut issues, format!("page {pi}: leaf decode failed: {e}"));
525                continue;
526            }
527        };
528        if leaf.len() != d.entry_count as usize {
529            push(
530                &mut issues,
531                format!("page {pi}: descriptor declares {} entries, leaf decoded {}", d.entry_count, leaf.len()),
532            );
533        }
534        decoded_total += leaf.len();
535
536        for e in &leaf {
537            if e.zoom < d.min_zoom || e.zoom > d.max_zoom {
538                push(
539                    &mut issues,
540                    format!("page {pi}: entry zoom {} outside descriptor [{}, {}]", e.zoom, d.min_zoom, d.max_zoom),
541                );
542            }
543            let b = tile_geo_bounds(e.zoom, e.x, e.y);
544            if floor_e7(b.0) < d.min_lon_e7
545                || floor_e7(b.1) < d.min_lat_e7
546                || ceil_e7(b.2) > d.max_lon_e7
547                || ceil_e7(b.3) > d.max_lat_e7
548            {
549                push(
550                    &mut issues,
551                    format!("page {pi}: descriptor bbox does not cover tile {}/{}/{}", e.zoom, e.x, e.y),
552                );
553            }
554            let lo = e.cover_t_min.unwrap_or(e.time_start);
555            if lo < d.t_min || e.time_end > d.t_max {
556                push(
557                    &mut issues,
558                    format!("page {pi}: descriptor t-bounds [{}, {}] do not cover entry [{}, {}]", d.t_min, d.t_max, lo, e.time_end),
559                );
560            }
561        }
562
563        // Cross-page (and within-page) directory-order monotonicity.
564        if let (Some(first), Some(last)) = (leaf.first(), leaf.last()) {
565            let fk = (first.zoom, first.hilbert, first.time_start);
566            if let Some(pl) = prev_last {
567                if pl > fk {
568                    push(
569                        &mut issues,
570                        format!("page {pi}: first key {fk:?} precedes previous page's last key {pl:?} (directory order violated)"),
571                    );
572                }
573            }
574            prev_last = Some((last.zoom, last.hilbert, last.time_start));
575        }
576    }
577
578    if decoded_total != declared_total {
579        push(
580            &mut issues,
581            format!("paged: descriptors declare {declared_total} entries, leaves decoded {decoded_total}"),
582        );
583    }
584    Ok(issues)
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    fn entry(z: u8, x: u32, y: u32, ts: i64, te: i64, cover: Option<i64>) -> TileEntry {
592        TileEntry {
593            zoom: z,
594            x,
595            y,
596            time_start: ts,
597            time_end: te,
598            pack_id: 0,
599            offset: (x as u64) * 64,
600            length: 50 + x,
601            uncompressed_size: 100 + x,
602            feature_count: x,
603            hilbert: crate::tile::TileId::new(z, x, y, 0).hilbert_index(),
604            crc32c: 0x1000 + x,
605            temporal_bucket_ms: Some(3_600_000),
606            cover_t_min: cover,
607        }
608    }
609
610    /// A spread-out synthetic corpus across zooms / cells / time buckets.
611    fn corpus(all_cover: bool) -> Vec<TileEntry> {
612        let mut v = Vec::new();
613        for z in [4u8, 8, 12] {
614            let n = 1u32 << z;
615            for i in 0..40u32 {
616                let x = (i * 7) % n;
617                let y = (i * 13) % n;
618                for b in 0..3i64 {
619                    let ts = b * 3_600_000 + i as i64 * 1000;
620                    let cover = if all_cover { Some(ts - 500) } else { None };
621                    v.push(entry(z, x, y, ts, ts + 3_599_000, cover));
622                }
623            }
624        }
625        v
626    }
627
628    /// The whole-load decode of a corpus (the ground truth paged must match).
629    fn whole_load(entries: &[TileEntry]) -> Vec<TileEntry> {
630        decode_directory(&encode_directory(entries)).unwrap()
631    }
632
633    #[test]
634    fn paged_decode_equals_whole_load_all_cover() {
635        let c = corpus(true);
636        let want = whole_load(&c);
637        for pe in [1usize, 2, 7, 50, 4096, 100_000] {
638            for zstd in [true, false] {
639                let enc = encode_paged_directory(&c, pe, zstd).unwrap();
640                let got = decode_paged_directory(&enc.bytes, enc.root_length, zstd).unwrap();
641                assert_eq!(got, want, "page_entries={pe} zstd={zstd}");
642            }
643        }
644    }
645
646    #[test]
647    fn paged_decode_equals_whole_load_mixed_cover() {
648        // A MIXED corpus: whole-load drops the cover section entirely (all
649        // None). Paged must match — its per-page codec must NOT emit cover for
650        // an accidentally-all-Some page.
651        let mut c = corpus(true);
652        c[0].cover_t_min = None; // make it mixed
653        let want = whole_load(&c);
654        assert!(want.iter().all(|e| e.cover_t_min.is_none()));
655        for pe in [1usize, 3, 4096] {
656            let enc = encode_paged_directory(&c, pe, true).unwrap();
657            let got = decode_paged_directory(&enc.bytes, enc.root_length, true).unwrap();
658            assert_eq!(got, want, "page_entries={pe}");
659            assert!(got.iter().all(|e| e.cover_t_min.is_none()));
660        }
661    }
662
663    #[test]
664    fn empty_roundtrips() {
665        let enc = encode_paged_directory(&[], 4096, true).unwrap();
666        assert_eq!(enc.page_count, 0);
667        let got = decode_paged_directory(&enc.bytes, enc.root_length, true).unwrap();
668        assert!(got.is_empty());
669    }
670
671    #[test]
672    fn small_dataset_is_one_page() {
673        let c = corpus(true);
674        let enc = encode_paged_directory(&c, 100_000, true).unwrap();
675        assert_eq!(enc.page_count, 1);
676    }
677
678    #[test]
679    fn page_count_matches_chunking() {
680        let c = corpus(true); // 3 zooms × 40 × 3 = 360 entries
681        assert_eq!(c.len(), 360);
682        let enc = encode_paged_directory(&c, 100, true).unwrap();
683        assert_eq!(enc.page_count, 4); // ceil(360/100)
684    }
685
686    #[test]
687    fn descriptor_bounds_cover_their_entries() {
688        let c = corpus(true);
689        let enc = encode_paged_directory(&c, 37, true).unwrap();
690        let root = decode_root(&unframe(&enc.bytes[..enc.root_length as usize], true).unwrap())
691            .unwrap();
692        // Re-derive the sorted slices the encoder used.
693        let mut sorted = c.clone();
694        sorted.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
695        let mut total = 0u32;
696        for (page, chunk) in root.pages.iter().zip(sorted.chunks(37)) {
697            assert_eq!(page.entry_count as usize, chunk.len());
698            total += page.entry_count;
699            for e in chunk {
700                let b = tile_geo_bounds(e.zoom, e.x, e.y);
701                assert!(e.zoom >= page.min_zoom && e.zoom <= page.max_zoom);
702                // The entry's true bbox must sit inside the (floored/ceiled)
703                // fixed-point descriptor bbox.
704                assert!(floor_e7(b.0) >= page.min_lon_e7, "min_lon under-covered");
705                assert!(floor_e7(b.1) >= page.min_lat_e7, "min_lat under-covered");
706                assert!(ceil_e7(b.2) <= page.max_lon_e7, "max_lon under-covered");
707                assert!(ceil_e7(b.3) <= page.max_lat_e7, "max_lat under-covered");
708                let lo = e.cover_t_min.unwrap_or(e.time_start);
709                assert!(lo >= page.t_min && e.time_end <= page.t_max);
710            }
711        }
712        assert_eq!(total as usize, c.len());
713    }
714
715    #[test]
716    fn cross_page_key_order_is_monotonic() {
717        let c = corpus(true);
718        let got = decode_paged_directory(
719            &encode_paged_directory(&c, 29, true).unwrap().bytes,
720            encode_paged_directory(&c, 29, true).unwrap().root_length,
721            true,
722        )
723        .unwrap();
724        // Decoded entries must be globally non-decreasing in (zoom, hilbert, t).
725        for w in got.windows(2) {
726            let a = (w[0].zoom, w[0].hilbert, w[0].time_start);
727            let b = (w[1].zoom, w[1].hilbert, w[1].time_start);
728            assert!(a <= b, "directory order violated across pages: {a:?} > {b:?}");
729        }
730    }
731
732    #[test]
733    fn root_encode_decode_roundtrips() {
734        let descs = vec![
735            PageDescriptor {
736                rel_offset: 0,
737                length: 1234,
738                entry_count: 4096,
739                min_zoom: 8,
740                max_zoom: 9,
741                min_lon_e7: -740_000_000,
742                min_lat_e7: 400_000_000,
743                max_lon_e7: -730_000_000,
744                max_lat_e7: 410_000_000,
745                t_min: 1_000,
746                t_max: 9_999,
747            },
748            PageDescriptor {
749                rel_offset: 1234,
750                length: 5678,
751                entry_count: 12,
752                min_zoom: 0,
753                max_zoom: 14,
754                min_lon_e7: -1_800_000_000,
755                min_lat_e7: -850_000_000,
756                max_lon_e7: 1_800_000_000,
757                max_lat_e7: 850_000_000,
758                t_min: i64::MIN + 1,
759                t_max: i64::MAX - 1,
760            },
761        ];
762        let raw = encode_root(4096, &descs);
763        let root = decode_root(&raw).unwrap();
764        assert_eq!(root.page_entries, 4096);
765        assert_eq!(root.pages, descs);
766    }
767
768    #[test]
769    fn overlaps_selects_the_right_pages() {
770        // Two well-separated pages; a query over page A must not select page B.
771        let a = PageDescriptor {
772            rel_offset: 0,
773            length: 1,
774            entry_count: 1,
775            min_zoom: 10,
776            max_zoom: 10,
777            min_lon_e7: -740_000_000,
778            min_lat_e7: 400_000_000,
779            max_lon_e7: -730_000_000,
780            max_lat_e7: 410_000_000,
781            t_min: 0,
782            t_max: 1000,
783        };
784        let b = PageDescriptor {
785            min_lon_e7: 1_000_000_000,
786            max_lon_e7: 1_010_000_000,
787            min_lat_e7: -100_000_000,
788            max_lat_e7: -90_000_000,
789            t_min: 5000,
790            t_max: 6000,
791            ..a.clone()
792        };
793        // Query box over A's region, A's time.
794        assert!(a.overlaps(10, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 0, 1000));
795        assert!(!b.overlaps(10, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 0, 1000));
796        // Wrong zoom prunes A.
797        assert!(!a.overlaps(9, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 0, 1000));
798        // Disjoint time prunes A.
799        assert!(!a.overlaps(10, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 2000, 3000));
800    }
801
802    #[test]
803    fn verify_paged_structure_clean_then_detects_corruption() {
804        let c = corpus(true);
805        let enc = encode_paged_directory(&c, 19, true).unwrap();
806        // A faithful build verifies clean.
807        let issues = verify_paged_structure(&enc.bytes, enc.root_length, true).unwrap();
808        assert!(issues.is_empty(), "clean build had issues: {issues:?}");
809
810        // Corrupt the root: shrink page 0's bbox so it no longer covers its
811        // entries (decode the root, tighten a bound, re-encode the root frame in
812        // place). Build a fresh paged object with a deliberately-too-small bbox.
813        let mut sorted = c.clone();
814        sorted.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
815        let root_raw = unframe(&enc.bytes[..enc.root_length as usize], true).unwrap();
816        let mut root = decode_root(&root_raw).unwrap();
817        // Make page 0 claim an empty bbox far from its tiles → cover violations.
818        root.pages[0].min_lon_e7 = 1_790_000_000;
819        root.pages[0].max_lon_e7 = 1_800_000_000;
820        // Also break temporal coverage on page 1.
821        root.pages[1].t_max = i64::MIN;
822        let bad_root = encode_root(root.page_entries, &root.pages);
823        let bad_root_frame = frame(bad_root, true, compression::ZSTD_LEVEL).unwrap();
824        // Reassemble: bad root frame + original leaves. rootLength changes.
825        let mut bad = bad_root_frame.clone();
826        bad.extend_from_slice(&enc.bytes[enc.root_length as usize..]);
827        let bad_issues =
828            verify_paged_structure(&bad, bad_root_frame.len() as u64, true).unwrap();
829        assert!(
830            bad_issues.iter().any(|s| s.contains("bbox does not cover")),
831            "expected a bbox-cover violation, got {bad_issues:?}"
832        );
833        assert!(
834            bad_issues.iter().any(|s| s.contains("t-bounds")),
835            "expected a t-bounds violation, got {bad_issues:?}"
836        );
837    }
838
839    #[test]
840    fn truncated_root_errors() {
841        let c = corpus(true);
842        let enc = encode_paged_directory(&c, 50, false).unwrap();
843        // rootLength claims more than the object holds.
844        assert!(decode_paged_directory(&enc.bytes, enc.bytes.len() as u64 + 1, false).is_err());
845        // A corrupt root version.
846        let mut raw = encode_root(50, &[]);
847        raw[0] = 99;
848        assert!(decode_root(&raw).is_err());
849    }
850}