Skip to main content

stt_core/
archive.rs

1//! STT archive format — a single-file, range-request-friendly container.
2//!
3//! ## Layout
4//!
5//! ```text
6//! [ 64-byte header ]
7//! [ tile blobs ... ]            compressed tile payloads (zstd, optionally
8//!                               against a shared dictionary; or gzip / none)
9//! [ dictionary    ]            optional shared zstd training dictionary
10//! [ directory     ]            the compact run-length tile index
11//! [ metadata      ]            UTF-8 JSON (also renders TileJSON 3.0 + STAC)
12//! ```
13//!
14//! The header records the byte ranges of the dictionary, directory and
15//! metadata so a reader can fetch them with at most three HTTP range requests,
16//! then each tile blob with one more.
17//!
18//! ## Format
19//!
20//! There is a single archive version, **v4** (`STT\x04`): blobs are integrity-
21//! tagged with CRC32C, the tile index is the compact columnar run-length
22//! [`crate::directory`], and a shared zstd dictionary slot is available. The
23//! legacy v2 (gzip/blake3) and v3 (Arrow-IPC index) formats have been removed —
24//! nothing is deployed, so archives regenerate from source.
25
26use crate::compression;
27use crate::error::{Error, Result};
28use crate::tile::TileId;
29use crate::types::Compression;
30use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
31use memmap2::Mmap;
32use std::fs::{File, OpenOptions};
33use std::io::{Read, Seek, SeekFrom, Write};
34use std::path::Path;
35
36/// Magic prefix for STT archives: `"STT"` + the format version byte.
37const MAGIC: &[u8; 4] = b"STT\x04";
38
39/// The one and only archive format version.
40pub const FORMAT_VERSION: u8 = 4;
41
42/// Fixed header size in bytes.
43const HEADER_SIZE: u64 = 64;
44
45/// Target size of the trained zstd dictionary. ~110 KB is zstd's typical sweet
46/// spot for corpora of many small, structurally-similar payloads (tiles).
47const DICT_MAX_SIZE: usize = 112 * 1024;
48/// Cap the dictionary-training sample so builds stay bounded on huge archives.
49const DICT_SAMPLE_MAX_TILES: usize = 8192;
50/// Byte cap on the dictionary-training sample.
51const DICT_SAMPLE_MAX_BYTES: usize = 32 * 1024 * 1024;
52
53/// One directory entry: where a tile lives and what it covers.
54#[derive(Debug, Clone, PartialEq)]
55pub struct TileEntry {
56    /// Zoom level.
57    pub zoom: u8,
58    /// Tile X coordinate.
59    pub x: u32,
60    /// Tile Y coordinate.
61    pub y: u32,
62    /// Inclusive temporal start (Unix ms).
63    pub time_start: i64,
64    /// Inclusive temporal end (Unix ms).
65    pub time_end: i64,
66    /// Index of the pack object holding this tile's blob (packed format).
67    ///
68    /// In the single-file v4 archive every tile lives in the one file, so this
69    /// is always `0` and [`offset`](Self::offset) is whole-file relative. In the
70    /// multi-object packed format this selects `manifest.packs[pack_id]` and
71    /// [`offset`](Self::offset) becomes **pack-relative**. A per-blob (per-run)
72    /// property, not a per-entry key — entries RLE-collapse only within one pack.
73    pub pack_id: u32,
74    /// Byte offset of the compressed blob.
75    ///
76    /// Whole-file relative for the v4 single-file archive; pack-relative (within
77    /// `packs[pack_id]`) for the packed format.
78    pub offset: u64,
79    /// Compressed blob length in bytes.
80    pub length: u32,
81    /// Uncompressed payload length in bytes.
82    pub uncompressed_size: u32,
83    /// Total feature count across the tile's layers.
84    pub feature_count: u32,
85    /// Hilbert index of `(zoom, x, y)` — directory sort key.
86    pub hilbert: u64,
87    /// CRC32C integrity tag of the compressed blob.
88    pub crc32c: u32,
89    /// Temporal bucket size in milliseconds this tile occupies.
90    ///
91    /// Base tiles record the archive's base bucket size; temporal-LOD tiles
92    /// record their coarser bucket. `None` when the archive carries no LOD
93    /// pyramid (readers fall back to `Metadata::temporal_bucket_ms`).
94    pub temporal_bucket_ms: Option<u64>,
95    /// **Tight lower covering bound** — the minimum feature *start* time actually
96    /// present in the tile. `time_start` is the addressable *bucket boundary*
97    /// (kept loose so `(z,x,y,bucket)` lookup works); `cover_t_min` is the real
98    /// earliest data, which a client uses to prune a tile whose features all lie
99    /// after a query window (`time_end` already gives the tight upper bound).
100    /// `None` when not computed — repacked/transcoded archives and pre-covering
101    /// builds — in which case clients fall back to `time_start`.
102    pub cover_t_min: Option<i64>,
103}
104
105impl TileEntry {
106    /// The tile's identity.
107    pub fn tile_id(&self) -> TileId {
108        TileId::new(self.zoom, self.x, self.y, self.time_start.max(0) as u64)
109    }
110}
111
112/// Parsed archive header.
113#[derive(Debug, Clone)]
114pub struct ArchiveHeader {
115    /// Format version (always [`FORMAT_VERSION`]).
116    pub version: u8,
117    /// Compression applied to every tile blob.
118    pub compression: Compression,
119    /// Byte offset of the directory.
120    pub index_offset: u64,
121    /// Directory length in bytes.
122    pub index_length: u64,
123    /// Byte offset of the metadata JSON.
124    pub metadata_offset: u64,
125    /// Metadata JSON length in bytes.
126    pub metadata_length: u64,
127    /// Byte offset of the optional shared zstd dictionary. Zero if absent.
128    pub dictionary_offset: u64,
129    /// Length of the optional shared zstd dictionary in bytes. Zero if absent.
130    pub dictionary_length: u64,
131}
132
133fn compression_to_byte(c: Compression) -> u8 {
134    match c {
135        Compression::None => 0,
136        Compression::Zstd => 2,
137    }
138}
139
140fn compression_from_byte(b: u8) -> Result<Compression> {
141    match b {
142        0 => Ok(Compression::None),
143        // 1 was gzip (removed; never shipped).
144        2 => Ok(Compression::Zstd),
145        other => Err(Error::InvalidArchive(format!(
146            "unknown compression code {other}"
147        ))),
148    }
149}
150
151impl ArchiveHeader {
152    /// Read and validate a v4 header.
153    pub fn read<R: Read>(reader: &mut R) -> Result<Self> {
154        let mut magic = [0u8; 4];
155        reader.read_exact(&mut magic)?;
156        if &magic != MAGIC {
157            return Err(Error::InvalidMagic);
158        }
159        let version = reader.read_u8()?;
160        // The 4th magic byte doubles as a version field; both must be the one
161        // supported version.
162        if version != FORMAT_VERSION {
163            return Err(Error::UnsupportedVersion(version));
164        }
165        let compression = compression_from_byte(reader.read_u8()?)?;
166        let index_offset = reader.read_u64::<LittleEndian>()?;
167        let index_length = reader.read_u64::<LittleEndian>()?;
168        let metadata_offset = reader.read_u64::<LittleEndian>()?;
169        let metadata_length = reader.read_u64::<LittleEndian>()?;
170        let dictionary_offset = reader.read_u64::<LittleEndian>()?;
171        let dictionary_length = reader.read_u64::<LittleEndian>()?;
172        // Drain the remaining reserved bytes (HEADER_SIZE - 54).
173        let mut reserved = [0u8; (HEADER_SIZE as usize) - 54];
174        reader.read_exact(&mut reserved)?;
175        Ok(Self {
176            version,
177            compression,
178            index_offset,
179            index_length,
180            metadata_offset,
181            metadata_length,
182            dictionary_offset,
183            dictionary_length,
184        })
185    }
186
187    /// Write the header.
188    pub fn write<W: Write>(&self, writer: &mut W) -> Result<()> {
189        writer.write_all(MAGIC)?;
190        writer.write_u8(FORMAT_VERSION)?;
191        writer.write_u8(compression_to_byte(self.compression))?;
192        writer.write_u64::<LittleEndian>(self.index_offset)?;
193        writer.write_u64::<LittleEndian>(self.index_length)?;
194        writer.write_u64::<LittleEndian>(self.metadata_offset)?;
195        writer.write_u64::<LittleEndian>(self.metadata_length)?;
196        writer.write_u64::<LittleEndian>(self.dictionary_offset)?;
197        writer.write_u64::<LittleEndian>(self.dictionary_length)?;
198        writer.write_all(&[0u8; (HEADER_SIZE as usize) - 54])?;
199        Ok(())
200    }
201}
202
203/// CRC32C integrity tag for a compressed blob.
204fn crc32c_tag(bytes: &[u8]) -> u32 {
205    crc32c::crc32c(bytes)
206}
207
208// ----------------------------------------------------------------------------
209// In-memory temporal lookup
210// ----------------------------------------------------------------------------
211
212/// Boundary-bucketed temporal index built in memory when an archive is opened.
213///
214/// `boundaries` holds every distinct `time_start`/`time_end`, ascending. For
215/// the largest boundary `<= t`, `buckets` lists the indices of every tile
216/// whose `[time_start, time_end]` interval contains that boundary — so
217/// "tiles overlapping time T" is an O(log n + k) lookup.
218#[derive(Debug, Default)]
219struct TemporalLookup {
220    boundaries: Vec<i64>,
221    bucket_offsets: Vec<u32>,
222    bucket_refs: Vec<u32>,
223}
224
225impl TemporalLookup {
226    /// Build the temporal lookup in `O(N log N + total bucket size)` time via a
227    /// sweep over half-open interval events plus an incremental active set.
228    ///
229    /// Snapshot semantics:
230    /// - `boundaries[i]` is a half-open boundary after which the active set is
231    ///   stable until `boundaries[i+1]` (or +∞ for the last entry).
232    /// - A query for time `t` uses `partition_point(|&b| b <= t)` to find the
233    ///   bucket index `b`. `b == 0` means the query is before any tile
234    ///   started → empty.
235    fn build(entries: &[TileEntry]) -> Self {
236        if entries.is_empty() {
237            return Self::default();
238        }
239
240        // Half-open events: START at `time_start`, END at `time_end + 1`
241        // (saturating so an interval ending at `i64::MAX` still produces a
242        // well-defined event time). STARTs sort BEFORE ENDs at ties, so an
243        // instantaneous interval `[t, t]` is active exactly at time `t`.
244        const START: u8 = 0;
245        const END: u8 = 1;
246        let mut events: Vec<(i64, u8, u32)> = Vec::with_capacity(entries.len() * 2);
247        for (i, e) in entries.iter().enumerate() {
248            events.push((e.time_start, START, i as u32));
249            events.push((e.time_end.saturating_add(1), END, i as u32));
250        }
251        events.sort_unstable_by_key(|&(t, kind, _)| (t, kind));
252
253        let mut boundaries: Vec<i64> = Vec::new();
254        let mut bucket_offsets: Vec<u32> = Vec::new();
255        let mut bucket_refs: Vec<u32> = Vec::new();
256        let mut active: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();
257
258        let mut i = 0;
259        let mut prev_bucket_start: usize = 0;
260        while i < events.len() {
261            let t = events[i].0;
262            while i < events.len() && events[i].0 == t {
263                let (_, kind, idx) = events[i];
264                if kind == START {
265                    active.insert(idx);
266                } else {
267                    active.remove(&idx);
268                }
269                i += 1;
270            }
271
272            // Skip when the active set matches the previous boundary exactly
273            // (STARTs and ENDs cancelled out at this time).
274            let prev_bucket_end = bucket_refs.len();
275            let prev_active_len = prev_bucket_end - prev_bucket_start;
276            if active.len() == prev_active_len {
277                let mut matches = true;
278                let prev_slice = &bucket_refs[prev_bucket_start..prev_bucket_end];
279                for (a, b) in active.iter().zip(prev_slice.iter()) {
280                    if a != b {
281                        matches = false;
282                        break;
283                    }
284                }
285                if matches {
286                    continue;
287                }
288            }
289            boundaries.push(t);
290            bucket_offsets.push(prev_bucket_end as u32);
291            prev_bucket_start = prev_bucket_end;
292            bucket_refs.extend(active.iter().copied());
293        }
294        // Sentinel offset so queries can slice `[bucket_offsets[b]..bucket_offsets[b+1]]`.
295        bucket_offsets.push(bucket_refs.len() as u32);
296
297        Self {
298            boundaries,
299            bucket_offsets,
300            bucket_refs,
301        }
302    }
303
304    /// Indices of every tile whose interval contains `t`.
305    fn at(&self, t: i64) -> &[u32] {
306        if self.boundaries.is_empty() {
307            return &[];
308        }
309        let bucket = self.boundaries.partition_point(|&b| b <= t);
310        if bucket == 0 {
311            return &[];
312        }
313        let bucket = bucket - 1;
314        let start = self.bucket_offsets[bucket] as usize;
315        let end = self.bucket_offsets[bucket + 1] as usize;
316        &self.bucket_refs[start..end]
317    }
318}
319
320// ----------------------------------------------------------------------------
321// Reader
322// ----------------------------------------------------------------------------
323
324/// Reader for an STT archive.
325///
326/// The archive file is memory-mapped on open; tile payload reads serve slices
327/// directly out of the mapping rather than allocating + `pread`-ing a fresh
328/// `Vec<u8>` per call. For warm caches this is allocation-free.
329pub struct ArchiveReader {
330    /// Owned file handle keeps the mmap alive even after `open` returns.
331    #[allow(dead_code)]
332    file: File,
333    /// Memory map covering the entire archive.
334    mmap: Mmap,
335    header: ArchiveHeader,
336    entries: Vec<TileEntry>,
337    metadata: crate::metadata::Metadata,
338    temporal: TemporalLookup,
339    /// Shared zstd dictionary loaded from the dictionary slot, if present.
340    dictionary: Option<Vec<u8>>,
341}
342
343impl ArchiveReader {
344    /// Open an archive for reading.
345    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
346        let file = File::open(path)?;
347        // SAFETY: we treat the mmap as read-only. The file is kept open for the
348        // lifetime of the reader, and we never write through the map.
349        let mmap = unsafe { Mmap::map(&file) }
350            .map_err(|e| Error::Other(format!("mmap failed: {e}")))?;
351
352        let mut header_cursor = std::io::Cursor::new(&mmap[..HEADER_SIZE as usize]);
353        let header = ArchiveHeader::read(&mut header_cursor)?;
354
355        let index_end = (header.index_offset + header.index_length) as usize;
356        if index_end > mmap.len() {
357            return Err(Error::InvalidArchive(format!(
358                "directory range {}..{} exceeds archive size {}",
359                header.index_offset,
360                index_end,
361                mmap.len()
362            )));
363        }
364        let entries =
365            crate::directory::decode_directory(&mmap[header.index_offset as usize..index_end])?;
366
367        let metadata_end = (header.metadata_offset + header.metadata_length) as usize;
368        if metadata_end > mmap.len() {
369            return Err(Error::InvalidArchive(format!(
370                "metadata range {}..{} exceeds archive size {}",
371                header.metadata_offset,
372                metadata_end,
373                mmap.len()
374            )));
375        }
376        let metadata = crate::metadata::Metadata::from_json_bytes(
377            &mmap[header.metadata_offset as usize..metadata_end],
378        )?;
379
380        let dictionary = if header.dictionary_length > 0 {
381            let dict_end = (header.dictionary_offset + header.dictionary_length) as usize;
382            if dict_end > mmap.len() {
383                return Err(Error::InvalidArchive(format!(
384                    "dictionary range {}..{} exceeds archive size {}",
385                    header.dictionary_offset,
386                    dict_end,
387                    mmap.len()
388                )));
389            }
390            Some(mmap[header.dictionary_offset as usize..dict_end].to_vec())
391        } else {
392            None
393        };
394
395        let temporal = TemporalLookup::build(&entries);
396
397        Ok(Self {
398            file,
399            mmap,
400            header,
401            entries,
402            metadata,
403            temporal,
404            dictionary,
405        })
406    }
407
408    /// Archive metadata.
409    pub fn metadata(&self) -> &crate::metadata::Metadata {
410        &self.metadata
411    }
412
413    /// Parsed header.
414    pub fn header(&self) -> &ArchiveHeader {
415        &self.header
416    }
417
418    /// Shared zstd dictionary, if the archive ships one.
419    pub fn dictionary(&self) -> Option<&[u8]> {
420        self.dictionary.as_deref()
421    }
422
423    /// All directory entries (sorted by zoom then Hilbert index).
424    pub fn entries(&self) -> &[TileEntry] {
425        &self.entries
426    }
427
428    /// Directory entries of every tile whose interval contains time `t`.
429    pub fn tiles_at_time(&self, t: i64) -> Vec<&TileEntry> {
430        self.temporal
431            .at(t)
432            .iter()
433            .filter_map(|&idx| self.entries.get(idx as usize))
434            // The bucket is keyed on the largest boundary <= t and is a
435            // superset; confirm the tile's interval actually contains t.
436            .filter(|e| e.time_start <= t && t <= e.time_end)
437            .collect()
438    }
439
440    /// Look up one tile by exact `(zoom, x, y, time)`.
441    pub fn find_tile(&self, zoom: u8, x: u32, y: u32, t: i64) -> Option<&TileEntry> {
442        self.tiles_at_time(t)
443            .into_iter()
444            .find(|e| e.zoom == zoom && e.x == x && e.y == y)
445    }
446
447    /// Read and decompress a tile's raw payload bytes, verifying its CRC32C.
448    pub fn read_payload(&self, entry: &TileEntry) -> Result<Vec<u8>> {
449        let start = entry.offset as usize;
450        let end = start + entry.length as usize;
451        if end > self.mmap.len() {
452            return Err(Error::InvalidArchive(format!(
453                "tile {:?} blob range {start}..{end} exceeds archive size {}",
454                entry.tile_id(),
455                self.mmap.len()
456            )));
457        }
458        let compressed = &self.mmap[start..end];
459
460        if crc32c_tag(compressed) != entry.crc32c {
461            return Err(Error::InvalidArchive(format!(
462                "tile {:?} failed integrity check (corrupt archive)",
463                entry.tile_id()
464            )));
465        }
466
467        let payload = if self.header.compression == Compression::Zstd {
468            compression::decompress_zstd_with_dict(compressed, self.dictionary.as_deref())?
469        } else {
470            compression::decompress(compressed, self.header.compression)?
471        };
472        if payload.len() != entry.uncompressed_size as usize {
473            return Err(Error::InvalidArchive(format!(
474                "tile {:?} decompressed to {} bytes, expected {}",
475                entry.tile_id(),
476                payload.len(),
477                entry.uncompressed_size
478            )));
479        }
480        Ok(payload)
481    }
482
483    /// Read and decode a tile into its Arrow layers.
484    pub fn read_layers(&self, entry: &TileEntry) -> Result<Vec<crate::arrow_tile::DecodedLayer>> {
485        let payload = self.read_payload(entry)?;
486        crate::arrow_tile::decode_tile(&payload)
487    }
488}
489
490// ----------------------------------------------------------------------------
491// Writer
492// ----------------------------------------------------------------------------
493
494/// A tile buffered for deferred, dictionary-trained compression.
495struct PendingTile {
496    z: u8,
497    x: u32,
498    y: u32,
499    hilbert: u64,
500    time_start: i64,
501    time_end: i64,
502    cover_t_min: Option<i64>,
503    feature_count: u32,
504    temporal_bucket_ms: Option<u64>,
505    payload: Vec<u8>,
506}
507
508/// Writer for creating an STT archive.
509///
510/// Two modes:
511/// - [`create`](Self::create) — *eager*: each tile is compressed and written as
512///   it arrives, so peak memory is bounded (good for streaming huge builds).
513/// - [`create_optimized`](Self::create_optimized) — *buffered*: tiles are held
514///   until [`finalize`](Self::finalize), where a shared zstd dictionary is
515///   trained, byte-identical blobs are deduplicated, and blobs are written in
516///   Hilbert order. Smaller archives at the cost of holding payloads in memory.
517pub struct ArchiveWriter {
518    file: File,
519    compression: Compression,
520    current_offset: u64,
521    entries: Vec<TileEntry>,
522    /// `Some` in the buffered/optimized mode (tiles parked until finalize).
523    pending: Option<Vec<PendingTile>>,
524    /// Blob byte-order applied at `finalize_buffered` (ignored in eager mode,
525    /// which never reorders blobs).
526    ordering: crate::curve::BlobOrdering,
527    /// Train + ship a shared zstd dictionary (buffered mode). OFF keeps the
528    /// archive decodable by readers without a dictionary-capable zstd (the
529    /// current TS reader throws on a dictionary), so blob *reordering* — which
530    /// is reader-safe on its own — can ship independently of the dict.
531    train_dict: bool,
532}
533
534impl ArchiveWriter {
535    /// Create an archive in eager (streaming) mode. Tile blobs use `compression`.
536    pub fn create<P: AsRef<Path>>(path: P, compression: Compression) -> Result<Self> {
537        Self::open_file(path, compression, None, crate::curve::BlobOrdering::default(), false)
538    }
539
540    /// Create an archive in buffered "optimized" mode: a shared zstd dictionary
541    /// is trained over the tile corpus, byte-identical blobs are deduplicated,
542    /// and blobs are laid down in 2D-spatial-Hilbert order for range-read
543    /// locality. Requires zstd. See [`create_optimized_with_ordering`] to pick a
544    /// different (e.g. space-time) blob order.
545    ///
546    /// [`create_optimized_with_ordering`]: Self::create_optimized_with_ordering
547    pub fn create_optimized<P: AsRef<Path>>(path: P) -> Result<Self> {
548        Self::create_optimized_with_ordering(path, crate::curve::BlobOrdering::SpatialMajor)
549    }
550
551    /// Buffered "optimized" mode with an explicit blob byte-[`ordering`]. The
552    /// directory index stays `(zoom, hilbert, time_start)` regardless; only the
553    /// physical blob layout changes, so readers are unaffected. The default
554    /// ([`BlobOrdering::Hilbert3`]) minimises range requests across the widest
555    /// range of datasets (see `examples/simulate_layout.rs`).
556    ///
557    /// [`ordering`]: crate::curve::BlobOrdering
558    /// [`BlobOrdering::Hilbert3`]: crate::curve::BlobOrdering::Hilbert3
559    pub fn create_optimized_with_ordering<P: AsRef<Path>>(
560        path: P,
561        ordering: crate::curve::BlobOrdering,
562    ) -> Result<Self> {
563        Self::open_file(path, Compression::Zstd, Some(Vec::new()), ordering, true)
564    }
565
566    /// Buffered mode that **reorders blobs but ships NO shared dictionary**, so
567    /// the result stays decodable by readers without a dictionary-capable zstd
568    /// (today's TS reader). Per-blob zstd + byte-identical dedup still apply.
569    /// This is the reader-safe way to get the range-request win from blob
570    /// [`ordering`](crate::curve::BlobOrdering) without the dict that the TS
571    /// reader can't yet decode.
572    pub fn create_reordered<P: AsRef<Path>>(
573        path: P,
574        ordering: crate::curve::BlobOrdering,
575    ) -> Result<Self> {
576        Self::open_file(path, Compression::Zstd, Some(Vec::new()), ordering, false)
577    }
578
579    fn open_file<P: AsRef<Path>>(
580        path: P,
581        compression: Compression,
582        pending: Option<Vec<PendingTile>>,
583        ordering: crate::curve::BlobOrdering,
584        train_dict: bool,
585    ) -> Result<Self> {
586        let mut file = OpenOptions::new()
587            .write(true)
588            .read(true)
589            .create(true)
590            .truncate(true)
591            .open(path)?;
592        file.seek(SeekFrom::Start(HEADER_SIZE))?;
593        Ok(Self {
594            file,
595            compression,
596            current_offset: HEADER_SIZE,
597            entries: Vec::new(),
598            pending,
599            ordering,
600            train_dict,
601        })
602    }
603
604    /// Add a tile. `payload` is the uncompressed tile payload (the layer frame
605    /// produced by [`crate::arrow_tile::encode_tile`]).
606    pub fn add_tile(
607        &mut self,
608        id: &TileId,
609        time_start: i64,
610        time_end: i64,
611        feature_count: u32,
612        payload: &[u8],
613    ) -> Result<()> {
614        self.add_tile_with_bucket(id, time_start, time_end, feature_count, None, payload)
615    }
616
617    /// Add a tile, tagging the directory entry with the temporal bucket size it
618    /// represents (used when emitting temporal-LOD aggregate tiles).
619    pub fn add_tile_with_bucket(
620        &mut self,
621        id: &TileId,
622        time_start: i64,
623        time_end: i64,
624        feature_count: u32,
625        temporal_bucket_ms: Option<u64>,
626        payload: &[u8],
627    ) -> Result<()> {
628        self.add_tile_full(id, time_start, time_end, None, feature_count, temporal_bucket_ms, payload)
629    }
630
631    /// Add a tile carrying the full directory metadata, including the tight
632    /// lower covering bound `cover_t_min` (the earliest feature *start* time
633    /// actually present — see [`TileEntry::cover_t_min`]). `None` leaves the
634    /// entry without a covering bound (clients fall back to `time_start`).
635    #[allow(clippy::too_many_arguments)]
636    pub fn add_tile_full(
637        &mut self,
638        id: &TileId,
639        time_start: i64,
640        time_end: i64,
641        cover_t_min: Option<i64>,
642        feature_count: u32,
643        temporal_bucket_ms: Option<u64>,
644        payload: &[u8],
645    ) -> Result<()> {
646        if let Some(pending) = self.pending.as_mut() {
647            pending.push(PendingTile {
648                z: id.z,
649                x: id.x,
650                y: id.y,
651                hilbert: id.hilbert_index(),
652                time_start,
653                time_end,
654                cover_t_min,
655                feature_count,
656                temporal_bucket_ms,
657                payload: payload.to_vec(),
658            });
659            return Ok(());
660        }
661
662        let uncompressed_size = payload.len() as u32;
663        let compressed = compression::compress(payload, self.compression)?;
664        let crc = crc32c_tag(&compressed);
665        let offset = self.current_offset;
666        let length = compressed.len() as u32;
667        self.file.write_all(&compressed)?;
668        self.current_offset += length as u64;
669
670        self.entries.push(TileEntry {
671            zoom: id.z,
672            x: id.x,
673            y: id.y,
674            time_start,
675            time_end,
676            // Single-file archive: one pack, whole-file-relative offsets.
677            pack_id: 0,
678            offset,
679            length,
680            uncompressed_size,
681            feature_count,
682            hilbert: id.hilbert_index(),
683            crc32c: crc,
684            temporal_bucket_ms,
685            cover_t_min,
686        });
687        Ok(())
688    }
689
690    /// Number of tiles staged so far (buffered or written).
691    pub fn tile_count(&self) -> usize {
692        match &self.pending {
693            Some(p) => p.len(),
694            None => self.entries.len(),
695        }
696    }
697
698    /// Finalise: write the dictionary (if any), directory, metadata, and header.
699    pub fn finalize(self, metadata: &crate::metadata::Metadata) -> Result<()> {
700        if self.pending.is_some() {
701            self.finalize_buffered(metadata)
702        } else {
703            self.finalize_eager(metadata)
704        }
705    }
706
707    /// Eager finalize: blobs are already written; lay down the directory,
708    /// metadata and header (no shared dictionary).
709    fn finalize_eager(mut self, metadata: &crate::metadata::Metadata) -> Result<()> {
710        self.entries
711            .sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
712        self.write_tail(metadata, 0, 0)
713    }
714
715    /// Buffered finalize: train a shared zstd dictionary over a sample of the
716    /// buffered payloads, compress every tile against it, deduplicate byte-
717    /// identical blobs, write blobs in Hilbert order, then the dictionary,
718    /// directory, metadata and header.
719    ///
720    /// Degrades gracefully to plain zstd (empty dictionary slot) when the
721    /// corpus is too small/sparse to train a useful dictionary.
722    fn finalize_buffered(mut self, metadata: &crate::metadata::Metadata) -> Result<()> {
723        let mut pending = self.pending.take().unwrap_or_default();
724
725        // Order the blobs (not just the index) so a viewport maps to a
726        // near-contiguous byte range — what lets the client's range coalescer
727        // merge neighbouring tiles into one HTTP request. The byte order is
728        // decoupled from the directory index (which stays (zoom,hilbert,t)); the
729        // best order depends on the dataset's space-vs-time shape, so it's a
730        // knob (default 3D Hilbert). See crate::curve.
731        let base_bucket = metadata.temporal_bucket_ms.max(1) as i64;
732        let tb = |p: &PendingTile| {
733            let b = p.temporal_bucket_ms.map(|v| v as i64).unwrap_or(base_bucket).max(1);
734            p.time_start.div_euclid(b)
735        };
736        let (tb_min, tb_max) = pending.iter().fold((i64::MAX, i64::MIN), |(lo, hi), p| {
737            let t = tb(p);
738            (lo.min(t), hi.max(t))
739        });
740        let tb_span = if pending.is_empty() { 0 } else { tb_max - tb_min };
741        // Resolve an `Auto` ordering to a concrete one from the dataset's
742        // space-vs-time cardinality (wide-time → spatial-major for playback
743        // locality; otherwise the 3D-Hilbert generalist).
744        let ordering = match self.ordering {
745            crate::curve::BlobOrdering::Auto => {
746                let max_z = pending.iter().map(|p| p.z).max().unwrap_or(0) as u32;
747                let time_bits = crate::curve::bits_for((tb_span.max(0) + 1) as u64);
748                crate::curve::BlobOrdering::choose(max_z, time_bits)
749            }
750            other => other,
751        };
752        // Total tiebreak after the curve key (which ties between base and
753        // temporal-LOD tiles of one cell) so the blob byte order — and any
754        // packed transcode derived from it — is reproducible across rebuilds.
755        pending.sort_by_key(|p| {
756            (
757                crate::curve::space_time_key(
758                    ordering, p.z, p.x, p.y, p.hilbert, p.time_start, tb(p), tb_min, tb_span,
759                ),
760                p.z,
761                p.x,
762                p.y,
763                p.time_start,
764                p.temporal_bucket_ms,
765            )
766        });
767
768        // Train a shared dictionary from a bounded sample of payloads (only when
769        // requested). Bytes shared across tiles (Arrow schema, dictionary key
770        // names, common coordinate prefixes) get hoisted into the dictionary
771        // once instead of repeating in every compressed blob. Skipped by the
772        // reorder-only path so the archive stays decodable by readers without a
773        // dictionary-capable zstd; `compress_zstd_with_dict(_, None)` then emits
774        // plain zstd and the dictionary section is omitted.
775        let dictionary = if self.train_dict {
776            let mut sample: Vec<&[u8]> = Vec::new();
777            let mut sample_bytes = 0usize;
778            for p in &pending {
779                if sample.len() >= DICT_SAMPLE_MAX_TILES || sample_bytes >= DICT_SAMPLE_MAX_BYTES {
780                    break;
781                }
782                sample_bytes += p.payload.len();
783                sample.push(p.payload.as_slice());
784            }
785            compression::train_zstd_dictionary_from_slices(&sample, DICT_MAX_SIZE)
786        } else {
787            None
788        };
789
790        // Stream the dictionary-compressed blobs, deduplicating byte-identical
791        // ones (a static cell repeated across time buckets writes its blob
792        // once); the directory's run-length encoding then collapses the shared
793        // references into a single run.
794        self.file.seek(SeekFrom::Start(HEADER_SIZE))?;
795        self.current_offset = HEADER_SIZE;
796        let mut blob_dedup: std::collections::HashMap<[u8; 32], (u64, u32, u32, u32)> =
797            std::collections::HashMap::new();
798        for p in &pending {
799            let compressed =
800                compression::compress_zstd_with_dict(&p.payload, dictionary.as_deref())?;
801            let uncompressed_size = p.payload.len() as u32;
802            // Strong hash of the *compressed* bytes — deterministic zstd makes
803            // identical payloads identical blobs, so this dedup is exact.
804            let key = *blake3::hash(&compressed).as_bytes();
805            let (offset, length, crc) = if let Some(&(off, len, _unc, crc)) = blob_dedup.get(&key) {
806                (off, len, crc)
807            } else {
808                let offset = self.current_offset;
809                let length = compressed.len() as u32;
810                let crc = crc32c_tag(&compressed);
811                self.file.write_all(&compressed)?;
812                self.current_offset += length as u64;
813                blob_dedup.insert(key, (offset, length, uncompressed_size, crc));
814                (offset, length, crc)
815            };
816            self.entries.push(TileEntry {
817                zoom: p.z,
818                x: p.x,
819                y: p.y,
820                time_start: p.time_start,
821                time_end: p.time_end,
822                // Single-file archive: one pack, whole-file-relative offsets.
823                pack_id: 0,
824                offset,
825                length,
826                uncompressed_size,
827                feature_count: p.feature_count,
828                hilbert: p.hilbert,
829                crc32c: crc,
830                temporal_bucket_ms: p.temporal_bucket_ms,
831                cover_t_min: p.cover_t_min,
832            });
833        }
834
835        // Dictionary section sits between the tile data and the directory so a
836        // reader can coalesce it with the directory range request.
837        let (dictionary_offset, dictionary_length) = if let Some(dict) = &dictionary {
838            let off = self.current_offset;
839            self.file.write_all(dict)?;
840            self.current_offset += dict.len() as u64;
841            (off, dict.len() as u64)
842        } else {
843            (0, 0)
844        };
845
846        // Bucket tiebreak keeps base-vs-LOD ties deterministic (see the blob
847        // sort above); the codec only requires the (zoom, hilbert, t) prefix.
848        self.entries
849            .sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
850        self.write_tail(metadata, dictionary_offset, dictionary_length)
851    }
852
853    /// Shared tail writer: directory + metadata + header. Assumes blobs (and the
854    /// dictionary, if any) are already written and `self.entries` is sorted.
855    fn write_tail(
856        mut self,
857        metadata: &crate::metadata::Metadata,
858        dictionary_offset: u64,
859        dictionary_length: u64,
860    ) -> Result<()> {
861        let index_bytes = crate::directory::encode_directory(&self.entries);
862        let index_offset = self.current_offset;
863        let index_length = index_bytes.len() as u64;
864        self.file.write_all(&index_bytes)?;
865        self.current_offset += index_length;
866
867        let metadata_bytes = metadata.to_json_bytes()?;
868        let metadata_offset = self.current_offset;
869        let metadata_length = metadata_bytes.len() as u64;
870        self.file.write_all(&metadata_bytes)?;
871        self.current_offset += metadata_length;
872
873        self.file.flush()?;
874        self.file.set_len(self.current_offset)?;
875
876        let header = ArchiveHeader {
877            version: FORMAT_VERSION,
878            compression: self.compression,
879            index_offset,
880            index_length,
881            metadata_offset,
882            metadata_length,
883            dictionary_offset,
884            dictionary_length,
885        };
886        self.file.seek(SeekFrom::Start(0))?;
887        header.write(&mut self.file)?;
888        self.file.flush()?;
889        Ok(())
890    }
891}
892
893/// High-level archive entry points.
894pub struct Archive;
895
896impl Archive {
897    /// Open an existing archive.
898    pub fn open<P: AsRef<Path>>(path: P) -> Result<ArchiveReader> {
899        ArchiveReader::open(path)
900    }
901
902    /// Create a new archive (eager mode).
903    pub fn create<P: AsRef<Path>>(path: P, compression: Compression) -> Result<ArchiveWriter> {
904        ArchiveWriter::create(path, compression)
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911    use crate::arrow_tile::{ColumnarLayer, GeometryColumn};
912    use tempfile::NamedTempFile;
913
914    fn point_layer(name: &str, ids: Vec<u64>, t0: i64) -> ColumnarLayer {
915        let n = ids.len();
916        ColumnarLayer {
917            name: name.to_string(),
918            feature_ids: ids,
919            start_times: vec![t0; n],
920            end_times: vec![t0 + 100; n],
921            geometry: GeometryColumn::Point(vec![[-122.4, 37.7]; n]),
922            vertex_times: None,
923            vertex_values: None,
924            triangles: None,
925            vertex_value_matrix: None,
926            properties: vec![],
927        }
928    }
929
930    #[test]
931    fn compression_byte_set_is_frozen() {
932        // The wire compression byte-set is FROZEN: 0=None, 2=Zstd. Byte 1 (the
933        // never-shipped gzip) is retired/reserved and must be REJECTED, not
934        // silently mapped. This locks the spec's compression codes to the
935        // canonical Rust reader (see data-format.md).
936        assert_eq!(compression_to_byte(Compression::None), 0);
937        assert_eq!(compression_to_byte(Compression::Zstd), 2);
938
939        assert_eq!(compression_from_byte(0).unwrap(), Compression::None);
940        assert_eq!(compression_from_byte(2).unwrap(), Compression::Zstd);
941
942        // Byte 1 (retired gzip) and any other code are hard-rejected.
943        assert!(compression_from_byte(1).is_err(), "byte 1 (gzip) must be rejected");
944        assert!(compression_from_byte(3).is_err());
945        assert!(compression_from_byte(255).is_err());
946
947        // Round-trip closure for the two live codes.
948        for c in [Compression::None, Compression::Zstd] {
949            assert_eq!(compression_from_byte(compression_to_byte(c)).unwrap(), c);
950        }
951    }
952
953    #[test]
954    fn header_roundtrips() {
955        let header = ArchiveHeader {
956            version: FORMAT_VERSION,
957            compression: Compression::Zstd,
958            index_offset: 1234,
959            index_length: 56,
960            metadata_offset: 1290,
961            metadata_length: 78,
962            dictionary_offset: 1100,
963            dictionary_length: 32,
964        };
965        let mut buf = Vec::new();
966        header.write(&mut buf).unwrap();
967        assert_eq!(buf.len(), HEADER_SIZE as usize);
968        assert_eq!(&buf[..4], MAGIC);
969        let read = ArchiveHeader::read(&mut std::io::Cursor::new(buf)).unwrap();
970        assert_eq!(read.version, FORMAT_VERSION);
971        assert_eq!(read.index_offset, 1234);
972        assert_eq!(read.dictionary_offset, 1100);
973        assert_eq!(read.dictionary_length, 32);
974        assert_eq!(read.compression, Compression::Zstd);
975    }
976
977    #[test]
978    fn rejects_foreign_magic() {
979        let mut buf = vec![b'X', b'Y', b'Z', 9];
980        buf.resize(HEADER_SIZE as usize, 0);
981        assert!(ArchiveHeader::read(&mut std::io::Cursor::new(buf)).is_err());
982    }
983
984    #[test]
985    fn eager_archive_roundtrips_tiles_and_temporal_lookup() {
986        let path = NamedTempFile::new().unwrap().into_temp_path();
987
988        let mut writer = ArchiveWriter::create(&path, Compression::Zstd).unwrap();
989        let tile_a = crate::arrow_tile::encode_tile(&[point_layer("default", vec![1], 1000)]).unwrap();
990        let tile_b = crate::arrow_tile::encode_tile(&[point_layer("default", vec![2, 3], 1500)]).unwrap();
991        let tile_c = crate::arrow_tile::encode_tile(&[point_layer("default", vec![4], 5000)]).unwrap();
992        writer.add_tile(&TileId::new(10, 1, 1, 1000), 1000, 2000, 1, &tile_a).unwrap();
993        writer.add_tile(&TileId::new(10, 2, 2, 1500), 1500, 3000, 2, &tile_b).unwrap();
994        writer.add_tile(&TileId::new(11, 4, 4, 5000), 5000, 6000, 1, &tile_c).unwrap();
995        writer.finalize(&crate::metadata::Metadata::new("test-archive")).unwrap();
996
997        let reader = ArchiveReader::open(&path).unwrap();
998        assert_eq!(reader.header().version, FORMAT_VERSION);
999        assert_eq!(reader.entries().len(), 3);
1000        assert_eq!(reader.metadata().name, "test-archive");
1001
1002        assert_eq!(reader.tiles_at_time(1800).len(), 2);
1003        let at = reader.tiles_at_time(2500);
1004        assert_eq!(at.len(), 1);
1005        assert_eq!(at[0].x, 2);
1006        assert!(reader.tiles_at_time(4000).is_empty());
1007
1008        let entry = reader.find_tile(10, 2, 2, 2000).unwrap().clone();
1009        let layers = reader.read_layers(&entry).unwrap();
1010        assert_eq!(layers.len(), 1);
1011        assert_eq!(layers[0].name, "default");
1012        assert_eq!(layers[0].batch.num_rows(), 2);
1013    }
1014
1015    #[test]
1016    fn corrupt_blob_is_detected() {
1017        let path = NamedTempFile::new().unwrap().into_temp_path();
1018        let mut writer = ArchiveWriter::create(&path, Compression::None).unwrap();
1019        let payload = crate::arrow_tile::encode_tile(&[point_layer("default", vec![1], 1000)]).unwrap();
1020        writer.add_tile(&TileId::new(5, 0, 0, 1000), 1000, 2000, 1, &payload).unwrap();
1021        writer.finalize(&crate::metadata::Metadata::new("corrupt")).unwrap();
1022
1023        let mut bytes = std::fs::read(&path).unwrap();
1024        bytes[HEADER_SIZE as usize] ^= 0xFF;
1025        std::fs::write(&path, &bytes).unwrap();
1026
1027        let reader = ArchiveReader::open(&path).unwrap();
1028        let entry = reader.entries()[0].clone();
1029        assert!(reader.read_payload(&entry).is_err());
1030    }
1031
1032    /// `TemporalLookup` build correctness + worst-case timing regression
1033    /// (50k entries must build in well under a second).
1034    #[test]
1035    fn temporal_lookup_build_is_fast_and_correct() {
1036        let bucket = 3_600_000i64; // 1 hour
1037        let mut entries = Vec::with_capacity(50_000);
1038        for cell in 0..200u32 {
1039            for b in 0..250u32 {
1040                let t_start = (b as i64) * bucket;
1041                entries.push(TileEntry {
1042                    zoom: 10,
1043                    x: cell,
1044                    y: 0,
1045                    time_start: t_start,
1046                    time_end: t_start + bucket - 1,
1047                    pack_id: 0,
1048                    offset: 0,
1049                    length: 0,
1050                    uncompressed_size: 0,
1051                    feature_count: 0,
1052                    hilbert: 0,
1053                    crc32c: 0,
1054                    temporal_bucket_ms: None,
1055                    cover_t_min: None,
1056                });
1057            }
1058        }
1059
1060        let started = std::time::Instant::now();
1061        let lookup = TemporalLookup::build(&entries);
1062        let elapsed_ms = started.elapsed().as_millis();
1063        assert!(elapsed_ms < 1000, "TemporalLookup::build took {elapsed_ms}ms for 50k entries");
1064
1065        let query_t = bucket * 123 + bucket / 2;
1066        let got: std::collections::BTreeSet<u32> = lookup.at(query_t).iter().copied().collect();
1067        let expected: std::collections::BTreeSet<u32> = entries
1068            .iter()
1069            .enumerate()
1070            .filter(|(_, e)| e.time_start <= query_t && query_t <= e.time_end)
1071            .map(|(i, _)| i as u32)
1072            .collect();
1073        assert_eq!(got, expected);
1074        assert!(lookup.at(-1).is_empty());
1075        assert!(lookup.at(bucket * 1_000_000).is_empty());
1076    }
1077
1078    /// Optimized (buffered) archive: byte-identical tiles across time dedup to a
1079    /// single blob and the run-length directory round-trips every entry.
1080    #[test]
1081    fn optimized_archive_dedups_and_roundtrips() {
1082        let path = NamedTempFile::new().unwrap().into_temp_path();
1083        let mut writer = ArchiveWriter::create_optimized(&path).unwrap();
1084
1085        let static_payload =
1086            crate::arrow_tile::encode_tile(&[point_layer("default", vec![1], 0)]).unwrap();
1087        for b in 0..5i64 {
1088            let t = b * 3_600_000;
1089            writer
1090                .add_tile(&TileId::new(9, 3, 4, t as u64), t, t + 3_599_999, 1, &static_payload)
1091                .unwrap();
1092        }
1093        let other =
1094            crate::arrow_tile::encode_tile(&[point_layer("default", vec![2, 3], 1000)]).unwrap();
1095        writer.add_tile(&TileId::new(9, 5, 6, 0), 0, 1000, 2, &other).unwrap();
1096        writer.finalize(&crate::metadata::Metadata::new("opt")).unwrap();
1097
1098        let reader = ArchiveReader::open(&path).unwrap();
1099        assert_eq!(reader.header().version, FORMAT_VERSION);
1100        assert_eq!(reader.entries().len(), 6);
1101
1102        let static_offsets: std::collections::BTreeSet<u64> = reader
1103            .entries()
1104            .iter()
1105            .filter(|e| e.x == 3 && e.y == 4)
1106            .map(|e| e.offset)
1107            .collect();
1108        assert_eq!(static_offsets.len(), 1, "static-across-time tiles should dedup to one blob");
1109
1110        let mid = reader.find_tile(9, 3, 4, 3 * 3_600_000).unwrap().clone();
1111        assert_eq!(reader.read_layers(&mid).unwrap()[0].batch.num_rows(), 1);
1112        let distinct = reader.entries().iter().find(|e| e.x == 5).unwrap().clone();
1113        assert_eq!(reader.read_layers(&distinct).unwrap()[0].batch.num_rows(), 2);
1114    }
1115
1116    /// The reorder-only buffered path applies the chosen blob ordering but ships
1117    /// NO shared dictionary, so the archive stays decodable by readers without a
1118    /// dictionary-capable zstd (today's TS reader throws on a dictionary). This
1119    /// is the reader-safety contract behind `stt-build --blob-ordering`.
1120    #[test]
1121    fn reordered_archive_has_no_dict_and_roundtrips() {
1122        use crate::curve::{space_time_key, BlobOrdering};
1123        let path = NamedTempFile::new().unwrap().into_temp_path();
1124        let mut writer = ArchiveWriter::create_reordered(&path, BlobOrdering::Hilbert3).unwrap();
1125
1126        // A small space×time grid with all-distinct payloads (no dedup) so blob
1127        // order is a 1:1 image of the curve order.
1128        let mut expected = 0usize;
1129        for x in 0..4u32 {
1130            for y in 0..4u32 {
1131                for b in 0..3i64 {
1132                    let t = b * 3_600_000;
1133                    let p = crate::arrow_tile::encode_tile(&[point_layer(
1134                        "default",
1135                        vec![(x * 16 + y) as u64 + b as u64 * 1000],
1136                        1000,
1137                    )])
1138                    .unwrap();
1139                    writer
1140                        .add_tile(&TileId::new(10, x, y, t as u64), t, t + 3_599_999, 1, &p)
1141                        .unwrap();
1142                    expected += 1;
1143                }
1144            }
1145        }
1146        writer
1147            .finalize(&crate::metadata::Metadata::new("reordered"))
1148            .unwrap();
1149
1150        let reader = ArchiveReader::open(&path).unwrap();
1151        // Reader-safety contract: NO shared dictionary section.
1152        assert!(reader.dictionary().is_none(), "reorder path must not ship a dict");
1153        assert_eq!(reader.entries().len(), expected);
1154        // Every tile still decodes.
1155        for e in reader.entries().to_vec() {
1156            assert_eq!(reader.read_layers(&e).unwrap()[0].batch.num_rows(), 1);
1157        }
1158        // Blobs are physically laid down in Hilbert order: walking entries by
1159        // ascending byte offset yields a non-decreasing curve key.
1160        let mut ents = reader.entries().to_vec();
1161        ents.sort_by_key(|e| e.offset);
1162        let key = |e: &TileEntry| {
1163            space_time_key(
1164                BlobOrdering::Hilbert3,
1165                e.zoom,
1166                e.x,
1167                e.y,
1168                e.hilbert,
1169                e.time_start,
1170                e.time_start.div_euclid(3_600_000),
1171                0,
1172                2,
1173            )
1174        };
1175        for w in ents.windows(2) {
1176            assert!(key(&w[0]) <= key(&w[1]), "blobs must be in Hilbert order on disk");
1177        }
1178    }
1179
1180    /// The shared dictionary shrinks a corpus of many small similar tiles and
1181    /// every tile still decodes against the embedded dictionary.
1182    #[test]
1183    fn optimized_dictionary_shrinks_and_roundtrips() {
1184        use crate::arrow_tile::{ColumnarLayer, GeometryColumn, PropertyColumn};
1185
1186        let kinds = ["bike", "car", "bus", "scooter"];
1187        let make_payload = |seed: u64| -> Vec<u8> {
1188            let f = 8usize;
1189            let mut feature_ids = Vec::with_capacity(f);
1190            let mut start_times = Vec::with_capacity(f);
1191            let mut end_times = Vec::with_capacity(f);
1192            let mut paths: Vec<Vec<[f64; 2]>> = Vec::with_capacity(f);
1193            let mut vtimes: Vec<Vec<i64>> = Vec::with_capacity(f);
1194            let mut kind: Vec<Option<String>> = Vec::with_capacity(f);
1195            for i in 0..f {
1196                feature_ids.push(seed * 100 + i as u64);
1197                let t0 = i as i64 * 1000;
1198                start_times.push(t0);
1199                end_times.push(t0 + 1000);
1200                paths.push(
1201                    (0..16)
1202                        .map(|j| {
1203                            [
1204                                -122.4 + i as f64 * 0.001 + j as f64 * 0.0001,
1205                                37.7 + seed as f64 * 0.0001 + j as f64 * 0.0001,
1206                            ]
1207                        })
1208                        .collect(),
1209                );
1210                vtimes.push((0..16).map(|j| t0 + j * 60).collect());
1211                kind.push(Some(kinds[(seed as usize + i) % kinds.len()].to_string()));
1212            }
1213            crate::arrow_tile::encode_tile(&[ColumnarLayer {
1214                name: "trips".to_string(),
1215                feature_ids,
1216                start_times,
1217                end_times,
1218                geometry: GeometryColumn::LineString(paths),
1219                vertex_times: Some(vtimes),
1220                vertex_values: None,
1221                triangles: None,
1222                vertex_value_matrix: None,
1223                properties: vec![("kind".to_string(), PropertyColumn::Categorical(kind))],
1224            }])
1225            .unwrap()
1226        };
1227
1228        let dir = tempfile::tempdir().unwrap();
1229        let opt_path = dir.path().join("opt.stt");
1230        let plain_path = dir.path().join("plain.stt");
1231
1232        let mut w = ArchiveWriter::create_optimized(&opt_path).unwrap();
1233        for s in 0..512u64 {
1234            w.add_tile(&TileId::new(10, (s % 32) as u32, (s / 32) as u32, 0), 0, 8000, 8, &make_payload(s))
1235                .unwrap();
1236        }
1237        w.finalize(&crate::metadata::Metadata::new("opt")).unwrap();
1238
1239        let mut w2 = ArchiveWriter::create(&plain_path, Compression::Zstd).unwrap();
1240        for s in 0..512u64 {
1241            w2.add_tile(&TileId::new(10, (s % 32) as u32, (s / 32) as u32, 0), 0, 8000, 8, &make_payload(s))
1242                .unwrap();
1243        }
1244        w2.finalize(&crate::metadata::Metadata::new("plain")).unwrap();
1245
1246        let opt_size = std::fs::metadata(&opt_path).unwrap().len();
1247        let plain_size = std::fs::metadata(&plain_path).unwrap().len();
1248        eprintln!("optimized: {opt_size} bytes, eager-zstd: {plain_size} bytes");
1249
1250        let reader = ArchiveReader::open(&opt_path).unwrap();
1251        assert!(reader.dictionary().is_some(), "expected a trained dictionary");
1252        let entry = reader.entries()[0].clone();
1253        assert_eq!(reader.read_layers(&entry).unwrap()[0].batch.num_rows(), 8);
1254        assert!(opt_size < plain_size, "dictionary should shrink: opt={opt_size}, plain={plain_size}");
1255    }
1256}