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 pub recommended: PlanId,
83}
84
85pub const MAX_COMPARE_VARIANTS: usize = 16;
87
88pub(crate) fn check_variant_count(n: usize) -> Result<(), PlannerError> {
90 if n < 2 {
91 return Err(PlannerError::InvalidGraph {
92 reason: "compare needs at least two variants".to_string(),
93 });
94 }
95 if n > MAX_COMPARE_VARIANTS {
96 return Err(PlannerError::InvalidGraph {
97 reason: format!("compare accepts at most {MAX_COMPARE_VARIANTS} variants"),
98 });
99 }
100 Ok(())
101}
102
103fn check_shared_budget(
106 inputs: &[(PlanId, String, PlanGraph)],
107 req: &MonteCarloRequest,
108) -> Result<(), PlannerError> {
109 let iterations = u64::from(req.iterations);
110 let total = inputs.iter().fold(0u64, |sum, (_, _, g)| {
111 sum.saturating_add(iterations.saturating_mul(work_size(g) as u64))
112 });
113 if total > MAX_WORK {
114 return Err(PlannerError::InvalidGraph {
115 reason: format!(
116 "compare monte carlo budget exceeded ({} iterations × nodes+edges summed over \
117 {} variants = {total} > {MAX_WORK})",
118 req.iterations,
119 inputs.len()
120 ),
121 });
122 }
123 Ok(())
124}
125
126const CRITERIA: [&str; 5] = [
127 "makespan",
128 "p80",
129 "criticality_risk",
130 "total_effort",
131 "peak_load",
132];
133
134fn criteria(card: &Scorecard) -> [f32; 5] {
135 let makespan = card.resource_makespan.unwrap_or(card.makespan);
136 let p80 = card.monte_carlo.as_ref().map_or(makespan, |m| m.p80);
137 [
138 makespan,
139 p80,
140 card.criticality_risk as f32,
141 card.total_effort,
142 card.peak_load.unwrap_or(0.0),
143 ]
144}
145
146fn dominates(a: &[f32; 5], b: &[f32; 5]) -> bool {
147 a.iter().zip(b).all(|(x, y)| x <= y) && a.iter().zip(b).any(|(x, y)| x < y)
148}
149
150fn diff(first: &PlanGraph, other: &PlanGraph) -> RevisionDiff {
151 let canon = |g: &PlanGraph| -> BTreeMap<String, serde_json::Value> {
152 g.deliverables
153 .iter()
154 .map(|d| (d.id.clone(), canonical_deliverable(d)))
155 .collect()
156 };
157 let (a, b) = (canon(first), canon(other));
158 RevisionDiff {
159 added: b.keys().filter(|k| !a.contains_key(*k)).cloned().collect(),
160 removed: a.keys().filter(|k| !b.contains_key(*k)).cloned().collect(),
161 changed: a
162 .iter()
163 .filter(|(k, v)| b.get(*k).is_some_and(|o| o != *v))
164 .map(|(k, _)| k.clone())
165 .collect(),
166 reopened: Vec::new(),
167 released_locks: Vec::new(),
168 }
169}
170
171fn rationale(i: usize, all: &[[f32; 5]]) -> String {
172 let (mut best, mut worst) = (Vec::new(), Vec::new());
173 for (c, name) in CRITERIA.iter().enumerate() {
174 let min = all.iter().map(|v| v[c]).fold(f32::INFINITY, f32::min);
175 let max = all.iter().map(|v| v[c]).fold(f32::NEG_INFINITY, f32::max);
176 if min < max {
177 if all[i][c] <= min {
178 best.push(*name);
179 }
180 if all[i][c] >= max {
181 worst.push(*name);
182 }
183 }
184 }
185 let list = |v: &[&str]| {
186 if v.is_empty() {
187 "none".to_string()
188 } else {
189 v.join(", ")
190 }
191 };
192 format!("best: {}; worst: {}", list(&best), list(&worst))
193}
194
195pub fn compare(
202 inputs: &[(PlanId, String, PlanGraph)],
203 req: &CompareRequest,
204) -> Result<Comparison, PlannerError> {
205 check_variant_count(inputs.len())?;
206 if let Some(mc) = &req.monte_carlo {
207 check_shared_budget(inputs, mc)?;
208 }
209
210 let mut cards: Vec<Scorecard> = Vec::with_capacity(inputs.len());
211 for (_, variant, graph) in inputs {
212 let report = lint(graph);
213 if let Some(f) = report
214 .findings
215 .iter()
216 .find(|f| f.severity == Severity::Error)
217 {
218 return Err(PlannerError::InvalidGraph {
219 reason: format!("variant {variant}: {} {}", f.code, f.message),
220 });
221 }
222 let cpm = crate::schedule::compute_cpm(graph)?;
223 let sched = req
224 .schedule
225 .as_ref()
226 .map(|s| resource_schedule(graph, s))
227 .transpose()?;
228 let mut card = scorecard(graph, &cpm, sched.as_ref(), &report);
229 card.monte_carlo = req
230 .monte_carlo
231 .as_ref()
232 .map(|m| monte_carlo(graph, m))
233 .transpose()?;
234 cards.push(card);
235 }
236
237 let values: Vec<[f32; 5]> = cards.iter().map(criteria).collect();
238 let w = &req.weights;
239 let weights = [
240 w.makespan,
241 w.p80,
242 w.criticality_risk,
243 w.total_effort,
244 w.peak_load,
245 ];
246 let mins: Vec<f32> = (0..5)
247 .map(|c| values.iter().map(|v| v[c]).fold(f32::INFINITY, f32::min))
248 .collect();
249 let scores: Vec<f32> = values
250 .iter()
251 .map(|v| {
252 (0..5)
253 .filter(|&c| mins[c] > 0.0)
254 .map(|c| weights[c] * (v[c] / mins[c]))
255 .sum()
256 })
257 .collect();
258
259 let mut order: Vec<usize> = (0..inputs.len()).collect();
260 order.sort_by(|&a, &b| {
261 scores[a]
262 .total_cmp(&scores[b])
263 .then_with(|| inputs[a].0.0.cmp(&inputs[b].0.0))
264 });
265 let mut ranks = vec![0u32; inputs.len()];
266 for (pos, &i) in order.iter().enumerate() {
267 ranks[i] = u32::try_from(pos + 1).unwrap_or(u32::MAX);
268 }
269
270 let variants = cards
271 .into_iter()
272 .enumerate()
273 .map(|(i, card)| VariantComparison {
274 plan_id: inputs[i].0.clone(),
275 variant: inputs[i].1.clone(),
276 scorecard: card,
277 diff_vs_first: diff(&inputs[0].2, &inputs[i].2),
278 pareto_optimal: !values.iter().any(|o| dominates(o, &values[i])),
279 score: scores[i],
280 rank: ranks[i],
281 rationale: rationale(i, &values),
282 })
283 .collect();
284
285 Ok(Comparison {
286 variants,
287 recommended: inputs[order[0]].0.clone(),
288 })
289}