Skip to main content

cpm_planner/
compare.rs

1//! Compare plan variants on the scorecard: Pareto front and weighted rank.
2
3use 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/// Per-criterion weights for the combined score (lower score is better).
21#[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/// What to compute for every variant.
49#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct CompareRequest {
52    /// Resource-level every variant against these capacities.
53    #[serde(default)]
54    pub schedule: Option<ScheduleRequest>,
55    /// Run Monte Carlo on every variant; P80 then comes from it.
56    #[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    /// Structural difference against the first input variant: `added`,
68    /// `removed` and `changed` (canonical definition differs) deliverable ids.
69    /// A comparison carries no runtime state, so `reopened` and
70    /// `released_locks` are always empty.
71    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    /// In input order; `rank` gives the ordering.
81    pub variants: Vec<VariantComparison>,
82    /// Variants other than `recommended` whose score equals the recommended
83    /// one within `1e-9`. Empty when the recommendation is unique.
84    #[serde(default)]
85    pub tied_with_recommended: Vec<PlanId>,
86    pub recommended: PlanId,
87}
88
89/// Most variants one comparison accepts.
90pub const MAX_COMPARE_VARIANTS: usize = 16;
91
92/// `INVALID_GRAPH` unless `2..=MAX_COMPARE_VARIANTS` variants are given.
93pub(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
107/// The Monte Carlo budget is shared: `iterations × (deliverables + edges)`
108/// summed over every variant must not exceed [`MAX_WORK`].
109fn 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
199/// Compare `inputs` (plan id, variant name, graph) on the scorecard.
200///
201/// Accepts 2..=[`MAX_COMPARE_VARIANTS`] inputs (`INVALID_GRAPH` otherwise).
202/// With `monte_carlo`, the work budget is shared across variants (see
203/// [`MAX_WORK`]). Pure and CPU-bound: the server runs it off the async
204/// runtime.
205pub 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    // Competition ranking: equal scores (within 1e-9) share a rank and the
270    // next distinct score skips the tied positions (1, 1, 3).
271    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}