Skip to main content

stt_optimize/
order_audit.rs

1//! Blob-ordering audit over a built archive: measure per-ordering range-read
2//! cost and recommend `--blob-ordering`.
3//!
4//! Where the layout advisor's ordering hint is an unsimulated access-shape
5//! guess (`advisors::layout`), this runs the shared simulator
6//! ([`stt_core::ordering_sim`]) over the archive's real native tiles and ranks
7//! the four orderings by the range reads the two canonical access patterns
8//! (scrub a viewport across time; pan one instant across space) would cost. It
9//! reads only the directory — no payload decode — so it is cheap on any size.
10
11use 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/// One ordering's simulated cost (range reads + bytes read per query + totals).
20#[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    /// Blended cost the ranking uses: `total_bytes + total_reads * gap`.
30    pub total_cost: u64,
31    pub recommended: bool,
32}
33
34/// The full ordering audit for one archive.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct OrderAuditReport {
37    /// Native-tier tiles the simulation ran over.
38    pub native_tiles: usize,
39    /// Coalescing gap used (bytes) — the reader's real default.
40    pub coalesce_gap_bytes: u64,
41    /// Every ordering, cheapest-first.
42    pub orderings: Vec<OrderingCostRow>,
43    /// The measured-best ordering.
44    pub recommended: String,
45    /// What `--blob-ordering auto` (the cardinality heuristic) would pick.
46    pub auto_choice: String,
47    /// The ordering the archive was built with (`manifest.blobOrdering`);
48    /// `None` for pre-2026-07 archives that don't record it.
49    pub current: Option<String>,
50}
51
52/// Audit an opened archive's blob ordering.
53pub 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    // Directory-only projection to the simulator's view (len = COMPRESSED
58    // on-disk blob size). Native tier only — coarse LOD tiers are aggregates.
59    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    // The recommendation is the cheapest SELECTABLE ordering (never morton3) —
77    // identical to what `--blob-ordering measured` would resolve.
78    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    // `auto`'s pick over the occupied extent — same F1 logic as finalize.
96    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
134/// Render an audit report as a doctor-style banner + table.
135pub 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        // Deep-time, narrow-space fixture built with Auto (records blobOrdering).
202        // v1-pinned: payloads are opaque bytes, not v2 frames (add_tile_full
203        // enforces frame/manifest version coherence).
204        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        // Cheapest-first by blended cost (NOT raw request count).
224        for pair in r.orderings.windows(2) {
225            assert!(pair[0].total_cost <= pair[1].total_cost);
226        }
227        // Recommended is the cheapest SELECTABLE ordering — never morton3, and
228        // no non-morton3 ordering is cheaper than it.
229        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        // The archive records its concrete ordering (F4a) and the audit surfaces it.
239        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}