1use std::collections::BTreeSet;
12
13use serde::{Deserialize, Serialize};
14
15use crate::analysis::inspect::InspectReport;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum Side {
21 Before,
22 After,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Delta {
28 pub before: u64,
30 pub after: u64,
32 pub delta: i64,
34 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#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct DeltaF {
54 pub before: f64,
56 pub after: f64,
58 pub delta: f64,
60 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#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ZoomDiff {
81 pub zoom: u8,
83 pub only_in: Option<Side>,
85 pub entries: Delta,
87 pub distinct_blobs: Delta,
89 pub blob_bytes_total: Delta,
91 pub avg_blob_bytes: DeltaF,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ColumnDiff {
99 pub name: String,
101 pub only_in: Option<Side>,
103 pub compressed_bytes: Delta,
105 pub bytes_per_feature: DeltaF,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct DiffReport {
112 pub before_name: String,
114 pub after_name: String,
116 pub tile_count: Delta,
118 pub feature_count: Delta,
120 pub compressed_bytes: Delta,
122 pub uncompressed_bytes: Delta,
124 pub compression_ratio: DeltaF,
126 pub dedup_ratio: DeltaF,
128 pub decode_sampled: bool,
131 pub per_zoom: Vec<ZoomDiff>,
133 pub per_column: Vec<ColumnDiff>,
135}
136
137pub 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 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
225fn 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
241pub 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 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 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 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 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 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 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}