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
171    /// Minimal synthetic analysis result — just enough populated fields for
172    /// `generate_recommendations`/`calculate_confidence` to run. The shared
173    /// default fixture is exactly what these tests need.
174    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        // The advisor layer must not disturb the basic recommendations.
220        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), // lossy: excluded
229            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), // lossy: excluded
233        ]);
234        let cmd = to_command(&rec, Path::new("data.parquet"), "timestamp");
235
236        // Non-lossy advice present, with values, in advisor (input) order.
237        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