Skip to main content

stt_optimize/analysis/
properties.rs

1//! Per-property statistics profiler: numeric percentiles (p50..p99) and
2//! categorical cardinality, feeding manifest `style_hints` (suggested color
3//! domains, playback duration, layer hint) so a fresh build renders sensibly
4//! without hand-tuning.
5//!
6//! The input seam ([`PropertyValues`]) is caller-agnostic: `stt-build` feeds
7//! it from the loaded feature set, and any future caller (a packed-archive
8//! re-profiler, a DB adaptor) can feed pre-collected values the same way.
9//! Hints are DEFAULTS — the renderer/user can always override them.
10
11use stt_core::metadata::{PropertyStyleHint, StyleHints};
12
13/// Version of the emitted `style_hints` block.
14pub const STYLE_HINTS_VERSION: u32 = 1;
15
16/// The layer-hint vocabulary the block may carry. Anything outside it is
17/// dropped rather than emitted (the manifest schema pins this enum).
18const LAYER_HINTS: [&str; 4] = ["points", "paths", "trips", "polygons"];
19
20/// Collected values for one property, as gathered by the caller.
21///
22/// * `Numeric` — samples widened to `f64` (any numeric source type).
23///   Non-finite entries are filtered by the profiler; callers need not
24///   pre-clean.
25/// * `Categorical` — string(-like) properties only need their distinct-value
26///   count; the values themselves never cross this seam.
27#[derive(Debug, Clone)]
28pub enum PropertyValues {
29    /// Numeric samples (any numeric source type widened to f64).
30    Numeric(Vec<f64>),
31    /// Categorical (string) property: distinct-value count.
32    Categorical { distinct: usize },
33}
34
35/// Profile a set of per-property value collections into a [`StyleHints`]
36/// block.
37///
38/// * Numeric properties yield min/p50/p90/p95/p97/p99/max plus a
39///   `suggested_domain` of `[min, p97]` with each endpoint rounded OUTWARD to
40///   2 significant figures (the "domain clamps at ~p97" convention). A
41///   property whose values are all non-finite (or empty) is omitted.
42/// * Categorical properties yield ONLY `name` + `cardinality`.
43/// * `suggested_playback_seconds = clamp(round(sqrt(bucket_count)), 20, 90)`
44///   where `bucket_count = time_range_ms / temporal_bucket_ms`; absent when
45///   `temporal_bucket_ms` is 0.
46/// * `layer_kind_hint` passes through only when it is one of the pinned
47///   `"points" | "paths" | "trips" | "polygons"` vocabulary.
48pub fn profile_properties(
49    props: &[(String, PropertyValues)],
50    time_range_ms: u64,
51    temporal_bucket_ms: u64,
52    layer_kind_hint: Option<&str>,
53) -> StyleHints {
54    let properties = props
55        .iter()
56        .filter_map(|(name, values)| match values {
57            PropertyValues::Numeric(raw) => numeric_hint(name, raw),
58            PropertyValues::Categorical { distinct } => Some(PropertyStyleHint {
59                name: name.clone(),
60                cardinality: Some((*distinct).min(u32::MAX as usize) as u32),
61                ..Default::default()
62            }),
63        })
64        .collect();
65
66    StyleHints {
67        version: STYLE_HINTS_VERSION,
68        properties,
69        suggested_playback_seconds: suggested_playback_seconds(
70            time_range_ms,
71            temporal_bucket_ms,
72        ),
73        layer_hint: layer_kind_hint
74            .filter(|h| LAYER_HINTS.contains(h))
75            .map(str::to_string),
76    }
77}
78
79/// Numeric percentile profile for one property. `None` when no finite value
80/// survives filtering (the property is then omitted from the block).
81fn numeric_hint(name: &str, raw: &[f64]) -> Option<PropertyStyleHint> {
82    let mut vals: Vec<f64> = raw.iter().copied().filter(|v| v.is_finite()).collect();
83    if vals.is_empty() {
84        return None;
85    }
86    vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
87    let min = vals[0];
88    let max = *vals.last().expect("non-empty per check");
89    let p97 = percentile(&vals, 0.97);
90    Some(PropertyStyleHint {
91        name: name.to_string(),
92        min: Some(min),
93        p50: Some(percentile(&vals, 0.50)),
94        p90: Some(percentile(&vals, 0.90)),
95        p95: Some(percentile(&vals, 0.95)),
96        p97: Some(p97),
97        p99: Some(percentile(&vals, 0.99)),
98        max: Some(max),
99        suggested_domain: Some([
100            round_2sf_outward(min, false),
101            round_2sf_outward(p97, true),
102        ]),
103        cardinality: None,
104    })
105}
106
107/// Sort-based percentile matching stt-build's heatmap-domain convention:
108/// `sorted[min(floor(n * q), n - 1)]`.
109fn percentile(sorted: &[f64], q: f64) -> f64 {
110    let idx = ((sorted.len() as f64 * q).floor() as usize).min(sorted.len() - 1);
111    sorted[idx]
112}
113
114/// Round `v` to 2 significant figures, OUTWARD from the suggested domain:
115/// `up = false` for the lower endpoint (toward −∞), `up = true` for the upper
116/// (toward +∞). Outward rounding guarantees the rounded domain still covers
117/// the raw `[min, p97]` interval. A small epsilon tolerates float noise so an
118/// already-2-sig-fig endpoint round-trips to itself (5.3 stays 5.3).
119fn round_2sf_outward(v: f64, up: bool) -> f64 {
120    if v == 0.0 || !v.is_finite() {
121        return v;
122    }
123    // Exponent of the 2nd significant digit: 5.27 -> -1, 971 -> 1, 0.123 -> -2.
124    let exp = v.abs().log10().floor() as i32 - 1;
125    let pow = 10f64.powi(exp.abs());
126    let scaled = if exp >= 0 { v / pow } else { v * pow };
127    let eps = 1e-9;
128    let r = if up {
129        (scaled - eps).ceil()
130    } else {
131        (scaled + eps).floor()
132    };
133    if exp >= 0 {
134        r * pow
135    } else {
136        // Divide (not multiply by 10^exp) so the result is the correctly
137        // rounded double, which prints clean: 53 / 10 -> 5.3, not 5.300…01.
138        r / pow
139    }
140}
141
142/// `clamp(round(sqrt(bucket_count)), 20, 90)` — short archives still get a
143/// watchable loop, huge ones don't crawl. `None` when the bucket size is
144/// unknown (0): no defensible default exists without a bucket count.
145fn suggested_playback_seconds(time_range_ms: u64, temporal_bucket_ms: u64) -> Option<u32> {
146    if temporal_bucket_ms == 0 {
147        return None;
148    }
149    let buckets = time_range_ms as f64 / temporal_bucket_ms as f64;
150    Some((buckets.sqrt().round() as u64).clamp(20, 90) as u32)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn numeric(name: &str, vals: Vec<f64>) -> (String, PropertyValues) {
158        (name.to_string(), PropertyValues::Numeric(vals))
159    }
160
161    #[test]
162    fn percentiles_on_known_1000_value_vec() {
163        // 1.0..=1000.0 shuffled deterministically: percentile indexing must
164        // match the heatmap-domain convention sorted[floor(n*q)].
165        let mut vals: Vec<f64> = (1..=1000).map(|i| i as f64).collect();
166        vals.reverse(); // profiler sorts internally
167        let hints = profile_properties(&[numeric("v", vals)], 0, 0, None);
168        assert_eq!(hints.version, STYLE_HINTS_VERSION);
169        let p = &hints.properties[0];
170        assert_eq!(p.name, "v");
171        assert_eq!(p.min, Some(1.0));
172        assert_eq!(p.p50, Some(501.0)); // sorted[floor(1000*0.50)] = sorted[500]
173        assert_eq!(p.p90, Some(901.0));
174        assert_eq!(p.p95, Some(951.0));
175        assert_eq!(p.p97, Some(971.0));
176        assert_eq!(p.p99, Some(991.0));
177        assert_eq!(p.max, Some(1000.0));
178        // Domain: [min, p97] rounded outward to 2 sig figs -> [1, 980].
179        assert_eq!(p.suggested_domain, Some([1.0, 980.0]));
180        assert_eq!(p.cardinality, None);
181    }
182
183    #[test]
184    fn domain_rounds_outward_to_two_sig_figs() {
185        // 100 values: min = 0.123, sorted[97] (= p97) = 5.27.
186        let mut vals = vec![0.123];
187        vals.extend((1..=96).map(|i| 0.2 + i as f64 * 0.05)); // 0.25..5.0
188        vals.extend([5.27, 6.0, 9.1]);
189        assert_eq!(vals.len(), 100);
190        let hints = profile_properties(&[numeric("mag", vals)], 0, 0, None);
191        let p = &hints.properties[0];
192        assert_eq!(p.p97, Some(5.27));
193        assert_eq!(p.suggested_domain, Some([0.12, 5.3]));
194    }
195
196    #[test]
197    fn outward_rounding_handles_signs_and_exact_endpoints() {
198        // Lower endpoint rounds toward -inf, upper toward +inf.
199        assert_eq!(round_2sf_outward(0.123, false), 0.12);
200        assert_eq!(round_2sf_outward(5.27, true), 5.3);
201        assert_eq!(round_2sf_outward(-0.123, false), -0.13);
202        assert_eq!(round_2sf_outward(-5.27, true), -5.2);
203        assert_eq!(round_2sf_outward(971.0, true), 980.0);
204        // Already 2 sig figs: stays put in both directions.
205        assert_eq!(round_2sf_outward(5.3, true), 5.3);
206        assert_eq!(round_2sf_outward(5.3, false), 5.3);
207        assert_eq!(round_2sf_outward(0.0, true), 0.0);
208    }
209
210    #[test]
211    fn nan_and_infinite_values_are_filtered() {
212        let vals = vec![f64::NAN, 1.0, f64::INFINITY, 2.0, f64::NEG_INFINITY, 3.0];
213        let hints = profile_properties(&[numeric("v", vals)], 0, 0, None);
214        let p = &hints.properties[0];
215        assert_eq!(p.min, Some(1.0));
216        assert_eq!(p.max, Some(3.0));
217    }
218
219    #[test]
220    fn empty_or_all_nan_numeric_property_is_omitted() {
221        let hints = profile_properties(
222            &[
223                numeric("empty", vec![]),
224                numeric("nans", vec![f64::NAN, f64::NAN]),
225                numeric("ok", vec![1.0]),
226            ],
227            0,
228            0,
229            None,
230        );
231        assert_eq!(hints.properties.len(), 1);
232        assert_eq!(hints.properties[0].name, "ok");
233    }
234
235    #[test]
236    fn categorical_carries_cardinality_only() {
237        let hints = profile_properties(
238            &[("category".to_string(), PropertyValues::Categorical { distinct: 7 })],
239            0,
240            0,
241            None,
242        );
243        let p = &hints.properties[0];
244        assert_eq!(p.name, "category");
245        assert_eq!(p.cardinality, Some(7));
246        assert_eq!(p.min, None);
247        assert_eq!(p.p50, None);
248        assert_eq!(p.p97, None);
249        assert_eq!(p.max, None);
250        assert_eq!(p.suggested_domain, None);
251    }
252
253    #[test]
254    fn playback_seconds_clamps_and_guards_zero_bucket() {
255        let hour = 3_600_000u64;
256        // 4 buckets -> sqrt = 2 -> clamps up to 20.
257        assert_eq!(
258            profile_properties(&[], 4 * hour, hour, None).suggested_playback_seconds,
259            Some(20)
260        );
261        // 2025 buckets -> sqrt = 45 -> passes through.
262        assert_eq!(
263            profile_properties(&[], 2025 * hour, hour, None).suggested_playback_seconds,
264            Some(45)
265        );
266        // 100_000 buckets -> sqrt ~316 -> clamps down to 90.
267        assert_eq!(
268            profile_properties(&[], 100_000 * hour, hour, None).suggested_playback_seconds,
269            Some(90)
270        );
271        // Zero bucket size: no defensible default -> absent.
272        assert_eq!(
273            profile_properties(&[], hour, 0, None).suggested_playback_seconds,
274            None
275        );
276    }
277
278    #[test]
279    fn layer_hint_passes_known_vocabulary_only() {
280        assert_eq!(
281            profile_properties(&[], 0, 0, Some("trips")).layer_hint.as_deref(),
282            Some("trips")
283        );
284        assert_eq!(profile_properties(&[], 0, 0, Some("hexagons")).layer_hint, None);
285        assert_eq!(profile_properties(&[], 0, 0, None).layer_hint, None);
286    }
287}