Skip to main content

stt_optimize/
diff.rs

1//! Tileset-to-tileset comparison for `stt-optimize diff`: totals, per-zoom
2//! directory stats, and per-column costs between two [`InspectReport`]s
3//! (typically before/after a re-encode or fleet reprocess), each as absolute
4//! and percent deltas.
5//!
6//! Directory-derived numbers (tile/blob counts, wire bytes) are exact on both
7//! sides; per-column numbers inherit whatever sampling the two inspections
8//! ran with ([`DiffReport::decode_sampled`] flags this), so treat them as
9//! attribution shifts, not absolute wire accounting.
10
11use std::collections::BTreeSet;
12
13use serde::{Deserialize, Serialize};
14
15use crate::analysis::inspect::InspectReport;
16
17/// Which side of the comparison a one-sided row exists on.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum Side {
21    Before,
22    After,
23}
24
25/// Absolute + percent change of an integer counter.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Delta {
28    /// Value on the `--before` side.
29    pub before: u64,
30    /// Value on the `--after` side.
31    pub after: u64,
32    /// `after - before`.
33    pub delta: i64,
34    /// `100 * delta / before`; `None` when `before == 0` (percent undefined).
35    pub pct: Option<f64>,
36}
37
38impl Delta {
39    fn new(before: u64, after: u64) -> Self {
40        let delta = after as i64 - before as i64;
41        let pct = (before != 0).then(|| 100.0 * delta as f64 / before as f64);
42        Self {
43            before,
44            after,
45            delta,
46            pct,
47        }
48    }
49}
50
51/// Absolute + percent change of a float metric (ratios, per-feature bytes).
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct DeltaF {
54    /// Value on the `--before` side.
55    pub before: f64,
56    /// Value on the `--after` side.
57    pub after: f64,
58    /// `after - before`.
59    pub delta: f64,
60    /// `100 * delta / before`; `None` when `before == 0.0`.
61    pub pct: Option<f64>,
62}
63
64impl DeltaF {
65    fn new(before: f64, after: f64) -> Self {
66        let delta = after - before;
67        let pct = (before != 0.0).then(|| 100.0 * delta / before);
68        Self {
69            before,
70            after,
71            delta,
72            pct,
73        }
74    }
75}
76
77/// Per-zoom directory comparison. A zoom present on only one side is flagged
78/// via `only_in` and diffed against zeros.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ZoomDiff {
81    /// Zoom level.
82    pub zoom: u8,
83    /// Set when the zoom exists in only one report.
84    pub only_in: Option<Side>,
85    /// Directory entries at this zoom.
86    pub entries: Delta,
87    /// Distinct physical blobs at this zoom.
88    pub distinct_blobs: Delta,
89    /// Entry-weighted compressed blob bytes at this zoom.
90    pub blob_bytes_total: Delta,
91    /// Average compressed blob size at this zoom.
92    pub avg_blob_bytes: DeltaF,
93}
94
95/// Per-column cost comparison (matched by column name). A column present on
96/// only one side is flagged via `only_in` and diffed against zeros.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ColumnDiff {
99    /// Column name (e.g. `geometry`, `vertex_time`, a property name).
100    pub name: String,
101    /// Set when the column exists in only one report.
102    pub only_in: Option<Side>,
103    /// Standalone re-encode bytes (IPC + zstd) over the decoded tiles.
104    pub compressed_bytes: Delta,
105    /// Standalone re-encode bytes per feature row.
106    pub bytes_per_feature: DeltaF,
107}
108
109/// Full comparison of two inspected tilesets.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct DiffReport {
112    /// Dataset name of the `--before` side.
113    pub before_name: String,
114    /// Dataset name of the `--after` side.
115    pub after_name: String,
116    /// Directory entry count.
117    pub tile_count: Delta,
118    /// Index-weighted feature total.
119    pub feature_count: Delta,
120    /// Total compressed blob bytes (the `--fail-on-growth` gate metric).
121    pub compressed_bytes: Delta,
122    /// Total uncompressed payload bytes.
123    pub uncompressed_bytes: Delta,
124    /// Whole-archive uncompressed/compressed ratio.
125    pub compression_ratio: DeltaF,
126    /// Whole-directory dedup ratio (`distinct_blobs / entries`).
127    pub dedup_ratio: DeltaF,
128    /// True when either side's decode pass was sampled — per-column deltas
129    /// then compare samples, not exhaustive decodes.
130    pub decode_sampled: bool,
131    /// Per-zoom directory deltas, ascending zoom (union of both sides).
132    pub per_zoom: Vec<ZoomDiff>,
133    /// Per-column cost deltas, largest side first (union of both sides).
134    pub per_column: Vec<ColumnDiff>,
135}
136
137/// Compare two inspection reports (`before` → `after`).
138pub fn diff(before: &InspectReport, after: &InspectReport) -> DiffReport {
139    let zooms: BTreeSet<u8> = before
140        .per_zoom
141        .iter()
142        .chain(after.per_zoom.iter())
143        .map(|z| z.zoom)
144        .collect();
145    let per_zoom: Vec<ZoomDiff> = zooms
146        .into_iter()
147        .map(|zoom| {
148            let b = before.per_zoom.iter().find(|z| z.zoom == zoom);
149            let a = after.per_zoom.iter().find(|z| z.zoom == zoom);
150            ZoomDiff {
151                zoom,
152                only_in: only_in(b.is_some(), a.is_some()),
153                entries: Delta::new(b.map_or(0, |z| z.entries), a.map_or(0, |z| z.entries)),
154                distinct_blobs: Delta::new(
155                    b.map_or(0, |z| z.distinct_blobs),
156                    a.map_or(0, |z| z.distinct_blobs),
157                ),
158                blob_bytes_total: Delta::new(
159                    b.map_or(0, |z| z.blob_bytes_total),
160                    a.map_or(0, |z| z.blob_bytes_total),
161                ),
162                avg_blob_bytes: DeltaF::new(
163                    b.map_or(0.0, |z| z.avg_blob_bytes),
164                    a.map_or(0.0, |z| z.avg_blob_bytes),
165                ),
166            }
167        })
168        .collect();
169
170    let names: BTreeSet<&str> = before
171        .per_column
172        .iter()
173        .chain(after.per_column.iter())
174        .map(|c| c.name.as_str())
175        .collect();
176    let mut per_column: Vec<ColumnDiff> = names
177        .into_iter()
178        .map(|name| {
179            let b = before.per_column.iter().find(|c| c.name == name);
180            let a = after.per_column.iter().find(|c| c.name == name);
181            ColumnDiff {
182                name: name.to_string(),
183                only_in: only_in(b.is_some(), a.is_some()),
184                compressed_bytes: Delta::new(
185                    b.map_or(0, |c| c.compressed_bytes),
186                    a.map_or(0, |c| c.compressed_bytes),
187                ),
188                bytes_per_feature: DeltaF::new(
189                    b.map_or(0.0, |c| c.bytes_per_feature),
190                    a.map_or(0.0, |c| c.bytes_per_feature),
191                ),
192            }
193        })
194        .collect();
195    // Largest column (on either side) first, name as the deterministic tiebreak.
196    per_column.sort_by(|x, y| {
197        let kx = x.compressed_bytes.before.max(x.compressed_bytes.after);
198        let ky = y.compressed_bytes.before.max(y.compressed_bytes.after);
199        ky.cmp(&kx).then_with(|| x.name.cmp(&y.name))
200    });
201
202    DiffReport {
203        before_name: before.name.clone(),
204        after_name: after.name.clone(),
205        tile_count: Delta::new(before.tile_count, after.tile_count),
206        feature_count: Delta::new(before.feature_count, after.feature_count),
207        compressed_bytes: Delta::new(before.compressed_bytes, after.compressed_bytes),
208        uncompressed_bytes: Delta::new(before.uncompressed_bytes, after.uncompressed_bytes),
209        compression_ratio: DeltaF::new(before.compression_ratio, after.compression_ratio),
210        dedup_ratio: DeltaF::new(before.dedup.dedup_ratio, after.dedup.dedup_ratio),
211        decode_sampled: before.decode.sampled || after.decode.sampled,
212        per_zoom,
213        per_column,
214    }
215}
216
217fn only_in(in_before: bool, in_after: bool) -> Option<Side> {
218    match (in_before, in_after) {
219        (true, false) => Some(Side::Before),
220        (false, true) => Some(Side::After),
221        _ => None,
222    }
223}
224
225/// `+3.2%` / `-1.4%`; `n/a` when the baseline was zero.
226fn fmt_pct(pct: Option<f64>) -> String {
227    match pct {
228        Some(p) => format!("{p:+.1}%"),
229        None => "n/a".to_string(),
230    }
231}
232
233fn one_sided_note(only_in: Option<Side>) -> &'static str {
234    match only_in {
235        Some(Side::Before) => "before only",
236        Some(Side::After) => "after only",
237        None => "",
238    }
239}
240
241/// Render the diff as compact aligned text.
242pub fn format_text(report: &DiffReport) -> String {
243    let mut out = String::new();
244    out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
245    out.push_str(&format!(
246        "         STT Diff - {} -> {}\n",
247        report.before_name, report.after_name
248    ));
249    out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
250
251    let row_u = |name: &str, d: &Delta| {
252        format!(
253            "  {:<20} {:>14} {:>14} {:>+14} {:>9}\n",
254            name,
255            d.before,
256            d.after,
257            d.delta,
258            fmt_pct(d.pct)
259        )
260    };
261    let row_mb = |name: &str, d: &Delta| {
262        format!(
263            "  {:<20} {:>14.2} {:>14.2} {:>+14.2} {:>9}\n",
264            name,
265            d.before as f64 / 1e6,
266            d.after as f64 / 1e6,
267            d.delta as f64 / 1e6,
268            fmt_pct(d.pct)
269        )
270    };
271    let row_f = |name: &str, d: &DeltaF| {
272        format!(
273            "  {:<20} {:>14.3} {:>14.3} {:>+14.3} {:>9}\n",
274            name,
275            d.before,
276            d.after,
277            d.delta,
278            fmt_pct(d.pct)
279        )
280    };
281
282    out.push_str("📊 Totals\n");
283    out.push_str(&format!(
284        "  {:<20} {:>14} {:>14} {:>14} {:>9}\n",
285        "metric", "before", "after", "delta", "pct"
286    ));
287    out.push_str(&row_u("tiles", &report.tile_count));
288    out.push_str(&row_u("features (index)", &report.feature_count));
289    out.push_str(&row_mb("compressed MB", &report.compressed_bytes));
290    out.push_str(&row_mb("uncompressed MB", &report.uncompressed_bytes));
291    out.push_str(&row_f("compression ratio", &report.compression_ratio));
292    out.push_str(&row_f("dedup ratio", &report.dedup_ratio));
293    out.push('\n');
294
295    out.push_str("🗂  Per-zoom blob bytes\n");
296    out.push_str(&format!(
297        "  zoom | {:>11} | {:>11} | {:>11} | {:>8} | entries         | note\n",
298        "before MB", "after MB", "delta MB", "pct"
299    ));
300    for z in &report.per_zoom {
301        out.push_str(&format!(
302            "    {:2} | {:>11.2} | {:>11.2} | {:>+11.2} | {:>8} | {:>7} -> {:<6} | {}\n",
303            z.zoom,
304            z.blob_bytes_total.before as f64 / 1e6,
305            z.blob_bytes_total.after as f64 / 1e6,
306            z.blob_bytes_total.delta as f64 / 1e6,
307            fmt_pct(z.blob_bytes_total.pct),
308            z.entries.before,
309            z.entries.after,
310            one_sided_note(z.only_in)
311        ));
312    }
313    out.push('\n');
314
315    if !report.per_column.is_empty() {
316        out.push_str(&format!(
317            "💾 Per-column cost (standalone IPC+zstd{})\n",
318            if report.decode_sampled {
319                "; sampled decode on at least one side"
320            } else {
321                ""
322            }
323        ));
324        out.push_str(&format!(
325            "  {:<22} {:>11} {:>11} {:>11} {:>8}  {:>7} -> {:<7}  note\n",
326            "column", "before KB", "after KB", "delta KB", "pct", "B/feat", "B/feat"
327        ));
328        for c in &report.per_column {
329            out.push_str(&format!(
330                "  {:<22} {:>11.1} {:>11.1} {:>+11.1} {:>8}  {:>7.2} -> {:<7.2}  {}\n",
331                c.name,
332                c.compressed_bytes.before as f64 / 1e3,
333                c.compressed_bytes.after as f64 / 1e3,
334                c.compressed_bytes.delta as f64 / 1e3,
335                fmt_pct(c.compressed_bytes.pct),
336                c.bytes_per_feature.before,
337                c.bytes_per_feature.after,
338                one_sided_note(c.only_in)
339            ));
340        }
341    }
342
343    out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
344    out
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::analysis::inspect::{ColumnCost, DecodeStats, DedupStats, ZoomStats};
351
352    fn zoom(zoom: u8, entries: u64, blob_bytes_total: u64) -> ZoomStats {
353        ZoomStats {
354            zoom,
355            entries,
356            distinct_blobs: entries,
357            blob_bytes_total,
358            blob_bytes_max: blob_bytes_total / entries.max(1),
359            avg_blob_bytes: blob_bytes_total as f64 / entries.max(1) as f64,
360            t_buckets: 1,
361        }
362    }
363
364    fn column(name: &str, compressed_bytes: u64, bytes_per_feature: f64) -> ColumnCost {
365        ColumnCost {
366            name: name.to_string(),
367            dtype: "Float64".to_string(),
368            compressed_bytes,
369            share: 0.5,
370            bytes_per_feature,
371            encoding_note: String::new(),
372        }
373    }
374
375    fn report(
376        name: &str,
377        compressed: u64,
378        per_zoom: Vec<ZoomStats>,
379        per_column: Vec<ColumnCost>,
380        sampled: bool,
381    ) -> InspectReport {
382        let tile_count: u64 = per_zoom.iter().map(|z| z.entries).sum();
383        InspectReport {
384            name: name.to_string(),
385            min_zoom: per_zoom.first().map_or(0, |z| z.zoom),
386            max_zoom: per_zoom.last().map_or(0, |z| z.zoom),
387            time_start_ms: 0,
388            time_end_ms: 3_600_000,
389            temporal_bucket_ms: 3_600_000,
390            tile_count,
391            feature_count: tile_count * 10,
392            pack_count: 1,
393            paged_directory: false,
394            compressed_bytes: compressed,
395            uncompressed_bytes: compressed * 3,
396            compression_ratio: 3.0,
397            per_zoom,
398            dedup: DedupStats {
399                entries: tile_count,
400                distinct_blobs: tile_count,
401                dedup_ratio: 1.0,
402            },
403            decode: DecodeStats {
404                tiles_decoded: tile_count,
405                tiles_total: tile_count,
406                sampled,
407                features_decoded: tile_count * 10,
408                distinct_layer_schemas: 1,
409            },
410            per_column,
411        }
412    }
413
414    #[test]
415    fn diff_totals_zooms_and_columns() {
416        // before: z3+z5, geometry+speed columns. after: z5 shrinks 10%, z3 is
417        // replaced by z2, speed disappears, quantized `speed_q` appears.
418        let before = report(
419            "old",
420            1_000_000,
421            vec![zoom(3, 10, 200_000), zoom(5, 40, 800_000)],
422            vec![column("geometry", 600_000, 15.0), column("speed", 100_000, 2.5)],
423            false,
424        );
425        let after = report(
426            "new",
427            900_000,
428            vec![zoom(2, 4, 180_000), zoom(5, 40, 720_000)],
429            vec![column("geometry", 600_000, 15.0), column("speed_q", 40_000, 1.0)],
430            true,
431        );
432
433        let d = diff(&before, &after);
434        assert_eq!(d.before_name, "old");
435        assert_eq!(d.after_name, "new");
436
437        // Totals: absolute + percent.
438        assert_eq!(d.compressed_bytes.before, 1_000_000);
439        assert_eq!(d.compressed_bytes.after, 900_000);
440        assert_eq!(d.compressed_bytes.delta, -100_000);
441        assert!((d.compressed_bytes.pct.unwrap() - -10.0).abs() < 1e-9);
442        assert_eq!(d.tile_count.delta, -6);
443        assert!(d.decode_sampled, "after side sampled → flagged");
444
445        // Per-zoom: union {2, 3, 5}, ascending, one-sided flags + zero fills.
446        assert_eq!(
447            d.per_zoom.iter().map(|z| z.zoom).collect::<Vec<_>>(),
448            vec![2, 3, 5]
449        );
450        let z2 = &d.per_zoom[0];
451        assert_eq!(z2.only_in, Some(Side::After));
452        assert_eq!(z2.entries.before, 0);
453        assert_eq!(z2.blob_bytes_total.pct, None, "zero baseline → pct n/a");
454        let z3 = &d.per_zoom[1];
455        assert_eq!(z3.only_in, Some(Side::Before));
456        assert_eq!(z3.blob_bytes_total.delta, -200_000);
457        let z5 = &d.per_zoom[2];
458        assert_eq!(z5.only_in, None);
459        assert!((z5.blob_bytes_total.pct.unwrap() - -10.0).abs() < 1e-9);
460        assert!((z5.avg_blob_bytes.pct.unwrap() - -10.0).abs() < 1e-9);
461
462        // Per-column: largest side first, one-sided flags both ways.
463        assert_eq!(
464            d.per_column.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
465            vec!["geometry", "speed", "speed_q"]
466        );
467        let speed = &d.per_column[1];
468        assert_eq!(speed.only_in, Some(Side::Before));
469        assert_eq!(speed.compressed_bytes.after, 0);
470        assert_eq!(speed.compressed_bytes.delta, -100_000);
471        let speed_q = &d.per_column[2];
472        assert_eq!(speed_q.only_in, Some(Side::After));
473        assert_eq!(speed_q.compressed_bytes.pct, None);
474        assert!((speed_q.bytes_per_feature.after - 1.0).abs() < 1e-9);
475
476        // Text rendering carries the headline numbers + one-sided notes.
477        let text = format_text(&d);
478        assert!(text.contains("old -> new"));
479        assert!(text.contains("-10.0%"));
480        assert!(text.contains("before only"));
481        assert!(text.contains("after only"));
482        assert!(text.contains("n/a"));
483        assert!(text.contains("sampled decode"));
484
485        // Serde round-trip (snake_case JSON, Side as "before"/"after").
486        let json = serde_json::to_string(&d).unwrap();
487        assert!(json.contains("\"only_in\":\"after\""));
488        let back: DiffReport = serde_json::from_str(&json).unwrap();
489        assert_eq!(back.per_column.len(), d.per_column.len());
490        assert_eq!(back.compressed_bytes.delta, d.compressed_bytes.delta);
491    }
492
493    #[test]
494    fn identical_reports_diff_to_zero() {
495        let r = report(
496            "same",
497            500_000,
498            vec![zoom(4, 8, 500_000)],
499            vec![column("geometry", 400_000, 12.0)],
500            false,
501        );
502        let d = diff(&r, &r);
503        assert_eq!(d.compressed_bytes.delta, 0);
504        assert!((d.compressed_bytes.pct.unwrap()).abs() < 1e-9);
505        assert!(!d.decode_sampled);
506        assert!(d.per_zoom.iter().all(|z| z.only_in.is_none()));
507        assert!(d.per_column.iter().all(|c| c.only_in.is_none()));
508        assert!(!format_text(&d).contains("only"));
509    }
510}