1use 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
34const ESTIMATE_LABEL: &str = "(estimated from measured column costs)";
37
38const PLAIN_F64_NOTE: &str = "plain f64 (unquantized)";
42
43const 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
57const RAW_F64_MIN_SHARE: f64 = 0.03;
60const RAW_F64_CRITICAL_SHARE: f64 = 0.5;
63const RAW_F64_SHRINK: f64 = 0.6;
66
67const ID_BPF_INFO: f64 = 4.0;
69const ID_BPF_WARN: f64 = 6.0;
72const ID_MIN_FEATURES_PER_TILE: f64 = 256.0;
76
77const DOCTOR_SAMPLE_TILES: usize = 8;
79
80const Z0_MIN_ZOOM: u8 = 4;
82const Z0_EXTENT_DEG: f64 = 2.0;
84
85const UNPAGED_TILE_LIMIT: u64 = 10_000;
87
88const OVERSIZED_BLOB_BYTES: u64 = 1024 * 1024;
90
91const SUMMARY_FEATURE_FLOOR: u64 = 1_000_000;
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum Severity {
103 Critical,
105 Warning,
107 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#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct Finding {
126 pub severity: Severity,
128 pub code: String,
130 pub message: String,
132 pub remediation: Vec<String>,
134 #[serde(skip_serializing_if = "Option::is_none")]
138 pub projected: Option<String>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct DoctorReport {
144 pub findings: Vec<Finding>,
146}
147
148pub 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
188struct ColState {
194 tiles: usize,
196 all_null: bool,
198 constant: bool,
200 exemplar: Option<ArrayData>,
202}
203
204struct SampledDecode {
206 tiles_decoded: usize,
208 tiles_total: usize,
210 total_rows: u64,
212 point_rows: u64,
214 columns: BTreeMap<String, ColState>,
216}
217
218fn 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
234fn 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 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
305fn 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
377fn 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 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
424fn 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
454fn 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 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
502fn 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
529fn 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
569fn 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
606pub 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 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 fn rand01(x: u64) -> f64 {
673 (mix(x) >> 11) as f64 / (1u64 << 53) as f64
674 }
675
676 struct FixtureSpec {
678 rows: usize,
679 tiles: usize,
680 hash_ids: bool,
682 stuck_column: bool,
684 quantize_magnitude: bool,
686 world_random_coords: bool,
688 identical_payloads: bool,
690 paged: Option<usize>,
692 zooms: Option<Vec<u8>>,
694 min_zoom: u8,
695 max_zoom: u8,
696 bounds: Option<BoundingBox>,
698 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 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 fn build(out: &std::path::Path, spec: &FixtureSpec) {
789 let cfg = EncoderConfig {
790 quantize_attrs: if spec.quantize_magnitude {
791 [("magnitude".to_string(), 0.01)].into_iter().collect()
792 } else {
793 Default::default()
794 },
795 ..Default::default()
796 };
797 let mut w = PackWriter::create(out, BlobOrdering::Auto, 64 * 1024)
798 .unwrap()
799 .with_paging(spec.paged);
800 let bucket = 3_600_000i64;
801 let shared = spec
802 .identical_payloads
803 .then(|| encode_tile_with(&[points_layer(0, spec)], &cfg).unwrap());
804 for k in 0..spec.tiles {
805 let payload = match &shared {
806 Some(p) => p.clone(),
807 None => encode_tile_with(&[points_layer(k as u64, spec)], &cfg).unwrap(),
808 };
809 let z = spec
810 .zooms
811 .as_ref()
812 .map(|zs| zs[k % zs.len()])
813 .unwrap_or(spec.max_zoom);
814 let x = (k as u32) % (1u32 << z);
815 let t0 = (k as i64) * bucket;
816 w.add_tile_full(
817 &TileId::new(z, x, 0, t0 as u64),
818 t0,
819 t0 + bucket - 1,
820 Some(t0),
821 spec.claimed_features.unwrap_or(spec.rows as u32),
822 Some(bucket as u64),
823 &payload,
824 )
825 .unwrap();
826 }
827 let mut meta = Metadata::new("doctor-fixture")
828 .with_temporal_bucket_ms(bucket as u64)
829 .with_zoom_levels(spec.min_zoom, spec.max_zoom);
830 if let Some(b) = spec.bounds {
831 meta = meta.with_bounds(b);
832 }
833 w.finalize(&meta).unwrap();
834 }
835
836 fn doctor_fixture(spec: &FixtureSpec, sample: Option<usize>) -> DoctorReport {
838 let dir = tempfile::tempdir().unwrap();
839 let out = dir.path().join("dataset");
840 build(&out, spec);
841 let ts = PackedTileset::open(&out).unwrap();
842 let report = inspect(&ts, sample).unwrap();
843 doctor(&ts, &report).unwrap()
844 }
845
846 fn find<'a>(report: &'a DoctorReport, code: &str) -> Option<&'a Finding> {
847 report.findings.iter().find(|f| f.code == code)
848 }
849
850 #[test]
854 fn raw_f64_column_fires_then_quantized_is_clean() {
855 let raw = doctor_fixture(&FixtureSpec::default(), None);
856 let f = find(&raw, "raw-f64-column").expect("raw-f64-column should fire");
857 assert!(
858 matches!(f.severity, Severity::Critical | Severity::Warning),
859 "severity {:?}",
860 f.severity
861 );
862 assert!(f.message.contains("magnitude"), "message: {}", f.message);
863 assert!(f.message.contains('%'), "message cites measured shares");
864 assert!(f.remediation.iter().any(|r| r.contains("--quantize-attr ")));
865 assert!(f
866 .remediation
867 .iter()
868 .any(|r| r.contains("--quantize-attrs-auto")));
869 let projected = f.projected.as_deref().expect("R1 carries a projection");
870 assert!(projected.contains("(estimated from measured column costs)"));
871
872 let clean = doctor_fixture(
875 &FixtureSpec {
876 quantize_magnitude: true,
877 ..Default::default()
878 },
879 None,
880 );
881 assert!(find(&clean, "raw-f64-column").is_none());
882 let warnings: Vec<_> = clean
883 .findings
884 .iter()
885 .filter(|f| f.severity <= Severity::Warning)
886 .collect();
887 assert!(
888 warnings.is_empty(),
889 "clean quantized fixture has Warning+ findings: {warnings:?}"
890 );
891 }
892
893 #[test]
896 fn expensive_feature_ids_fires_on_hash_ids() {
897 let report = doctor_fixture(
898 &FixtureSpec {
899 hash_ids: true,
900 ..Default::default()
901 },
902 None,
903 );
904 let f = find(&report, "expensive-feature-ids").expect("should fire");
905 assert_eq!(f.severity, Severity::Warning);
907 assert!(f.message.contains("B/feature"), "message: {}", f.message);
908 assert!(f.remediation.iter().any(|r| r.contains("sequential ids")));
909 assert!(f
910 .projected
911 .as_deref()
912 .unwrap()
913 .contains("(estimated from measured column costs)"));
914 }
915
916 #[test]
919 fn dead_columns_fires_for_constant_column() {
920 let report = doctor_fixture(
921 &FixtureSpec {
922 stuck_column: true,
923 ..Default::default()
924 },
925 None,
926 );
927 let f = find(&report, "dead-columns").expect("dead-columns should fire");
928 assert_eq!(f.severity, Severity::Info);
929 assert!(f.message.contains("`stuck`"), "message: {}", f.message);
930 assert!(f.message.contains("sampled"), "must admit sampling");
931 assert_eq!(f.remediation, vec!["--exclude stuck".to_string()]);
932 assert!(
934 !report.findings.iter().any(|f| f.code == "dead-columns"
935 && (f.message.contains("`magnitude`") || f.message.contains("`kind`"))),
936 "varying columns flagged dead"
937 );
938 }
939
940 #[test]
943 fn z0_bomb_fires_for_tiny_bounds_deep_pyramid() {
944 let report = doctor_fixture(
945 &FixtureSpec {
946 tiles: 4,
947 rows: 50,
948 zooms: Some(vec![0, 1, 2, 10]),
949 min_zoom: 0,
950 max_zoom: 10,
951 bounds: Some(BoundingBox {
952 min_lon: -73.8,
953 min_lat: 45.4,
954 max_lon: -73.3,
955 max_lat: 45.9,
956 }),
957 ..Default::default()
958 },
959 None,
960 );
961 let f = find(&report, "z0-bomb").expect("z0-bomb should fire");
962 assert_eq!(f.severity, Severity::Warning);
963 assert_eq!(f.remediation, vec!["--min-zoom 9".to_string()]);
965 assert!(
967 f.message.contains("3 tile entries"),
968 "message: {}",
969 f.message
970 );
971
972 let wide = doctor_fixture(
974 &FixtureSpec {
975 tiles: 4,
976 rows: 50,
977 zooms: Some(vec![0, 1, 2, 10]),
978 min_zoom: 0,
979 max_zoom: 10,
980 bounds: None,
981 ..Default::default()
982 },
983 None,
984 );
985 assert!(find(&wide, "z0-bomb").is_none());
986 }
987
988 #[test]
991 fn unpaged_large_fires_then_paged_is_quiet() {
992 let spec = FixtureSpec {
993 rows: 4,
994 tiles: 10_001,
995 identical_payloads: true,
996 zooms: Some(vec![14]),
997 min_zoom: 5,
998 max_zoom: 14,
999 ..Default::default()
1000 };
1001 let report = doctor_fixture(&spec, Some(4));
1002 let f = find(&report, "unpaged-large").expect("unpaged-large should fire");
1003 assert_eq!(f.severity, Severity::Warning);
1004 assert!(f.message.contains("10001"), "message: {}", f.message);
1005 assert!(f
1006 .remediation
1007 .iter()
1008 .any(|r| r.contains("paged directory is the default")));
1009
1010 let paged = doctor_fixture(
1011 &FixtureSpec {
1012 paged: Some(4096),
1013 ..spec
1014 },
1015 Some(4),
1016 );
1017 assert!(find(&paged, "unpaged-large").is_none());
1018 }
1019
1020 #[test]
1023 fn oversized_blobs_fires_and_orders_remediation() {
1024 let report = doctor_fixture(
1025 &FixtureSpec {
1026 rows: 100_000,
1027 tiles: 1,
1028 world_random_coords: true,
1029 ..Default::default()
1030 },
1031 None,
1032 );
1033 let f = find(&report, "oversized-blobs").expect("oversized-blobs should fire");
1034 assert_eq!(f.severity, Severity::Warning);
1035 assert!(f.message.contains("MiB"), "message: {}", f.message);
1036 assert!(f.message.contains("z10/0/0"), "message: {}", f.message);
1037 assert!(f.remediation[0].contains("--min-zoom"));
1039 assert!(f.remediation[1].contains("--summary-tier"));
1040 assert!(f.remediation[2].contains("--maximum-tile-bytes"));
1041 assert!(f.remediation[2].contains("DROP"));
1042
1043 let ranks: Vec<_> = report
1045 .findings
1046 .iter()
1047 .map(|f| (f.severity, f.code.clone()))
1048 .collect();
1049 let mut sorted = ranks.clone();
1050 sorted.sort();
1051 assert_eq!(ranks, sorted, "findings not severity/code sorted");
1052 }
1053
1054 #[test]
1057 fn missing_summary_tier_fires_for_large_point_dataset() {
1058 let report = doctor_fixture(
1059 &FixtureSpec {
1060 rows: 100,
1061 tiles: 2,
1062 claimed_features: Some(600_000),
1063 quantize_magnitude: true,
1064 ..Default::default()
1065 },
1066 None,
1067 );
1068 let f = find(&report, "missing-summary-tier").expect("should fire");
1069 assert_eq!(f.severity, Severity::Info);
1070 assert!(f.message.contains("1200000"), "message: {}", f.message);
1071 assert!(f.remediation[0].contains("--summary-tier quadbin"));
1072 }
1073
1074 #[test]
1077 fn report_serializes_and_renders() {
1078 let report = doctor_fixture(
1079 &FixtureSpec {
1080 stuck_column: true,
1081 ..Default::default()
1082 },
1083 None,
1084 );
1085 let json = serde_json::to_string_pretty(&report).unwrap();
1086 let back: DoctorReport = serde_json::from_str(&json).unwrap();
1087 assert_eq!(back.findings.len(), report.findings.len());
1088 assert!(!json.contains("\"projected\": null"));
1090 assert!(json.contains("\"severity\": \"info\""));
1091
1092 let text = format_text(&report);
1093 assert!(text.contains("STT Doctor"));
1094 let raw = find(&report, "raw-f64-column").unwrap();
1097 assert!(text.contains(&format!("[{}] raw-f64-column", raw.severity)));
1098 assert!(text.contains("[INFO] dead-columns"));
1099 assert!(text.contains("fix: --exclude stuck"));
1100 assert!(text.contains("projected:"));
1101
1102 let empty = format_text(&DoctorReport { findings: vec![] });
1103 assert!(empty.contains("No findings"));
1104 }
1105}