1use 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
19const ZSTD_HIGH_CONFIDENCE_SHRINK_PCT: f64 = 5.0;
22
23const SHORT_DURATION_MS: u64 = 7 * 86_400_000;
25
26const LONG_DURATION_MS: u64 = 90 * 86_400_000;
28
29const PACK_ADVICE_MIN_ARCHIVE_BYTES: usize = 5 * (1 << 30);
32
33const DEFAULT_PACK_MIB: usize = 64;
35
36const SUGGESTED_PACK_MIB: usize = 128;
39
40pub 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
51fn 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 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 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
114fn 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 _ => 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
157fn 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 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 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 assert!(!matches!(publish.confidence, AdviceConfidence::Low));
280 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 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 assert_eq!(
362 pack.projected.as_deref(),
363 Some("~160 pack objects instead of ~320 (estimate)")
364 );
365 }
366}