Skip to main content

cpm_planner/
resource_schedule.rs

1//! Resource-constrained scheduling: levels a plan against per-resource
2//! capacities with event-driven list scheduling, then reports the driving
3//! chain (what each link waited on) and project / feeding buffers.
4
5use crate::estimator::EffortEstimator;
6use crate::plan::{Deliverable, FINISH_ID, PlanGraph, PlannerError};
7use crate::task::CriticalPathResult;
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, BTreeSet, HashMap};
10
11const EPS: f32 = 1e-4;
12const UNASSIGNED: &str = "unassigned";
13
14/// Default `resource_key`: deliverables name their resource in
15/// `metadata.owner`.
16pub(crate) fn default_resource_key() -> String {
17    "owner".to_string()
18}
19
20/// Default `project_buffer_pct`.
21pub(crate) fn default_buffer_pct() -> f32 {
22    25.0
23}
24
25/// Check `project_buffer_pct` is a finite percentage in `0..=100`.
26pub(crate) fn check_buffer_pct(pct: f32) -> Result<(), String> {
27    if pct.is_finite() && (0.0..=100.0).contains(&pct) {
28        Ok(())
29    } else {
30        Err(format!(
31            "project_buffer_pct must be between 0 and 100; got {pct}"
32        ))
33    }
34}
35
36/// Input to [`resource_schedule`].
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct ScheduleRequest {
40    /// Units available per resource name.
41    pub capacities: BTreeMap<String, u32>,
42    /// Metadata key naming a deliverable's resource.
43    #[serde(default = "default_resource_key")]
44    pub resource_key: String,
45    /// Buffer size as a percentage of the chain's scheduled length.
46    #[serde(default = "default_buffer_pct")]
47    pub project_buffer_pct: f32,
48}
49
50/// One deliverable's leveled position.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct ScheduledRow {
53    pub id: String,
54    pub resource: String,
55    pub start: f32,
56    pub finish: f32,
57}
58
59/// Busy time of one resource.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct ResourceLoad {
62    pub resource: String,
63    pub capacity: u32,
64    pub busy_hours: f32,
65    pub utilisation: f32,
66}
67
68/// What a driving-chain link waited on before starting.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum WaitKind {
72    Start,
73    Dependency,
74    Resource,
75}
76
77/// One link of the driving chain.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct ChainLink {
80    pub id: String,
81    pub waited_on: WaitKind,
82}
83
84/// Buffer protecting the point where a side chain joins the driving chain.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct FeedingBuffer {
87    pub joins: String,
88    pub from_chain: Vec<String>,
89    pub buffer_hours: f32,
90}
91
92/// Result of [`resource_schedule`].
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct ResourceSchedule {
95    pub makespan: f32,
96    /// Unconstrained CPM makespan, for comparison.
97    pub cpm_makespan: f32,
98    /// Graph order, no synthetic rows.
99    pub rows: Vec<ScheduledRow>,
100    /// Sorted by resource.
101    pub load: Vec<ResourceLoad>,
102    /// Execution order; the first link waited on `Start`.
103    pub driving_chain: Vec<ChainLink>,
104    /// Percentage of the summed scheduled lengths on `driving_chain`.
105    pub project_buffer_hours: f32,
106    pub feeding_buffers: Vec<FeedingBuffer>,
107}
108
109struct Node {
110    len: f32,
111    resource: String,
112    /// (predecessor index, lag); a repeated id keeps its largest lag.
113    preds: Vec<(usize, f32)>,
114}
115
116/// Level `graph` against `req.capacities`.
117///
118/// The graph is validated exactly as `plan.submit` validates it, so a
119/// reserved or duplicate id, a cycle, an unknown prerequisite or an
120/// out-of-range hour value is [`PlannerError::InvalidGraph`].
121pub fn resource_schedule(
122    graph: &PlanGraph,
123    req: &ScheduleRequest,
124) -> Result<ResourceSchedule, PlannerError> {
125    crate::planner::validate_graph(graph)?;
126    let cpm = crate::schedule::compute_cpm(graph)?;
127    resource_schedule_with_cpm(graph, req, &cpm)
128}
129
130/// [`resource_schedule`] over an already validated graph and its CPM
131/// result, so callers that already ran CPM (`simulate`) do not repeat it.
132pub(crate) fn resource_schedule_with_cpm(
133    graph: &PlanGraph,
134    req: &ScheduleRequest,
135    cpm: &CriticalPathResult,
136) -> Result<ResourceSchedule, PlannerError> {
137    check_buffer_pct(req.project_buffer_pct)
138        .map_err(|reason| PlannerError::InvalidGraph { reason })?;
139    let cpm_makespan = cpm.get_task(FINISH_ID).map_or(0.0, |t| t.earliest_finish);
140
141    let nodes = build_nodes(graph, &req.resource_key)?;
142    check_capacities(&nodes, &req.capacities)?;
143    let n = nodes.len();
144    let order = topological_order(&nodes)?;
145    let tail = tails(&nodes, &order);
146
147    // Allocate units only for resources that carry work, and never more than
148    // the number of work-bearing deliverables on that resource: extra units
149    // could never be occupied, and sizing by raw capacity can demand huge
150    // allocations for large (even u32::MAX) capacities.
151    let mut work_per_resource: BTreeMap<&str, usize> = BTreeMap::new();
152    for n in nodes.iter().filter(|n| n.len > 0.0) {
153        *work_per_resource.entry(n.resource.as_str()).or_insert(0) += 1;
154    }
155    let mut units: BTreeMap<&str, Vec<f32>> = work_per_resource
156        .into_iter()
157        .map(|(resource, work)| {
158            let capacity = req
159                .capacities
160                .get(resource)
161                .copied()
162                .expect("INVARIANT: capacities validated before allocation")
163                as usize;
164            (resource, vec![0.0; capacity.min(work)])
165        })
166        .collect();
167    let mut start = vec![f32::NAN; n];
168    let mut finish = vec![f32::NAN; n];
169    let mut done = 0;
170    while done < n {
171        // Candidate: every predecessor placed; its earliest feasible start.
172        let mut best: Option<(usize, f32, usize)> = None; // (idx, start, unit)
173        for i in 0..n {
174            if !start[i].is_nan() || nodes[i].preds.iter().any(|&(p, _)| start[p].is_nan()) {
175                continue;
176            }
177            let ready = nodes[i]
178                .preds
179                .iter()
180                .map(|&(p, lag)| finish[p] + lag)
181                .fold(0.0_f32, f32::max);
182            let (s, unit) = if nodes[i].len > 0.0 {
183                let pool = &units[nodes[i].resource.as_str()];
184                let (u, free) = pool
185                    .iter()
186                    .enumerate()
187                    .fold(
188                        (0, f32::INFINITY),
189                        |acc, (u, &f)| {
190                            if f < acc.1 { (u, f) } else { acc }
191                        },
192                    );
193                (ready.max(free), u)
194            } else {
195                (ready, 0)
196            };
197            let better = match best {
198                None => true,
199                Some((b, bs, _)) => {
200                    if (s - bs).abs() > EPS {
201                        s < bs
202                    } else {
203                        let (zi, zb) = (nodes[i].len <= 0.0, nodes[b].len <= 0.0);
204                        if zi != zb {
205                            zi
206                        } else if (tail[i] - tail[b]).abs() > EPS {
207                            tail[i] > tail[b]
208                        } else {
209                            graph.deliverables[i].id < graph.deliverables[b].id
210                        }
211                    }
212                }
213            };
214            if better {
215                best = Some((i, s, unit));
216            }
217        }
218        let Some((i, s, unit)) = best else {
219            return Err(PlannerError::InvalidGraph {
220                reason: "resource schedule could not place every deliverable".to_string(),
221            });
222        };
223        start[i] = s;
224        finish[i] = s + nodes[i].len;
225        if nodes[i].len > 0.0
226            && let Some(pool) = units.get_mut(nodes[i].resource.as_str())
227        {
228            pool[unit] = finish[i];
229        }
230        done += 1;
231    }
232
233    let ids: Vec<&str> = graph.deliverables.iter().map(|d| d.id.as_str()).collect();
234    let makespan = finish.iter().copied().fold(0.0_f32, f32::max);
235    let rows = (0..n)
236        .map(|i| ScheduledRow {
237            id: ids[i].to_string(),
238            resource: nodes[i].resource.clone(),
239            start: start[i],
240            finish: finish[i],
241        })
242        .collect();
243
244    let chain = driving_chain(&nodes, &ids, &start, &finish);
245    let chain_set: BTreeSet<usize> = chain.iter().map(|&(i, _)| i).collect();
246    let pct = req.project_buffer_pct / 100.0;
247    let project_buffer_hours = pct * chain.iter().map(|&(i, _)| nodes[i].len).sum::<f32>();
248    let feeding_buffers = feeding_buffers(&nodes, &ids, &order, &chain_set, pct);
249
250    Ok(ResourceSchedule {
251        makespan,
252        cpm_makespan,
253        rows,
254        load: loads(&nodes, &req.capacities, makespan),
255        driving_chain: chain
256            .iter()
257            .map(|&(i, waited_on)| ChainLink {
258                id: ids[i].to_string(),
259                waited_on,
260            })
261            .collect(),
262        project_buffer_hours,
263        feeding_buffers,
264    })
265}
266
267fn build_nodes(graph: &PlanGraph, key: &str) -> Result<Vec<Node>, PlannerError> {
268    let estimator = EffortEstimator::new();
269    let index: HashMap<&str, usize> = graph
270        .deliverables
271        .iter()
272        .enumerate()
273        .map(|(i, d)| (d.id.as_str(), i))
274        .collect();
275    graph
276        .deliverables
277        .iter()
278        .map(|d| {
279            let mut preds: BTreeMap<usize, f32> = BTreeMap::new();
280            for p in &d.prerequisites {
281                let &pi = index
282                    .get(p.id())
283                    .ok_or_else(|| PlannerError::InvalidGraph {
284                        reason: format!("{} lists unknown prerequisite {}", d.id, p.id()),
285                    })?;
286                let lag = preds.entry(pi).or_insert(0.0);
287                *lag = lag.max(p.lag_hours().max(0.0));
288            }
289            Ok(Node {
290                len: crate::schedule::scheduled_length(d, &estimator),
291                resource: resource_of(d, key),
292                preds: preds.into_iter().collect(),
293            })
294        })
295        .collect()
296}
297
298fn resource_of(d: &Deliverable, key: &str) -> String {
299    d.metadata
300        .get(key)
301        .and_then(serde_json::Value::as_str)
302        .unwrap_or(UNASSIGNED)
303        .to_string()
304}
305
306/// Only work that occupies time needs capacity; milestones are exempt.
307fn check_capacities(nodes: &[Node], caps: &BTreeMap<String, u32>) -> Result<(), PlannerError> {
308    let missing: BTreeSet<&String> = nodes
309        .iter()
310        .filter(|n| n.len > 0.0)
311        .map(|n| &n.resource)
312        .filter(|r| caps.get(*r).copied().unwrap_or(0) == 0)
313        .collect();
314    if missing.is_empty() {
315        Ok(())
316    } else {
317        Err(PlannerError::InvalidCapacities {
318            missing: missing.into_iter().cloned().collect(),
319        })
320    }
321}
322
323/// Kahn order over `preds`; errors on a cycle (unreachable once the graph
324/// is validated, but never loop on it).
325fn topological_order(nodes: &[Node]) -> Result<Vec<usize>, PlannerError> {
326    let mut succs: Vec<Vec<usize>> = vec![Vec::new(); nodes.len()];
327    for (i, n) in nodes.iter().enumerate() {
328        for &(p, _) in &n.preds {
329            succs[p].push(i);
330        }
331    }
332    let mut indeg: Vec<usize> = nodes.iter().map(|n| n.preds.len()).collect();
333    let mut order: Vec<usize> = (0..nodes.len()).filter(|&i| indeg[i] == 0).collect();
334    let mut head = 0;
335    while head < order.len() {
336        let u = order[head];
337        head += 1;
338        for &v in &succs[u] {
339            indeg[v] -= 1;
340            if indeg[v] == 0 {
341                order.push(v);
342            }
343        }
344    }
345    if order.len() == nodes.len() {
346        Ok(order)
347    } else {
348        Err(PlannerError::InvalidGraph {
349            reason: "resource schedule found a prerequisite cycle".to_string(),
350        })
351    }
352}
353
354/// Longest remaining tail per node: its length plus the heaviest successor
355/// (edge lag + that successor's tail). Filled in reverse topological order,
356/// so no recursion.
357fn tails(nodes: &[Node], order: &[usize]) -> Vec<f32> {
358    let mut succs: Vec<Vec<(usize, f32)>> = vec![Vec::new(); nodes.len()];
359    for (i, n) in nodes.iter().enumerate() {
360        for &(p, lag) in &n.preds {
361            succs[p].push((i, lag));
362        }
363    }
364    let mut tail = vec![0.0_f32; nodes.len()];
365    for &i in order.iter().rev() {
366        let best = succs[i]
367            .iter()
368            .map(|&(s, lag)| lag + tail[s])
369            .fold(0.0_f32, f32::max);
370        tail[i] = nodes[i].len + best;
371    }
372    tail
373}
374
375fn driving_chain(
376    nodes: &[Node],
377    ids: &[&str],
378    start: &[f32],
379    finish: &[f32],
380) -> Vec<(usize, WaitKind)> {
381    let Some(mut cur) = (0..nodes.len()).min_by(|&a, &b| {
382        finish[b]
383            .total_cmp(&finish[a])
384            .then_with(|| ids[a].cmp(ids[b]))
385    }) else {
386        return Vec::new();
387    };
388    let mut rev = Vec::new();
389    loop {
390        let s = start[cur];
391        if s <= EPS {
392            rev.push((cur, WaitKind::Start));
393            break;
394        }
395        let dep = nodes[cur]
396            .preds
397            .iter()
398            .filter(|&&(p, lag)| (finish[p] + lag - s).abs() <= EPS)
399            .map(|&(p, _)| p)
400            .min_by(|&a, &b| ids[a].cmp(ids[b]));
401        if let Some(p) = dep {
402            rev.push((cur, WaitKind::Dependency));
403            cur = p;
404            continue;
405        }
406        let res = (0..nodes.len())
407            .filter(|&j| {
408                j != cur
409                    && nodes[j].len > 0.0
410                    && nodes[j].resource == nodes[cur].resource
411                    && (finish[j] - s).abs() <= EPS
412            })
413            .min_by(|&a, &b| ids[a].cmp(ids[b]));
414        if let Some(j) = res {
415            rev.push((cur, WaitKind::Resource));
416            cur = j;
417            continue;
418        }
419        rev.push((cur, WaitKind::Start));
420        break;
421    }
422    rev.reverse();
423    rev
424}
425
426fn feeding_buffers(
427    nodes: &[Node],
428    ids: &[&str],
429    order: &[usize],
430    chain: &BTreeSet<usize>,
431    pct: f32,
432) -> Vec<FeedingBuffer> {
433    // Heaviest off-chain dependency chain ending at each node, filled in
434    // topological order: (summed length, best off-chain predecessor, first
435    // node of that chain). Ties go to the chain whose first id is smaller.
436    let mut sum = vec![0.0_f32; nodes.len()];
437    let mut via: Vec<Option<usize>> = vec![None; nodes.len()];
438    let mut head: Vec<usize> = (0..nodes.len()).collect();
439    for &i in order {
440        let mut top: Option<usize> = None;
441        for &(p, _) in nodes[i].preds.iter().filter(|(p, _)| !chain.contains(p)) {
442            let take = match top {
443                None => true,
444                Some(t) => {
445                    sum[p] > sum[t] + EPS
446                        || ((sum[p] - sum[t]).abs() <= EPS && ids[head[p]] < ids[head[t]])
447                }
448            };
449            if take {
450                top = Some(p);
451            }
452        }
453        sum[i] = top.map_or(0.0, |t| sum[t]) + nodes[i].len;
454        via[i] = top;
455        if let Some(t) = top {
456            head[i] = head[t];
457        }
458    }
459    let path_to = |end: usize| -> Vec<usize> {
460        let mut path = vec![end];
461        let mut cur = end;
462        while let Some(p) = via[cur] {
463            path.push(p);
464            cur = p;
465        }
466        path.reverse();
467        path
468    };
469
470    let mut out = Vec::new();
471    for &c in chain {
472        for &(p, _) in nodes[c].preds.iter().filter(|(p, _)| !chain.contains(p)) {
473            out.push(FeedingBuffer {
474                joins: ids[c].to_string(),
475                from_chain: path_to(p).iter().map(|&i| ids[i].to_string()).collect(),
476                buffer_hours: pct * sum[p],
477            });
478        }
479    }
480    out.sort_by(|a, b| {
481        a.joins
482            .cmp(&b.joins)
483            .then_with(|| a.from_chain.first().cmp(&b.from_chain.first()))
484    });
485    out
486}
487
488fn loads(nodes: &[Node], caps: &BTreeMap<String, u32>, makespan: f32) -> Vec<ResourceLoad> {
489    let mut busy: BTreeMap<&str, f32> = BTreeMap::new();
490    for n in nodes.iter().filter(|n| n.len > 0.0) {
491        *busy.entry(n.resource.as_str()).or_insert(0.0) += n.len;
492    }
493    busy.into_iter()
494        .map(|(resource, busy_hours)| {
495            let capacity = caps
496                .get(resource)
497                .copied()
498                .expect("INVARIANT: capacities validated before allocation");
499            let available = capacity as f32 * makespan;
500            ResourceLoad {
501                resource: resource.to_string(),
502                capacity,
503                busy_hours,
504                utilisation: if available > 0.0 {
505                    busy_hours / available
506                } else {
507                    0.0
508                },
509            }
510        })
511        .collect()
512}