Skip to main content

stt_optimize/analysis/
inspect.rs

1//! Built-tileset inspection: per-zoom directory stats, per-column compressed
2//! byte attribution, dedup and compression ratios.
3//!
4//! Library port of the `packed-stats` and `point_column_stats` stt-core
5//! examples. Directory-derived stats (per-zoom, dedup, wire totals) are always
6//! computed over EVERY entry — they cost no payload reads. Only the expensive
7//! Arrow decode + per-column re-encode is optionally restricted to a
8//! deterministic sample (mirroring `stt-validate --sample` semantics).
9//!
10//! Per-column attribution re-encodes each decoded column alone (Arrow IPC +
11//! zstd-19) to get a fair compressed-byte share. The per-column sum exceeds the
12//! whole-tile size (lost cross-column sharing + per-column IPC framing), so it
13//! is used for *shares*, not absolute wire accounting. The technique is
14//! geometry-agnostic: every column — point/line/polygon geometry included — is
15//! a plain Arrow array that a single-column `RecordBatch` can carry.
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::sync::Arc;
19
20use anyhow::{Context, Result};
21use arrow::array::RecordBatch;
22use arrow::datatypes::{DataType, Field, Schema};
23use arrow::ipc::writer::StreamWriter;
24use serde::{Deserialize, Serialize};
25use stt_core::arrow_tile::{DecodedLayer, STT_QUANT_ATTR_META_KEY, STT_QUANT_META_KEY};
26use stt_core::compression::compress_zstd_with_dict_level;
27
28use crate::packed::PackedTileset;
29
30/// zstd level for the standalone per-column re-encode. Fixed at the publish
31/// level so shares are comparable across datasets regardless of the level
32/// their blobs were built with.
33const COLUMN_ZSTD_LEVEL: i32 = 19;
34
35/// Directory statistics for one zoom level.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ZoomStats {
38    /// Zoom level.
39    pub zoom: u8,
40    /// Directory entries at this zoom.
41    pub entries: u64,
42    /// Distinct physical blobs referenced (entries sharing a deduped blob
43    /// count once). Blob identity is `(pack_id, offset)`.
44    pub distinct_blobs: u64,
45    /// Sum of compressed blob lengths over ENTRIES (a shared blob counts once
46    /// per referencing entry — the bytes a reader streaming this zoom fetches).
47    pub blob_bytes_total: u64,
48    /// Largest single compressed blob at this zoom.
49    pub blob_bytes_max: u64,
50    /// `blob_bytes_total / entries`.
51    pub avg_blob_bytes: f64,
52    /// Distinct temporal buckets (`time_start` values) at this zoom.
53    pub t_buckets: u64,
54}
55
56/// Entries-vs-blobs dedup accounting over the whole directory.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct DedupStats {
59    /// Total directory entries.
60    pub entries: u64,
61    /// Distinct physical blobs (`(pack_id, offset)` pairs).
62    pub distinct_blobs: u64,
63    /// `distinct_blobs / entries` — `1.0` means no dedup, `< 1.0` means
64    /// byte-identical tiles were collapsed at build time.
65    pub dedup_ratio: f64,
66}
67
68/// Decode-pass statistics (the only part of the report that reads payloads).
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DecodeStats {
71    /// Entries whose payload was decoded.
72    pub tiles_decoded: u64,
73    /// Total entries (== `InspectReport::tile_count`; here for ratio context).
74    pub tiles_total: u64,
75    /// True when the decode covered a sampled subset, so a reader never
76    /// mistakes sampled per-column numbers for exhaustive ones.
77    pub sampled: bool,
78    /// Feature rows summed over the decoded tiles' layers.
79    pub features_decoded: u64,
80    /// Distinct layer-schema signatures across decoded tiles. `> 1` means
81    /// producer drift (tiles disagree on columns or types).
82    pub distinct_layer_schemas: u64,
83}
84
85/// Compressed-byte attribution for one column (merged by name across layers
86/// and decoded tiles).
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct ColumnCost {
89    /// Column name (e.g. `geometry`, `vertex_time`, a property name).
90    pub name: String,
91    /// Arrow data type, `Debug`-formatted.
92    pub dtype: String,
93    /// Standalone re-encode size (IPC + zstd-19) summed over decoded tiles.
94    pub compressed_bytes: u64,
95    /// `compressed_bytes / Σ all columns' compressed_bytes` — the fair share.
96    pub share: f64,
97    /// `compressed_bytes / rows` over the batches that carry this column.
98    pub bytes_per_feature: f64,
99    /// Encoding flag the doctor keys off: `dictionary-encoded`, `quantized
100    /// attr (stt:qa)`, `quantized coords (stt:quant)`, `u16 vertex-time
101    /// deltas`, `plain f64 (unquantized)` — empty when nothing notable.
102    pub encoding_note: String,
103}
104
105/// Full inspection report for a packed tileset.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct InspectReport {
108    /// Dataset name from the manifest metadata.
109    pub name: String,
110    /// Metadata min zoom.
111    pub min_zoom: u8,
112    /// Metadata max zoom.
113    pub max_zoom: u8,
114    /// Time range start (Unix ms).
115    pub time_start_ms: u64,
116    /// Time range end (Unix ms).
117    pub time_end_ms: u64,
118    /// Base temporal bucket size (ms).
119    pub temporal_bucket_ms: u64,
120    /// Directory entry count.
121    pub tile_count: u64,
122    /// Index-weighted feature total (sum of per-entry counts, all entries).
123    pub feature_count: u64,
124    /// Pack objects in the manifest.
125    pub pack_count: u64,
126    /// Whether the directory ships paged (reporting only; reads are identical).
127    pub paged_directory: bool,
128    /// Sum of compressed blob lengths over all entries (directory, total).
129    pub compressed_bytes: u64,
130    /// Sum of uncompressed payload sizes over all entries (directory, total).
131    pub uncompressed_bytes: u64,
132    /// `uncompressed_bytes / compressed_bytes`.
133    pub compression_ratio: f64,
134    /// Per-zoom directory stats (always total).
135    pub per_zoom: Vec<ZoomStats>,
136    /// Whole-directory dedup accounting (always total).
137    pub dedup: DedupStats,
138    /// Decode-pass stats (sampled when `sample` was given).
139    pub decode: DecodeStats,
140    /// Per-column compressed-cost attribution, largest first (from the same
141    /// decoded subset as `decode`).
142    pub per_column: Vec<ColumnCost>,
143}
144
145/// Deterministic stride for sampling: pick every `ceil(total/n)`-th entry
146/// starting at index 0, yielding at most `n` evenly-spread tiles. Same
147/// semantics as `stt-validate --sample`: reproducible across runs, no
148/// randomness. Callers guard `n == 0` (decode nothing).
149fn sample_stride(total: usize, n: usize) -> usize {
150    total.div_ceil(n).max(1)
151}
152
153/// Schema signature for producer-drift detection: layer name + every field's
154/// `name:type`, sorted so layer order can't alias two identical schemas.
155fn schema_signature(layers: &[DecodedLayer]) -> String {
156    let mut parts: Vec<String> = layers
157        .iter()
158        .map(|layer| {
159            let cols: Vec<String> = layer
160                .batch
161                .schema()
162                .fields()
163                .iter()
164                .map(|f| format!("{}:{:?}", f.name(), f.data_type()))
165                .collect();
166            format!("{}{{{}}}", layer.name, cols.join(","))
167        })
168        .collect();
169    parts.sort();
170    parts.join("|")
171}
172
173/// Does `dt` contain `Float64` anywhere in its (possibly nested) type tree?
174fn contains_f64(dt: &DataType) -> bool {
175    match dt {
176        DataType::Float64 => true,
177        DataType::List(f) | DataType::LargeList(f) | DataType::FixedSizeList(f, _) => {
178            contains_f64(f.data_type())
179        }
180        DataType::Dictionary(_, v) => contains_f64(v),
181        _ => false,
182    }
183}
184
185/// Does `dt` contain `needle` as a leaf type?
186fn contains_leaf(dt: &DataType, needle: &DataType) -> bool {
187    if dt == needle {
188        return true;
189    }
190    match dt {
191        DataType::List(f) | DataType::LargeList(f) | DataType::FixedSizeList(f, _) => {
192            contains_leaf(f.data_type(), needle)
193        }
194        _ => false,
195    }
196}
197
198/// Derive the encoding flag for a field — the "smells" the recommendation
199/// pass keys off. Empty when nothing notable.
200fn encoding_note(field: &Field) -> String {
201    if field.metadata().contains_key(STT_QUANT_META_KEY) {
202        return "quantized coords (stt:quant)".to_string();
203    }
204    if field.metadata().contains_key(STT_QUANT_ATTR_META_KEY) {
205        return "quantized attr (stt:qa)".to_string();
206    }
207    if matches!(field.data_type(), DataType::Dictionary(_, _)) {
208        return "dictionary-encoded".to_string();
209    }
210    if field.name() == "vertex_time" {
211        if contains_leaf(field.data_type(), &DataType::UInt16) {
212            return "u16 vertex-time deltas".to_string();
213        }
214        if contains_leaf(field.data_type(), &DataType::Int64) {
215            return "i64 absolute vertex-time".to_string();
216        }
217    }
218    if contains_f64(field.data_type()) {
219        return "plain f64 (unquantized)".to_string();
220    }
221    String::new()
222}
223
224/// Re-encode a batch standalone (Arrow IPC stream + zstd-19) and return the
225/// compressed length — the fair-share cost unit for column attribution.
226fn ipc_zstd_len(batch: &RecordBatch) -> Result<u64> {
227    let mut buf = Vec::new();
228    {
229        let mut w =
230            StreamWriter::try_new(&mut buf, &batch.schema()).context("column IPC writer init")?;
231        w.write(batch).context("column IPC write")?;
232        w.finish().context("column IPC finish")?;
233    }
234    Ok(compress_zstd_with_dict_level(&buf, None, COLUMN_ZSTD_LEVEL)?.len() as u64)
235}
236
237/// Inspect a packed tileset.
238///
239/// `sample`: `None` decodes every tile; `Some(n)` decodes a deterministic,
240/// evenly-spread sample of at most `n` tiles (every `ceil(total/n)`-th
241/// directory entry, starting at 0 — `stt-validate --sample` semantics);
242/// `Some(0)` skips the decode pass entirely. Directory-derived stats
243/// (`per_zoom`, `dedup`, the wire totals) are ALWAYS computed over all
244/// entries — only the decode-based stats (`decode`, `per_column`) sample.
245pub fn inspect(tileset: &PackedTileset, sample: Option<usize>) -> Result<InspectReport> {
246    let entries = tileset.entries();
247    let meta = tileset.metadata();
248
249    // --- Directory pass: always total, no payload reads --------------------
250    #[derive(Default)]
251    struct ZoomAcc {
252        entries: u64,
253        blobs: BTreeSet<(u32, u64)>,
254        bytes_total: u64,
255        bytes_max: u64,
256        t_starts: BTreeSet<i64>,
257    }
258    let mut per_zoom: BTreeMap<u8, ZoomAcc> = BTreeMap::new();
259    let mut all_blobs: BTreeSet<(u32, u64)> = BTreeSet::new();
260    let mut compressed_bytes = 0u64;
261    let mut uncompressed_bytes = 0u64;
262    let mut feature_count = 0u64;
263    for e in entries {
264        let z = per_zoom.entry(e.zoom).or_default();
265        z.entries += 1;
266        z.blobs.insert((e.pack_id, e.offset));
267        z.bytes_total += e.length as u64;
268        z.bytes_max = z.bytes_max.max(e.length as u64);
269        z.t_starts.insert(e.time_start);
270        all_blobs.insert((e.pack_id, e.offset));
271        compressed_bytes += e.length as u64;
272        uncompressed_bytes += e.uncompressed_size as u64;
273        feature_count += e.feature_count as u64;
274    }
275    let per_zoom: Vec<ZoomStats> = per_zoom
276        .into_iter()
277        .map(|(zoom, z)| ZoomStats {
278            zoom,
279            entries: z.entries,
280            distinct_blobs: z.blobs.len() as u64,
281            blob_bytes_total: z.bytes_total,
282            blob_bytes_max: z.bytes_max,
283            avg_blob_bytes: z.bytes_total as f64 / z.entries.max(1) as f64,
284            t_buckets: z.t_starts.len() as u64,
285        })
286        .collect();
287    let dedup = DedupStats {
288        entries: entries.len() as u64,
289        distinct_blobs: all_blobs.len() as u64,
290        dedup_ratio: all_blobs.len() as f64 / entries.len().max(1) as f64,
291    };
292
293    // --- Decode pass: sampled when requested --------------------------------
294    #[derive(Default)]
295    struct ColAcc {
296        dtype: String,
297        note: String,
298        compressed: u64,
299        rows: u64,
300    }
301    let mut cols: BTreeMap<String, ColAcc> = BTreeMap::new();
302    let mut schemas: BTreeSet<String> = BTreeSet::new();
303    let mut tiles_decoded = 0u64;
304    let mut features_decoded = 0u64;
305    let stride = sample.map(|n| {
306        if n == 0 {
307            usize::MAX
308        } else {
309            sample_stride(entries.len(), n)
310        }
311    });
312    for (idx, e) in entries.iter().enumerate() {
313        let decode_this = match stride {
314            None => true,
315            Some(usize::MAX) => false,
316            Some(s) => idx % s == 0,
317        };
318        if !decode_this {
319            continue;
320        }
321        let layers = tileset.read_layers(e).with_context(|| {
322            format!(
323                "decoding tile z{}/{}/{} t{}",
324                e.zoom, e.x, e.y, e.time_start
325            )
326        })?;
327        tiles_decoded += 1;
328        schemas.insert(schema_signature(&layers));
329        for layer in &layers {
330            let batch = &layer.batch;
331            let rows = batch.num_rows() as u64;
332            features_decoded += rows;
333            let schema = batch.schema();
334            for (i, field) in schema.fields().iter().enumerate() {
335                // Strip field metadata from the standalone re-encode: Arrow IPC
336                // serializes the metadata HashMap in nondeterministic order, so
337                // keeping it would make repeated inspections disagree by a few
338                // bytes. The metadata is negligible for shares; the encoding
339                // note (derived from the ORIGINAL field below) preserves it.
340                let clean = field.as_ref().clone().with_metadata(Default::default());
341                let one = RecordBatch::try_new(
342                    Arc::new(Schema::new(vec![clean])),
343                    vec![batch.column(i).clone()],
344                )
345                .context("single-column batch")?;
346                let c = cols.entry(field.name().clone()).or_default();
347                c.compressed += ipc_zstd_len(&one)?;
348                c.rows += rows;
349                c.dtype = format!("{:?}", field.data_type());
350                c.note = encoding_note(field);
351            }
352        }
353    }
354    let col_total: u64 = cols.values().map(|c| c.compressed).sum();
355    let mut per_column: Vec<ColumnCost> = cols
356        .into_iter()
357        .map(|(name, c)| ColumnCost {
358            name,
359            dtype: c.dtype,
360            compressed_bytes: c.compressed,
361            share: c.compressed as f64 / col_total.max(1) as f64,
362            bytes_per_feature: c.compressed as f64 / c.rows.max(1) as f64,
363            encoding_note: c.note,
364        })
365        .collect();
366    per_column.sort_by(|a, b| b.compressed_bytes.cmp(&a.compressed_bytes));
367
368    let time_range = tileset.time_range();
369    Ok(InspectReport {
370        name: tileset.name().to_string(),
371        min_zoom: meta.min_zoom,
372        max_zoom: meta.max_zoom,
373        time_start_ms: time_range.start,
374        time_end_ms: time_range.end,
375        temporal_bucket_ms: meta.temporal_bucket_ms,
376        tile_count: entries.len() as u64,
377        feature_count,
378        pack_count: tileset.pack_count() as u64,
379        paged_directory: tileset.is_paged(),
380        compressed_bytes,
381        uncompressed_bytes,
382        compression_ratio: uncompressed_bytes as f64 / compressed_bytes.max(1) as f64,
383        per_zoom,
384        dedup,
385        decode: DecodeStats {
386            tiles_decoded,
387            tiles_total: entries.len() as u64,
388            sampled: stride.is_some(),
389            features_decoded,
390            distinct_layer_schemas: schemas.len() as u64,
391        },
392        per_column,
393    })
394}
395
396/// Render the report as compact aligned text.
397pub fn format_text(report: &InspectReport) -> String {
398    let mut out = String::new();
399    out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
400    out.push_str(&format!("         STT Inspect - {}\n", report.name));
401    out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
402
403    out.push_str("📊 Dataset\n");
404    out.push_str(&format!(
405        "  Tiles: {}   Features (index): {}   Zoom: {}-{}\n",
406        report.tile_count, report.feature_count, report.min_zoom, report.max_zoom
407    ));
408    out.push_str(&format!(
409        "  Time: {}..{} ms   Base bucket: {} ms\n",
410        report.time_start_ms, report.time_end_ms, report.temporal_bucket_ms
411    ));
412    out.push_str(&format!(
413        "  Packs: {}   Directory: {}\n",
414        report.pack_count,
415        if report.paged_directory {
416            "paged"
417        } else {
418            "single"
419        }
420    ));
421    out.push_str(&format!(
422        "  Wire: {:.2} MB compressed -> {:.2} MB decoded ({:.2}x)\n\n",
423        report.compressed_bytes as f64 / 1e6,
424        report.uncompressed_bytes as f64 / 1e6,
425        report.compression_ratio
426    ));
427
428    out.push_str("🗂  Per-zoom directory\n");
429    out.push_str("  zoom |  entries | distinct |  total MB |  max KB |  avg KB | t-buckets\n");
430    for z in &report.per_zoom {
431        out.push_str(&format!(
432            "    {:2} | {:8} | {:8} | {:9.2} | {:7.1} | {:7.1} | {:9}\n",
433            z.zoom,
434            z.entries,
435            z.distinct_blobs,
436            z.blob_bytes_total as f64 / 1e6,
437            z.blob_bytes_max as f64 / 1e3,
438            z.avg_blob_bytes / 1e3,
439            z.t_buckets
440        ));
441    }
442    out.push_str(&format!(
443        "  dedup: {} entries -> {} distinct blobs (ratio {:.3})\n\n",
444        report.dedup.entries, report.dedup.distinct_blobs, report.dedup.dedup_ratio
445    ));
446
447    out.push_str(&format!(
448        "🔬 Decode ({} of {} tiles{})\n",
449        report.decode.tiles_decoded,
450        report.decode.tiles_total,
451        if report.decode.sampled {
452            ", sampled"
453        } else {
454            ""
455        }
456    ));
457    out.push_str(&format!(
458        "  features decoded: {}   distinct layer schemas: {}\n\n",
459        report.decode.features_decoded, report.decode.distinct_layer_schemas
460    ));
461
462    if !report.per_column.is_empty() {
463        out.push_str("💾 Per-column cost (standalone IPC+zstd-19; shares, not absolute wire)\n");
464        out.push_str(&format!(
465            "  {:<22} {:<28} {:>10} {:>9} {:>7}  note\n",
466            "column", "dtype", "comp KB", "B/feat", "share%"
467        ));
468        for c in &report.per_column {
469            let dt = if c.dtype.len() > 27 {
470                format!("{}…", &c.dtype[..26])
471            } else {
472                c.dtype.clone()
473            };
474            out.push_str(&format!(
475                "  {:<22} {:<28} {:>10.1} {:>9.2} {:>6.1}%  {}\n",
476                c.name,
477                dt,
478                c.compressed_bytes as f64 / 1e3,
479                c.bytes_per_feature,
480                100.0 * c.share,
481                c.encoding_note
482            ));
483        }
484    }
485
486    out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
487    out
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use stt_core::arrow_tile::{
494        encode_tile_with, ColumnarLayer, EncoderConfig, GeometryColumn, PropertyColumn,
495    };
496    use stt_core::curve::BlobOrdering;
497    use stt_core::metadata::Metadata;
498    use stt_core::pack::PackWriter;
499    use stt_core::tile::TileId;
500
501    /// A line layer with vertex times (small deltas → u16 encoding), one f64
502    /// property and one categorical (dictionary) property — enough surface to
503    /// exercise the geometry-agnostic column attribution + encoding notes.
504    fn line_layer(seed: u64, n: usize) -> ColumnarLayer {
505        let verts_per = 8usize;
506        let geometry: Vec<Vec<[f64; 2]>> = (0..n)
507            .map(|i| {
508                (0..verts_per)
509                    .map(|v| {
510                        [
511                            -73.6 + (seed as f64) * 0.01 + v as f64 * 0.001,
512                            45.5 + i as f64 * 0.002,
513                        ]
514                    })
515                    .collect()
516            })
517            .collect();
518        let vertex_times: Vec<Vec<i64>> = (0..n)
519            .map(|_| (0..verts_per).map(|v| v as i64 * 50).collect())
520            .collect();
521        ColumnarLayer {
522            name: "default".to_string(),
523            feature_ids: (0..n as u64).map(|i| seed * 1000 + i).collect(),
524            start_times: vec![0; n],
525            end_times: vec![400; n],
526            geometry: GeometryColumn::LineString(geometry),
527            vertex_times: Some(vertex_times),
528            vertex_values: None,
529            triangles: None,
530            vertex_value_matrix: None,
531            properties: vec![
532                (
533                    "speed".to_string(),
534                    PropertyColumn::Numeric((0..n).map(|i| Some(i as f64 * 1.5)).collect()),
535                ),
536                (
537                    "kind".to_string(),
538                    PropertyColumn::Categorical(
539                        (0..n)
540                            .map(|i| Some(["bike", "ferry"][i % 2].to_string()))
541                            .collect(),
542                    ),
543                ),
544            ],
545        }
546    }
547
548    /// Build a real tiny packed tileset: 3 line tiles at z5 (one payload
549    /// duplicated across two entries → dedup) + 1 at z3, two time buckets.
550    /// Frames ride the writer's (default v2) format version + template
551    /// collector so the fixture is version-coherent.
552    fn build_fixture(out: &std::path::Path) {
553        let mut w = PackWriter::create(out, BlobOrdering::Auto, 64 * 1024).unwrap();
554        let cfg = EncoderConfig {
555            format_version: w.format_version(),
556            template_collector: Some(w.template_collector()),
557            ..EncoderConfig::default()
558        };
559        let bucket = 3_600_000i64;
560        let dup = encode_tile_with(&[line_layer(7, 40)], &cfg).unwrap();
561        // z5: two entries sharing the SAME payload bytes (different cells) +
562        // one distinct, across two time buckets.
563        w.add_tile_full(
564            &TileId::new(5, 1, 1, 0),
565            0,
566            bucket - 1,
567            Some(0),
568            40,
569            Some(bucket as u64),
570            &dup,
571        )
572        .unwrap();
573        w.add_tile_full(
574            &TileId::new(5, 2, 1, bucket as u64),
575            bucket,
576            2 * bucket - 1,
577            Some(bucket),
578            40,
579            Some(bucket as u64),
580            &dup,
581        )
582        .unwrap();
583        let distinct = encode_tile_with(&[line_layer(9, 40)], &cfg).unwrap();
584        w.add_tile_full(
585            &TileId::new(5, 3, 1, 0),
586            0,
587            bucket - 1,
588            Some(0),
589            40,
590            Some(bucket as u64),
591            &distinct,
592        )
593        .unwrap();
594        // z3 overview tile.
595        let overview = encode_tile_with(&[line_layer(11, 40)], &cfg).unwrap();
596        w.add_tile_full(
597            &TileId::new(3, 0, 0, 0),
598            0,
599            bucket - 1,
600            Some(0),
601            40,
602            Some(bucket as u64),
603            &overview,
604        )
605        .unwrap();
606        let meta = Metadata::new("inspect-fixture")
607            .with_temporal_bucket_ms(bucket as u64)
608            .with_zoom_levels(3, 5);
609        w.finalize(&meta).unwrap();
610    }
611
612    #[test]
613    fn inspect_full_report_on_real_fixture() {
614        let dir = tempfile::tempdir().unwrap();
615        let out = dir.path().join("dataset");
616        build_fixture(&out);
617
618        let ts = PackedTileset::open(&out).unwrap();
619        let report = inspect(&ts, None).unwrap();
620
621        // Per-zoom directory stats (always total).
622        assert_eq!(report.tile_count, 4);
623        assert_eq!(report.per_zoom.len(), 2);
624        let z3 = &report.per_zoom[0];
625        let z5 = &report.per_zoom[1];
626        assert_eq!(
627            (z3.zoom, z3.entries, z3.distinct_blobs, z3.t_buckets),
628            (3, 1, 1, 1)
629        );
630        assert_eq!((z5.zoom, z5.entries, z5.t_buckets), (5, 3, 2));
631        // The duplicated payload collapses: 3 entries, 2 physical blobs.
632        assert_eq!(z5.distinct_blobs, 2);
633        assert!(z5.blob_bytes_max > 0);
634        assert!((z5.avg_blob_bytes - z5.blob_bytes_total as f64 / 3.0).abs() < 1e-9);
635
636        // Dedup over the whole directory: 4 entries, 3 distinct blobs.
637        assert_eq!(report.dedup.entries, 4);
638        assert_eq!(report.dedup.distinct_blobs, 3);
639        assert!(report.dedup.dedup_ratio < 1.0);
640
641        // Wire totals from the directory; real zstd must beat 1x on this data.
642        assert!(
643            report.compression_ratio > 1.0,
644            "ratio {}",
645            report.compression_ratio
646        );
647        assert!(report.compressed_bytes > 0 && report.uncompressed_bytes > report.compressed_bytes);
648
649        // Full (unsampled) decode.
650        assert!(!report.decode.sampled);
651        assert_eq!(report.decode.tiles_decoded, 4);
652        assert_eq!(report.decode.features_decoded, 160);
653        assert_eq!(report.decode.distinct_layer_schemas, 1);
654        assert_eq!(report.feature_count, 160);
655
656        // Per-column attribution generalizes to line geometry: shares sum to
657        // ~1.0 and every expected column is present.
658        let share_sum: f64 = report.per_column.iter().map(|c| c.share).sum();
659        assert!((share_sum - 1.0).abs() < 1e-9, "shares sum to {share_sum}");
660        let by_name = |n: &str| {
661            report
662                .per_column
663                .iter()
664                .find(|c| c.name == n)
665                .unwrap_or_else(|| panic!("column {n} missing"))
666        };
667        for name in ["geometry", "vertex_time", "speed", "kind", "id"] {
668            assert!(by_name(name).compressed_bytes > 0);
669            assert!(by_name(name).bytes_per_feature > 0.0);
670        }
671
672        // Encoding notes: the doctor's smells.
673        assert_eq!(by_name("geometry").encoding_note, "plain f64 (unquantized)");
674        assert_eq!(by_name("speed").encoding_note, "plain f64 (unquantized)");
675        assert_eq!(by_name("kind").encoding_note, "dictionary-encoded");
676        assert_eq!(
677            by_name("vertex_time").encoding_note,
678            "u16 vertex-time deltas"
679        );
680
681        // Text rendering carries the headline numbers.
682        let text = format_text(&report);
683        assert!(text.contains("inspect-fixture"));
684        assert!(text.contains("geometry"));
685        assert!(text.contains("dedup: 4 entries -> 3 distinct blobs"));
686        assert!(!text.contains("sampled"));
687    }
688
689    #[test]
690    fn inspect_sampled_decode_keeps_directory_stats_total() {
691        let dir = tempfile::tempdir().unwrap();
692        let out = dir.path().join("dataset");
693        build_fixture(&out);
694        let ts = PackedTileset::open(&out).unwrap();
695
696        // sample=2 over 4 entries → stride 2 → exactly entries 0 and 2 decode.
697        let report = inspect(&ts, Some(2)).unwrap();
698        assert!(report.decode.sampled);
699        assert_eq!(report.decode.tiles_decoded, 2);
700        assert_eq!(report.decode.features_decoded, 80);
701        // Directory stats stay total despite the sampled decode.
702        assert_eq!(report.tile_count, 4);
703        assert_eq!(report.dedup.entries, 4);
704        assert_eq!(report.dedup.distinct_blobs, 3);
705        assert_eq!(report.per_zoom.iter().map(|z| z.entries).sum::<u64>(), 4);
706        // Shares still normalize over the sampled subset.
707        let share_sum: f64 = report.per_column.iter().map(|c| c.share).sum();
708        assert!((share_sum - 1.0).abs() < 1e-9);
709        // Deterministic: a rerun samples the same tiles.
710        let rerun = inspect(&ts, Some(2)).unwrap();
711        assert_eq!(
712            rerun
713                .per_column
714                .iter()
715                .map(|c| (c.name.clone(), c.compressed_bytes))
716                .collect::<Vec<_>>(),
717            report
718                .per_column
719                .iter()
720                .map(|c| (c.name.clone(), c.compressed_bytes))
721                .collect::<Vec<_>>()
722        );
723        assert!(format_text(&report).contains("sampled"));
724
725        // sample=0 decodes nothing; sample >= total decodes everything.
726        let none = inspect(&ts, Some(0)).unwrap();
727        assert_eq!(none.decode.tiles_decoded, 0);
728        assert!(none.per_column.is_empty());
729        assert_eq!(none.dedup.entries, 4);
730        let all = inspect(&ts, Some(100)).unwrap();
731        assert_eq!(all.decode.tiles_decoded, 4);
732        assert!(all.decode.sampled);
733    }
734
735    #[test]
736    fn report_serializes_to_json_and_back() {
737        let dir = tempfile::tempdir().unwrap();
738        let out = dir.path().join("dataset");
739        build_fixture(&out);
740        let ts = PackedTileset::open(&out).unwrap();
741        let report = inspect(&ts, None).unwrap();
742
743        let json = serde_json::to_string_pretty(&report).unwrap();
744        let back: InspectReport = serde_json::from_str(&json).unwrap();
745        assert_eq!(back.tile_count, report.tile_count);
746        assert_eq!(back.per_column.len(), report.per_column.len());
747        assert_eq!(back.dedup.distinct_blobs, report.dedup.distinct_blobs);
748    }
749}