1use anyhow::Result;
14
15use super::{Advice, AdviceConfidence};
16use crate::analysis::AnalysisResult;
17use crate::loader::{LoadedData, PropValue, SampledFeature};
18use crate::measure::{measure_sample, MeasureSettings, MeasuredEncoding};
19
20const EARTH_CIRCUMFERENCE_M: f64 = 40_075_016.686;
23
24const PRECISION_LADDER_M: [f64; 6] = [5.0, 1.0, 0.5, 0.1, 0.05, 0.01];
28
29const MIN_COORD_SHRINK: f64 = 0.05;
31
32const MIN_ATTR_SHRINK: f64 = 0.03;
34
35const HIGH_CONFIDENCE_SHRINK: f64 = 0.15;
37
38pub fn advise(result: &AnalysisResult, data: &LoadedData) -> Result<Vec<Advice>> {
41 let baseline = match result.measured.clone() {
44 Some(measured) => Some(measured),
45 None => measure_sample(&data.sample, &MeasureSettings::default())?,
46 };
47 let Some(baseline) = baseline else {
48 return Ok(Vec::new());
51 };
52
53 let mut advice = Vec::new();
54 if let Some(coords) = coords_advice(result, data, &baseline)? {
55 advice.push(coords);
56 }
57 if let Some(attrs) = attrs_advice(data, &baseline)? {
58 advice.push(attrs);
59 }
60 Ok(advice)
61}
62
63fn coords_advice(
68 result: &AnalysisResult,
69 data: &LoadedData,
70 baseline: &MeasuredEncoding,
71) -> Result<Option<Advice>> {
72 let max_zoom = result.spatial.recommended_max_zoom;
73 let lat_mid = (result.bounds.min_lat + result.bounds.max_lat) / 2.0;
74 let m_per_px = meters_per_pixel(max_zoom, lat_mid);
75 let candidate = snap_down_precision(m_per_px / 4.0);
76
77 let trial = measure_sample(
78 &data.sample,
79 &MeasureSettings {
80 quantize_coords_m: Some(candidate),
81 ..MeasureSettings::default()
82 },
83 )?;
84 let Some(trial) = trial else {
85 return Ok(None);
86 };
87 let shrink = shrink_vs(baseline, &trial);
88 if shrink < MIN_COORD_SHRINK {
89 return Ok(None);
90 }
91
92 let resolution = format!(
93 "at max zoom {max_zoom} one pixel covers ~{m_per_px:.2} m at lat {lat_mid:.1}°, \
94 so {candidate} m fixed-point coords stay below a quarter-pixel of error"
95 );
96 let why = match column_share(baseline, "geometry") {
97 Some(share) => format!(
98 "geometry is {:.0}% of measured column bytes; {resolution}",
99 share * 100.0
100 ),
101 None => resolution,
102 };
103 Ok(Some(Advice {
104 flag: "--quantize-coords".to_string(),
105 value: Some(candidate.to_string()),
106 why,
107 projected: Some(projected_shrink(shrink)),
108 lossy: true,
109 confidence: confidence_for(shrink),
110 }))
111}
112
113fn attrs_advice(data: &LoadedData, baseline: &MeasuredEncoding) -> Result<Option<Advice>> {
118 let float_cols = fractional_property_names(&data.sample);
119 if float_cols.is_empty() {
120 return Ok(None);
121 }
122
123 let trial = measure_sample(
124 &data.sample,
125 &MeasureSettings {
126 quantize_attrs_auto: true,
127 ..MeasureSettings::default()
128 },
129 )?;
130 let Some(trial) = trial else {
131 return Ok(None);
132 };
133 let shrink = shrink_vs(baseline, &trial);
134 if shrink < MIN_ATTR_SHRINK {
135 return Ok(None);
136 }
137
138 let mut cited: Vec<String> = baseline
141 .per_column
142 .iter()
143 .filter(|c| float_cols.iter().any(|name| name == &c.name))
144 .take(2)
145 .map(|c| {
146 format!(
147 "`{}` ({:.0}% of measured column bytes)",
148 c.name,
149 c.share * 100.0
150 )
151 })
152 .collect();
153 if cited.is_empty() {
154 cited = float_cols
155 .iter()
156 .take(2)
157 .map(|name| format!("`{name}`"))
158 .collect();
159 }
160 let why = format!(
161 "near-incompressible Float64 propert{} {} shrink to range-adaptive UInt16 (~65k levels)",
162 if cited.len() == 1 { "y" } else { "ies" },
163 cited.join(" and ")
164 );
165 Ok(Some(Advice {
166 flag: "--quantize-attrs-auto".to_string(),
167 value: None,
168 why,
169 projected: Some(projected_shrink(shrink)),
170 lossy: true,
171 confidence: confidence_for(shrink),
172 }))
173}
174
175fn meters_per_pixel(zoom: u8, lat_deg: f64) -> f64 {
178 EARTH_CIRCUMFERENCE_M * lat_deg.to_radians().cos() / (256.0 * f64::powi(2.0, zoom as i32))
179}
180
181fn snap_down_precision(precision_m: f64) -> f64 {
184 for &rung in &PRECISION_LADDER_M {
185 if precision_m >= rung {
186 return rung;
187 }
188 }
189 PRECISION_LADDER_M[PRECISION_LADDER_M.len() - 1]
190}
191
192fn column_share(baseline: &MeasuredEncoding, name: &str) -> Option<f64> {
194 baseline
195 .per_column
196 .iter()
197 .find(|c| c.name == name)
198 .map(|c| c.share)
199}
200
201fn shrink_vs(baseline: &MeasuredEncoding, trial: &MeasuredEncoding) -> f64 {
204 1.0 - trial.bytes_total as f64 / baseline.bytes_total.max(1) as f64
205}
206
207fn projected_shrink(shrink: f64) -> String {
210 format!("-{:.0}% sample encode (measured)", shrink * 100.0)
211}
212
213fn confidence_for(shrink: f64) -> AdviceConfidence {
214 if shrink >= HIGH_CONFIDENCE_SHRINK {
215 AdviceConfidence::High
216 } else {
217 AdviceConfidence::Medium
218 }
219}
220
221fn fractional_property_names(sample: &[SampledFeature]) -> Vec<String> {
227 let mut names: Vec<String> = Vec::new();
228 for feature in sample {
229 for (name, value) in &feature.properties {
230 if let PropValue::Number(x) = value {
231 if x.is_finite() && x.fract() != 0.0 && !names.iter().any(|n| n == name) {
232 names.push(name.clone());
233 }
234 }
235 }
236 }
237 names
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::analysis;
244 use crate::loader::{AnalyzableFeature, GeometryType};
245 use geo_types::{Geometry, Point};
246 use stt_core::types::{BoundingBox, TimeRange};
247
248 fn noise(i: usize, salt: u64) -> f64 {
251 ((i as u64).wrapping_add(salt).wrapping_mul(2_654_435_761) % 1_000_000) as f64 / 1e6
252 }
253
254 fn point_sample(n: usize, float_prop: bool) -> Vec<SampledFeature> {
258 (0..n)
259 .map(|i| {
260 let numeric = if float_prop {
261 (
262 "magnitude".to_string(),
263 PropValue::Number(1.0 + noise(i, 7) * 9.0),
264 )
265 } else {
266 ("count".to_string(), PropValue::Number((i % 10) as f64))
267 };
268 SampledFeature {
269 geometry: Geometry::Point(Point::new(
270 -73.8 + (i as f64 * 0.003) % 0.6 + noise(i, 0) * 1e-3,
271 45.2 + (i % 7) as f64 * 0.08 + noise(i, 17) * 1e-3,
272 )),
273 timestamp_ms: 1_600_000_000_000 + i as u64 * 60_000,
274 properties: vec![
275 numeric,
276 (
277 "region".to_string(),
278 PropValue::Text(format!("region-{}", i % 5)),
279 ),
280 ],
281 }
282 })
283 .collect()
284 }
285
286 fn loaded(sample: Vec<SampledFeature>) -> LoadedData {
287 let features = sample
288 .iter()
289 .map(|f| {
290 let (lon, lat) = match &f.geometry {
291 Geometry::Point(p) => (p.x(), p.y()),
292 _ => (0.0, 0.0),
293 };
294 AnalyzableFeature {
295 lon,
296 lat,
297 timestamp: f.timestamp_ms,
298 geometry_type: GeometryType::Point,
299 vertex_count: 1,
300 estimated_size: 25,
301 property_count: f.properties.len(),
302 }
303 })
304 .collect();
305 LoadedData {
306 features,
307 bounds: BoundingBox {
308 min_lon: -73.8,
309 min_lat: 45.2,
310 max_lon: -73.2,
311 max_lat: 45.8,
312 },
313 time_range: TimeRange {
314 start: 1_600_000_000_000,
315 end: 1_600_012_000_000,
316 },
317 sample,
318 }
319 }
320
321 fn analysis_result(data: &LoadedData, max_zoom: u8) -> AnalysisResult {
325 let mut spatial = analysis::spatial::analyze(data).unwrap();
326 spatial.recommended_max_zoom = max_zoom;
327 spatial.recommended_min_zoom = spatial.recommended_min_zoom.min(max_zoom);
328 let temporal = analysis::temporal::analyze(data).unwrap();
329 let geometry = analysis::geometry::analyze(data).unwrap();
330 let measured = measure_sample(&data.sample, &MeasureSettings::default()).unwrap();
331 let density =
332 analysis::density::analyze(data, &spatial, &temporal, measured.as_ref()).unwrap();
333 AnalysisResult {
334 source: "synthetic.parquet".to_string(),
335 feature_count: data.features.len(),
336 bounds: data.bounds,
337 spatial,
338 temporal,
339 geometry,
340 density,
341 measured,
342 }
343 }
344
345 #[test]
346 fn high_precision_points_get_measured_coords_advice() {
347 let data = loaded(point_sample(200, true));
348 let result = analysis_result(&data, 14);
349 let advice = advise(&result, &data).unwrap();
350 let coords = advice
351 .iter()
352 .find(|a| a.flag == "--quantize-coords")
353 .expect("high-entropy f64 coords must produce measured coords advice");
354 assert_eq!(coords.value.as_deref(), Some("1"));
356 assert!(coords.lossy);
357 let projected = coords.projected.as_deref().unwrap();
358 assert!(
359 projected.starts_with('-') && projected.contains("(measured)"),
360 "projected = {projected}"
361 );
362 assert!(
363 coords.why.contains("zoom 14") && coords.why.contains("1 m"),
364 "why = {}",
365 coords.why
366 );
367 }
368
369 #[test]
370 fn fractional_float_property_gets_attrs_advice() {
371 let data = loaded(point_sample(200, true));
372 let result = analysis_result(&data, 14);
373 let advice = advise(&result, &data).unwrap();
374 let attrs = advice
375 .iter()
376 .find(|a| a.flag == "--quantize-attrs-auto")
377 .expect("high-entropy Float64 property must produce attrs advice");
378 assert!(attrs.lossy);
379 assert!(attrs.value.is_none());
380 assert!(attrs.why.contains("magnitude"), "why = {}", attrs.why);
381 assert!(attrs.projected.as_deref().unwrap().contains("(measured)"));
382 }
383
384 #[test]
385 fn tiny_sample_produces_no_advice() {
386 let data = loaded(point_sample(30, true));
387 let result = analysis_result(&data, 14);
388 assert!(
389 result.measured.is_none(),
390 "under 50 features must not measure"
391 );
392 let advice = advise(&result, &data).unwrap();
393 assert!(
394 advice.is_empty(),
395 "lossy advice without measurement: {advice:?}"
396 );
397 }
398
399 #[test]
400 fn integer_and_string_properties_get_no_attrs_advice() {
401 let data = loaded(point_sample(200, false));
402 let result = analysis_result(&data, 14);
403 let advice = advise(&result, &data).unwrap();
404 assert!(
405 !advice.iter().any(|a| a.flag == "--quantize-attrs-auto"),
406 "integer-valued numeric props must not trigger attr quantization"
407 );
408 }
409
410 #[test]
411 fn precision_snaps_down_the_ladder_and_clamps() {
412 assert_eq!(snap_down_precision(23.0), 5.0);
413 assert_eq!(snap_down_precision(5.0), 5.0);
414 assert_eq!(snap_down_precision(1.67), 1.0);
415 assert_eq!(snap_down_precision(0.7), 0.5);
416 assert_eq!(snap_down_precision(0.09), 0.05);
417 assert_eq!(snap_down_precision(0.002), 0.01);
418 }
419}