Skip to main content

stt_optimize/
recommend.rs

1//! Parameter recommendation engine
2//!
3//! Combines analysis results to generate optimal stt-build parameters.
4
5use crate::advisors::Advice;
6use crate::analysis::AnalysisResult;
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10/// Recommended parameters for stt-build.
11///
12/// `stt-build --auto` folds `min_zoom`, `max_zoom`, and `temporal_bucket_ms`
13/// into its own args and logs `confidence`/`explanations` (`--auto encode`
14/// additionally applies the non-lossy byte-level entries of `advice`); all
15/// fields also appear in the standalone `analyze`/`recommend` reports.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Recommendations {
18    /// Minimum zoom level
19    pub min_zoom: u8,
20    /// Maximum zoom level
21    pub max_zoom: u8,
22    /// Suggested temporal bucket size in milliseconds (0 = no bucketing)
23    pub temporal_bucket_ms: u64,
24    /// Human-readable temporal bucket description
25    pub temporal_bucket_human: String,
26    /// Confidence level in recommendations (0-100)
27    pub confidence: u8,
28    /// Explanation of key decisions
29    pub explanations: Vec<String>,
30    /// Evidence-based advisor suggestions ([`crate::advisors::run_all`]),
31    /// in advisor order (quantize, temporal, layout, budget). Entries with
32    /// `lossy: true` are surfaced only — they never join [`to_command`] and
33    /// are never auto-applied by `stt-build --auto`.
34    #[serde(default)]
35    pub advice: Vec<Advice>,
36}
37
38/// Generate recommendations from analysis results, attaching the advisor
39/// suggestions (pass an empty `Vec` when the advisors were not run).
40pub fn generate_recommendations(result: &AnalysisResult, advice: Vec<Advice>) -> Recommendations {
41    let mut explanations = Vec::new();
42
43    // Zoom levels from spatial analysis
44    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    // Temporal bucketing
52    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    // Calculate confidence based on data quality
62    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
75/// Calculate confidence score (0-100)
76fn calculate_confidence(result: &AnalysisResult) -> u8 {
77    let mut score = 100u8;
78
79    // Lower confidence with fewer features
80    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    // Lower confidence with many issues
87    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    // Lower confidence with sparse data
94    if matches!(result.spatial.distribution, crate::analysis::spatial::SpatialDistribution::Sparse) {
95        score = score.saturating_sub(10);
96    }
97
98    // Lower confidence with complex geometry
99    if matches!(result.geometry.complexity, crate::analysis::geometry::GeometryComplexity::VeryComplex) {
100        score = score.saturating_sub(10);
101    }
102
103    score
104}
105
106/// Convert recommendations to a build config JSON structure
107pub 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
123/// Convert recommendations to an stt-build command line.
124///
125/// NON-LOSSY advisor advice is appended (flag + value, in advisor order) so
126/// the pasteable command carries the reversible byte-level/semantic levers.
127/// LOSSY advice (quantization, budgets) NEVER joins the command — it degrades
128/// data and stays a per-dataset opt-in the user must add by hand.
129pub fn to_command(
130    recommendations: &Recommendations,
131    input: &Path,
132    time_field: &str,
133) -> String {
134    // Suggest the packed dataset DIRECTORY (the input's stem): stt-build's
135    // output is a directory tree, not a single file.
136    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    // Stable order: exactly the advisor emit order (quantize, temporal,
154    // layout, budget), filtered to the non-lossy entries.
155    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    /// Minimal synthetic analysis result — just enough populated fields for
180    /// `generate_recommendations`/`calculate_confidence` to run.
181    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        // The advisor layer must not disturb the basic recommendations.
293        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), // lossy: excluded
302            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), // lossy: excluded
306        ]);
307        let cmd = to_command(&rec, Path::new("data.parquet"), "timestamp");
308
309        // Non-lossy advice present, with values, in advisor (input) order.
310        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