Skip to main content

stt_optimize/
measure.rs

1//! Sample-encode measurement: push a deterministic sample of source features
2//! through the real stt-core encoder (+ zstd) to replace formula-based size
3//! estimates with measured bytes.
4//!
5//! The loader retains a stride sample of full geometries and property values
6//! ([`crate::loader::SampledFeature`]); [`measure_sample`] groups the dominant
7//! geometry kind into ONE synthetic tile layer, encodes it through
8//! [`stt_core::arrow_tile::encode_tile_with`] (the production encoder, driven
9//! by an explicit [`EncoderConfig`] so no process-globals are touched), zstd-
10//! compresses the payload, and attributes per-column costs by re-encoding each
11//! column alone — the same fair-share method stt-core's `point_column_stats`
12//! example uses.
13
14use std::sync::Arc;
15
16use anyhow::{Context, Result};
17use arrow::array::RecordBatch;
18use arrow::datatypes::Schema;
19use arrow::ipc::writer::StreamWriter;
20use serde::{Deserialize, Serialize};
21use stt_core::arrow_tile::{
22    decode_tile, encode_tile_with, ColumnarLayer, Coord, EncoderConfig, GeometryColumn,
23    PropertyColumn,
24};
25use stt_core::compression::compress_zstd_with_dict_level;
26
27use crate::loader::{PropValue, SampledFeature};
28
29/// Below this many usable sampled features the measurement is noise (zstd
30/// framing and dictionary overheads dominate) and [`measure_sample`] returns
31/// `None` — callers fall back to the formula estimates.
32const MIN_MEASURE_FEATURES: usize = 50;
33
34/// Encoder/compression settings for a sample measurement — the same levers a
35/// real `stt-build` run exposes (`--zstd-level`, `--quantize-coords`,
36/// `--quantize-attrs-auto`).
37#[derive(Debug, Clone)]
38pub struct MeasureSettings {
39    /// zstd level applied to the encoded tile payload.
40    pub zstd_level: i32,
41    /// Fixed-point coordinate quantization ground precision in meters
42    /// (`None` = Float64 coordinates, the build default).
43    pub quantize_coords_m: Option<f64>,
44    /// Range-adaptive `UInt16` quantization for Float64 numeric properties.
45    pub quantize_attrs_auto: bool,
46}
47
48impl Default for MeasureSettings {
49    /// The `stt-build` defaults: zstd level 3, no quantization.
50    fn default() -> Self {
51        Self {
52            zstd_level: 3,
53            quantize_coords_m: None,
54            quantize_attrs_auto: false,
55        }
56    }
57}
58
59/// Measured encoder output for a sample: real compressed bytes per feature
60/// plus per-column cost attribution.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct MeasuredEncoding {
63    /// Source features actually encoded (the dominant-geometry-kind subset of
64    /// the sample; multi-part geometries count once even though they encode
65    /// as one row per part).
66    pub features: usize,
67    /// Encoder geometry kind that was measured (`"point"` / `"line"` /
68    /// `"polygon"`) — for mixed-geometry sources, the dominant kind.
69    pub geometry_kind: String,
70    /// Compressed size of the encoded synthetic tile (tile payload + zstd).
71    pub bytes_total: usize,
72    /// `bytes_total / features`.
73    pub bytes_per_feature: f64,
74    /// Uncompressed-payload / compressed ratio (replaces the assumed 3x).
75    pub zstd_ratio: f64,
76    /// Per-column compressed cost, sorted descending by bytes.
77    pub per_column: Vec<ColumnCost>,
78}
79
80/// Compressed cost of one encoded tile column.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ColumnCost {
83    /// Column name (e.g. `geometry`, `start_time`, a property name).
84    pub name: String,
85    /// Bytes when this column is IPC-encoded alone and zstd-compressed.
86    pub compressed_bytes: usize,
87    /// Fraction of the summed per-column bytes in `[0, 1]`. Shares are of the
88    /// per-column sum, which exceeds `bytes_total` (single-column re-encoding
89    /// loses cross-column sharing and repays IPC framing per column).
90    pub share: f64,
91}
92
93/// The encoder geometry bucket a sampled geometry falls into (the tiler emits
94/// one layer per kind, so a synthetic layer must be single-kind).
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96enum GeomKind {
97    Point = 0,
98    Line = 1,
99    Polygon = 2,
100}
101
102impl GeomKind {
103    fn name(self) -> &'static str {
104        match self {
105            GeomKind::Point => "point",
106            GeomKind::Line => "line",
107            GeomKind::Polygon => "polygon",
108        }
109    }
110}
111
112/// Classify a sampled geometry; `None` for GeometryCollection (no encoder
113/// bucket — such features are excluded from the measurement).
114fn kind_of(geom: &geo_types::Geometry<f64>) -> Option<GeomKind> {
115    use geo_types::Geometry as G;
116    match geom {
117        G::Point(_) | G::MultiPoint(_) => Some(GeomKind::Point),
118        G::Line(_) | G::LineString(_) | G::MultiLineString(_) => Some(GeomKind::Line),
119        G::Polygon(_) | G::MultiPolygon(_) | G::Rect(_) | G::Triangle(_) => {
120            Some(GeomKind::Polygon)
121        }
122        G::GeometryCollection(_) => None,
123    }
124}
125
126/// Measure the real encoded + compressed size of a loader sample.
127///
128/// Groups the sample as ONE synthetic tile layer of the dominant geometry
129/// kind (mixed-geometry samples measure the dominant kind's subset only —
130/// [`MeasuredEncoding::geometry_kind`] records which), encodes it through the
131/// production stt-core encoder with the given settings, and compresses with
132/// zstd. Returns `Ok(None)` when the usable subset is smaller than
133/// [`MIN_MEASURE_FEATURES`].
134pub fn measure_sample(
135    sample: &[SampledFeature],
136    settings: &MeasureSettings,
137) -> Result<Option<MeasuredEncoding>> {
138    // Dominant kind, ties broken toward point > line > polygon (deterministic).
139    let mut counts = [0usize; 3];
140    for f in sample {
141        if let Some(kind) = kind_of(&f.geometry) {
142            counts[kind as usize] += 1;
143        }
144    }
145    let mut dominant = GeomKind::Point;
146    for kind in [GeomKind::Line, GeomKind::Polygon] {
147        if counts[kind as usize] > counts[dominant as usize] {
148            dominant = kind;
149        }
150    }
151
152    let subset: Vec<&SampledFeature> = sample
153        .iter()
154        .filter(|f| kind_of(&f.geometry) == Some(dominant))
155        .collect();
156    if subset.len() < MIN_MEASURE_FEATURES {
157        return Ok(None);
158    }
159
160    let layer = build_layer(&subset, dominant);
161    let cfg = EncoderConfig {
162        quantize_coords_m: settings.quantize_coords_m,
163        quantize_attrs_auto: settings.quantize_attrs_auto,
164        ..EncoderConfig::default()
165    };
166    let payload = encode_tile_with(&[layer], &cfg).context("sample tile encode failed")?;
167    let compressed = compress_zstd_with_dict_level(&payload, None, settings.zstd_level)
168        .context("sample tile compression failed")?;
169    let per_column = attribute_columns(&payload, settings.zstd_level)?;
170
171    Ok(Some(MeasuredEncoding {
172        features: subset.len(),
173        geometry_kind: dominant.name().to_string(),
174        bytes_total: compressed.len(),
175        bytes_per_feature: compressed.len() as f64 / subset.len() as f64,
176        zstd_ratio: payload.len() as f64 / compressed.len().max(1) as f64,
177        per_column,
178    }))
179}
180
181fn line_coords(ls: &geo_types::LineString<f64>) -> Vec<Coord> {
182    ls.0.iter().map(|c| [c.x, c.y]).collect()
183}
184
185fn polygon_rings(polygon: &geo_types::Polygon<f64>) -> Vec<Vec<Coord>> {
186    std::iter::once(polygon.exterior())
187        .chain(polygon.interiors().iter())
188        .map(line_coords)
189        .collect()
190}
191
192/// Point parts of a point-kind geometry (a MultiPoint flattens to one row per
193/// point, the shape a tiler split produces).
194fn point_parts(geom: &geo_types::Geometry<f64>) -> Vec<Coord> {
195    use geo_types::Geometry as G;
196    match geom {
197        G::Point(p) => vec![[p.x(), p.y()]],
198        G::MultiPoint(mp) => mp.0.iter().map(|p| [p.x(), p.y()]).collect(),
199        _ => Vec::new(),
200    }
201}
202
203/// Line parts of a line-kind geometry; empty parts are dropped.
204fn line_parts(geom: &geo_types::Geometry<f64>) -> Vec<Vec<Coord>> {
205    use geo_types::Geometry as G;
206    let parts = match geom {
207        G::Line(l) => vec![vec![[l.start.x, l.start.y], [l.end.x, l.end.y]]],
208        G::LineString(ls) => vec![line_coords(ls)],
209        G::MultiLineString(mls) => mls.0.iter().map(line_coords).collect(),
210        _ => Vec::new(),
211    };
212    parts.into_iter().filter(|p| !p.is_empty()).collect()
213}
214
215/// Polygon parts (ring lists) of a polygon-kind geometry; empty parts dropped.
216fn polygon_parts(geom: &geo_types::Geometry<f64>) -> Vec<Vec<Vec<Coord>>> {
217    use geo_types::Geometry as G;
218    let parts = match geom {
219        G::Polygon(p) => vec![polygon_rings(p)],
220        G::MultiPolygon(mp) => mp.0.iter().map(polygon_rings).collect(),
221        G::Rect(r) => vec![polygon_rings(&r.to_polygon())],
222        G::Triangle(t) => vec![polygon_rings(&t.to_polygon())],
223        _ => Vec::new(),
224    };
225    parts
226        .into_iter()
227        .filter(|rings| rings.iter().any(|ring| !ring.is_empty()))
228        .collect()
229}
230
231/// Assemble the sampled subset into one synthetic tile layer. Multi-part
232/// geometries flatten into one row per part, duplicating times and property
233/// values (what a tiler's multi-geometry split produces), so their full cost
234/// still lands on the one source feature.
235fn build_layer(subset: &[&SampledFeature], kind: GeomKind) -> ColumnarLayer {
236    // Property schema: names in first-seen order; Numeric vs Categorical from
237    // the first value seen (all rows come from one Parquet schema, so mixed
238    // types per name don't occur in practice — mismatches encode as null).
239    let mut names: Vec<String> = Vec::new();
240    let mut is_numeric: Vec<bool> = Vec::new();
241    for feature in subset {
242        for (name, value) in &feature.properties {
243            if !names.iter().any(|n| n == name) {
244                names.push(name.clone());
245                is_numeric.push(matches!(value, PropValue::Number(_)));
246            }
247        }
248    }
249
250    let mut ids: Vec<u64> = Vec::new();
251    let mut starts: Vec<i64> = Vec::new();
252    let mut ends: Vec<i64> = Vec::new();
253    let mut points: Vec<Coord> = Vec::new();
254    let mut lines: Vec<Vec<Coord>> = Vec::new();
255    let mut polygons: Vec<Vec<Vec<Coord>>> = Vec::new();
256    let mut numeric_cols: Vec<Vec<Option<f64>>> = vec![Vec::new(); names.len()];
257    let mut categorical_cols: Vec<Vec<Option<String>>> = vec![Vec::new(); names.len()];
258
259    for feature in subset {
260        let n_parts = match kind {
261            GeomKind::Point => {
262                let parts = point_parts(&feature.geometry);
263                let n = parts.len();
264                points.extend(parts);
265                n
266            }
267            GeomKind::Line => {
268                let parts = line_parts(&feature.geometry);
269                let n = parts.len();
270                lines.extend(parts);
271                n
272            }
273            GeomKind::Polygon => {
274                let parts = polygon_parts(&feature.geometry);
275                let n = parts.len();
276                polygons.extend(parts);
277                n
278            }
279        };
280        for _ in 0..n_parts {
281            ids.push(ids.len() as u64);
282            starts.push(feature.timestamp_ms as i64);
283            ends.push(feature.timestamp_ms as i64);
284            for (col, name) in names.iter().enumerate() {
285                let value = feature
286                    .properties
287                    .iter()
288                    .find(|(n, _)| n == name)
289                    .map(|(_, v)| v);
290                if is_numeric[col] {
291                    numeric_cols[col].push(match value {
292                        Some(PropValue::Number(x)) => Some(*x),
293                        _ => None,
294                    });
295                } else {
296                    categorical_cols[col].push(match value {
297                        Some(PropValue::Text(s)) => Some(s.clone()),
298                        _ => None,
299                    });
300                }
301            }
302        }
303    }
304
305    let geometry = match kind {
306        GeomKind::Point => GeometryColumn::Point(points),
307        GeomKind::Line => GeometryColumn::LineString(lines),
308        GeomKind::Polygon => GeometryColumn::Polygon(polygons),
309    };
310    let properties = names
311        .into_iter()
312        .enumerate()
313        .map(|(col, name)| {
314            let column = if is_numeric[col] {
315                PropertyColumn::Numeric(std::mem::take(&mut numeric_cols[col]))
316            } else {
317                PropertyColumn::Categorical(std::mem::take(&mut categorical_cols[col]))
318            };
319            (name, column)
320        })
321        .collect();
322
323    ColumnarLayer {
324        name: "default".to_string(),
325        feature_ids: ids,
326        start_times: starts,
327        end_times: ends,
328        geometry,
329        vertex_times: None,
330        vertex_values: None,
331        vertex_value_matrix: None,
332        triangles: None,
333        properties,
334    }
335}
336
337/// Per-column compressed-cost attribution: decode the encoded tile and
338/// re-encode each column alone (single-field IPC stream + zstd at the same
339/// level). Shares are of the per-column sum; sorted descending by bytes with
340/// name as the deterministic tiebreak.
341fn attribute_columns(payload: &[u8], zstd_level: i32) -> Result<Vec<ColumnCost>> {
342    let layers = decode_tile(payload).context("sample tile decode failed")?;
343    let mut costs: Vec<(String, usize)> = Vec::new();
344    for layer in &layers {
345        let batch = &layer.batch;
346        let schema = batch.schema();
347        for (idx, field) in schema.fields().iter().enumerate() {
348            let one = RecordBatch::try_new(
349                Arc::new(Schema::new(vec![field.as_ref().clone()])),
350                vec![batch.column(idx).clone()],
351            )
352            .context("single-column batch build failed")?;
353            let mut ipc = Vec::new();
354            {
355                let mut writer = StreamWriter::try_new(&mut ipc, &one.schema())
356                    .context("column IPC writer init failed")?;
357                writer.write(&one).context("column IPC write failed")?;
358                writer.finish().context("column IPC finish failed")?;
359            }
360            let compressed = compress_zstd_with_dict_level(&ipc, None, zstd_level)
361                .context("column compression failed")?;
362            costs.push((field.name().clone(), compressed.len()));
363        }
364    }
365    let total: usize = costs.iter().map(|(_, bytes)| bytes).sum();
366    let mut out: Vec<ColumnCost> = costs
367        .into_iter()
368        .map(|(name, compressed_bytes)| ColumnCost {
369            name,
370            compressed_bytes,
371            share: compressed_bytes as f64 / total.max(1) as f64,
372        })
373        .collect();
374    out.sort_by(|a, b| {
375        b.compressed_bytes
376            .cmp(&a.compressed_bytes)
377            .then_with(|| a.name.cmp(&b.name))
378    });
379    Ok(out)
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use geo_types::{Geometry, LineString, Point};
386
387    /// n spread-out points with one numeric and one string property. Knuth-hash
388    /// jitter keeps the f64 coordinate mantissas high-entropy so quantization
389    /// has real bytes to win.
390    fn point_sample(n: usize) -> Vec<SampledFeature> {
391        (0..n)
392            .map(|i| {
393                let jitter = |salt: u64| {
394                    ((i as u64).wrapping_add(salt).wrapping_mul(2_654_435_761) % 100_000) as f64
395                        * 1e-7
396                };
397                SampledFeature {
398                    geometry: Geometry::Point(Point::new(
399                        -73.5 + i as f64 * 0.0013 + jitter(0),
400                        45.5 + (i % 7) as f64 * 0.0021 + jitter(17),
401                    )),
402                    timestamp_ms: 1_600_000_000_000 + i as u64 * 1_000,
403                    properties: vec![
404                        (
405                            "magnitude".to_string(),
406                            PropValue::Number(1.0 + (i % 90) as f64 * 0.137),
407                        ),
408                        (
409                            "region".to_string(),
410                            PropValue::Text(format!("region-{}", i % 5)),
411                        ),
412                    ],
413                }
414            })
415            .collect()
416    }
417
418    #[test]
419    fn measures_point_sample() {
420        let sample = point_sample(200);
421        let measured = measure_sample(&sample, &MeasureSettings::default())
422            .unwrap()
423            .expect("200 features is enough to measure");
424        assert_eq!(measured.features, 200);
425        assert_eq!(measured.geometry_kind, "point");
426        assert!(measured.bytes_total > 0);
427        assert!(measured.bytes_per_feature > 0.0);
428        assert!(measured.zstd_ratio > 0.0);
429
430        let share_sum: f64 = measured.per_column.iter().map(|c| c.share).sum();
431        assert!((share_sum - 1.0).abs() < 1e-9, "shares sum to {share_sum}");
432        for name in ["geometry", "magnitude", "region", "id", "start_time"] {
433            assert!(
434                measured.per_column.iter().any(|c| c.name == name),
435                "missing column {name}"
436            );
437        }
438        // Sorted descending by compressed bytes.
439        for pair in measured.per_column.windows(2) {
440            assert!(pair[0].compressed_bytes >= pair[1].compressed_bytes);
441        }
442    }
443
444    #[test]
445    fn quantized_coords_never_larger() {
446        let sample = point_sample(500);
447        let base = measure_sample(&sample, &MeasureSettings::default())
448            .unwrap()
449            .unwrap();
450        let quantized = measure_sample(
451            &sample,
452            &MeasureSettings {
453                quantize_coords_m: Some(0.1),
454                ..MeasureSettings::default()
455            },
456        )
457        .unwrap()
458        .unwrap();
459        assert!(
460            quantized.bytes_total <= base.bytes_total,
461            "quantized {} > unquantized {}",
462            quantized.bytes_total,
463            base.bytes_total
464        );
465    }
466
467    #[test]
468    fn mixed_geometry_measures_dominant_kind_subset() {
469        let mut sample = point_sample(150);
470        for i in 0..50 {
471            sample.push(SampledFeature {
472                geometry: Geometry::LineString(LineString::from(vec![
473                    (0.0, 0.0),
474                    (i as f64 * 0.01, 1.0),
475                ])),
476                timestamp_ms: 0,
477                properties: vec![],
478            });
479        }
480        let measured = measure_sample(&sample, &MeasureSettings::default())
481            .unwrap()
482            .unwrap();
483        assert_eq!(measured.geometry_kind, "point");
484        assert_eq!(measured.features, 150);
485    }
486
487    #[test]
488    fn empty_or_tiny_sample_returns_none() {
489        assert!(measure_sample(&[], &MeasureSettings::default())
490            .unwrap()
491            .is_none());
492        let tiny = point_sample(MIN_MEASURE_FEATURES - 1);
493        assert!(measure_sample(&tiny, &MeasureSettings::default())
494            .unwrap()
495            .is_none());
496    }
497}