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;
use crate::analysis::density::DensityAnalysis;
use crate::analysis::geometry::{
GeometryAnalysis, GeometryComplexity, PropertyStats, SizeStats, VertexStats,
};
use crate::analysis::spatial::{SpatialAnalysis, SpatialDistribution};
use crate::analysis::temporal::{EventsPerDayStats, TemporalAnalysis, TemporalDistribution};
use std::collections::HashMap;
use stt_core::types::BoundingBox;
fn synthetic_result() -> AnalysisResult {
AnalysisResult {
source: "synthetic.parquet".to_string(),
feature_count: 10_000,
bounds: BoundingBox::new(-74.0, 45.0, -73.0, 46.0),
spatial: SpatialAnalysis {
zoom_coverage: Vec::new(),
hotspots: Vec::new(),
recommended_min_zoom: 0,
recommended_max_zoom: 10,
distribution: SpatialDistribution::Regional,
},
temporal: TemporalAnalysis {
time_start: 0,
time_end: 86_400_000,
duration_ms: 86_400_000,
duration_human: "1 day".to_string(),
unique_timestamps: 1_000,
distribution: TemporalDistribution::Uniform,
recommended_bucket_ms: 3_600_000,
recommended_bucket_human: "1 hour".to_string(),
hourly_distribution: vec![0; 24],
daily_distribution: vec![0; 7],
monthly_distribution: vec![0; 12],
events_per_day: EventsPerDayStats {
min: 0.0,
max: 0.0,
avg: 0.0,
median: 0.0,
std_dev: 0.0,
},
},
geometry: GeometryAnalysis {
type_distribution: HashMap::new(),
dominant_type: "Point".to_string(),
vertex_stats: VertexStats {
min: 1,
max: 1,
avg: 1.0,
median: 1,
p95: 1,
p99: 1,
total: 10_000,
},
size_stats: SizeStats {
min: 100,
max: 100,
avg: 100.0,
median: 100,
p95: 100,
p99: 100,
total: 1_000_000,
},
property_stats: PropertyStats {
min: 2,
max: 2,
avg: 2.0,
},
complexity: GeometryComplexity::Simple,
},
density: DensityAnalysis {
per_zoom: Vec::new(),
estimated_tile_count: 100,
estimated_archive_size: 1 << 20,
issues: Vec::new(),
},
measured: None,
}
}
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}");
}
}