1use crate::lint::{Severity, lint};
4use crate::metrics::{Scorecard, scorecard};
5use crate::monte_carlo::{MAX_WORK, MonteCarloRequest, monte_carlo, work_size};
6use crate::plan::{PlanGraph, PlanId, PlannerError};
7use crate::planner::canonical_deliverable;
8use crate::resource_schedule::{ScheduleRequest, resource_schedule};
9use crate::revise::RevisionDiff;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12
13fn w1() -> f32 {
14 1.0
15}
16fn w05() -> f32 {
17 0.5
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct CompareWeights {
24 #[serde(default = "w1")]
25 pub makespan: f32,
26 #[serde(default = "w1")]
27 pub p80: f32,
28 #[serde(default = "w1")]
29 pub criticality_risk: f32,
30 #[serde(default = "w05")]
31 pub total_effort: f32,
32 #[serde(default = "w05")]
33 pub peak_load: f32,
34}
35
36impl Default for CompareWeights {
37 fn default() -> Self {
38 Self {
39 makespan: w1(),
40 p80: w1(),
41 criticality_risk: w1(),
42 total_effort: w05(),
43 peak_load: w05(),
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct CompareRequest {
52 #[serde(default)]
54 pub schedule: Option<ScheduleRequest>,
55 #[serde(default)]
57 pub monte_carlo: Option<MonteCarloRequest>,
58 #[serde(default)]
59 pub weights: CompareWeights,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct VariantComparison {
64 pub plan_id: PlanId,
65 pub variant: String,
66 pub scorecard: Scorecard,
67 pub diff_vs_first: RevisionDiff,
72 pub pareto_optimal: bool,
73 pub score: f32,
74 pub rank: u32,
75 pub rationale: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct Comparison {
80 pub variants: Vec<VariantComparison>,
82 #[serde(default)]
85 pub tied_with_recommended: Vec<PlanId>,
86 pub recommended: PlanId,
87}
88
89pub const MAX_COMPARE_VARIANTS: usize = 16;
91
92pub(crate) fn check_variant_count(n: usize) -> Result<(), PlannerError> {
94 if n < 2 {
95 return Err(PlannerError::InvalidGraph {
96 reason: "compare needs at least two variants".to_string(),
97 });
98 }
99 if n > MAX_COMPARE_VARIANTS {
100 return Err(PlannerError::InvalidGraph {
101 reason: format!("compare accepts at most {MAX_COMPARE_VARIANTS} variants"),
102 });
103 }
104 Ok(())
105}
106
107fn check_shared_budget(
110 inputs: &[(PlanId, String, PlanGraph)],
111 req: &MonteCarloRequest,
112) -> Result<(), PlannerError> {
113 let iterations = u64::from(req.iterations);
114 let total = inputs.iter().fold(0u64, |sum, (_, _, g)| {
115 sum.saturating_add(iterations.saturating_mul(work_size(g) as u64))
116 });
117 if total > MAX_WORK {
118 return Err(PlannerError::InvalidGraph {
119 reason: format!(
120 "compare monte carlo budget exceeded ({} iterations × nodes+edges summed over \
121 {} variants = {total} > {MAX_WORK})",
122 req.iterations,
123 inputs.len()
124 ),
125 });
126 }
127 Ok(())
128}
129
130const CRITERIA: [&str; 5] = [
131 "makespan",
132 "p80",
133 "criticality_risk",
134 "total_effort",
135 "peak_load",
136];
137
138fn criteria(card: &Scorecard) -> [f32; 5] {
139 let makespan = card.resource_makespan.unwrap_or(card.makespan);
140 let p80 = card.monte_carlo.as_ref().map_or(makespan, |m| m.p80);
141 [
142 makespan,
143 p80,
144 card.criticality_risk as f32,
145 card.total_effort,
146 card.peak_load.unwrap_or(0.0),
147 ]
148}
149
150fn dominates(a: &[f32; 5], b: &[f32; 5]) -> bool {
151 a.iter().zip(b).all(|(x, y)| x <= y) && a.iter().zip(b).any(|(x, y)| x < y)
152}
153
154fn diff(first: &PlanGraph, other: &PlanGraph) -> RevisionDiff {
155 let canon = |g: &PlanGraph| -> BTreeMap<String, serde_json::Value> {
156 g.deliverables
157 .iter()
158 .map(|d| (d.id.clone(), canonical_deliverable(d)))
159 .collect()
160 };
161 let (a, b) = (canon(first), canon(other));
162 RevisionDiff {
163 added: b.keys().filter(|k| !a.contains_key(*k)).cloned().collect(),
164 removed: a.keys().filter(|k| !b.contains_key(*k)).cloned().collect(),
165 changed: a
166 .iter()
167 .filter(|(k, v)| b.get(*k).is_some_and(|o| o != *v))
168 .map(|(k, _)| k.clone())
169 .collect(),
170 reopened: Vec::new(),
171 released_locks: Vec::new(),
172 }
173}
174
175fn rationale(i: usize, all: &[[f32; 5]]) -> String {
176 let (mut best, mut worst) = (Vec::new(), Vec::new());
177 for (c, name) in CRITERIA.iter().enumerate() {
178 let min = all.iter().map(|v| v[c]).fold(f32::INFINITY, f32::min);
179 let max = all.iter().map(|v| v[c]).fold(f32::NEG_INFINITY, f32::max);
180 if min < max {
181 if all[i][c] <= min {
182 best.push(*name);
183 }
184 if all[i][c] >= max {
185 worst.push(*name);
186 }
187 }
188 }
189 let list = |v: &[&str]| {
190 if v.is_empty() {
191 "none".to_string()
192 } else {
193 v.join(", ")
194 }
195 };
196 format!("best: {}; worst: {}", list(&best), list(&worst))
197}
198
199pub fn compare(
206 inputs: &[(PlanId, String, PlanGraph)],
207 req: &CompareRequest,
208) -> Result<Comparison, PlannerError> {
209 check_variant_count(inputs.len())?;
210 if let Some(mc) = &req.monte_carlo {
211 check_shared_budget(inputs, mc)?;
212 }
213
214 let mut cards: Vec<Scorecard> = Vec::with_capacity(inputs.len());
215 for (_, variant, graph) in inputs {
216 let report = lint(graph);
217 if let Some(f) = report
218 .findings
219 .iter()
220 .find(|f| f.severity == Severity::Error)
221 {
222 return Err(PlannerError::InvalidGraph {
223 reason: format!("variant {variant}: {} {}", f.code, f.message),
224 });
225 }
226 let cpm = crate::schedule::compute_cpm(graph)?;
227 let sched = req
228 .schedule
229 .as_ref()
230 .map(|s| resource_schedule(graph, s))
231 .transpose()?;
232 let mut card = scorecard(graph, &cpm, sched.as_ref(), &report);
233 card.monte_carlo = req
234 .monte_carlo
235 .as_ref()
236 .map(|m| monte_carlo(graph, m))
237 .transpose()?;
238 cards.push(card);
239 }
240
241 let values: Vec<[f32; 5]> = cards.iter().map(criteria).collect();
242 let w = &req.weights;
243 let weights = [
244 w.makespan,
245 w.p80,
246 w.criticality_risk,
247 w.total_effort,
248 w.peak_load,
249 ];
250 let mins: Vec<f32> = (0..5)
251 .map(|c| values.iter().map(|v| v[c]).fold(f32::INFINITY, f32::min))
252 .collect();
253 let scores: Vec<f32> = values
254 .iter()
255 .map(|v| {
256 (0..5)
257 .filter(|&c| mins[c] > 0.0)
258 .map(|c| weights[c] * (v[c] / mins[c]))
259 .sum()
260 })
261 .collect();
262
263 let mut order: Vec<usize> = (0..inputs.len()).collect();
264 order.sort_by(|&a, &b| {
265 scores[a]
266 .total_cmp(&scores[b])
267 .then_with(|| inputs[a].0.0.cmp(&inputs[b].0.0))
268 });
269 let mut ranks = vec![0u32; inputs.len()];
272 for (pos, &i) in order.iter().enumerate() {
273 ranks[i] = if pos > 0 && (scores[i] - scores[order[pos - 1]]).abs() <= 1e-9 {
274 ranks[order[pos - 1]]
275 } else {
276 u32::try_from(pos + 1).unwrap_or(u32::MAX)
277 };
278 }
279
280 let recommended_index = order[0];
281 let recommended_score = scores[recommended_index];
282 let recommended_variant = inputs[recommended_index].1.clone();
283 let tied_with_recommended: Vec<PlanId> = inputs
284 .iter()
285 .enumerate()
286 .filter(|(i, _)| *i != recommended_index && (scores[*i] - recommended_score).abs() <= 1e-9)
287 .map(|(_, (id, _, _))| id.clone())
288 .collect();
289
290 let variants = cards
291 .into_iter()
292 .enumerate()
293 .map(|(i, card)| VariantComparison {
294 plan_id: inputs[i].0.clone(),
295 variant: inputs[i].1.clone(),
296 scorecard: card,
297 diff_vs_first: diff(&inputs[0].2, &inputs[i].2),
298 pareto_optimal: !values.iter().any(|o| dominates(o, &values[i])),
299 score: scores[i],
300 rank: ranks[i],
301 rationale: if i != recommended_index && (scores[i] - recommended_score).abs() <= 1e-9 {
302 format!(
303 "tied on score with {recommended_variant}; {}",
304 rationale(i, &values)
305 )
306 } else {
307 rationale(i, &values)
308 },
309 })
310 .collect();
311
312 Ok(Comparison {
313 variants,
314 tied_with_recommended,
315 recommended: inputs[recommended_index].0.clone(),
316 })
317}