Skip to main content

stt_optimize/
report.rs

1//! Report generation (text and JSON formats)
2//!
3//! Generates human-readable and machine-readable reports from analysis results.
4
5use crate::analysis::density::IssueSeverity;
6use crate::analysis::AnalysisResult;
7use crate::recommend::Recommendations;
8use anyhow::Result;
9
10/// Generate a text report
11pub fn generate_text(result: &AnalysisResult, recommendations: &Recommendations) -> String {
12    let mut output = String::new();
13
14    // Header
15    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
16    output.push_str(&format!("         STT Optimization Report - {}\n", result.source));
17    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
18
19    // Dataset Summary
20    output.push_str("📊 Dataset Summary\n");
21    output.push_str(&format!("  Features:        {:>12}\n", format_number(result.feature_count)));
22    output.push_str(&format!("  Time Range:      {}\n", result.temporal.time_range_description()));
23    output.push_str(&format!(
24        "  Spatial Bounds:  [{:.2}, {:.2}] to [{:.2}, {:.2}]\n",
25        result.bounds.min_lon,
26        result.bounds.min_lat,
27        result.bounds.max_lon,
28        result.bounds.max_lat
29    ));
30    output.push_str(&format!("  Geometry Type:   {} ({})\n", 
31        result.geometry.dominant_type,
32        format_type_distribution(&result.geometry.type_distribution)
33    ));
34    output.push('\n');
35
36    // Spatial Analysis
37    output.push_str("🗺️  Spatial Analysis\n");
38    output.push_str(&format!("  Distribution:    {}\n", result.spatial.distribution));
39    
40    // Show coverage at key zoom levels
41    for z in [4, 6, 8, 10, 12, 14, 16].iter() {
42        if let Some(cov) = result.spatial.zoom_coverage.iter().find(|c| c.zoom == *z) {
43            output.push_str(&format!(
44                "  Coverage at z{}:  {:.2}% ({} tiles)\n",
45                z, cov.coverage_percent, cov.occupied_tiles
46            ));
47        }
48    }
49    
50    output.push_str(&format!(
51        "  Recommended Zoom: {}-{}\n",
52        result.spatial.recommended_min_zoom,
53        result.spatial.recommended_max_zoom
54    ));
55
56    if !result.spatial.hotspots.is_empty() {
57        output.push_str("  Hotspots:\n");
58        for (i, hotspot) in result.spatial.hotspots.iter().take(3).enumerate() {
59            let name = hotspot.name.as_deref().unwrap_or("Unknown region");
60            output.push_str(&format!(
61                "    {}. {} ({} features)\n",
62                i + 1, name, format_number(hotspot.feature_count)
63            ));
64        }
65    }
66    output.push('\n');
67
68    // Temporal Analysis
69    output.push_str("⏰ Temporal Analysis\n");
70    output.push_str(&format!("  Duration:        {}\n", result.temporal.duration_human));
71    output.push_str(&format!("  Distribution:    {}\n", result.temporal.distribution));
72    output.push_str(&format!(
73        "  Events/day avg:  {:.1}\n",
74        result.temporal.events_per_day.avg
75    ));
76    output.push_str(&format!(
77        "  Unique times:    {}\n",
78        format_number(result.temporal.unique_timestamps)
79    ));
80    output.push_str(&format!(
81        "  Suggested bucket: {}\n",
82        result.temporal.recommended_bucket_human
83    ));
84    output.push('\n');
85
86    // Geometry Analysis
87    output.push_str("📐 Geometry Analysis\n");
88    output.push_str(&format!("  Complexity:      {}\n", result.geometry.complexity));
89    output.push_str(&format!(
90        "  Vertices (avg):  {:.1}\n",
91        result.geometry.vertex_stats.avg
92    ));
93    output.push_str(&format!(
94        "  Vertices (p95):  {}\n",
95        result.geometry.vertex_stats.p95
96    ));
97    output.push_str(&format!(
98        "  Avg size/feat:   {} bytes\n",
99        result.geometry.size_stats.avg as usize
100    ));
101    output.push_str(&format!(
102        "  Total size:      {}\n",
103        format_bytes(result.geometry.size_stats.total)
104    ));
105    output.push('\n');
106
107    // Size Estimation
108    output.push_str("💾 Size Estimation\n");
109    output.push_str(&format!(
110        "  Est. tiles:      {} (at recommended settings)\n",
111        format_number(result.density.estimated_tile_count)
112    ));
113    output.push_str(&format!(
114        "  Est. archive:    {} compressed\n",
115        format_bytes(result.density.estimated_archive_size)
116    ));
117    output.push('\n');
118
119    // Measured encoding (sampled) — present when the loader retained a large
120    // enough sample to push through the real encoder.
121    if let Some(m) = &result.measured {
122        output.push_str("🔬 Measured Encoding (sampled)\n");
123        output.push_str(&format!(
124            "  Sample:          {} features ({})\n",
125            format_number(m.features),
126            m.geometry_kind
127        ));
128        output.push_str(&format!(
129            "  Bytes/feature:   {:.1} compressed\n",
130            m.bytes_per_feature
131        ));
132        output.push_str(&format!("  Zstd ratio:      {:.2}x\n", m.zstd_ratio));
133        if !m.per_column.is_empty() {
134            output.push_str("  Top columns:\n");
135            for c in m.per_column.iter().take(5) {
136                output.push_str(&format!(
137                    "    {:<18} {:>5.1}%  ({})\n",
138                    c.name,
139                    c.share * 100.0,
140                    format_bytes(c.compressed_bytes)
141                ));
142            }
143        }
144        output.push('\n');
145    }
146
147    // Issues
148    if !result.density.issues.is_empty() {
149        output.push_str("⚠️  Issues\n");
150        for issue in &result.density.issues {
151            let icon = match issue.severity {
152                IssueSeverity::Error => "❌",
153                IssueSeverity::Warning => "⚠️",
154                IssueSeverity::Info => "ℹ️",
155            };
156            output.push_str(&format!("  {} {}\n", icon, issue.description));
157            output.push_str(&format!("     → {}\n", issue.suggestion));
158        }
159        output.push('\n');
160    }
161
162    // Recommendations
163    output.push_str("💡 Recommendations\n");
164    output.push_str(&format!(
165        "  --min-zoom {}\n",
166        recommendations.min_zoom
167    ));
168    output.push_str(&format!(
169        "  --max-zoom {}\n",
170        recommendations.max_zoom
171    ));
172    output.push('\n');
173
174    // Confidence
175    output.push_str(&format!(
176        "  Confidence: {}%\n",
177        recommendations.confidence
178    ));
179    output.push('\n');
180
181    // Advisor suggestions — evidence-based flag advice beyond the zoom/bucket
182    // basics. Lossy levers are marked: they are opt-in and never auto-applied.
183    if !recommendations.advice.is_empty() {
184        output.push_str("🧭 Advisor\n");
185        for advice in &recommendations.advice {
186            let mut line = format!("  {}", advice.flag);
187            if let Some(value) = &advice.value {
188                line.push(' ');
189                line.push_str(value);
190            }
191            if let Some(projected) = &advice.projected {
192                line.push_str(&format!("  [{}]", projected));
193            }
194            line.push_str(&format!("  ({} confidence)", advice.confidence));
195            if advice.lossy {
196                line.push_str("  [LOSSY - opt-in]");
197            }
198            output.push_str(&line);
199            output.push('\n');
200            output.push_str(&format!("     → {}\n", advice.why));
201        }
202        output.push('\n');
203    }
204
205    // Suggested Command
206    output.push_str("📋 Suggested Command:\n");
207    // The suggested --output is the packed dataset DIRECTORY (the input's
208    // stem): stt-build's output is a directory tree, not a single file.
209    output.push_str(&format!(
210        "  stt-build --input {} --output {} \\\n",
211        result.source,
212        result.source.trim_end_matches(".parquet").trim_end_matches(".geoparquet")
213    ));
214    output.push_str(&format!(
215        "    --time-field timestamp --min-zoom {} --max-zoom {}\n",
216        recommendations.min_zoom, recommendations.max_zoom
217    ));
218    output.push('\n');
219
220    // Footer
221    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
222
223    output
224}
225
226/// Generate a JSON report
227pub fn generate_json(result: &AnalysisResult, recommendations: &Recommendations) -> Result<String> {
228    let report = serde_json::json!({
229        "source": result.source,
230        "feature_count": result.feature_count,
231        "bounds": {
232            "min_lon": result.bounds.min_lon,
233            "min_lat": result.bounds.min_lat,
234            "max_lon": result.bounds.max_lon,
235            "max_lat": result.bounds.max_lat,
236        },
237        "spatial": {
238            "distribution": format!("{}", result.spatial.distribution),
239            "recommended_min_zoom": result.spatial.recommended_min_zoom,
240            "recommended_max_zoom": result.spatial.recommended_max_zoom,
241            "hotspots": result.spatial.hotspots.iter().map(|h| {
242                serde_json::json!({
243                    "lon": h.lon,
244                    "lat": h.lat,
245                    "feature_count": h.feature_count,
246                    "name": h.name,
247                })
248            }).collect::<Vec<_>>(),
249            "zoom_coverage": result.spatial.zoom_coverage.iter().map(|c| {
250                serde_json::json!({
251                    "zoom": c.zoom,
252                    "coverage_percent": c.coverage_percent,
253                    "occupied_tiles": c.occupied_tiles,
254                    "avg_features_per_tile": c.avg_features_per_tile,
255                })
256            }).collect::<Vec<_>>(),
257        },
258        "temporal": {
259            "time_start": result.temporal.time_start,
260            "time_end": result.temporal.time_end,
261            "duration_ms": result.temporal.duration_ms,
262            "duration_human": result.temporal.duration_human,
263            "distribution": format!("{}", result.temporal.distribution),
264            "unique_timestamps": result.temporal.unique_timestamps,
265            "recommended_bucket_ms": result.temporal.recommended_bucket_ms,
266            "recommended_bucket_human": result.temporal.recommended_bucket_human,
267            "events_per_day": {
268                "avg": result.temporal.events_per_day.avg,
269                "min": result.temporal.events_per_day.min,
270                "max": result.temporal.events_per_day.max,
271            },
272        },
273        "geometry": {
274            "dominant_type": result.geometry.dominant_type,
275            "type_distribution": result.geometry.type_distribution,
276            "complexity": format!("{}", result.geometry.complexity),
277            "vertex_stats": {
278                "min": result.geometry.vertex_stats.min,
279                "max": result.geometry.vertex_stats.max,
280                "avg": result.geometry.vertex_stats.avg,
281                "median": result.geometry.vertex_stats.median,
282                "p95": result.geometry.vertex_stats.p95,
283            },
284            "size_stats": {
285                "min": result.geometry.size_stats.min,
286                "max": result.geometry.size_stats.max,
287                "avg": result.geometry.size_stats.avg,
288                "total": result.geometry.size_stats.total,
289            },
290        },
291        "density": {
292            "per_zoom": result.density.per_zoom.iter().map(|z| {
293                serde_json::json!({
294                    "zoom": z.zoom,
295                    "tile_count": z.tile_count,
296                    "avg_features_per_tile": z.avg_features_per_tile,
297                    "median_features_per_tile": z.median_features_per_tile,
298                    "max_features_per_tile": z.max_features_per_tile,
299                    "oversized_tiles": z.oversized_tiles,
300                    "undersized_tiles": z.undersized_tiles,
301                    "estimated_size_uncompressed": z.estimated_size_uncompressed,
302                    "estimated_size_compressed": z.estimated_size_compressed,
303                })
304            }).collect::<Vec<_>>(),
305            "estimated_tile_count": result.density.estimated_tile_count,
306            "estimated_archive_size": result.density.estimated_archive_size,
307            "issues": result.density.issues.iter().map(|i| {
308                serde_json::json!({
309                    "severity": format!("{}", i.severity),
310                    "description": i.description,
311                    "suggestion": i.suggestion,
312                })
313            }).collect::<Vec<_>>(),
314        },
315        // Measured sample encoding (null when the sample was too small).
316        "measured": result.measured,
317        "recommendations": {
318            "min_zoom": recommendations.min_zoom,
319            "max_zoom": recommendations.max_zoom,
320            "temporal_bucket_ms": recommendations.temporal_bucket_ms,
321            "confidence": recommendations.confidence,
322            "explanations": recommendations.explanations,
323        },
324        // Advisor suggestions, verbatim (each entry: flag/value/why/projected/
325        // lossy/confidence — lossy ones are opt-in, never auto-applied).
326        "advice": recommendations.advice,
327    });
328
329    Ok(serde_json::to_string_pretty(&report)?)
330}
331
332/// Format a number with thousands separators
333fn format_number(n: usize) -> String {
334    let s = n.to_string();
335    let mut result = String::new();
336    for (i, c) in s.chars().rev().enumerate() {
337        if i > 0 && i % 3 == 0 {
338            result.insert(0, ',');
339        }
340        result.insert(0, c);
341    }
342    result
343}
344
345/// Format bytes as human-readable string
346fn format_bytes(bytes: usize) -> String {
347    const KB: usize = 1024;
348    const MB: usize = 1024 * KB;
349    const GB: usize = 1024 * MB;
350
351    if bytes >= GB {
352        format!("{:.2} GB", bytes as f64 / GB as f64)
353    } else if bytes >= MB {
354        format!("{:.2} MB", bytes as f64 / MB as f64)
355    } else if bytes >= KB {
356        format!("{:.2} KB", bytes as f64 / KB as f64)
357    } else {
358        format!("{} bytes", bytes)
359    }
360}
361
362/// Format type distribution as percentage string
363fn format_type_distribution(dist: &std::collections::HashMap<String, usize>) -> String {
364    let total: usize = dist.values().sum();
365    if total == 0 {
366        return "N/A".to_string();
367    }
368
369    let mut parts: Vec<String> = dist
370        .iter()
371        .map(|(t, c)| {
372            let pct = *c as f64 / total as f64 * 100.0;
373            if pct > 99.0 {
374                format!("100% {}", t)
375            } else if pct > 1.0 {
376                format!("{:.0}% {}", pct, t)
377            } else {
378                String::new()
379            }
380        })
381        .filter(|s| !s.is_empty())
382        .collect();
383
384    parts.sort();
385    parts.reverse();
386    parts.join(", ")
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_format_number() {
395        assert_eq!(format_number(1000), "1,000");
396        assert_eq!(format_number(1000000), "1,000,000");
397        assert_eq!(format_number(123), "123");
398    }
399
400    #[test]
401    fn test_format_bytes() {
402        assert_eq!(format_bytes(500), "500 bytes");
403        assert_eq!(format_bytes(1024), "1.00 KB");
404        assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
405        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
406    }
407}
408
409