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
171 fn synthetic_result() -> AnalysisResult {
175 crate::test_support::sample_analysis()
176 }
177
178 fn advice(flag: &str, value: Option<&str>, lossy: bool) -> Advice {
179 Advice {
180 flag: flag.to_string(),
181 value: value.map(str::to_string),
182 why: format!("{flag}: synthetic rationale"),
183 projected: None,
184 lossy,
185 confidence: AdviceConfidence::Medium,
186 }
187 }
188
189 fn rec_with_advice(advice: Vec<Advice>) -> Recommendations {
190 Recommendations {
191 min_zoom: 0,
192 max_zoom: 10,
193 temporal_bucket_ms: 3_600_000,
194 temporal_bucket_human: "1 hour".to_string(),
195 confidence: 85,
196 explanations: vec![],
197 advice,
198 }
199 }
200
201 #[test]
202 fn test_to_command() {
203 let rec = rec_with_advice(vec![]);
204 let cmd = to_command(&rec, Path::new("data.parquet"), "timestamp");
205 assert!(cmd.contains("--min-zoom 0"));
206 assert!(cmd.contains("--max-zoom 10"));
207 }
208
209 #[test]
210 fn generate_recommendations_attaches_advice() {
211 let result = synthetic_result();
212 let rec = generate_recommendations(
213 &result,
214 vec![advice("--publish", None, false), advice("--quantize-coords", Some("1"), true)],
215 );
216 assert_eq!(rec.advice.len(), 2);
217 assert_eq!(rec.advice[0].flag, "--publish");
218 assert_eq!(rec.advice[1].flag, "--quantize-coords");
219 assert_eq!(rec.min_zoom, 0);
221 assert_eq!(rec.max_zoom, 10);
222 assert_eq!(rec.temporal_bucket_ms, 3_600_000);
223 }
224
225 #[test]
226 fn to_command_appends_only_non_lossy_advice_in_stable_order() {
227 let rec = rec_with_advice(vec![
228 advice("--quantize-coords", Some("1"), true), advice("--temporal-lod", Some("1d,30d"), false),
230 advice("--publish", None, false),
231 advice("--blob-ordering", Some("spatial"), false),
232 advice("--maximum-tile-features", Some("10000"), true), ]);
234 let cmd = to_command(&rec, Path::new("data.parquet"), "timestamp");
235
236 let lod = cmd.find("--temporal-lod 1d,30d").expect("temporal-lod in command");
238 let publish = cmd.find("--publish").expect("publish in command");
239 let ordering = cmd.find("--blob-ordering spatial").expect("blob-ordering in command");
240 assert!(lod < publish && publish < ordering, "advisor order preserved: {cmd}");
241 }
242
243 #[test]
244 fn lossy_advice_never_joins_to_command() {
245 let rec = rec_with_advice(vec![
246 advice("--quantize-coords", Some("1"), true),
247 advice("--quantize-attrs-auto", None, true),
248 advice("--maximum-tile-features", Some("10000"), true),
249 ]);
250 let cmd = to_command(&rec, Path::new("data.parquet"), "timestamp");
251 assert!(!cmd.contains("--quantize-coords"), "lossy flag leaked: {cmd}");
252 assert!(!cmd.contains("--quantize-attrs-auto"), "lossy flag leaked: {cmd}");
253 assert!(!cmd.contains("--maximum-tile-features"), "lossy flag leaked: {cmd}");
254 }
255}
256
257