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}