Skip to main content

stt_optimize/advisors/
quantize.rs

1//! Coordinate / attribute quantization advisor (`--quantize-coords`,
2//! `--quantize-attrs-auto`): derives candidate precision from the max-zoom
3//! ground resolution and verifies the win by trial-encoding the sample.
4//!
5//! Quantization is the repo's #1 measured size lever (coords −25..47% on
6//! coord-heavy datasets, attrs up to −80% per Float64 column), but it is
7//! LOSSY, so this advisor never speaks without evidence: every recommendation
8//! is backed by a real [`crate::measure::measure_sample`] trial encode
9//! through the production stt-core encoder, compared against ONE shared
10//! baseline measurement at build defaults. Too-small samples produce no
11//! advice at all.
12
13use 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
20/// WGS84 equatorial circumference in meters — the `2πR` in the standard
21/// tile-pyramid ground-resolution formula.
22const EARTH_CIRCUMFERENCE_M: f64 = 40_075_016.686;
23
24/// Candidate `--quantize-coords` precisions in meters, descending. A derived
25/// precision snaps DOWN to the first entry it covers and never goes below the
26/// last (0.01 m is already sub-centimeter — finer buys nothing).
27const PRECISION_LADDER_M: [f64; 6] = [5.0, 1.0, 0.5, 0.1, 0.05, 0.01];
28
29/// Minimum measured shrink to recommend `--quantize-coords`.
30const MIN_COORD_SHRINK: f64 = 0.05;
31
32/// Minimum measured shrink to recommend `--quantize-attrs-auto`.
33const MIN_ATTR_SHRINK: f64 = 0.03;
34
35/// Measured shrink at or above this is `High` confidence, below is `Medium`.
36const HIGH_CONFIDENCE_SHRINK: f64 = 0.15;
37
38/// Recommend coordinate / attribute quantization when a trial encode of the
39/// loader sample measures a real shrink versus the build-default baseline.
40pub fn advise(result: &AnalysisResult, data: &LoadedData) -> Result<Vec<Advice>> {
41    // ONE baseline at build defaults, shared by both trials. The analysis
42    // pipeline usually measured it already; re-measure only when absent.
43    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        // Sample too small to trial-encode. Quantization is lossy, so no
49        // unmeasured claims — emit nothing.
50        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
63/// `--quantize-coords`: candidate precision = a quarter-pixel of ground
64/// resolution at the recommended max zoom (rendering cannot show the
65/// discarded bits), snapped down the [`PRECISION_LADDER_M`]; emitted only
66/// when the trial encode shrinks the sample by [`MIN_COORD_SHRINK`] or more.
67fn 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
113/// `--quantize-attrs-auto`: when the sample carries fractional Float64
114/// properties, trial-encode with range-adaptive UInt16 attribute quantization
115/// (coords untouched so the effect is attributable); emitted only when the
116/// measured shrink reaches [`MIN_ATTR_SHRINK`].
117fn 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    // Name the top float column(s) by measured baseline share (per_column is
139    // already sorted descending by bytes).
140    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
175/// Ground resolution of one 256px-tile pixel at `zoom`, at latitude
176/// `lat_deg`: `circumference * cos(lat) / (256 * 2^zoom)`.
177fn 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
181/// Snap a derived precision DOWN to the [`PRECISION_LADDER_M`] (the first
182/// rung it covers); precisions below the ladder clamp to its finest rung.
183fn 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
192/// Measured baseline share of the named column, if it was attributed.
193fn 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
201/// Fractional shrink of a trial encode versus the baseline (positive =
202/// smaller, negative = the trial got BIGGER).
203fn shrink_vs(baseline: &MeasuredEncoding, trial: &MeasuredEncoding) -> f64 {
204    1.0 - trial.bytes_total as f64 / baseline.bytes_total.max(1) as f64
205}
206
207/// The `projected` string for a measured shrink, e.g. `-36% sample encode
208/// (measured)`.
209fn 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
221/// Sampled property names carrying at least one finite fractional numeric
222/// value, in first-seen order. The loader widens every numeric Arrow type to
223/// f64 (dropping the source type), so a fractional value is the evidence that
224/// a column is genuinely Float64 — integer-valued columns lose nothing to
225/// `--quantize-attrs-auto` being skipped and never trigger it here.
226fn 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    /// Deterministic pseudo-noise in [0, 1) (Knuth multiplicative hash) —
249    /// keeps f64 mantissas high-entropy so quantization has real bytes to win.
250    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    /// n Montréal-area points. `float_prop` picks a high-precision fractional
255    /// f64 property (`magnitude`) versus an integer-valued one (`count`); a
256    /// categorical `region` string rides along either way.
257    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    /// Real-analyzer AnalysisResult over the synthetic data, with the
322    /// recommended max zoom pinned so the precision-ladder derivation is
323    /// deterministic.
324    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        // z14 at lat 45.5 → ~6.7 m/px, /4 = 1.67 m → snaps down to 1 m.
355        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}