Skip to main content

stt_optimize/
doctor.rs

1//! Tileset doctor: severity-ranked findings over a built (packed) tileset,
2//! each with a concrete remediation flag and a projection derived from the
3//! inspect report's measured per-column costs.
4//!
5//! Productizes the manual optimization passes this repo keeps re-running by
6//! hand (`point_column_stats` → `--quantize-attr`, the id-column hunt, the
7//! paged-directory migration, the summary-tier retrofit). Every rule keys off
8//! numbers already **measured** for THIS tileset — directory entries from
9//! [`PackedTileset`] and per-column compressed costs from [`InspectReport`] —
10//! and cites them in its message. Projections are estimated from those
11//! measured column shares; the doctor never re-encodes anything, so every
12//! `projected` string carries the "(estimated from measured column costs)"
13//! label.
14//!
15//! Rules (stable kebab-case codes):
16//! - `raw-f64-column` — plain Float64 property columns worth quantizing.
17//! - `expensive-feature-ids` — near-incompressible (hash-like) feature ids.
18//! - `dead-columns` — constant / all-null property columns (sampled decode).
19//! - `z0-bomb` — a deep shallow pyramid under a tiny geographic extent.
20//! - `unpaged-large` — whole-load directory on a large tile count.
21//! - `oversized-blobs` — individual tiles past 1 MiB compressed.
22//! - `missing-summary-tier` — huge point dataset with no aggregated tier.
23
24use std::collections::BTreeMap;
25
26use anyhow::{Context, Result};
27use arrow::array::{Array, ArrayData, RecordBatch};
28use arrow::datatypes::DataType;
29use serde::{Deserialize, Serialize};
30
31use crate::analysis::inspect::InspectReport;
32use crate::packed::PackedTileset;
33
34/// Label appended to every `projected` string: doctor deltas are derived from
35/// the inspect report's measured per-column shares, never from a re-encode.
36const ESTIMATE_LABEL: &str = "(estimated from measured column costs)";
37
38/// Must match [`crate::analysis::inspect`]'s `encoding_note` for an
39/// unquantized, non-dictionary Float64 column — the smell `raw-f64-column`
40/// keys off.
41const PLAIN_F64_NOTE: &str = "plain f64 (unquantized)";
42
43/// Core (non-property) tile columns the property rules skip: they are either
44/// structural or covered by their own levers (`--quantize-coords` for
45/// geometry, the vertex-time delta encoding), not by `--quantize-attr`.
46const RESERVED_COLUMNS: [&str; 8] = [
47    "id",
48    "start_time",
49    "end_time",
50    "geometry",
51    "vertex_time",
52    "vertex_value",
53    "vertex_value_matrix",
54    "triangles",
55];
56
57/// `raw-f64-column` only flags columns carrying at least this share of the
58/// measured column cost — below it the projected win is noise.
59const RAW_F64_MIN_SHARE: f64 = 0.03;
60/// Combined flagged share at which `raw-f64-column` escalates to Critical
61/// (the Waymo case: id + z alone were ~78% of the dataset).
62const RAW_F64_CRITICAL_SHARE: f64 = 0.5;
63/// Assumed shrink of a flagged column once quantized (measured passes in this
64/// repo landed 50–75%; 60% is the conservative middle).
65const RAW_F64_SHRINK: f64 = 0.6;
66
67/// `expensive-feature-ids` fires above this measured B/feature…
68const ID_BPF_INFO: f64 = 4.0;
69/// …and escalates to Warning here (sequential ids land ~1 B/feature; anything
70/// past 6 is spending more than a raw u32 per row on identity alone).
71const ID_BPF_WARN: f64 = 6.0;
72/// `expensive-feature-ids` needs at least this many decoded features per
73/// decoded tile: below it, per-tile IPC framing dominates the standalone
74/// column re-encode and B/feature reads high even for sequential ids.
75const ID_MIN_FEATURES_PER_TILE: f64 = 256.0;
76
77/// Max tiles the doctor's own stride-sampled decode pass reads.
78const DOCTOR_SAMPLE_TILES: usize = 8;
79
80/// `z0-bomb` triggers when `min_zoom` is at or below this…
81const Z0_MIN_ZOOM: u8 = 4;
82/// …while the metadata bounds span less than this many degrees in both axes.
83const Z0_EXTENT_DEG: f64 = 2.0;
84
85/// `unpaged-large` fires past this many directory entries.
86const UNPAGED_TILE_LIMIT: u64 = 10_000;
87
88/// `oversized-blobs` threshold on a single compressed blob.
89const OVERSIZED_BLOB_BYTES: u64 = 1024 * 1024;
90
91/// `missing-summary-tier` floor on the index-weighted feature count.
92const SUMMARY_FEATURE_FLOOR: u64 = 1_000_000;
93
94/// Finding severity, most severe first.
95///
96/// Doctor-local rather than reusing [`crate::analysis::density::IssueSeverity`]:
97/// findings need a total order for the severity-first report sort (that enum
98/// derives neither `Ord` nor `PartialEq`), and the doctor's top tier is
99/// "Critical" (fix before shipping), not a data-loading "Error".
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum Severity {
103    /// Dominant measured waste — fix before publishing this tileset.
104    Critical,
105    /// Concrete, measured inefficiency with a known remediation.
106    Warning,
107    /// Worth knowing; act only if it matches your use case.
108    Info,
109}
110
111impl std::fmt::Display for Severity {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Severity::Critical => write!(f, "CRITICAL"),
115            Severity::Warning => write!(f, "WARNING"),
116            Severity::Info => write!(f, "INFO"),
117        }
118    }
119}
120
121/// One doctor finding: what is wrong (with this tileset's measured numbers),
122/// how to fix it, and — when derivable from the measured column costs — the
123/// projected win.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct Finding {
126    /// Severity tier (report sorts most severe first).
127    pub severity: Severity,
128    /// Stable kebab-case rule code (e.g. `raw-f64-column`).
129    pub code: String,
130    /// Human-readable diagnosis citing the tileset's measured numbers.
131    pub message: String,
132    /// Concrete remediations — builder flags / commands, in preference order.
133    pub remediation: Vec<String>,
134    /// Projected effect of the remediation, always labeled
135    /// "(estimated from measured column costs)". `None` when no meaningful
136    /// projection can be derived without re-encoding.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub projected: Option<String>,
139}
140
141/// Full doctor report: findings sorted severity-first, then by code.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct DoctorReport {
144    /// All findings, most severe first (empty = healthy).
145    pub findings: Vec<Finding>,
146}
147
148/// Run every doctor rule over a packed tileset + its inspect report.
149///
150/// Directory- and column-cost rules read only the (already computed) report;
151/// the dead-column and point-dominance checks decode up to
152/// [`DOCTOR_SAMPLE_TILES`] stride-sampled tiles via
153/// [`PackedTileset::read_layers`]. Findings come back sorted severity-first,
154/// then by code, then message, so output is deterministic.
155pub fn doctor(tileset: &PackedTileset, report: &InspectReport) -> Result<DoctorReport> {
156    let sampled = sample_decode(tileset)?;
157
158    let mut findings = Vec::new();
159    if let Some(f) = rule_raw_f64_columns(report) {
160        findings.push(f);
161    }
162    if let Some(f) = rule_expensive_feature_ids(report) {
163        findings.push(f);
164    }
165    findings.extend(rule_dead_columns(&sampled));
166    if let Some(f) = rule_z0_bomb(tileset, report) {
167        findings.push(f);
168    }
169    if let Some(f) = rule_unpaged_large(tileset, report) {
170        findings.push(f);
171    }
172    if let Some(f) = rule_oversized_blobs(tileset, report) {
173        findings.push(f);
174    }
175    if let Some(f) = rule_missing_summary_tier(tileset, report, &sampled) {
176        findings.push(f);
177    }
178
179    findings.sort_by(|a, b| {
180        a.severity
181            .cmp(&b.severity)
182            .then_with(|| a.code.cmp(&b.code))
183            .then_with(|| a.message.cmp(&b.message))
184    });
185    Ok(DoctorReport { findings })
186}
187
188// ----------------------------------------------------------------------------
189// Sampled decode (shared by dead-columns + missing-summary-tier)
190// ----------------------------------------------------------------------------
191
192/// Per-property-column constancy state across the sampled tiles.
193struct ColState {
194    /// Sampled tiles (with rows) this column appeared in.
195    tiles: usize,
196    /// Every sampled row was null.
197    all_null: bool,
198    /// Every sampled row logically equals the first sampled row.
199    constant: bool,
200    /// 1-row slice of the first sampled value (logical-equality exemplar).
201    exemplar: Option<ArrayData>,
202}
203
204/// What the doctor's own stride-sampled decode pass observed.
205struct SampledDecode {
206    /// Tiles actually decoded.
207    tiles_decoded: usize,
208    /// Total directory entries (for "N of M" wording).
209    tiles_total: usize,
210    /// Rows summed over sampled layers.
211    total_rows: u64,
212    /// Rows in layers whose geometry is `geoarrow.point`.
213    point_rows: u64,
214    /// Per property-column constancy state, keyed by column name.
215    columns: BTreeMap<String, ColState>,
216}
217
218/// Is this decoded layer a point layer? Prefers the `stt:geometry` schema
219/// metadata the encoder bakes; falls back to the geometry field's shape
220/// (points encode as `FixedSizeList`, lines/polygons as nested lists).
221fn is_point_layer(batch: &RecordBatch) -> bool {
222    if let Some(kind) = batch.schema().metadata().get("stt:geometry") {
223        return kind == "geoarrow.point";
224    }
225    matches!(
226        batch
227            .schema()
228            .field_with_name("geometry")
229            .map(|f| f.data_type().clone()),
230        Ok(DataType::FixedSizeList(_, _))
231    )
232}
233
234/// Decode up to [`DOCTOR_SAMPLE_TILES`] evenly-strided tiles (same
235/// deterministic stride semantics as `inspect`/`stt-validate --sample`) and
236/// fold per-column constancy + geometry-kind row counts.
237fn sample_decode(tileset: &PackedTileset) -> Result<SampledDecode> {
238    let entries = tileset.entries();
239    let mut out = SampledDecode {
240        tiles_decoded: 0,
241        tiles_total: entries.len(),
242        total_rows: 0,
243        point_rows: 0,
244        columns: BTreeMap::new(),
245    };
246    if entries.is_empty() {
247        return Ok(out);
248    }
249    let stride = entries.len().div_ceil(DOCTOR_SAMPLE_TILES).max(1);
250    for e in entries.iter().step_by(stride) {
251        let layers = tileset.read_layers(e).with_context(|| {
252            format!(
253                "doctor: decoding tile z{}/{}/{} t{}",
254                e.zoom, e.x, e.y, e.time_start
255            )
256        })?;
257        out.tiles_decoded += 1;
258        for layer in &layers {
259            let batch = &layer.batch;
260            let rows = batch.num_rows();
261            if rows == 0 {
262                continue;
263            }
264            out.total_rows += rows as u64;
265            if is_point_layer(batch) {
266                out.point_rows += rows as u64;
267            }
268            let schema = batch.schema();
269            for (i, field) in schema.fields().iter().enumerate() {
270                if RESERVED_COLUMNS.contains(&field.name().as_str()) {
271                    continue;
272                }
273                let arr = batch.column(i);
274                let st = out.columns.entry(field.name().clone()).or_insert(ColState {
275                    tiles: 0,
276                    all_null: true,
277                    constant: true,
278                    exemplar: None,
279                });
280                st.tiles += 1;
281                if arr.null_count() != arr.len() {
282                    st.all_null = false;
283                }
284                if st.constant {
285                    // Logical (offset-aware, dictionary-resolving) equality of
286                    // 1-row slices against the first sampled value; bail on the
287                    // first mismatch so varying columns cost one comparison.
288                    if st.exemplar.is_none() {
289                        st.exemplar = Some(arr.slice(0, 1).to_data());
290                    }
291                    let exemplar = st.exemplar.as_ref().unwrap();
292                    for r in 0..rows {
293                        if arr.slice(r, 1).to_data() != *exemplar {
294                            st.constant = false;
295                            break;
296                        }
297                    }
298                }
299            }
300        }
301    }
302    Ok(out)
303}
304
305// ----------------------------------------------------------------------------
306// Rules
307// ----------------------------------------------------------------------------
308
309/// `raw-f64-column`: property columns shipping as plain Float64 (not
310/// quantized, not dictionary) with a measured share ≥ 3%. One finding listing
311/// every such column, worst first. Geometry is deliberately out of scope —
312/// its lever is `--quantize-coords`, not `--quantize-attr`.
313fn rule_raw_f64_columns(report: &InspectReport) -> Option<Finding> {
314    let mut flagged: Vec<_> = report
315        .per_column
316        .iter()
317        .filter(|c| {
318            !RESERVED_COLUMNS.contains(&c.name.as_str())
319                && c.encoding_note == PLAIN_F64_NOTE
320                && c.share >= RAW_F64_MIN_SHARE
321        })
322        .collect();
323    if flagged.is_empty() {
324        return None;
325    }
326    flagged.sort_by(|a, b| b.share.total_cmp(&a.share));
327
328    let total_share: f64 = flagged.iter().map(|c| c.share).sum();
329    let listed = flagged
330        .iter()
331        .map(|c| {
332            format!(
333                "`{}` ({:.1}% of column bytes, {:.2} B/feature)",
334                c.name,
335                100.0 * c.share,
336                c.bytes_per_feature
337            )
338        })
339        .collect::<Vec<_>>()
340        .join(", ");
341    let saved_bytes = report.compressed_bytes as f64 * total_share * RAW_F64_SHRINK;
342    Some(Finding {
343        severity: if total_share >= RAW_F64_CRITICAL_SHARE {
344            Severity::Critical
345        } else {
346            Severity::Warning
347        },
348        code: "raw-f64-column".to_string(),
349        message: format!(
350            "{} property column(s) ship as raw Float64 and together cost {:.1}% of this \
351             tileset's measured column bytes: {}. Raw f64 attributes are near-incompressible; \
352             fixed-point ints are both smaller and far more compressible.",
353            flagged.len(),
354            100.0 * total_share,
355            listed
356        ),
357        remediation: vec![
358            format!(
359                "--quantize-attr <name>=<prec> (per column, e.g. --quantize-attr {}=0.01)",
360                flagged[0].name
361            ),
362            "--quantize-attrs-auto (range-adaptive u16 for every remaining raw Float64 property)"
363                .to_string(),
364        ],
365        projected: Some(format!(
366            "~{:.0}% smaller dataset wire (~{:.2} of {:.2} MB) after quantizing the flagged \
367             columns, assuming ~{:.0}% per-column shrink {}",
368            100.0 * total_share * RAW_F64_SHRINK,
369            saved_bytes / 1e6,
370            report.compressed_bytes as f64 / 1e6,
371            100.0 * RAW_F64_SHRINK,
372            ESTIMATE_LABEL
373        )),
374    })
375}
376
377/// `expensive-feature-ids`: the `id` column measured above 4 B/feature —
378/// hash-like or explicit source ids carry full entropy per row, so zstd
379/// cannot shrink them, while builder-assigned sequential ids compress to
380/// ~1 B/feature.
381fn rule_expensive_feature_ids(report: &InspectReport) -> Option<Finding> {
382    let id = report.per_column.iter().find(|c| c.name == "id")?;
383    if id.bytes_per_feature <= ID_BPF_INFO {
384        return None;
385    }
386    // Sparse tilesets (few features per tile) inflate B/feature with per-tile
387    // IPC framing — no id-cost signal survives that noise floor.
388    if report.decode.tiles_decoded > 0
389        && (report.decode.features_decoded as f64 / report.decode.tiles_decoded as f64)
390            < ID_MIN_FEATURES_PER_TILE
391    {
392        return None;
393    }
394    Some(Finding {
395        severity: if id.bytes_per_feature >= ID_BPF_WARN {
396            Severity::Warning
397        } else {
398            Severity::Info
399        },
400        code: "expensive-feature-ids".to_string(),
401        message: format!(
402            "feature-id column costs {:.2} B/feature ({:.1}% of measured column bytes) — \
403             hash-like or explicit source ids are near-incompressible (full entropy per row); \
404             builder-assigned sequential ids compress to ~1 B/feature.",
405            id.bytes_per_feature,
406            100.0 * id.share
407        ),
408        remediation: vec![
409            "rebuild with the current stt-build — anonymous point features get sequential ids \
410             automatically"
411                .to_string(),
412            "if explicit source ids are load-bearing (picking, cross-dataset joins), reconsider \
413             whether they must ship in tiles or a sequential remap would do"
414                .to_string(),
415        ],
416        projected: Some(format!(
417            "up to ~{:.1}% of dataset wire reclaimable from the id column {}",
418            100.0 * id.share,
419            ESTIMATE_LABEL
420        )),
421    })
422}
423
424/// `dead-columns`: property columns constant or all-null across every sampled
425/// tile they appear in (and appearing in more than one sampled tile). Sampled
426/// evidence only — the message says so.
427fn rule_dead_columns(sampled: &SampledDecode) -> Vec<Finding> {
428    sampled
429        .columns
430        .iter()
431        .filter(|(_, st)| st.tiles > 1 && (st.constant || st.all_null))
432        .map(|(name, st)| {
433            let what = if st.all_null {
434                "entirely null"
435            } else {
436                "a single constant value"
437            };
438            Finding {
439                severity: Severity::Info,
440                code: "dead-columns".to_string(),
441                message: format!(
442                    "property column `{name}` is {what} across all {} sampled tiles that carry \
443                     it ({} of {} tiles decoded — sampled, not proven; verify before excluding). \
444                     A constant column ships no information a renderer can use.",
445                    st.tiles, sampled.tiles_decoded, sampled.tiles_total
446                ),
447                remediation: vec![format!("--exclude {name}")],
448                projected: None,
449            }
450        })
451        .collect()
452}
453
454/// `z0-bomb`: a shallow pyramid (min_zoom ≤ 4) under bounds spanning less
455/// than 2° in both axes — every zoom below the suggested floor re-ships the
456/// whole dataset in one or two near-duplicate tiles.
457fn rule_z0_bomb(tileset: &PackedTileset, report: &InspectReport) -> Option<Finding> {
458    let bounds = &tileset.metadata().bounds;
459    let lon_ext = bounds.max_lon - bounds.min_lon;
460    let lat_ext = bounds.max_lat - bounds.min_lat;
461    if report.min_zoom > Z0_MIN_ZOOM || lon_ext >= Z0_EXTENT_DEG || lat_ext >= Z0_EXTENT_DEG {
462        return None;
463    }
464    // The zoom where the bounds extent covers ~2–8 tiles: one axis of the
465    // world is 360° wide, a tile at z spans 360/2^z degrees of longitude.
466    let max_ext = lon_ext.max(lat_ext).max(1e-9);
467    let raw = (360.0 / max_ext).log2().ceil() as i64;
468    let hi = i64::from(report.max_zoom)
469        .saturating_sub(1)
470        .max(i64::from(Z0_MIN_ZOOM));
471    let floor = raw.clamp(i64::from(Z0_MIN_ZOOM), hi) as u8;
472    if floor <= report.min_zoom {
473        return None;
474    }
475    let (shallow_tiles, shallow_bytes) = report
476        .per_zoom
477        .iter()
478        .filter(|z| z.zoom < floor)
479        .fold((0u64, 0u64), |(t, b), z| {
480            (t + z.entries, b + z.blob_bytes_total)
481        });
482    Some(Finding {
483        severity: Severity::Warning,
484        code: "z0-bomb".to_string(),
485        message: format!(
486            "min_zoom is {} but the metadata bounds span only {:.2}° × {:.2}° — the shallow \
487             pyramid below z{} holds {} tile entries ({:.2} MB) that mostly re-ship the whole \
488             dataset; z{} already covers these bounds with ~2-8 tiles.",
489            report.min_zoom,
490            lon_ext,
491            lat_ext,
492            floor,
493            shallow_tiles,
494            shallow_bytes as f64 / 1e6,
495            floor
496        ),
497        remediation: vec![format!("--min-zoom {floor}")],
498        projected: None,
499    })
500}
501
502/// `unpaged-large`: a single whole-load directory past 10k entries — every
503/// cold reader downloads the entire directory before its first tile fetch.
504fn rule_unpaged_large(tileset: &PackedTileset, report: &InspectReport) -> Option<Finding> {
505    if tileset.is_paged() || report.tile_count <= UNPAGED_TILE_LIMIT {
506        return None;
507    }
508    Some(Finding {
509        severity: Severity::Warning,
510        code: "unpaged-large".to_string(),
511        message: format!(
512            "single whole-load directory with {} entries — a cold reader must download the \
513             full directory before its first tile fetch; the paged container fetches only the \
514             leaf pages a viewport/time-window touches.",
515            report.tile_count
516        ),
517        remediation: vec![
518            "rebuild with the current stt-build — the paged directory is the default (avoid \
519             --single-directory)"
520                .to_string(),
521            "generated datasets: re-run the source generator (stt-generate builds paged + \
522             publish-tuned straight from source)"
523                .to_string(),
524        ],
525        projected: None,
526    })
527}
528
529/// `oversized-blobs`: directory entries whose compressed blob exceeds 1 MiB.
530/// Structural fixes (zoom floor, summary tier) come first; the opt-in budgets
531/// drop data and are never presented as the default fix.
532fn rule_oversized_blobs(tileset: &PackedTileset, report: &InspectReport) -> Option<Finding> {
533    let over: Vec<_> = tileset
534        .entries()
535        .iter()
536        .filter(|e| u64::from(e.length) > OVERSIZED_BLOB_BYTES)
537        .collect();
538    let worst = over.iter().max_by_key(|e| e.length)?;
539    let avg = report.compressed_bytes as f64 / report.tile_count.max(1) as f64;
540    Some(Finding {
541        severity: Severity::Warning,
542        code: "oversized-blobs".to_string(),
543        message: format!(
544            "{} of {} directory entries exceed 1 MiB compressed; worst is z{}/{}/{} t{} at \
545             {:.2} MiB (dataset average {:.1} KB) — oversized tiles stall first paint on slow \
546             links.",
547            over.len(),
548            report.tile_count,
549            worst.zoom,
550            worst.x,
551            worst.y,
552            worst.time_start,
553            f64::from(worst.length) / OVERSIZED_BLOB_BYTES as f64,
554            avg / 1e3
555        ),
556        remediation: vec![
557            "raise --min-zoom so the densest shallow tiles are never emitted".to_string(),
558            "--summary-tier quadbin (serve a pre-aggregated tier at low zooms instead of raw \
559             features)"
560                .to_string(),
561            "opt-in last resort: --maximum-tile-bytes / --maximum-tile-features — WARNING: \
562             these DROP features from over-budget tiles (STT never thins by default)"
563                .to_string(),
564        ],
565        projected: None,
566    })
567}
568
569/// `missing-summary-tier`: >1M features, point-dominant sampled payloads, and
570/// no pre-aggregated summary tier in the metadata — low zooms re-ship raw
571/// points a renderer can only overplot.
572fn rule_missing_summary_tier(
573    tileset: &PackedTileset,
574    report: &InspectReport,
575    sampled: &SampledDecode,
576) -> Option<Finding> {
577    if tileset.metadata().summary_tier.is_some()
578        || report.feature_count <= SUMMARY_FEATURE_FLOOR
579        || sampled.total_rows == 0
580        || sampled.point_rows * 2 < sampled.total_rows
581    {
582        return None;
583    }
584    Some(Finding {
585        severity: Severity::Info,
586        code: "missing-summary-tier".to_string(),
587        message: format!(
588            "no summary tier on {} (index-weighted) features with a point-dominant payload \
589             ({:.0}% of {} sampled rows over {} tiles) — low zooms re-ship raw points a \
590             renderer can only overplot; a pre-aggregated tier reads at output resolution \
591             instead of N.",
592            report.feature_count,
593            100.0 * sampled.point_rows as f64 / sampled.total_rows as f64,
594            sampled.total_rows,
595            sampled.tiles_decoded
596        ),
597        remediation: vec![
598            "--summary-tier quadbin --summary-columns <name:agg,...> (pre-aggregated low-zoom \
599             tier; `count` is always emitted)"
600                .to_string(),
601        ],
602        projected: None,
603    })
604}
605
606// ----------------------------------------------------------------------------
607// Text rendering
608// ----------------------------------------------------------------------------
609
610/// Render the report as compact aligned text (severity-ranked, one block per
611/// finding).
612pub fn format_text(report: &DoctorReport) -> String {
613    let mut out = String::new();
614    out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
615    out.push_str("         STT Doctor\n");
616    out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
617
618    if report.findings.is_empty() {
619        out.push_str("No findings — this tileset passes every doctor rule.\n");
620        out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
621        return out;
622    }
623
624    let count = |s: Severity| report.findings.iter().filter(|f| f.severity == s).count();
625    out.push_str(&format!(
626        "{} finding(s): {} critical, {} warning, {} info\n\n",
627        report.findings.len(),
628        count(Severity::Critical),
629        count(Severity::Warning),
630        count(Severity::Info)
631    ));
632
633    for f in &report.findings {
634        out.push_str(&format!("[{}] {}\n", f.severity, f.code));
635        out.push_str(&format!("  {}\n", f.message));
636        for r in &f.remediation {
637            out.push_str(&format!("  fix: {r}\n"));
638        }
639        if let Some(p) = &f.projected {
640            out.push_str(&format!("  projected: {p}\n"));
641        }
642        out.push('\n');
643    }
644
645    out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
646    out
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use crate::analysis::inspect::inspect;
653    use stt_core::arrow_tile::{
654        encode_tile_with, ColumnarLayer, EncoderConfig, GeometryColumn, PropertyColumn,
655    };
656    use stt_core::curve::BlobOrdering;
657    use stt_core::metadata::Metadata;
658    use stt_core::pack::PackWriter;
659    use stt_core::tile::TileId;
660    use stt_core::types::BoundingBox;
661
662    /// splitmix64: deterministic full-entropy values (so "random" columns are
663    /// genuinely incompressible without any RNG dependency).
664    fn mix(x: u64) -> u64 {
665        let mut z = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
666        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
667        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
668        z ^ (z >> 31)
669    }
670
671    /// Deterministic uniform f64 in `[0, 1)`.
672    fn rand01(x: u64) -> f64 {
673        (mix(x) >> 11) as f64 / (1u64 << 53) as f64
674    }
675
676    /// Everything a fixture varies; defaults describe a small healthy dataset.
677    struct FixtureSpec {
678        rows: usize,
679        tiles: usize,
680        /// Hash-like (full-entropy) feature ids instead of sequential.
681        hash_ids: bool,
682        /// Add a `stuck` property that is the same value in every row/tile.
683        stuck_column: bool,
684        /// Encode `magnitude` fixed-point (`--quantize-attr magnitude=0.01`).
685        quantize_magnitude: bool,
686        /// Full-entropy world-spread coordinates (incompressible geometry).
687        world_random_coords: bool,
688        /// Reuse one payload for every entry (fast large-directory fixtures).
689        identical_payloads: bool,
690        /// Leaf-page size; `None` = single whole-load directory.
691        paged: Option<usize>,
692        /// Per-tile zoom override (cycled); `None` = all tiles at `max_zoom`.
693        zooms: Option<Vec<u8>>,
694        min_zoom: u8,
695        max_zoom: u8,
696        /// Metadata bounds; `None` keeps the whole-world default.
697        bounds: Option<BoundingBox>,
698        /// Claimed per-entry feature count; `None` = the real row count.
699        claimed_features: Option<u32>,
700    }
701
702    impl Default for FixtureSpec {
703        fn default() -> Self {
704            Self {
705                rows: 2000,
706                tiles: 2,
707                hash_ids: false,
708                stuck_column: false,
709                quantize_magnitude: false,
710                world_random_coords: false,
711                identical_payloads: false,
712                paged: None,
713                zooms: None,
714                min_zoom: 5,
715                max_zoom: 10,
716                bounds: None,
717                claimed_features: None,
718            }
719        }
720    }
721
722    /// Point layer with a full-entropy `magnitude` f64, an alternating
723    /// `kind` categorical, and optionally a constant `stuck` column.
724    fn points_layer(seed: u64, spec: &FixtureSpec) -> ColumnarLayer {
725        let n = spec.rows;
726        let feature_ids: Vec<u64> = if spec.hash_ids {
727            (0..n)
728                .map(|i| mix(seed.wrapping_mul(1_000_003).wrapping_add(i as u64)))
729                .collect()
730        } else {
731            (0..n as u64).map(|i| seed * 1_000_000 + i).collect()
732        };
733        let geometry: Vec<[f64; 2]> = (0..n)
734            .map(|i| {
735                if spec.world_random_coords {
736                    [
737                        -180.0 + 360.0 * rand01(seed ^ (i as u64 * 2 + 1)),
738                        -85.0 + 170.0 * rand01(seed ^ (i as u64 * 2 + 2)),
739                    ]
740                } else {
741                    [
742                        -73.9 + (i % 50) as f64 * 0.001,
743                        45.4 + (i / 50) as f64 * 0.001,
744                    ]
745                }
746            })
747            .collect();
748        let mut properties = vec![
749            (
750                "magnitude".to_string(),
751                PropertyColumn::Numeric(
752                    (0..n)
753                        .map(|i| Some(rand01(seed * 31 + i as u64) * 10.0))
754                        .collect(),
755                ),
756            ),
757            (
758                "kind".to_string(),
759                PropertyColumn::Categorical(
760                    (0..n)
761                        .map(|i| Some(["bike", "ferry"][i % 2].to_string()))
762                        .collect(),
763                ),
764            ),
765        ];
766        if spec.stuck_column {
767            properties.push((
768                "stuck".to_string(),
769                PropertyColumn::Numeric(vec![Some(42.0); n]),
770            ));
771        }
772        ColumnarLayer {
773            name: "default".to_string(),
774            feature_ids,
775            start_times: vec![0; n],
776            end_times: vec![100; n],
777            geometry: GeometryColumn::Point(geometry),
778            vertex_times: None,
779            vertex_values: None,
780            triangles: None,
781            vertex_value_matrix: None,
782            properties,
783        }
784    }
785
786    /// Build a real packed dataset per the spec (PackWriter + encode_tile_with,
787    /// the exact pattern of the packed.rs/inspect.rs test fixtures). Frames
788    /// ride the writer's (default v2) format version + template collector so
789    /// the fixture is version-coherent — what a default `stt-build` emits.
790    fn build(out: &std::path::Path, spec: &FixtureSpec) {
791        let mut w = PackWriter::create(out, BlobOrdering::Auto, 64 * 1024)
792            .unwrap()
793            .with_paging(spec.paged);
794        let cfg = EncoderConfig {
795            quantize_attrs: if spec.quantize_magnitude {
796                [("magnitude".to_string(), 0.01)].into_iter().collect()
797            } else {
798                Default::default()
799            },
800            format_version: w.format_version(),
801            template_collector: Some(w.template_collector()),
802            ..Default::default()
803        };
804        let bucket = 3_600_000i64;
805        let shared = spec
806            .identical_payloads
807            .then(|| encode_tile_with(&[points_layer(0, spec)], &cfg).unwrap());
808        for k in 0..spec.tiles {
809            let payload = match &shared {
810                Some(p) => p.clone(),
811                None => encode_tile_with(&[points_layer(k as u64, spec)], &cfg).unwrap(),
812            };
813            let z = spec
814                .zooms
815                .as_ref()
816                .map(|zs| zs[k % zs.len()])
817                .unwrap_or(spec.max_zoom);
818            let x = (k as u32) % (1u32 << z);
819            let t0 = (k as i64) * bucket;
820            w.add_tile_full(
821                &TileId::new(z, x, 0, t0 as u64),
822                t0,
823                t0 + bucket - 1,
824                Some(t0),
825                spec.claimed_features.unwrap_or(spec.rows as u32),
826                Some(bucket as u64),
827                &payload,
828            )
829            .unwrap();
830        }
831        let mut meta = Metadata::new("doctor-fixture")
832            .with_temporal_bucket_ms(bucket as u64)
833            .with_zoom_levels(spec.min_zoom, spec.max_zoom);
834        if let Some(b) = spec.bounds {
835            meta = meta.with_bounds(b);
836        }
837        w.finalize(&meta).unwrap();
838    }
839
840    /// Build + inspect + doctor in one go.
841    fn doctor_fixture(spec: &FixtureSpec, sample: Option<usize>) -> DoctorReport {
842        let dir = tempfile::tempdir().unwrap();
843        let out = dir.path().join("dataset");
844        build(&out, spec);
845        let ts = PackedTileset::open(&out).unwrap();
846        let report = inspect(&ts, sample).unwrap();
847        doctor(&ts, &report).unwrap()
848    }
849
850    fn find<'a>(report: &'a DoctorReport, code: &str) -> Option<&'a Finding> {
851        report.findings.iter().find(|f| f.code == code)
852    }
853
854    /// R1 fires on a plain-f64 property tileset (worst-first message, labeled
855    /// projection, the two quantize flags) and does NOT fire once the same
856    /// data ships quantized.
857    #[test]
858    fn raw_f64_column_fires_then_quantized_is_clean() {
859        let raw = doctor_fixture(&FixtureSpec::default(), None);
860        let f = find(&raw, "raw-f64-column").expect("raw-f64-column should fire");
861        assert!(
862            matches!(f.severity, Severity::Critical | Severity::Warning),
863            "severity {:?}",
864            f.severity
865        );
866        assert!(f.message.contains("magnitude"), "message: {}", f.message);
867        assert!(f.message.contains('%'), "message cites measured shares");
868        assert!(f.remediation.iter().any(|r| r.contains("--quantize-attr ")));
869        assert!(f
870            .remediation
871            .iter()
872            .any(|r| r.contains("--quantize-attrs-auto")));
873        let projected = f.projected.as_deref().expect("R1 carries a projection");
874        assert!(projected.contains("(estimated from measured column costs)"));
875
876        // Same data, quantized: R1 gone, and the whole fixture is clean at
877        // Warning+ (Info findings are allowed).
878        let clean = doctor_fixture(
879            &FixtureSpec {
880                quantize_magnitude: true,
881                ..Default::default()
882            },
883            None,
884        );
885        assert!(find(&clean, "raw-f64-column").is_none());
886        let warnings: Vec<_> = clean
887            .findings
888            .iter()
889            .filter(|f| f.severity <= Severity::Warning)
890            .collect();
891        assert!(
892            warnings.is_empty(),
893            "clean quantized fixture has Warning+ findings: {warnings:?}"
894        );
895    }
896
897    /// R2 fires on hash-like (full-entropy) feature ids: >4 B/feature can't
898    /// happen for the builder's sequential ids.
899    #[test]
900    fn expensive_feature_ids_fires_on_hash_ids() {
901        let report = doctor_fixture(
902            &FixtureSpec {
903                hash_ids: true,
904                ..Default::default()
905            },
906            None,
907        );
908        let f = find(&report, "expensive-feature-ids").expect("should fire");
909        // 8 random bytes/row cannot compress below 8 B/feature — Warning tier.
910        assert_eq!(f.severity, Severity::Warning);
911        assert!(f.message.contains("B/feature"), "message: {}", f.message);
912        assert!(f.remediation.iter().any(|r| r.contains("sequential ids")));
913        assert!(f
914            .projected
915            .as_deref()
916            .unwrap()
917            .contains("(estimated from measured column costs)"));
918    }
919
920    /// R3 fires for a constant property column, names it, says the evidence
921    /// is sampled, and suggests `--exclude`.
922    #[test]
923    fn dead_columns_fires_for_constant_column() {
924        let report = doctor_fixture(
925            &FixtureSpec {
926                stuck_column: true,
927                ..Default::default()
928            },
929            None,
930        );
931        let f = find(&report, "dead-columns").expect("dead-columns should fire");
932        assert_eq!(f.severity, Severity::Info);
933        assert!(f.message.contains("`stuck`"), "message: {}", f.message);
934        assert!(f.message.contains("sampled"), "must admit sampling");
935        assert_eq!(f.remediation, vec!["--exclude stuck".to_string()]);
936        // The varying columns must NOT be reported dead.
937        assert!(
938            !report.findings.iter().any(|f| f.code == "dead-columns"
939                && (f.message.contains("`magnitude`") || f.message.contains("`kind`"))),
940            "varying columns flagged dead"
941        );
942    }
943
944    /// R4 fires for min_zoom 0 + tiny bounds, computes the ~2-8-tile zoom
945    /// floor, and cites the shallow-pyramid entry count.
946    #[test]
947    fn z0_bomb_fires_for_tiny_bounds_deep_pyramid() {
948        let report = doctor_fixture(
949            &FixtureSpec {
950                tiles: 4,
951                rows: 50,
952                zooms: Some(vec![0, 1, 2, 10]),
953                min_zoom: 0,
954                max_zoom: 10,
955                bounds: Some(BoundingBox {
956                    min_lon: -73.8,
957                    min_lat: 45.4,
958                    max_lon: -73.3,
959                    max_lat: 45.9,
960                }),
961                ..Default::default()
962            },
963            None,
964        );
965        let f = find(&report, "z0-bomb").expect("z0-bomb should fire");
966        assert_eq!(f.severity, Severity::Warning);
967        // 0.5° extent → ceil(log2(360/0.5)) = 10, clamped to max_zoom-1 = 9.
968        assert_eq!(f.remediation, vec!["--min-zoom 9".to_string()]);
969        // Three fixture tiles sit below the suggested floor (z0, z1, z2).
970        assert!(
971            f.message.contains("3 tile entries"),
972            "message: {}",
973            f.message
974        );
975
976        // Wide bounds: same pyramid, no finding.
977        let wide = doctor_fixture(
978            &FixtureSpec {
979                tiles: 4,
980                rows: 50,
981                zooms: Some(vec![0, 1, 2, 10]),
982                min_zoom: 0,
983                max_zoom: 10,
984                bounds: None,
985                ..Default::default()
986            },
987            None,
988        );
989        assert!(find(&wide, "z0-bomb").is_none());
990    }
991
992    /// R5 fires for a >10k-entry single whole-load directory and stays quiet
993    /// once the same dataset ships paged.
994    #[test]
995    fn unpaged_large_fires_then_paged_is_quiet() {
996        let spec = FixtureSpec {
997            rows: 4,
998            tiles: 10_001,
999            identical_payloads: true,
1000            zooms: Some(vec![14]),
1001            min_zoom: 5,
1002            max_zoom: 14,
1003            ..Default::default()
1004        };
1005        let report = doctor_fixture(&spec, Some(4));
1006        let f = find(&report, "unpaged-large").expect("unpaged-large should fire");
1007        assert_eq!(f.severity, Severity::Warning);
1008        assert!(f.message.contains("10001"), "message: {}", f.message);
1009        assert!(f
1010            .remediation
1011            .iter()
1012            .any(|r| r.contains("paged directory is the default")));
1013
1014        let paged = doctor_fixture(
1015            &FixtureSpec {
1016                paged: Some(4096),
1017                ..spec
1018            },
1019            Some(4),
1020        );
1021        assert!(find(&paged, "unpaged-large").is_none());
1022    }
1023
1024    /// R6 fires with one artificially large (incompressible) blob and cites
1025    /// its address; budgets are presented as opt-in with a drop warning.
1026    #[test]
1027    fn oversized_blobs_fires_and_orders_remediation() {
1028        let report = doctor_fixture(
1029            &FixtureSpec {
1030                rows: 100_000,
1031                tiles: 1,
1032                world_random_coords: true,
1033                ..Default::default()
1034            },
1035            None,
1036        );
1037        let f = find(&report, "oversized-blobs").expect("oversized-blobs should fire");
1038        assert_eq!(f.severity, Severity::Warning);
1039        assert!(f.message.contains("MiB"), "message: {}", f.message);
1040        assert!(f.message.contains("z10/0/0"), "message: {}", f.message);
1041        // Structural fixes first, data-dropping budgets last + flagged.
1042        assert!(f.remediation[0].contains("--min-zoom"));
1043        assert!(f.remediation[1].contains("--summary-tier"));
1044        assert!(f.remediation[2].contains("--maximum-tile-bytes"));
1045        assert!(f.remediation[2].contains("DROP"));
1046
1047        // Findings come out sorted: severity first, then code.
1048        let ranks: Vec<_> = report
1049            .findings
1050            .iter()
1051            .map(|f| (f.severity, f.code.clone()))
1052            .collect();
1053        let mut sorted = ranks.clone();
1054        sorted.sort();
1055        assert_eq!(ranks, sorted, "findings not severity/code sorted");
1056    }
1057
1058    /// R7 fires for >1M (index-weighted) point features without a summary
1059    /// tier.
1060    #[test]
1061    fn missing_summary_tier_fires_for_large_point_dataset() {
1062        let report = doctor_fixture(
1063            &FixtureSpec {
1064                rows: 100,
1065                tiles: 2,
1066                claimed_features: Some(600_000),
1067                quantize_magnitude: true,
1068                ..Default::default()
1069            },
1070            None,
1071        );
1072        let f = find(&report, "missing-summary-tier").expect("should fire");
1073        assert_eq!(f.severity, Severity::Info);
1074        assert!(f.message.contains("1200000"), "message: {}", f.message);
1075        assert!(f.remediation[0].contains("--summary-tier quadbin"));
1076    }
1077
1078    /// Serde round-trip (projected is absent, not null-filled, when None) and
1079    /// the text rendering.
1080    #[test]
1081    fn report_serializes_and_renders() {
1082        let report = doctor_fixture(
1083            &FixtureSpec {
1084                stuck_column: true,
1085                ..Default::default()
1086            },
1087            None,
1088        );
1089        let json = serde_json::to_string_pretty(&report).unwrap();
1090        let back: DoctorReport = serde_json::from_str(&json).unwrap();
1091        assert_eq!(back.findings.len(), report.findings.len());
1092        // dead-columns has no projection → the key is skipped entirely.
1093        assert!(!json.contains("\"projected\": null"));
1094        assert!(json.contains("\"severity\": \"info\""));
1095
1096        let text = format_text(&report);
1097        assert!(text.contains("STT Doctor"));
1098        // Severity tag matches the finding (Critical when the flagged share
1099        // dominates this little fixture, Warning otherwise).
1100        let raw = find(&report, "raw-f64-column").unwrap();
1101        assert!(text.contains(&format!("[{}] raw-f64-column", raw.severity)));
1102        assert!(text.contains("[INFO] dead-columns"));
1103        assert!(text.contains("fix: --exclude stuck"));
1104        assert!(text.contains("projected:"));
1105
1106        let empty = format_text(&DoctorReport { findings: vec![] });
1107        assert!(empty.contains("No findings"));
1108    }
1109}