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};
const ZSTD_HIGH_CONFIDENCE_SHRINK_PCT: f64 = 5.0;
const SHORT_DURATION_MS: u64 = 7 * 86_400_000;
const LONG_DURATION_MS: u64 = 90 * 86_400_000;
const PACK_ADVICE_MIN_ARCHIVE_BYTES: usize = 5 * (1 << 30);
const DEFAULT_PACK_MIB: usize = 64;
const SUGGESTED_PACK_MIB: usize = 128;
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)
}
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)?;
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,
});
}
}
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,
})
}
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
),
)
}
_ => return None,
};
Some(Advice {
flag: "--blob-ordering".to_string(),
value: Some(value.to_string()),
why,
projected: None,
lossy: false,
confidence: AdviceConfidence::Low,
})
}
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;
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"));
assert!(!matches!(publish.confidence, AdviceConfidence::Low));
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());
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"));
assert_eq!(
pack.projected.as_deref(),
Some("~160 pack objects instead of ~320 (estimate)")
);
}
}