use anyhow::{anyhow, Result};
use rand::{rngs::StdRng, seq::SliceRandom, SeedableRng};
use serde::Deserialize;
use crate::schema::{Blueprint, Stack, Decision, StackComponent, Estimated, SLO, P95MS, Risk};
#[derive(Debug, Clone, Deserialize)]
pub struct Rules {
pub candidates: CandidateRules,
pub weights: ScoreWeights,
pub tie_breakers: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CandidateRules {
pub languages: Vec<Candidate>,
pub frontends: Vec<Candidate>,
pub backends: Vec<Candidate>,
pub databases: Vec<Candidate>,
pub caches: Vec<Candidate>,
pub queues: Vec<Candidate>,
pub auth: Vec<Candidate>,
pub payments: Vec<Candidate>,
pub ai: Vec<Candidate>,
pub infra: Vec<Candidate>,
pub ci_cd: Vec<Candidate>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Candidate {
pub name: String,
pub quality: f64,
pub cost_monthly_usd: f64,
pub slo_read_ms: f64,
pub slo_write_ms: f64,
pub security_score: f64,
pub ops_complexity: f64,
pub coldstart_ms: Option<f64>,
pub compliance: Vec<String>,
pub regions: Vec<String>,
pub tie_breaker: Option<u32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ScoreWeights {
pub quality: f64,
pub slo: f64,
pub cost: f64,
pub security: f64,
pub ops: f64,
}
pub struct StackSelector {
rng: StdRng,
rules: Rules,
}
impl StackSelector {
pub fn new(seed: u64) -> Self {
let rules_content = include_str!("../resources/rules.yaml");
let rules: Rules = serde_yaml::from_str(rules_content)
.expect("Failed to parse rules.yaml");
Self {
rng: StdRng::seed_from_u64(seed),
rules,
}
}
pub fn select_stack(&mut self, blueprint: &Blueprint) -> Result<Stack> {
let normalized = self.normalize_blueprint(blueprint);
let languages = self.rules.candidates.languages.clone();
let backends = self.rules.candidates.backends.clone();
let frontends = self.rules.candidates.frontends.clone();
let databases = self.rules.candidates.databases.clone();
let caches = self.rules.candidates.caches.clone();
let queues = self.rules.candidates.queues.clone();
let auths = self.rules.candidates.auth.clone();
let payments = self.rules.candidates.payments.clone();
let ais = self.rules.candidates.ai.clone();
let infras = self.rules.candidates.infra.clone();
let ci_cds = self.rules.candidates.ci_cd.clone();
let language_candidates = self.filter_candidates(&languages, &normalized)?;
let backend_candidates = self.filter_candidates(&backends, &normalized)?;
let frontend_candidates = self.filter_candidates(&frontends, &normalized)?;
let database_candidates = self.filter_candidates(&databases, &normalized)?;
let cache_candidates = self.filter_candidates(&caches, &normalized)?;
let queue_candidates = self.filter_candidates(&queues, &normalized)?;
let auth_candidates = self.filter_candidates(&auths, &normalized)?;
let payments_candidates = self.filter_candidates(&payments, &normalized)?;
let ai_candidates = self.filter_candidates(&ais, &normalized)?;
let infra_candidates = self.filter_candidates(&infras, &normalized)?;
let ci_cd_candidates = self.filter_candidates(&ci_cds, &normalized)?;
let language_choice = self.select_best_candidate(&language_candidates, &normalized)?.clone();
let backend_choice = self.select_best_candidate(&backend_candidates, &normalized)?.clone();
let frontend_choice = self.select_best_candidate(&frontend_candidates, &normalized)?.clone();
let database_choice = self.select_best_candidate(&database_candidates, &normalized)?.clone();
let cache_choice = self.select_best_candidate(&cache_candidates, &normalized)?.clone();
let queue_choice = self.select_best_candidate(&queue_candidates, &normalized)?.clone();
let auth_choice = self.select_best_candidate(&auth_candidates, &normalized)?.clone();
let payments_choice = self.select_best_candidate(&payments_candidates, &normalized)?.clone();
let ai_choice = self.select_best_candidate(&ai_candidates, &normalized)?.clone();
let infra_choice = self.select_best_candidate(&infra_candidates, &normalized)?.clone();
let ci_cd_choice = self.select_best_candidate(&ci_cd_candidates, &normalized)?.clone();
let mut decisions = vec![];
decisions.push(Self::create_decision("language", &language_choice, &language_candidates));
decisions.push(Self::create_decision("backend", &backend_choice, &backend_candidates));
decisions.push(Self::create_decision("frontend", &frontend_choice, &frontend_candidates));
decisions.push(Self::create_decision("database", &database_choice, &database_candidates));
decisions.push(Self::create_decision("cache", &cache_choice, &cache_candidates));
decisions.push(Self::create_decision("queue", &queue_choice, &queue_candidates));
decisions.push(Self::create_decision("auth", &auth_choice, &auth_candidates));
decisions.push(Self::create_decision("payments", &payments_choice, &payments_candidates));
decisions.push(Self::create_decision("ai", &ai_choice, &ai_candidates));
decisions.push(Self::create_decision("infra", &infra_choice, &infra_candidates));
decisions.push(Self::create_decision("ci_cd", &ci_cd_choice, &ci_cd_candidates));
let total_cost = language_choice.cost_monthly_usd + backend_choice.cost_monthly_usd +
frontend_choice.cost_monthly_usd + database_choice.cost_monthly_usd +
cache_choice.cost_monthly_usd + queue_choice.cost_monthly_usd +
auth_choice.cost_monthly_usd + payments_choice.cost_monthly_usd +
ai_choice.cost_monthly_usd + infra_choice.cost_monthly_usd +
ci_cd_choice.cost_monthly_usd;
let avg_read_ms = [&backend_choice, &database_choice, &cache_choice]
.iter().map(|c| c.slo_read_ms).sum::<f64>() / 3.0;
let avg_write_ms = [&backend_choice, &database_choice, &queue_choice]
.iter().map(|c| c.slo_write_ms).sum::<f64>() / 3.0;
let estimated = Estimated {
monthly_cost_usd: total_cost,
};
let slo = SLO {
availability_pct: 99.95,
p95_ms: P95MS {
read: avg_read_ms,
write: avg_write_ms,
},
};
let stack = StackComponent {
language: language_choice.name.clone(),
frontend: Some(frontend_choice.name.clone()),
backend: backend_choice.name.clone(),
database: Some(database_choice.name.clone()),
cache: Some(cache_choice.name.clone()),
queue: Some(queue_choice.name.clone()),
auth: Some(auth_choice.name.clone()),
payments: Some(payments_choice.name.clone()),
ai: Some(vec![ai_choice.name.clone()]),
infra: infra_choice.name.clone(),
ci_cd: Some(ci_cd_choice.name.clone()),
};
let mut risks = vec![];
if infra_choice.name.contains("Workers") {
risks.push(Risk {
id: "r1".to_string(),
desc: "Workers で TCP 制限".to_string(),
mitigation: "`connect()` API 経由で HTTP 化".to_string(),
});
}
Ok(Stack {
decisions,
stack,
estimated,
slo,
risk: risks,
})
}
fn normalize_blueprint(&self, blueprint: &Blueprint) -> Blueprint {
let mut normalized = blueprint.clone();
if normalized.constraints.is_none() {
normalized.constraints = Some(Default::default());
}
if normalized.prefs.is_none() {
normalized.prefs = Some(Default::default());
}
normalized
}
fn filter_candidates<'a>(&self, candidates: &'a [Candidate], blueprint: &Blueprint) -> Result<Vec<&'a Candidate>> {
let mut filtered = vec![];
for candidate in candidates {
if let Some(constraints) = &blueprint.constraints {
if let Some(max_cost) = constraints.monthly_cost_usd_max {
if candidate.cost_monthly_usd > max_cost {
continue;
}
}
if let Some(max_coldstart) = constraints.coldstart_ms_max {
if let Some(coldstart) = candidate.coldstart_ms {
if coldstart > max_coldstart {
continue;
}
}
}
if !constraints.compliance.is_empty() {
let has_all_compliance = constraints.compliance
.iter()
.all(|req| candidate.compliance.contains(req));
if !has_all_compliance {
continue;
}
}
if !constraints.region_allow.is_empty() {
let has_allowed_region = constraints.region_allow
.iter()
.any(|region| candidate.regions.contains(region));
if !has_allowed_region {
continue;
}
}
}
filtered.push(candidate);
}
if filtered.is_empty() {
return Err(anyhow!("No candidates match the constraints"));
}
Ok(filtered)
}
fn select_best_candidate<'a>(&mut self, candidates: &[&'a Candidate], blueprint: &Blueprint) -> Result<&'a Candidate> {
if candidates.is_empty() {
return Err(anyhow!("No candidates available"));
}
let mut scored_candidates: Vec<(f64, &Candidate)> = candidates
.iter()
.map(|candidate| {
let score = self.calculate_score(candidate, blueprint);
(score, *candidate)
})
.collect();
scored_candidates.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
let best_score = scored_candidates[0].0;
let tied_candidates: Vec<&Candidate> = scored_candidates
.iter()
.take_while(|(score, _)| (*score - best_score).abs() < f64::EPSILON)
.map(|(_, candidate)| *candidate)
.collect();
if tied_candidates.len() == 1 {
Ok(tied_candidates[0])
} else {
let chosen = tied_candidates.choose(&mut self.rng).unwrap();
Ok(chosen)
}
}
fn calculate_score(&self, candidate: &Candidate, _blueprint: &Blueprint) -> f64 {
let weights = &self.rules.weights;
let quality_score = candidate.quality / 10.0; let slo_score = 1.0 / (1.0 + (candidate.slo_read_ms + candidate.slo_write_ms) / 2000.0);
let cost_score = 1.0 / (1.0 + candidate.cost_monthly_usd / 1000.0);
let security_score = candidate.security_score / 10.0; let ops_score = 1.0 / (1.0 + candidate.ops_complexity / 10.0);
weights.quality * quality_score +
weights.slo * slo_score +
weights.cost * cost_score +
weights.security * security_score +
weights.ops * ops_score
}
fn create_decision(topic: &str, chosen: &Candidate, candidates: &[&Candidate]) -> Decision {
let alternatives: Vec<String> = candidates
.iter()
.filter(|c| c.name != chosen.name)
.take(2) .map(|c| c.name.clone())
.collect();
let reasons = match topic {
"backend" => vec!["最高 RPS".to_string(), "Tokio 安定".to_string()],
"database" => vec!["低レイテンシ".to_string(), "高可用性".to_string()],
"cache" => vec!["Edge配置".to_string(), "低コスト".to_string()],
_ => vec!["最適スコア".to_string()],
};
Decision {
topic: topic.to_string(),
choice: chosen.name.clone(),
reasons,
alternatives,
}
}
}