Skip to main content

stt_core/
ordering_sim.rs

1//! Measured blob-ordering picker: simulate per-ordering HTTP range-read cost.
2//!
3//! [`BlobOrdering::choose`](crate::curve::BlobOrdering::choose) is a cheap
4//! cardinality heuristic. This module is the honest alternative it references:
5//! over the real native-tier tiles, lay each candidate ordering's byte string
6//! down (with the writer's exact total sort key), simulate the two canonical
7//! access patterns — *scrub a viewport across all time* and *pan one instant
8//! across space* — under HTTP range coalescing, and rank the orderings by the
9//! range-read cost they actually incur. It is a faithful Rust port of the
10//! showcase probe (`examples/showcase/src/lib/spaceCurve.ts`), sharpened to sort
11//! with the production [`crate::curve::space_time_key`] so the simulated layout
12//! is byte-identical to what the writer lays down.
13//!
14//! Two callers share it: the opt-in `--blob-ordering measured` build mode
15//! (`PackWriter::with_measured_ordering`) and the `stt-optimize order-audit`
16//! subcommand. It is a **pure, deterministic** function of its inputs — all
17//! integer arithmetic, stable sorts, no RNG — because the resolved ordering
18//! sets every content-addressed pack name; any nondeterminism would churn pack
19//! hashes and break immutable-CDN caching.
20
21use std::collections::{BTreeMap, HashSet};
22
23use crate::curve::{self, BlobOrdering};
24
25/// Default range-read coalescing gap: two needed blobs fuse into one request
26/// when at most this many **unneeded** bytes lie between them. Matches the
27/// production reader's `DEFAULT_RANGE_COALESCE_GAP` (packages/core, archive.ts)
28/// so the simulation models what the client actually does.
29pub const DEFAULT_COALESCE_GAP_BYTES: u64 = 2 * 1024 * 1024;
30
31/// The orderings evaluated, in **final-tiebreak priority order**: a genuine
32/// cost tie resolves to the earliest entry, so `SpatialMajor` wins ties (which
33/// reproduces the shallow-time `choose` rule for free) and `Morton3` sits
34/// dead-last and can never win a tie.
35pub const CANDIDATES: [BlobOrdering; 4] = [
36    BlobOrdering::SpatialMajor,
37    BlobOrdering::Hilbert3,
38    BlobOrdering::TimeMajor,
39    BlobOrdering::Morton3,
40];
41
42/// One tile as the simulator sees it. `len` is the blob byte weight — the
43/// uncompressed `payload.len()` at finalize, or the compressed `entry.length`
44/// in the advisor; because range *count* (the primary cost) is weight-insensitive
45/// both call sites pick the same winner.
46#[derive(Debug, Clone, Copy)]
47pub struct TileSample {
48    pub z: u8,
49    pub x: u32,
50    pub y: u32,
51    pub hilbert: u64,
52    pub time_start: i64,
53    /// Time-bucket index (`time_start / bucket_ms`).
54    pub tb: i64,
55    pub len: u64,
56}
57
58/// Simulation knobs. The build path always uses the default so pack hashes stay
59/// reproducible; `coalesce_gap_bytes` is exposed for offline sweeps.
60#[derive(Debug, Clone, Copy)]
61pub struct SimOptions {
62    pub coalesce_gap_bytes: u64,
63}
64
65impl Default for SimOptions {
66    fn default() -> Self {
67        Self { coalesce_gap_bytes: DEFAULT_COALESCE_GAP_BYTES }
68    }
69}
70
71/// Cost of one access pattern under one ordering.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct QueryCost {
74    /// Coalesced range requests.
75    pub reads: u64,
76    /// Bytes actually transferred (needed + over-read swept up inside fused runs).
77    pub bytes_read: u64,
78    /// Bytes the query genuinely needs (ordering-independent).
79    pub bytes_needed: u64,
80}
81
82impl QueryCost {
83    const ZERO: QueryCost = QueryCost { reads: 0, bytes_read: 0, bytes_needed: 0 };
84}
85
86/// Aggregate cost of one ordering across the canonical query mix.
87#[derive(Debug, Clone, Copy)]
88pub struct OrderingCost {
89    pub ordering: BlobOrdering,
90    pub scrub: QueryCost,
91    pub pan: QueryCost,
92    pub total_reads: u64,
93    pub total_bytes_read: u64,
94    /// Blended cost in bytes-equivalent: `total_bytes_read + total_reads * gap`.
95    /// The reader over-reads up to `gap` bytes to save one request, so it prices
96    /// a request at exactly `gap` bytes — this is the same trade the coalescer
97    /// makes, so ranking by it is self-consistent. Request count *alone* is a
98    /// broken proxy (fusing an entire archive into "1 read" of hundreds of MiB
99    /// would look free); bytes *alone* ignores per-request latency.
100    pub cost: u64,
101}
102
103/// Orderings the pickers may *select*. `Morton3` is deliberately absent — it is
104/// offered only as an explicit `--blob-ordering morton3` for research; its long
105/// jumps hurt locality and its rare measured "wins" are marginal over-read edges
106/// on tiny datasets. It is still `evaluate`d and reported for comparison.
107pub const SELECTABLE: [BlobOrdering; 3] = [
108    BlobOrdering::SpatialMajor,
109    BlobOrdering::Hilbert3,
110    BlobOrdering::TimeMajor,
111];
112
113/// Rank every candidate ordering cheapest-first for these tiles by the blended
114/// [`OrderingCost::cost`] (bytes read + reads × gap), with the stable sort
115/// keeping `CANDIDATES` order as the final tiebreak (so `SpatialMajor` wins
116/// genuine ties). `Morton3` is included for reporting but never *selected* by
117/// [`measured_ordering`] (see [`SELECTABLE`]).
118pub fn evaluate(samples: &[TileSample], opts: SimOptions) -> Vec<OrderingCost> {
119    let gap = opts.coalesce_gap_bytes;
120    if samples.is_empty() {
121        return CANDIDATES
122            .iter()
123            .map(|&ordering| OrderingCost {
124                ordering,
125                scrub: QueryCost::ZERO,
126                pan: QueryCost::ZERO,
127                total_reads: 0,
128                total_bytes_read: 0,
129                cost: 0,
130            })
131            .collect();
132    }
133
134    let (tb_min, tb_max) = samples
135        .iter()
136        .fold((i64::MAX, i64::MIN), |(lo, hi), s| (lo.min(s.tb), hi.max(s.tb)));
137    let tb_span = tb_max - tb_min;
138
139    let scrub = scrub_hits(samples);
140    let pan = pan_hits(samples);
141
142    let mut costs: Vec<OrderingCost> = CANDIDATES
143        .iter()
144        .map(|&ordering| {
145            let order = linearize(samples, ordering, tb_min, tb_span);
146            let sc = simulate_query(&order, samples, &scrub, gap);
147            let pn = simulate_query(&order, samples, &pan, gap);
148            let total_reads = sc.reads + pn.reads;
149            let total_bytes_read = sc.bytes_read + pn.bytes_read;
150            OrderingCost {
151                ordering,
152                scrub: sc,
153                pan: pn,
154                total_reads,
155                total_bytes_read,
156                cost: total_bytes_read.saturating_add(total_reads.saturating_mul(gap)),
157            }
158        })
159        .collect();
160
161    // Stable sort by blended cost → ties keep CANDIDATES order (Spatial first).
162    costs.sort_by(|a, b| a.cost.cmp(&b.cost));
163    costs
164}
165
166/// The measured-best **selectable** ordering for these tiles — the cheapest that
167/// is not `Morton3` (`SpatialMajor` on empty input). `Morton3` is reported by
168/// [`evaluate`] but never chosen (see [`SELECTABLE`]).
169pub fn measured_ordering(samples: &[TileSample], opts: SimOptions) -> BlobOrdering {
170    evaluate(samples, opts)
171        .into_iter()
172        .map(|c| c.ordering)
173        .find(|o| *o != BlobOrdering::Morton3)
174        .unwrap_or(BlobOrdering::SpatialMajor)
175}
176
177/// Index permutation of `samples` in `ordering` byte order, using the writer's
178/// exact total sort key (`crates/stt-core/src/pack.rs` finalize) so the
179/// simulated layout is byte-identical to what the writer lays down.
180fn linearize(samples: &[TileSample], ordering: BlobOrdering, tb_min: i64, tb_span: i64) -> Vec<u32> {
181    let mut idx: Vec<u32> = (0..samples.len() as u32).collect();
182    idx.sort_by_key(|&i| {
183        let s = &samples[i as usize];
184        (
185            curve::space_time_key(
186                ordering, s.z, s.x, s.y, s.hilbert, s.time_start, s.tb, tb_min, tb_span,
187            ),
188            s.z,
189            s.x,
190            s.y,
191            s.time_start,
192        )
193    });
194    idx
195}
196
197/// Simulate one access pattern: walk the linearized blobs, fusing two needed
198/// blobs into one request when at most `gap_bytes` unneeded bytes lie between
199/// them (else a new request). `bytes_read` sums every blob inside a fused run.
200fn simulate_query(order: &[u32], samples: &[TileSample], hit: &[bool], gap_bytes: u64) -> QueryCost {
201    let mut reads = 0u64;
202    let mut bytes_read = 0u64;
203    let mut bytes_needed = 0u64;
204
205    let mut in_run = false;
206    let mut run_bytes = 0u64; // needed + swept over-read in the current fused run
207    let mut gap_since_hit = 0u64; // unneeded bytes since the last hit (fuse candidate)
208
209    for &i in order {
210        let s = &samples[i as usize];
211        let h = hit[i as usize];
212        if h {
213            bytes_needed += s.len;
214        }
215        if !in_run {
216            if h {
217                in_run = true;
218                run_bytes = s.len;
219                gap_since_hit = 0;
220            }
221            // leading unneeded blobs are simply skipped
222        } else if h {
223            // fuse: the pending gap becomes over-read, plus this blob
224            run_bytes += gap_since_hit + s.len;
225            gap_since_hit = 0;
226        } else {
227            gap_since_hit += s.len;
228            if gap_since_hit > gap_bytes {
229                // gap too wide — close the run (trailing gap is not transferred)
230                reads += 1;
231                bytes_read += run_bytes;
232                in_run = false;
233                run_bytes = 0;
234                gap_since_hit = 0;
235            }
236        }
237    }
238    if in_run {
239        reads += 1;
240        bytes_read += run_bytes;
241    }
242    QueryCost { reads, bytes_read, bytes_needed }
243}
244
245/// "Scrub a viewport": a contiguous 2D-Hilbert spatial band (the central quarter
246/// of the distinct occupied cells in Hilbert order) read across ALL time — a
247/// neighbourhood you zoom to and play through. Rewards keeping each cell's whole
248/// timeline byte-contiguous (`SpatialMajor`).
249fn scrub_hits(samples: &[TileSample]) -> Vec<bool> {
250    let mut hs: Vec<u64> = samples.iter().map(|s| s.hilbert).collect();
251    hs.sort_unstable();
252    hs.dedup();
253    let m = hs.len();
254    if m == 0 {
255        return vec![false; samples.len()];
256    }
257    // Central quarter by rank: [m*3/8 .. max(m*5/8, m*3/8 + 1)) — integer math,
258    // equal to the FE's floor(m*0.375)..floor(m*0.625) (3/8, 5/8 are exact).
259    let lo = m * 3 / 8;
260    let hi = (m * 5 / 8).max(lo + 1);
261    let band: HashSet<u64> = hs[lo..hi].iter().copied().collect();
262    samples.iter().map(|s| band.contains(&s.hilbert)).collect()
263}
264
265/// "Pan at one instant": the single densest time bucket (by summed bytes; ties
266/// resolve to the smallest bucket for determinism) read across the whole map.
267/// Rewards keeping one instant's whole map byte-contiguous (`TimeMajor`).
268fn pan_hits(samples: &[TileSample]) -> Vec<bool> {
269    let mut by_tb: BTreeMap<i64, u64> = BTreeMap::new();
270    for s in samples {
271        *by_tb.entry(s.tb).or_insert(0) += s.len;
272    }
273    let mut best_tb = 0i64;
274    let mut best_w = 0u64;
275    let mut found = false;
276    for (&tb, &w) in by_tb.iter() {
277        // BTreeMap iterates ascending tb; strict `>` keeps the smallest on ties.
278        if !found || w > best_w {
279            best_w = w;
280            best_tb = tb;
281            found = true;
282        }
283    }
284    samples.iter().map(|s| s.tb == best_tb).collect()
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    // A gap small enough (relative to the fixtures' 1000-byte blobs) that
292    // coalescing engages but over-read is penalised — so the blended cost
293    // actually discriminates orderings (the 2 MiB default would fuse tiny
294    // fixtures to ~1 read for everything).
295    const OPTS: SimOptions = SimOptions { coalesce_gap_bytes: 1500 };
296    const LEN: u64 = 1000;
297
298    fn s(x: u32, y: u32, hilbert: u64, tb: i64, len: u64) -> TileSample {
299        TileSample { z: 8, x, y, hilbert, time_start: tb, tb, len }
300    }
301
302    #[test]
303    fn deep_time_playback_prefers_spatial() {
304        // Two cells, a long shared timeline: playback (scrub a cell across all
305        // time) wants each cell's whole timeline byte-contiguous → SpatialMajor.
306        // Under time-major the band scatters and coalescing over-reads the whole
307        // archive, so the blended cost correctly penalises it.
308        let mut samples = Vec::new();
309        for t in 0..24i64 {
310            samples.push(s(0, 0, 0, t, LEN));
311            samples.push(s(1, 0, 1, t, LEN));
312        }
313        assert_eq!(measured_ordering(&samples, OPTS), BlobOrdering::SpatialMajor);
314    }
315
316    #[test]
317    fn single_bucket_resolves_to_spatial() {
318        // One time bucket → SpatialMajor (pure 2D Hilbert); the degenerate 3D
319        // time axis never helps. (Measured-path parity with the F2 choose rule.)
320        let samples: Vec<TileSample> = (0..16u32).map(|i| s(i, 0, i as u64, 0, LEN)).collect();
321        assert_eq!(measured_ordering(&samples, OPTS), BlobOrdering::SpatialMajor);
322    }
323
324    #[test]
325    fn morton3_is_reported_but_never_selected() {
326        // A range of shapes; whatever `evaluate` ranks, the selected ordering is
327        // the cheapest NON-morton3, and morton3 still appears in the report.
328        for spread in [1u32, 8, 32] {
329            let mut samples = Vec::new();
330            for t in 0..8i64 {
331                for x in 0..spread {
332                    samples.push(s(x, x % 3, (x as u64) * 7 + 1, t, LEN + x as u64));
333                }
334            }
335            let ranked = evaluate(&samples, OPTS);
336            assert!(ranked.iter().any(|c| c.ordering == BlobOrdering::Morton3), "morton3 reported");
337            let expected = ranked
338                .iter()
339                .map(|c| c.ordering)
340                .find(|o| *o != BlobOrdering::Morton3)
341                .unwrap();
342            assert_eq!(measured_ordering(&samples, OPTS), expected);
343            assert_ne!(measured_ordering(&samples, OPTS), BlobOrdering::Morton3);
344        }
345    }
346
347    #[test]
348    fn cost_is_bytes_plus_reads_times_gap() {
349        // The ranking key is the blended cost, not raw request count.
350        let samples: Vec<TileSample> = (0..12u32).map(|i| s(i, 0, i as u64, (i % 4) as i64, LEN)).collect();
351        for c in evaluate(&samples, OPTS) {
352            assert_eq!(c.cost, c.total_bytes_read + c.total_reads * OPTS.coalesce_gap_bytes);
353        }
354    }
355
356    #[test]
357    fn evaluate_is_deterministic() {
358        let mut samples = Vec::new();
359        for t in 0..10i64 {
360            for x in 0..5u32 {
361                samples.push(s(x, x % 2, (x as u64) * 3 + 1, t, 70 + t as u64));
362            }
363        }
364        let a = evaluate(&samples, SimOptions::default());
365        let b = evaluate(&samples, SimOptions::default());
366        let key = |v: &[OrderingCost]| {
367            v.iter().map(|c| (c.ordering.as_str(), c.cost)).collect::<Vec<_>>()
368        };
369        assert_eq!(key(&a), key(&b));
370    }
371
372    #[test]
373    fn empty_input_is_safe_and_spatial() {
374        assert_eq!(measured_ordering(&[], SimOptions::default()), BlobOrdering::SpatialMajor);
375        assert_eq!(evaluate(&[], SimOptions::default()).len(), 4);
376    }
377
378    #[test]
379    fn linearize_matches_spatial_major_hand_sort() {
380        // SpatialMajor key = (hilbert, time_start): grouped by hilbert then time.
381        let samples = vec![s(1, 0, 5, 2, 10), s(0, 0, 1, 1, 10), s(1, 0, 5, 0, 10)];
382        let order = linearize(&samples, BlobOrdering::SpatialMajor, 0, 2);
383        // hilbert 1 (idx1) first; then hilbert 5 by time_start: idx2 (t0) then idx0 (t2).
384        assert_eq!(order, vec![1, 2, 0]);
385    }
386}