1use anyhow::Result;
12use serde::{Deserialize, Serialize};
13
14use stt_core::curve::{self, BlobOrdering};
15use stt_core::ordering_sim::{self, SimOptions, TileSample};
16
17use crate::packed::PackedTileset;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct OrderingCostRow {
22 pub ordering: String,
23 pub scrub_reads: u64,
24 pub scrub_bytes: u64,
25 pub pan_reads: u64,
26 pub pan_bytes: u64,
27 pub total_reads: u64,
28 pub total_bytes: u64,
29 pub total_cost: u64,
31 pub recommended: bool,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct OrderAuditReport {
37 pub native_tiles: usize,
39 pub coalesce_gap_bytes: u64,
41 pub orderings: Vec<OrderingCostRow>,
43 pub recommended: String,
45 pub auto_choice: String,
47 pub current: Option<String>,
50}
51
52pub fn order_audit(tileset: &PackedTileset) -> Result<OrderAuditReport> {
54 let (_min_z, max_z) = tileset.zoom_range();
55 let bucket_ms = (tileset.temporal_bucket_ms().max(1)) as i64;
56
57 let samples: Vec<TileSample> = tileset
60 .entries()
61 .iter()
62 .filter(|e| e.zoom == max_z)
63 .map(|e| TileSample {
64 z: e.zoom,
65 x: e.x,
66 y: e.y,
67 hilbert: e.hilbert,
68 time_start: e.time_start,
69 tb: e.time_start.div_euclid(bucket_ms),
70 len: e.length as u64,
71 })
72 .collect();
73
74 let opts = SimOptions::default();
75 let ranked = ordering_sim::evaluate(&samples, opts);
76 let recommended = ordering_sim::measured_ordering(&samples, opts);
79
80 let orderings: Vec<OrderingCostRow> = ranked
81 .iter()
82 .map(|c| OrderingCostRow {
83 ordering: c.ordering.as_str().to_string(),
84 scrub_reads: c.scrub.reads,
85 scrub_bytes: c.scrub.bytes_read,
86 pan_reads: c.pan.reads,
87 pan_bytes: c.pan.bytes_read,
88 total_reads: c.total_reads,
89 total_bytes: c.total_bytes_read,
90 total_cost: c.cost,
91 recommended: c.ordering == recommended,
92 })
93 .collect();
94
95 let auto_choice = if samples.is_empty() {
97 BlobOrdering::SpatialMajor
98 } else {
99 let (mut x_min, mut x_max, mut y_min, mut y_max) = (u32::MAX, 0u32, u32::MAX, 0u32);
100 let (mut tb_min, mut tb_max) = (i64::MAX, i64::MIN);
101 for s in &samples {
102 x_min = x_min.min(s.x);
103 x_max = x_max.max(s.x);
104 y_min = y_min.min(s.y);
105 y_max = y_max.max(s.y);
106 tb_min = tb_min.min(s.tb);
107 tb_max = tb_max.max(s.tb);
108 }
109 let space_bits = curve::bits_for((x_max - x_min).max(y_max - y_min) as u64 + 1);
110 let time_bits = curve::bits_for((tb_max - tb_min).max(0) as u64 + 1);
111 BlobOrdering::choose(space_bits, time_bits)
112 };
113
114 Ok(OrderAuditReport {
115 native_tiles: samples.len(),
116 coalesce_gap_bytes: opts.coalesce_gap_bytes,
117 orderings,
118 recommended: recommended.as_str().to_string(),
119 auto_choice: auto_choice.as_str().to_string(),
120 current: tileset.manifest().blob_ordering.clone(),
121 })
122}
123
124fn mib(bytes: u64) -> String {
125 if bytes >= 1024 * 1024 {
126 format!("{:.1} MiB", bytes as f64 / (1024.0 * 1024.0))
127 } else if bytes >= 1024 {
128 format!("{} KiB", bytes / 1024)
129 } else {
130 format!("{bytes} B")
131 }
132}
133
134pub fn format_text(r: &OrderAuditReport) -> String {
136 use std::fmt::Write;
137 let mut s = String::new();
138 let _ = writeln!(
139 s,
140 "Blob-ordering audit — {} native tiles, {} coalescing gap",
141 r.native_tiles,
142 mib(r.coalesce_gap_bytes)
143 );
144 let _ = writeln!(s);
145 let _ = writeln!(
146 s,
147 " {:<11} {:>10} {:>12} {:>12}",
148 "ordering", "range rds", "bytes read", "cost"
149 );
150 for row in &r.orderings {
151 let mark = if row.recommended { " * " } else { " " };
152 let note = if row.ordering == "morton3" { " (research only)" } else { "" };
153 let _ = writeln!(
154 s,
155 "{}{:<11} {:>10} {:>12} {:>12}{}",
156 mark,
157 row.ordering,
158 row.total_reads,
159 mib(row.total_bytes),
160 mib(row.total_cost),
161 note
162 );
163 }
164 let _ = writeln!(s);
165 let _ = writeln!(
166 s,
167 " cost = bytes read + reads × {} gap (the reader's own request/byte trade)",
168 mib(r.coalesce_gap_bytes)
169 );
170 let _ = writeln!(s, " recommended : {} (measured — lowest cost)", r.recommended);
171 let _ = writeln!(s, " auto picks : {}", r.auto_choice);
172 match &r.current {
173 Some(c) if *c == r.recommended => {
174 let _ = writeln!(s, " current : {c} (already the measured best)");
175 }
176 Some(c) => {
177 let _ = writeln!(
178 s,
179 " current : {c} — rebuild with `--blob-ordering measured` to switch to {}",
180 r.recommended
181 );
182 }
183 None => {
184 let _ = writeln!(s, " current : not recorded (pre-2026-07 archive)");
185 }
186 }
187 s
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use stt_core::metadata::Metadata;
194 use stt_core::pack::PackWriter;
195 use stt_core::tile::TileId;
196
197 #[test]
198 fn audit_ranks_and_recommends_over_a_real_archive() {
199 let dir = tempfile::tempdir().unwrap();
200 let out = dir.path().join("audit");
201 let mut w = PackWriter::create(&out, BlobOrdering::Auto, 1 << 20)
205 .unwrap()
206 .with_format_version(stt_core::pack::PACKED_FORMAT_VERSION_V1);
207 let bucket = 3_600_000i64;
208 for x in 0..3u32 {
209 for b in 0..24i64 {
210 let t = b * bucket;
211 let id = TileId::new(10, 4_000 + x, 5_000, t as u64);
212 let payload = format!("t-{x}-{b}").into_bytes();
213 w.add_tile_full(&id, t, t + bucket - 1, Some(t), 1, None, &payload).unwrap();
214 }
215 }
216 let meta = Metadata::new("audit").with_temporal_bucket_ms(bucket as u64);
217 w.finalize(&meta).unwrap();
218
219 let ts = PackedTileset::open(&out).unwrap();
220 let r = order_audit(&ts).unwrap();
221
222 assert_eq!(r.orderings.len(), 4);
223 for pair in r.orderings.windows(2) {
225 assert!(pair[0].total_cost <= pair[1].total_cost);
226 }
227 assert_ne!(r.recommended, "morton3");
230 assert!(r.orderings.iter().filter(|o| o.recommended).count() == 1);
231 let rec = r.orderings.iter().find(|o| o.recommended).unwrap();
232 assert_eq!(rec.ordering, r.recommended);
233 for o in &r.orderings {
234 if o.ordering != "morton3" {
235 assert!(o.total_cost >= rec.total_cost);
236 }
237 }
238 let current = r.current.clone().expect("built-with-Auto archive records blobOrdering");
240 assert!(matches!(current.as_str(), "spatial" | "time-major" | "hilbert3" | "morton3"));
241 assert!(matches!(r.auto_choice.as_str(), "spatial" | "time-major" | "hilbert3" | "morton3"));
242 assert!(format_text(&r).contains("recommended :"));
243 }
244}