1use 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
14pub(crate) fn default_resource_key() -> String {
17 "owner".to_string()
18}
19
20pub(crate) fn default_buffer_pct() -> f32 {
22 25.0
23}
24
25pub(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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct ScheduleRequest {
40 pub capacities: BTreeMap<String, u32>,
42 #[serde(default = "default_resource_key")]
44 pub resource_key: String,
45 #[serde(default = "default_buffer_pct")]
47 pub project_buffer_pct: f32,
48}
49
50#[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#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct ChainLink {
80 pub id: String,
81 pub waited_on: WaitKind,
82}
83
84#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct ResourceSchedule {
95 pub makespan: f32,
96 pub cpm_makespan: f32,
98 pub rows: Vec<ScheduledRow>,
100 pub load: Vec<ResourceLoad>,
102 pub driving_chain: Vec<ChainLink>,
104 pub project_buffer_hours: f32,
106 pub feeding_buffers: Vec<FeedingBuffer>,
107}
108
109struct Node {
110 len: f32,
111 resource: String,
112 preds: Vec<(usize, f32)>,
114}
115
116pub 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
130pub(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 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 let mut best: Option<(usize, f32, usize)> = None; 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
306fn 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
323fn 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
354fn 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 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}