Skip to main content

stt_optimize/advisors/
layout.rs

1//! Wire/layout advisor (`--publish` (zstd 19), `--blob-ordering`,
2//! `--pack-size`): compression measured on the sample, ordering from the
3//! spatial/temporal access shape.
4//!
5//! This is the weakest-evidence advice tier: only the zstd trial re-encodes
6//! the real sample (and can reach High confidence); blob ordering is an
7//! access-shape heuristic and pack sizing a formula estimate, so both are
8//! capped at Low confidence. Everything here is byte-level and reversible —
9//! `lossy: false` throughout.
10
11use anyhow::Result;
12
13use super::{Advice, AdviceConfidence};
14use crate::analysis::spatial::SpatialDistribution;
15use crate::analysis::AnalysisResult;
16use crate::loader::LoadedData;
17use crate::measure::{measure_sample, MeasureSettings, MeasuredEncoding};
18
19/// Measured sample shrink at zstd 19 (vs level 3) at or above this many
20/// percent earns High confidence; a smaller measured win stays Medium.
21const ZSTD_HIGH_CONFIDENCE_SHRINK_PCT: f64 = 5.0;
22
23/// "Short" playback window for the spatial-major heuristic (~7 days in ms).
24const SHORT_DURATION_MS: u64 = 7 * 86_400_000;
25
26/// "Long" playback window for the time-major heuristic (~90 days in ms).
27const LONG_DURATION_MS: u64 = 90 * 86_400_000;
28
29/// Only archives estimated above this size (~5 GiB) get pack-size advice —
30/// below it the default 64 MiB packs keep the object count small anyway.
31const PACK_ADVICE_MIN_ARCHIVE_BYTES: usize = 5 * (1 << 30);
32
33/// The stt-build default pack target (MiB), per docs/api/cli-reference.md.
34const DEFAULT_PACK_MIB: usize = 64;
35
36/// Suggested pack target (MiB) for large archives: halves the object count
37/// while staying well under the 512 MB CDN per-object cap.
38const SUGGESTED_PACK_MIB: usize = 128;
39
40/// Advise on wire/layout levers: publish-grade zstd (measured on the sample
41/// when possible), blob ordering from the access shape, and pack sizing for
42/// large archives.
43pub fn advise(result: &AnalysisResult, data: &LoadedData) -> Result<Vec<Advice>> {
44    let mut advice = Vec::new();
45    advice.push(publish_advice(result, data)?);
46    advice.extend(blob_ordering_advice(result));
47    advice.extend(pack_size_advice(result));
48    Ok(advice)
49}
50
51/// `--publish` (bundles zstd 19; the directory is already paged by default):
52/// trial-encode the sample at level 19 against the level-3 baseline. Falls
53/// back to typical-range guidance at Low confidence when the sample can't be
54/// measured.
55fn publish_advice(result: &AnalysisResult, data: &LoadedData) -> Result<Advice> {
56    let publish_settings = MeasureSettings {
57        zstd_level: 19,
58        ..MeasureSettings::default()
59    };
60    let at_19 = measure_sample(&data.sample, &publish_settings)?;
61    // Baseline at the build default (level 3): reuse the analysis measurement
62    // when present, otherwise measure it here with identical settings.
63    let at_3: Option<MeasuredEncoding> = match &result.measured {
64        Some(m) => Some(m.clone()),
65        None => measure_sample(&data.sample, &MeasureSettings::default())?,
66    };
67
68    if let (Some(base), Some(hi)) = (&at_3, &at_19) {
69        if base.bytes_total > 0 {
70            let shrink_pct =
71                (base.bytes_total as f64 - hi.bytes_total as f64) / base.bytes_total as f64 * 100.0;
72            let confidence = if shrink_pct >= ZSTD_HIGH_CONFIDENCE_SHRINK_PCT {
73                AdviceConfidence::High
74            } else {
75                AdviceConfidence::Medium
76            };
77            return Ok(Advice {
78                flag: "--publish".to_string(),
79                value: None,
80                why: format!(
81                    "zstd 19 encodes the {}-feature sample to {} B vs {} B at the \
82                     default level 3; decode-free wire savings for deployment \
83                     builds; dev builds can stay at 3 for speed",
84                    hi.features, hi.bytes_total, base.bytes_total
85                ),
86                projected: Some(format!(
87                    "{:+.1}% sample encode (measured, zstd 3 vs 19)",
88                    -shrink_pct
89                )),
90                lossy: false,
91                confidence,
92            });
93        }
94    }
95
96    // No measurable sample: safe generic guidance, clearly sourced as typical
97    // (not this dataset's numbers) and downgraded to Low.
98    Ok(Advice {
99        flag: "--publish".to_string(),
100        value: None,
101        why: format!(
102            "sample too small to trial-encode ({} usable sampled features); \
103             zstd 19 typically saves 10..19% wire bytes on STT tiles (typical, \
104             not measured on this dataset); decode-free for clients; dev \
105             builds can stay at 3 for speed",
106            data.sample.len()
107        ),
108        projected: None,
109        lossy: false,
110        confidence: AdviceConfidence::Low,
111    })
112}
113
114/// `--blob-ordering`: only when the access shape clearly matches one of the
115/// two strong patterns (Localized + short window -> `spatial`, Global/Regional
116/// + long window -> `time-major`); anything else keeps the `auto` default.
117fn blob_ordering_advice(result: &AnalysisResult) -> Option<Advice> {
118    let duration_ms = result.temporal.duration_ms;
119    let duration = &result.temporal.duration_human;
120    let (value, why) = match result.spatial.distribution {
121        SpatialDistribution::Localized if duration_ms < SHORT_DURATION_MS => (
122            "spatial",
123            format!(
124                "{} features are spatially Localized over only {}: viewport-local \
125                 reads dominate, so spatial-major blob order keeps a viewport's \
126                 tiles in fewer packs (access-shape heuristic, not simulated)",
127                result.feature_count, duration
128            ),
129        ),
130        SpatialDistribution::Global | SpatialDistribution::Regional
131            if duration_ms > LONG_DURATION_MS =>
132        {
133            (
134                "time-major",
135                format!(
136                    "{} features spread {} across {}: playback sweeps time, so \
137                 time-major blob order keeps consecutive time buckets in the \
138                 same packs (access-shape heuristic, not simulated)",
139                    result.feature_count, result.spatial.distribution, duration
140                ),
141            )
142        }
143        // Ambiguous access shape: the `auto` default is fine — emitting it
144        // would just restate the default.
145        _ => return None,
146    };
147    Some(Advice {
148        flag: "--blob-ordering".to_string(),
149        value: Some(value.to_string()),
150        why,
151        projected: None,
152        lossy: false,
153        confidence: AdviceConfidence::Low,
154    })
155}
156
157/// `--pack-size 128`: only for archives estimated above ~5 GiB, where the
158/// default 64 MiB target starts producing a large pack-object count.
159fn pack_size_advice(result: &AnalysisResult) -> Option<Advice> {
160    let archive = result.density.estimated_archive_size;
161    if archive <= PACK_ADVICE_MIN_ARCHIVE_BYTES {
162        return None;
163    }
164    let gib = archive as f64 / (1u64 << 30) as f64;
165    let packs_default = archive.div_ceil(DEFAULT_PACK_MIB << 20);
166    let packs_suggested = archive.div_ceil(SUGGESTED_PACK_MIB << 20);
167    Some(Advice {
168        flag: "--pack-size".to_string(),
169        value: Some(SUGGESTED_PACK_MIB.to_string()),
170        why: format!(
171            "estimated archive ~{:.1} GiB is ~{} pack objects at the default \
172             {} MiB; {} MiB caps the object count (CDN/R2 list+range \
173             friendliness) while staying well under the 512 MB per-object cap",
174            gib, packs_default, DEFAULT_PACK_MIB, SUGGESTED_PACK_MIB
175        ),
176        projected: Some(format!(
177            "~{} pack objects instead of ~{} (estimate)",
178            packs_suggested, packs_default
179        )),
180        lossy: false,
181        confidence: AdviceConfidence::Low,
182    })
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::loader::{PropValue, SampledFeature};
189    use geo_types::{Geometry, Point};
190    use stt_core::types::{BoundingBox, TimeRange};
191
192    const DAY_MS: u64 = 86_400_000;
193
194    /// n spread-out points with a repetitive string property (compressible, so
195    /// zstd 19 has real bytes to win over level 3) and a jittered numeric one.
196    fn point_sample(n: usize) -> Vec<SampledFeature> {
197        (0..n)
198            .map(|i| {
199                let jitter = |salt: u64| {
200                    ((i as u64).wrapping_add(salt).wrapping_mul(2_654_435_761) % 100_000) as f64
201                        * 1e-7
202                };
203                SampledFeature {
204                    geometry: Geometry::Point(Point::new(
205                        -73.5 + i as f64 * 0.0013 + jitter(0),
206                        45.5 + (i % 7) as f64 * 0.0021 + jitter(17),
207                    )),
208                    timestamp_ms: 1_600_000_000_000 + i as u64 * 1_000,
209                    properties: vec![
210                        (
211                            "magnitude".to_string(),
212                            PropValue::Number(1.0 + (i % 90) as f64 * 0.137),
213                        ),
214                        (
215                            "region".to_string(),
216                            PropValue::Text(format!("region-{}", i % 5)),
217                        ),
218                    ],
219                }
220            })
221            .collect()
222    }
223
224    fn synthetic_data(sample: Vec<SampledFeature>) -> LoadedData {
225        LoadedData {
226            features: Vec::new(),
227            bounds: BoundingBox::new(-74.0, 45.0, -73.0, 46.0),
228            time_range: TimeRange::new(0, DAY_MS),
229            sample,
230        }
231    }
232
233    fn synthetic_result(
234        distribution: SpatialDistribution,
235        duration_ms: u64,
236        estimated_archive_size: usize,
237        measured: Option<MeasuredEncoding>,
238    ) -> AnalysisResult {
239        // The shared default (10 000 points, one uniform day, empty density),
240        // with only the fields the layout advisor reads overridden.
241        let mut r = crate::test_support::sample_analysis();
242        r.spatial.distribution = distribution;
243        r.temporal.time_end = duration_ms;
244        r.temporal.duration_ms = duration_ms;
245        r.temporal.duration_human = format!("{:.1} days", duration_ms as f64 / DAY_MS as f64);
246        r.density.estimated_archive_size = estimated_archive_size;
247        r.measured = measured;
248        r
249    }
250
251    fn find<'a>(advice: &'a [Advice], flag: &str) -> Option<&'a Advice> {
252        advice.iter().find(|a| a.flag == flag)
253    }
254
255    #[test]
256    fn measured_publish_advice_projects_negative_shrink() {
257        let data = synthetic_data(point_sample(400));
258        let measured = measure_sample(&data.sample, &MeasureSettings::default())
259            .unwrap()
260            .expect("400 features is enough to measure");
261        let result = synthetic_result(
262            SpatialDistribution::Regional,
263            30 * DAY_MS,
264            100 << 20,
265            Some(measured),
266        );
267
268        let advice = advise(&result, &data).unwrap();
269        let publish = find(&advice, "--publish").expect("publish advice");
270        assert!(publish.value.is_none());
271        assert!(!publish.lossy);
272        let projected = publish.projected.as_deref().expect("measured projection");
273        assert!(
274            projected.starts_with('-'),
275            "zstd 19 should shrink the sample, got {projected}"
276        );
277        assert!(projected.contains("measured"));
278        // Measured advice never sits at the unmeasured Low tier.
279        assert!(!matches!(publish.confidence, AdviceConfidence::Low));
280        // Rule 2: the why cites this dataset's sample numbers.
281        assert!(publish.why.contains("400-feature sample"));
282    }
283
284    #[test]
285    fn unmeasurable_sample_downgrades_publish_to_low() {
286        let data = synthetic_data(Vec::new());
287        let result = synthetic_result(SpatialDistribution::Regional, 30 * DAY_MS, 100 << 20, None);
288
289        let advice = advise(&result, &data).unwrap();
290        let publish = find(&advice, "--publish").expect("publish advice");
291        assert!(publish.projected.is_none());
292        assert!(matches!(publish.confidence, AdviceConfidence::Low));
293        assert!(publish.why.contains("typical"));
294        assert!(!publish.lossy);
295    }
296
297    #[test]
298    fn localized_short_dataset_orders_spatial() {
299        let data = synthetic_data(Vec::new());
300        let result = synthetic_result(SpatialDistribution::Localized, 3 * DAY_MS, 100 << 20, None);
301
302        let advice = advise(&result, &data).unwrap();
303        let ordering = find(&advice, "--blob-ordering").expect("blob-ordering advice");
304        assert_eq!(ordering.value.as_deref(), Some("spatial"));
305        assert!(!ordering.lossy);
306        assert!(matches!(ordering.confidence, AdviceConfidence::Low));
307        assert!(ordering.why.contains("not simulated"));
308    }
309
310    #[test]
311    fn global_yearlong_dataset_orders_time_major() {
312        let data = synthetic_data(Vec::new());
313        let result = synthetic_result(SpatialDistribution::Global, 365 * DAY_MS, 100 << 20, None);
314
315        let advice = advise(&result, &data).unwrap();
316        let ordering = find(&advice, "--blob-ordering").expect("blob-ordering advice");
317        assert_eq!(ordering.value.as_deref(), Some("time-major"));
318        assert!(matches!(ordering.confidence, AdviceConfidence::Low));
319    }
320
321    #[test]
322    fn ambiguous_access_shape_keeps_auto_ordering() {
323        let data = synthetic_data(Vec::new());
324        // Regional + 30 days matches neither strong pattern; nor does a
325        // Localized dataset with a long window.
326        for (dist, days) in [
327            (SpatialDistribution::Regional, 30),
328            (SpatialDistribution::Localized, 365),
329        ] {
330            let result = synthetic_result(dist, days * DAY_MS, 100 << 20, None);
331            let advice = advise(&result, &data).unwrap();
332            assert!(
333                find(&advice, "--blob-ordering").is_none(),
334                "auto default should not be restated"
335            );
336        }
337    }
338
339    #[test]
340    fn small_archive_yields_no_pack_size_advice() {
341        let data = synthetic_data(Vec::new());
342        let result = synthetic_result(SpatialDistribution::Regional, 30 * DAY_MS, 1 << 30, None);
343
344        let advice = advise(&result, &data).unwrap();
345        assert!(find(&advice, "--pack-size").is_none());
346    }
347
348    #[test]
349    fn large_archive_suggests_128_mib_packs() {
350        let data = synthetic_data(Vec::new());
351        let result = synthetic_result(SpatialDistribution::Regional, 30 * DAY_MS, 20 << 30, None);
352
353        let advice = advise(&result, &data).unwrap();
354        let pack = find(&advice, "--pack-size").expect("pack-size advice");
355        assert_eq!(pack.value.as_deref(), Some("128"));
356        assert!(matches!(pack.confidence, AdviceConfidence::Low));
357        assert!(!pack.lossy);
358        assert!(pack.why.contains("20.0 GiB"));
359        assert!(pack.why.contains("512 MB"));
360        // 20 GiB: 320 packs at 64 MiB, 160 at 128 MiB.
361        assert_eq!(
362            pack.projected.as_deref(),
363            Some("~160 pack objects instead of ~320 (estimate)")
364        );
365    }
366}