stt-optimize 0.3.0

Spatiotemporal dataset analyzer and optimizer for STT file generation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! Wire/layout advisor (`--publish` (zstd 19), `--blob-ordering`,
//! `--pack-size`): compression measured on the sample, ordering from the
//! spatial/temporal access shape.
//!
//! This is the weakest-evidence advice tier: only the zstd trial re-encodes
//! the real sample (and can reach High confidence); blob ordering is an
//! access-shape heuristic and pack sizing a formula estimate, so both are
//! capped at Low confidence. Everything here is byte-level and reversible —
//! `lossy: false` throughout.

use anyhow::Result;

use super::{Advice, AdviceConfidence};
use crate::analysis::spatial::SpatialDistribution;
use crate::analysis::AnalysisResult;
use crate::loader::LoadedData;
use crate::measure::{measure_sample, MeasureSettings, MeasuredEncoding};

/// Measured sample shrink at zstd 19 (vs level 3) at or above this many
/// percent earns High confidence; a smaller measured win stays Medium.
const ZSTD_HIGH_CONFIDENCE_SHRINK_PCT: f64 = 5.0;

/// "Short" playback window for the spatial-major heuristic (~7 days in ms).
const SHORT_DURATION_MS: u64 = 7 * 86_400_000;

/// "Long" playback window for the time-major heuristic (~90 days in ms).
const LONG_DURATION_MS: u64 = 90 * 86_400_000;

/// Only archives estimated above this size (~5 GiB) get pack-size advice —
/// below it the default 64 MiB packs keep the object count small anyway.
const PACK_ADVICE_MIN_ARCHIVE_BYTES: usize = 5 * (1 << 30);

/// The stt-build default pack target (MiB), per docs/api/cli-reference.md.
const DEFAULT_PACK_MIB: usize = 64;

/// Suggested pack target (MiB) for large archives: halves the object count
/// while staying well under the 512 MB CDN per-object cap.
const SUGGESTED_PACK_MIB: usize = 128;

/// Advise on wire/layout levers: publish-grade zstd (measured on the sample
/// when possible), blob ordering from the access shape, and pack sizing for
/// large archives.
pub fn advise(result: &AnalysisResult, data: &LoadedData) -> Result<Vec<Advice>> {
    let mut advice = Vec::new();
    advice.push(publish_advice(result, data)?);
    advice.extend(blob_ordering_advice(result));
    advice.extend(pack_size_advice(result));
    Ok(advice)
}

/// `--publish` (bundles zstd 19; the directory is already paged by default):
/// trial-encode the sample at level 19 against the level-3 baseline. Falls
/// back to typical-range guidance at Low confidence when the sample can't be
/// measured.
fn publish_advice(result: &AnalysisResult, data: &LoadedData) -> Result<Advice> {
    let publish_settings = MeasureSettings {
        zstd_level: 19,
        ..MeasureSettings::default()
    };
    let at_19 = measure_sample(&data.sample, &publish_settings)?;
    // Baseline at the build default (level 3): reuse the analysis measurement
    // when present, otherwise measure it here with identical settings.
    let at_3: Option<MeasuredEncoding> = match &result.measured {
        Some(m) => Some(m.clone()),
        None => measure_sample(&data.sample, &MeasureSettings::default())?,
    };

    if let (Some(base), Some(hi)) = (&at_3, &at_19) {
        if base.bytes_total > 0 {
            let shrink_pct =
                (base.bytes_total as f64 - hi.bytes_total as f64) / base.bytes_total as f64 * 100.0;
            let confidence = if shrink_pct >= ZSTD_HIGH_CONFIDENCE_SHRINK_PCT {
                AdviceConfidence::High
            } else {
                AdviceConfidence::Medium
            };
            return Ok(Advice {
                flag: "--publish".to_string(),
                value: None,
                why: format!(
                    "zstd 19 encodes the {}-feature sample to {} B vs {} B at the \
                     default level 3; decode-free wire savings for deployment \
                     builds; dev builds can stay at 3 for speed",
                    hi.features, hi.bytes_total, base.bytes_total
                ),
                projected: Some(format!(
                    "{:+.1}% sample encode (measured, zstd 3 vs 19)",
                    -shrink_pct
                )),
                lossy: false,
                confidence,
            });
        }
    }

    // No measurable sample: safe generic guidance, clearly sourced as typical
    // (not this dataset's numbers) and downgraded to Low.
    Ok(Advice {
        flag: "--publish".to_string(),
        value: None,
        why: format!(
            "sample too small to trial-encode ({} usable sampled features); \
             zstd 19 typically saves 10..19% wire bytes on STT tiles (typical, \
             not measured on this dataset); decode-free for clients; dev \
             builds can stay at 3 for speed",
            data.sample.len()
        ),
        projected: None,
        lossy: false,
        confidence: AdviceConfidence::Low,
    })
}

/// `--blob-ordering`: only when the access shape clearly matches one of the
/// two strong patterns (Localized + short window -> `spatial`, Global/Regional
/// + long window -> `time-major`); anything else keeps the `auto` default.
fn blob_ordering_advice(result: &AnalysisResult) -> Option<Advice> {
    let duration_ms = result.temporal.duration_ms;
    let duration = &result.temporal.duration_human;
    let (value, why) = match result.spatial.distribution {
        SpatialDistribution::Localized if duration_ms < SHORT_DURATION_MS => (
            "spatial",
            format!(
                "{} features are spatially Localized over only {}: viewport-local \
                 reads dominate, so spatial-major blob order keeps a viewport's \
                 tiles in fewer packs (access-shape heuristic, not simulated)",
                result.feature_count, duration
            ),
        ),
        SpatialDistribution::Global | SpatialDistribution::Regional
            if duration_ms > LONG_DURATION_MS =>
        {
            (
                "time-major",
                format!(
                    "{} features spread {} across {}: playback sweeps time, so \
                 time-major blob order keeps consecutive time buckets in the \
                 same packs (access-shape heuristic, not simulated)",
                    result.feature_count, result.spatial.distribution, duration
                ),
            )
        }
        // Ambiguous access shape: the `auto` default is fine — emitting it
        // would just restate the default.
        _ => return None,
    };
    Some(Advice {
        flag: "--blob-ordering".to_string(),
        value: Some(value.to_string()),
        why,
        projected: None,
        lossy: false,
        confidence: AdviceConfidence::Low,
    })
}

/// `--pack-size 128`: only for archives estimated above ~5 GiB, where the
/// default 64 MiB target starts producing a large pack-object count.
fn pack_size_advice(result: &AnalysisResult) -> Option<Advice> {
    let archive = result.density.estimated_archive_size;
    if archive <= PACK_ADVICE_MIN_ARCHIVE_BYTES {
        return None;
    }
    let gib = archive as f64 / (1u64 << 30) as f64;
    let packs_default = archive.div_ceil(DEFAULT_PACK_MIB << 20);
    let packs_suggested = archive.div_ceil(SUGGESTED_PACK_MIB << 20);
    Some(Advice {
        flag: "--pack-size".to_string(),
        value: Some(SUGGESTED_PACK_MIB.to_string()),
        why: format!(
            "estimated archive ~{:.1} GiB is ~{} pack objects at the default \
             {} MiB; {} MiB caps the object count (CDN/R2 list+range \
             friendliness) while staying well under the 512 MB per-object cap",
            gib, packs_default, DEFAULT_PACK_MIB, SUGGESTED_PACK_MIB
        ),
        projected: Some(format!(
            "~{} pack objects instead of ~{} (estimate)",
            packs_suggested, packs_default
        )),
        lossy: false,
        confidence: AdviceConfidence::Low,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::analysis::density::DensityAnalysis;
    use crate::analysis::geometry::{
        GeometryAnalysis, GeometryComplexity, PropertyStats, SizeStats, VertexStats,
    };
    use crate::analysis::spatial::SpatialAnalysis;
    use crate::analysis::temporal::{EventsPerDayStats, TemporalAnalysis, TemporalDistribution};
    use crate::loader::{PropValue, SampledFeature};
    use geo_types::{Geometry, Point};
    use std::collections::HashMap;
    use stt_core::types::{BoundingBox, TimeRange};

    const DAY_MS: u64 = 86_400_000;

    /// n spread-out points with a repetitive string property (compressible, so
    /// zstd 19 has real bytes to win over level 3) and a jittered numeric one.
    fn point_sample(n: usize) -> Vec<SampledFeature> {
        (0..n)
            .map(|i| {
                let jitter = |salt: u64| {
                    ((i as u64).wrapping_add(salt).wrapping_mul(2_654_435_761) % 100_000) as f64
                        * 1e-7
                };
                SampledFeature {
                    geometry: Geometry::Point(Point::new(
                        -73.5 + i as f64 * 0.0013 + jitter(0),
                        45.5 + (i % 7) as f64 * 0.0021 + jitter(17),
                    )),
                    timestamp_ms: 1_600_000_000_000 + i as u64 * 1_000,
                    properties: vec![
                        (
                            "magnitude".to_string(),
                            PropValue::Number(1.0 + (i % 90) as f64 * 0.137),
                        ),
                        (
                            "region".to_string(),
                            PropValue::Text(format!("region-{}", i % 5)),
                        ),
                    ],
                }
            })
            .collect()
    }

    fn synthetic_data(sample: Vec<SampledFeature>) -> LoadedData {
        LoadedData {
            features: Vec::new(),
            bounds: BoundingBox::new(-74.0, 45.0, -73.0, 46.0),
            time_range: TimeRange::new(0, DAY_MS),
            sample,
        }
    }

    fn synthetic_result(
        distribution: SpatialDistribution,
        duration_ms: u64,
        estimated_archive_size: usize,
        measured: Option<MeasuredEncoding>,
    ) -> AnalysisResult {
        AnalysisResult {
            source: "synthetic.parquet".to_string(),
            feature_count: 10_000,
            bounds: BoundingBox::new(-74.0, 45.0, -73.0, 46.0),
            spatial: SpatialAnalysis {
                zoom_coverage: Vec::new(),
                hotspots: Vec::new(),
                recommended_min_zoom: 0,
                recommended_max_zoom: 10,
                distribution,
            },
            temporal: TemporalAnalysis {
                time_start: 0,
                time_end: duration_ms,
                duration_ms,
                duration_human: format!("{:.1} days", duration_ms as f64 / DAY_MS as f64),
                unique_timestamps: 1_000,
                distribution: TemporalDistribution::Uniform,
                recommended_bucket_ms: 3_600_000,
                recommended_bucket_human: "1 hour".to_string(),
                hourly_distribution: vec![0; 24],
                daily_distribution: vec![0; 7],
                monthly_distribution: vec![0; 12],
                events_per_day: EventsPerDayStats {
                    min: 0.0,
                    max: 0.0,
                    avg: 0.0,
                    median: 0.0,
                    std_dev: 0.0,
                },
            },
            geometry: GeometryAnalysis {
                type_distribution: HashMap::new(),
                dominant_type: "Point".to_string(),
                vertex_stats: VertexStats {
                    min: 1,
                    max: 1,
                    avg: 1.0,
                    median: 1,
                    p95: 1,
                    p99: 1,
                    total: 10_000,
                },
                size_stats: SizeStats {
                    min: 100,
                    max: 100,
                    avg: 100.0,
                    median: 100,
                    p95: 100,
                    p99: 100,
                    total: 1_000_000,
                },
                property_stats: PropertyStats {
                    min: 2,
                    max: 2,
                    avg: 2.0,
                },
                complexity: GeometryComplexity::Simple,
            },
            density: DensityAnalysis {
                per_zoom: Vec::new(),
                estimated_tile_count: 100,
                estimated_archive_size,
                issues: Vec::new(),
            },
            measured,
        }
    }

    fn find<'a>(advice: &'a [Advice], flag: &str) -> Option<&'a Advice> {
        advice.iter().find(|a| a.flag == flag)
    }

    #[test]
    fn measured_publish_advice_projects_negative_shrink() {
        let data = synthetic_data(point_sample(400));
        let measured = measure_sample(&data.sample, &MeasureSettings::default())
            .unwrap()
            .expect("400 features is enough to measure");
        let result = synthetic_result(
            SpatialDistribution::Regional,
            30 * DAY_MS,
            100 << 20,
            Some(measured),
        );

        let advice = advise(&result, &data).unwrap();
        let publish = find(&advice, "--publish").expect("publish advice");
        assert!(publish.value.is_none());
        assert!(!publish.lossy);
        let projected = publish.projected.as_deref().expect("measured projection");
        assert!(
            projected.starts_with('-'),
            "zstd 19 should shrink the sample, got {projected}"
        );
        assert!(projected.contains("measured"));
        // Measured advice never sits at the unmeasured Low tier.
        assert!(!matches!(publish.confidence, AdviceConfidence::Low));
        // Rule 2: the why cites this dataset's sample numbers.
        assert!(publish.why.contains("400-feature sample"));
    }

    #[test]
    fn unmeasurable_sample_downgrades_publish_to_low() {
        let data = synthetic_data(Vec::new());
        let result = synthetic_result(SpatialDistribution::Regional, 30 * DAY_MS, 100 << 20, None);

        let advice = advise(&result, &data).unwrap();
        let publish = find(&advice, "--publish").expect("publish advice");
        assert!(publish.projected.is_none());
        assert!(matches!(publish.confidence, AdviceConfidence::Low));
        assert!(publish.why.contains("typical"));
        assert!(!publish.lossy);
    }

    #[test]
    fn localized_short_dataset_orders_spatial() {
        let data = synthetic_data(Vec::new());
        let result = synthetic_result(SpatialDistribution::Localized, 3 * DAY_MS, 100 << 20, None);

        let advice = advise(&result, &data).unwrap();
        let ordering = find(&advice, "--blob-ordering").expect("blob-ordering advice");
        assert_eq!(ordering.value.as_deref(), Some("spatial"));
        assert!(!ordering.lossy);
        assert!(matches!(ordering.confidence, AdviceConfidence::Low));
        assert!(ordering.why.contains("not simulated"));
    }

    #[test]
    fn global_yearlong_dataset_orders_time_major() {
        let data = synthetic_data(Vec::new());
        let result = synthetic_result(SpatialDistribution::Global, 365 * DAY_MS, 100 << 20, None);

        let advice = advise(&result, &data).unwrap();
        let ordering = find(&advice, "--blob-ordering").expect("blob-ordering advice");
        assert_eq!(ordering.value.as_deref(), Some("time-major"));
        assert!(matches!(ordering.confidence, AdviceConfidence::Low));
    }

    #[test]
    fn ambiguous_access_shape_keeps_auto_ordering() {
        let data = synthetic_data(Vec::new());
        // Regional + 30 days matches neither strong pattern; nor does a
        // Localized dataset with a long window.
        for (dist, days) in [
            (SpatialDistribution::Regional, 30),
            (SpatialDistribution::Localized, 365),
        ] {
            let result = synthetic_result(dist, days * DAY_MS, 100 << 20, None);
            let advice = advise(&result, &data).unwrap();
            assert!(
                find(&advice, "--blob-ordering").is_none(),
                "auto default should not be restated"
            );
        }
    }

    #[test]
    fn small_archive_yields_no_pack_size_advice() {
        let data = synthetic_data(Vec::new());
        let result = synthetic_result(SpatialDistribution::Regional, 30 * DAY_MS, 1 << 30, None);

        let advice = advise(&result, &data).unwrap();
        assert!(find(&advice, "--pack-size").is_none());
    }

    #[test]
    fn large_archive_suggests_128_mib_packs() {
        let data = synthetic_data(Vec::new());
        let result = synthetic_result(SpatialDistribution::Regional, 30 * DAY_MS, 20 << 30, None);

        let advice = advise(&result, &data).unwrap();
        let pack = find(&advice, "--pack-size").expect("pack-size advice");
        assert_eq!(pack.value.as_deref(), Some("128"));
        assert!(matches!(pack.confidence, AdviceConfidence::Low));
        assert!(!pack.lossy);
        assert!(pack.why.contains("20.0 GiB"));
        assert!(pack.why.contains("512 MB"));
        // 20 GiB: 320 packs at 64 MiB, 160 at 128 MiB.
        assert_eq!(
            pack.projected.as_deref(),
            Some("~160 pack objects instead of ~320 (estimate)")
        );
    }
}