1use crate::advisors::Advice;
6use crate::analysis::AnalysisResult;
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Recommendations {
18 pub min_zoom: u8,
20 pub max_zoom: u8,
22 pub temporal_bucket_ms: u64,
24 pub temporal_bucket_human: String,
26 pub confidence: u8,
28 pub explanations: Vec<String>,
30 #[serde(default)]
35 pub advice: Vec<Advice>,
36}
37
38pub fn generate_recommendations(result: &AnalysisResult, advice: Vec<Advice>) -> Recommendations {
41 let mut explanations = Vec::new();
42
43 let min_zoom = result.spatial.recommended_min_zoom;
45 let max_zoom = result.spatial.recommended_max_zoom;
46 explanations.push(format!(
47 "Zoom range {}-{} based on spatial coverage ({})",
48 min_zoom, max_zoom, result.spatial.distribution
49 ));
50
51 let temporal_bucket_ms = result.temporal.recommended_bucket_ms;
53 let temporal_bucket_human = result.temporal.recommended_bucket_human.clone();
54 if temporal_bucket_ms > 0 {
55 explanations.push(format!(
56 "Temporal bucket {} for {} distribution",
57 temporal_bucket_human, result.temporal.distribution
58 ));
59 }
60
61 let confidence = calculate_confidence(result);
63
64 Recommendations {
65 min_zoom,
66 max_zoom,
67 temporal_bucket_ms,
68 temporal_bucket_human,
69 confidence,
70 explanations,
71 advice,
72 }
73}
74
75fn calculate_confidence(result: &AnalysisResult) -> u8 {
77 let mut score = 100u8;
78
79 if result.feature_count < 1000 {
81 score = score.saturating_sub(20);
82 } else if result.feature_count < 10000 {
83 score = score.saturating_sub(10);
84 }
85
86 if result.density.issues.len() > 3 {
88 score = score.saturating_sub(15);
89 } else if !result.density.issues.is_empty() {
90 score = score.saturating_sub(5);
91 }
92
93 if matches!(result.spatial.distribution, crate::analysis::spatial::SpatialDistribution::Sparse) {
95 score = score.saturating_sub(10);
96 }
97
98 if matches!(result.geometry.complexity, crate::analysis::geometry::GeometryComplexity::VeryComplex) {
100 score = score.saturating_sub(10);
101 }
102
103 score
104}
105
106pub fn to_build_config(
108 recommendations: &Recommendations,
109 input: &Path,
110 time_field: &str,
111) -> serde_json::Value {
112 serde_json::json!({
113 "input": input.to_string_lossy(),
114 "time_field": time_field,
115 "min_zoom": recommendations.min_zoom,
116 "max_zoom": recommendations.max_zoom,
117 "temporal_bucket_ms": recommendations.temporal_bucket_ms,
118 "confidence": recommendations.confidence,
119 "explanations": recommendations.explanations,
120 })
121}
122
123pub fn to_command(
130 recommendations: &Recommendations,
131 input: &Path,
132 time_field: &str,
133) -> String {
134 let output = input.with_extension("");
137 let output_str = output.file_name()
138 .map(|n| n.to_string_lossy().to_string())
139 .unwrap_or_else(|| "output".to_string());
140
141 let input_str = input.file_name()
142 .map(|n| n.to_string_lossy().to_string())
143 .unwrap_or_else(|| "input.parquet".to_string());
144
145 let mut cmd = format!(
146 "stt-build --input {} --output {} \\\n --time-field {} --min-zoom {} --max-zoom {}",
147 input_str,
148 output_str,
149 time_field,
150 recommendations.min_zoom,
151 recommendations.max_zoom,
152 );
153 for advice in recommendations.advice.iter().filter(|a| !a.lossy) {
156 cmd.push_str(" \\\n ");
157 cmd.push_str(&advice.flag);
158 if let Some(value) = &advice.value {
159 cmd.push(' ');
160 cmd.push_str(value);
161 }
162 }
163 cmd
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::advisors::AdviceConfidence;
170 use crate::analysis::density::DensityAnalysis;
171 use crate::analysis::geometry::{
172 GeometryAnalysis, GeometryComplexity, PropertyStats, SizeStats, VertexStats,
173 };
174 use crate::analysis::spatial::{SpatialAnalysis, SpatialDistribution};
175 use crate::analysis::temporal::{EventsPerDayStats, TemporalAnalysis, TemporalDistribution};
176 use std::collections::HashMap;
177 use stt_core::types::BoundingBox;
178
179 fn synthetic_result() -> AnalysisResult {
182 AnalysisResult {
183 source: "synthetic.parquet".to_string(),
184 feature_count: 10_000,
185 bounds: BoundingBox::new(-74.0, 45.0, -73.0, 46.0),
186 spatial: SpatialAnalysis {
187 zoom_coverage: Vec::new(),
188 hotspots: Vec::new(),
189 recommended_min_zoom: 0,
190 recommended_max_zoom: 10,
191 distribution: SpatialDistribution::Regional,
192 },
193 temporal: TemporalAnalysis {
194 time_start: 0,
195 time_end: 86_400_000,
196 duration_ms: 86_400_000,
197 duration_human: "1 day".to_string(),
198 unique_timestamps: 1_000,
199 distribution: TemporalDistribution::Uniform,
200 recommended_bucket_ms: 3_600_000,
201 recommended_bucket_human: "1 hour".to_string(),
202 hourly_distribution: vec![0; 24],
203 daily_distribution: vec![0; 7],
204 monthly_distribution: vec![0; 12],
205 events_per_day: EventsPerDayStats {
206 min: 0.0,
207 max: 0.0,
208 avg: 0.0,
209 median: 0.0,
210 std_dev: 0.0,
211 },
212 },
213 geometry: GeometryAnalysis {
214 type_distribution: HashMap::new(),
215 dominant_type: "Point".to_string(),
216 vertex_stats: VertexStats {
217 min: 1,
218 max: 1,
219 avg: 1.0,
220 median: 1,
221 p95: 1,
222 p99: 1,
223 total: 10_000,
224 },
225 size_stats: SizeStats {
226 min: 100,
227 max: 100,
228 avg: 100.0,
229 median: 100,
230 p95: 100,
231 p99: 100,
232 total: 1_000_000,
233 },
234 property_stats: PropertyStats {
235 min: 2,
236 max: 2,
237 avg: 2.0,
238 },
239 complexity: GeometryComplexity::Simple,
240 },
241 density: DensityAnalysis {
242 per_zoom: Vec::new(),
243 estimated_tile_count: 100,
244 estimated_archive_size: 1 << 20,
245 issues: Vec::new(),
246 },
247 measured: None,
248 }
249 }
250
251 fn advice(flag: &str, value: Option<&str>, lossy: bool) -> Advice {
252 Advice {
253 flag: flag.to_string(),
254 value: value.map(str::to_string),
255 why: format!("{flag}: synthetic rationale"),
256 projected: None,
257 lossy,
258 confidence: AdviceConfidence::Medium,
259 }
260 }
261
262 fn rec_with_advice(advice: Vec<Advice>) -> Recommendations {
263 Recommendations {
264 min_zoom: 0,
265 max_zoom: 10,
266 temporal_bucket_ms: 3_600_000,
267 temporal_bucket_human: "1 hour".to_string(),
268 confidence: 85,
269 explanations: vec![],
270 advice,
271 }
272 }
273
274 #[test]
275 fn test_to_command() {
276 let rec = rec_with_advice(vec![]);
277 let cmd = to_command(&rec, Path::new("data.parquet"), "timestamp");
278 assert!(cmd.contains("--min-zoom 0"));
279 assert!(cmd.contains("--max-zoom 10"));
280 }
281
282 #[test]
283 fn generate_recommendations_attaches_advice() {
284 let result = synthetic_result();
285 let rec = generate_recommendations(
286 &result,
287 vec![advice("--publish", None, false), advice("--quantize-coords", Some("1"), true)],
288 );
289 assert_eq!(rec.advice.len(), 2);
290 assert_eq!(rec.advice[0].flag, "--publish");
291 assert_eq!(rec.advice[1].flag, "--quantize-coords");
292 assert_eq!(rec.min_zoom, 0);
294 assert_eq!(rec.max_zoom, 10);
295 assert_eq!(rec.temporal_bucket_ms, 3_600_000);
296 }
297
298 #[test]
299 fn to_command_appends_only_non_lossy_advice_in_stable_order() {
300 let rec = rec_with_advice(vec![
301 advice("--quantize-coords", Some("1"), true), advice("--temporal-lod", Some("1d,30d"), false),
303 advice("--publish", None, false),
304 advice("--blob-ordering", Some("spatial"), false),
305 advice("--maximum-tile-features", Some("10000"), true), ]);
307 let cmd = to_command(&rec, Path::new("data.parquet"), "timestamp");
308
309 let lod = cmd.find("--temporal-lod 1d,30d").expect("temporal-lod in command");
311 let publish = cmd.find("--publish").expect("publish in command");
312 let ordering = cmd.find("--blob-ordering spatial").expect("blob-ordering in command");
313 assert!(lod < publish && publish < ordering, "advisor order preserved: {cmd}");
314 }
315
316 #[test]
317 fn lossy_advice_never_joins_to_command() {
318 let rec = rec_with_advice(vec![
319 advice("--quantize-coords", Some("1"), true),
320 advice("--quantize-attrs-auto", None, true),
321 advice("--maximum-tile-features", Some("10000"), true),
322 ]);
323 let cmd = to_command(&rec, Path::new("data.parquet"), "timestamp");
324 assert!(!cmd.contains("--quantize-coords"), "lossy flag leaked: {cmd}");
325 assert!(!cmd.contains("--quantize-attrs-auto"), "lossy flag leaked: {cmd}");
326 assert!(!cmd.contains("--maximum-tile-features"), "lossy flag leaked: {cmd}");
327 }
328}
329
330