use crate::advisors::Advice;
use crate::analysis::AnalysisResult;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recommendations {
pub min_zoom: u8,
pub max_zoom: u8,
pub temporal_bucket_ms: u64,
pub temporal_bucket_human: String,
pub confidence: u8,
pub explanations: Vec<String>,
#[serde(default)]
pub advice: Vec<Advice>,
}
pub fn generate_recommendations(result: &AnalysisResult, advice: Vec<Advice>) -> Recommendations {
let mut explanations = Vec::new();
let min_zoom = result.spatial.recommended_min_zoom;
let max_zoom = result.spatial.recommended_max_zoom;
explanations.push(format!(
"Zoom range {}-{} based on spatial coverage ({})",
min_zoom, max_zoom, result.spatial.distribution
));
let temporal_bucket_ms = result.temporal.recommended_bucket_ms;
let temporal_bucket_human = result.temporal.recommended_bucket_human.clone();
if temporal_bucket_ms > 0 {
explanations.push(format!(
"Temporal bucket {} for {} distribution",
temporal_bucket_human, result.temporal.distribution
));
}
let confidence = calculate_confidence(result);
Recommendations {
min_zoom,
max_zoom,
temporal_bucket_ms,
temporal_bucket_human,
confidence,
explanations,
advice,
}
}
fn calculate_confidence(result: &AnalysisResult) -> u8 {
let mut score = 100u8;
if result.feature_count < 1000 {
score = score.saturating_sub(20);
} else if result.feature_count < 10000 {
score = score.saturating_sub(10);
}
if result.density.issues.len() > 3 {
score = score.saturating_sub(15);
} else if !result.density.issues.is_empty() {
score = score.saturating_sub(5);
}
if matches!(result.spatial.distribution, crate::analysis::spatial::SpatialDistribution::Sparse) {
score = score.saturating_sub(10);
}
if matches!(result.geometry.complexity, crate::analysis::geometry::GeometryComplexity::VeryComplex) {
score = score.saturating_sub(10);
}
score
}
pub fn to_build_config(
recommendations: &Recommendations,
input: &Path,
time_field: &str,
) -> serde_json::Value {
serde_json::json!({
"input": input.to_string_lossy(),
"time_field": time_field,
"min_zoom": recommendations.min_zoom,
"max_zoom": recommendations.max_zoom,
"temporal_bucket_ms": recommendations.temporal_bucket_ms,
"confidence": recommendations.confidence,
"explanations": recommendations.explanations,
})
}
pub fn to_command(
recommendations: &Recommendations,
input: &Path,
time_field: &str,
) -> String {
let output = input.with_extension("");
let output_str = output.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "output".to_string());
let input_str = input.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "input.parquet".to_string());
let mut cmd = format!(
"stt-build --input {} --output {} \\\n --time-field {} --min-zoom {} --max-zoom {}",
input_str,
output_str,
time_field,
recommendations.min_zoom,
recommendations.max_zoom,
);
for advice in recommendations.advice.iter().filter(|a| !a.lossy) {
cmd.push_str(" \\\n ");
cmd.push_str(&advice.flag);
if let Some(value) = &advice.value {
cmd.push(' ');
cmd.push_str(value);
}
}
cmd
}
#[cfg(test)]
mod tests {
use super::*;
use crate::advisors::AdviceConfidence;
fn synthetic_result() -> AnalysisResult {
crate::test_support::sample_analysis()
}
fn advice(flag: &str, value: Option<&str>, lossy: bool) -> Advice {
Advice {
flag: flag.to_string(),
value: value.map(str::to_string),
why: format!("{flag}: synthetic rationale"),
projected: None,
lossy,
confidence: AdviceConfidence::Medium,
}
}
fn rec_with_advice(advice: Vec<Advice>) -> Recommendations {
Recommendations {
min_zoom: 0,
max_zoom: 10,
temporal_bucket_ms: 3_600_000,
temporal_bucket_human: "1 hour".to_string(),
confidence: 85,
explanations: vec![],
advice,
}
}
#[test]
fn test_to_command() {
let rec = rec_with_advice(vec![]);
let cmd = to_command(&rec, Path::new("data.parquet"), "timestamp");
assert!(cmd.contains("--min-zoom 0"));
assert!(cmd.contains("--max-zoom 10"));
}
#[test]
fn generate_recommendations_attaches_advice() {
let result = synthetic_result();
let rec = generate_recommendations(
&result,
vec![advice("--publish", None, false), advice("--quantize-coords", Some("1"), true)],
);
assert_eq!(rec.advice.len(), 2);
assert_eq!(rec.advice[0].flag, "--publish");
assert_eq!(rec.advice[1].flag, "--quantize-coords");
assert_eq!(rec.min_zoom, 0);
assert_eq!(rec.max_zoom, 10);
assert_eq!(rec.temporal_bucket_ms, 3_600_000);
}
#[test]
fn to_command_appends_only_non_lossy_advice_in_stable_order() {
let rec = rec_with_advice(vec![
advice("--quantize-coords", Some("1"), true), advice("--temporal-lod", Some("1d,30d"), false),
advice("--publish", None, false),
advice("--blob-ordering", Some("spatial"), false),
advice("--maximum-tile-features", Some("10000"), true), ]);
let cmd = to_command(&rec, Path::new("data.parquet"), "timestamp");
let lod = cmd.find("--temporal-lod 1d,30d").expect("temporal-lod in command");
let publish = cmd.find("--publish").expect("publish in command");
let ordering = cmd.find("--blob-ordering spatial").expect("blob-ordering in command");
assert!(lod < publish && publish < ordering, "advisor order preserved: {cmd}");
}
#[test]
fn lossy_advice_never_joins_to_command() {
let rec = rec_with_advice(vec![
advice("--quantize-coords", Some("1"), true),
advice("--quantize-attrs-auto", None, true),
advice("--maximum-tile-features", Some("10000"), true),
]);
let cmd = to_command(&rec, Path::new("data.parquet"), "timestamp");
assert!(!cmd.contains("--quantize-coords"), "lossy flag leaked: {cmd}");
assert!(!cmd.contains("--quantize-attrs-auto"), "lossy flag leaked: {cmd}");
assert!(!cmd.contains("--maximum-tile-features"), "lossy flag leaked: {cmd}");
}
}