1use crate::gates::{UnmetGate, evaluate};
2use crate::{EngineError, Transition};
3use chrono::{DateTime, Utc};
4use dpm_model::{
5 ActorId, Applicability, Dependency, DependencyId, DependencyKind, DependencyPolicy, Key, Plan,
6 StartBasis, Timeline, WorkItemId, WorkKind,
7};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10use std::fmt;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum EdgeRelaxation {
16 Removed,
18 Policy,
20 Lag,
22 Kind,
24 StartBasis,
26 Other,
28}
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum RelaxedConstraint {
34 Dependency {
36 dependency: DependencyId,
38 predecessor: Key,
40 successor: Key,
42 relaxation: EdgeRelaxation,
44 },
45 Gate {
47 work: Key,
49 transition: Transition,
51 gate: UnmetGate,
53 },
54}
55
56impl fmt::Display for RelaxedConstraint {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 match self {
59 Self::Dependency {
60 dependency,
61 predecessor,
62 successor,
63 relaxation,
64 } => write!(
65 f,
66 "dependency {dependency} ({predecessor} -> {successor}): {relaxation:?}"
67 ),
68 Self::Gate {
69 work,
70 transition,
71 gate,
72 } => write!(
73 f,
74 "the {transition} gate of {work}: {}",
75 crate::gates::describe(gate)
76 ),
77 }
78 }
79}
80
81pub(crate) fn refuse_own_relaxation(
90 current: &Plan,
91 proposed: &Plan,
92 actor: &ActorId,
93 at: DateTime<Utc>,
94) -> Result<(), EngineError> {
95 let owned: BTreeSet<WorkItemId> = current
96 .work_items
97 .values()
98 .filter(|w| w.held_by(actor))
99 .map(|w| w.id)
100 .collect();
101 if owned.is_empty() {
102 return Ok(());
103 }
104 let downstream = through_milestones(current, &owned, Direction::Downstream);
105 let upstream = through_milestones(current, &owned, Direction::Upstream);
106 let refuse = |work: Key, relaxed| EngineError::OwnGateRelaxed {
107 actor: actor.clone(),
108 work,
109 relaxed: Box::new(relaxed),
110 };
111 for edge in current.dependencies.iter().filter(|e| {
112 owned.contains(&e.predecessor)
113 || owned.contains(&e.successor)
114 || downstream.contains(&e.predecessor)
115 || upstream.contains(&e.successor)
116 }) {
117 if let Some(relaxation) = edge_relaxation(edge, proposed) {
118 let mine =
119 if owned.contains(&edge.predecessor) || downstream.contains(&edge.predecessor) {
120 edge.predecessor
121 } else {
122 edge.successor
123 };
124 let relaxed = RelaxedConstraint::Dependency {
125 dependency: edge.id,
126 predecessor: key(current, edge.predecessor),
127 successor: key(current, edge.successor),
128 relaxation,
129 };
130 return Err(refuse(key(current, mine), relaxed));
131 }
132 }
133 let forwarding: BTreeSet<WorkItemId> = owned.union(&downstream).copied().collect();
135 match relaxed_gate(current, proposed, &owned, &forwarding, at) {
136 Some((mine, relaxed)) => Err(refuse(mine, relaxed)),
137 None => Ok(()),
138 }
139}
140
141#[derive(Clone, Copy)]
143enum Direction {
144 Downstream,
146 Upstream,
148}
149
150fn through_milestones(
157 plan: &Plan,
158 owned: &BTreeSet<WorkItemId>,
159 direction: Direction,
160) -> BTreeSet<WorkItemId> {
161 let is_milestone = |id: &WorkItemId| {
162 plan.work_items
163 .get(id)
164 .is_some_and(|w| w.kind == WorkKind::Milestone)
165 };
166 let mut reached = BTreeSet::new();
167 let mut frontier: Vec<WorkItemId> = owned.iter().copied().collect();
168 while let Some(node) = frontier.pop() {
169 for edge in &plan.dependencies {
170 let next = match direction {
171 Direction::Downstream if edge.predecessor == node => edge.successor,
172 Direction::Upstream if edge.successor == node => edge.predecessor,
173 _ => continue,
174 };
175 if is_milestone(&next) && reached.insert(next) {
176 frontier.push(next);
177 }
178 }
179 }
180 reached
181}
182
183fn edge_relaxation(old: &Dependency, proposed: &Plan) -> Option<EdgeRelaxation> {
186 let same_pair: Vec<_> = proposed
187 .dependencies
188 .iter()
189 .filter(|e| e.predecessor == old.predecessor && e.successor == old.successor)
190 .collect();
191 let weakenings: Vec<_> = same_pair.iter().map(|e| weakening(old, e)).collect();
192 if weakenings.iter().any(Option::is_none) {
193 return None;
194 }
195 let nearest = same_pair
196 .iter()
197 .position(|e| e.id == old.id)
198 .or((!same_pair.is_empty()).then_some(0));
199 Some(
200 nearest
201 .and_then(|i| weakenings.get(i).copied().flatten())
202 .unwrap_or(EdgeRelaxation::Removed),
203 )
204}
205
206fn weakening(old: &Dependency, new: &Dependency) -> Option<EdgeRelaxation> {
207 if old.policy == DependencyPolicy::Hard && new.policy == DependencyPolicy::Soft {
208 return Some(EdgeRelaxation::Policy);
209 }
210 if new.lag_hours < old.lag_hours {
211 return Some(EdgeRelaxation::Lag);
212 }
213 if !implies(new.kind, old.kind) {
214 return Some(EdgeRelaxation::Kind);
215 }
216 if old.start_basis == StartBasis::Verified && new.start_basis == StartBasis::Provisional {
217 return Some(EdgeRelaxation::StartBasis);
218 }
219 let normalized = Dependency {
220 id: old.id,
221 kind: old.kind,
222 lag_hours: old.lag_hours,
223 policy: old.policy,
224 start_basis: old.start_basis,
225 rationale: old.rationale.clone(),
226 ..new.clone()
227 };
228 (normalized != *old).then_some(EdgeRelaxation::Other)
229}
230
231fn implies(new: DependencyKind, old: DependencyKind) -> bool {
234 use DependencyKind::{FinishFinish, FinishStart, StartFinish, StartStart};
235 new == old
236 || new == FinishStart
237 || (old == StartFinish && matches!(new, StartStart | FinishFinish))
238}
239
240fn relaxed_gate(
243 current: &Plan,
244 proposed: &Plan,
245 owned: &BTreeSet<WorkItemId>,
246 forwarding: &BTreeSet<WorkItemId>,
247 at: DateTime<Utc>,
248) -> Option<(Key, RelaxedConstraint)> {
249 let (before, after) = (Timeline::at(current, at), Timeline::at(proposed, at));
250 let forwarding_keys: BTreeSet<Key> = forwarding.iter().map(|id| key(current, *id)).collect();
251 let successors = current
252 .dependencies
253 .iter()
254 .filter(|e| forwarding.contains(&e.predecessor))
255 .map(|e| e.successor);
256 let scope: BTreeSet<_> = forwarding.iter().copied().chain(successors).collect();
257 for id in scope {
258 let (Some(old), Some(new)) = (current.work_items.get(&id), proposed.work_items.get(&id))
259 else {
260 continue;
261 };
262 for transition in Transition::ALL {
263 let was = evaluate(current, old, transition, &before).unmet;
264 let now = evaluate(proposed, new, transition, &after).unmet;
265 for gate in was {
266 let concerns = if owned.contains(&id) {
267 Some(old.key.clone())
268 } else {
269 owned_subject(&gate, forwarding, &forwarding_keys)
270 };
271 if let Some(mine) = concerns
272 && !now.iter().any(|g| same_gate(g, &gate))
273 {
274 let relaxed = RelaxedConstraint::Gate {
275 work: old.key.clone(),
276 transition,
277 gate,
278 };
279 return Some((mine, relaxed));
280 }
281 }
282 }
283 }
284 None
285}
286
287fn owned_subject(
289 gate: &UnmetGate,
290 owned: &BTreeSet<WorkItemId>,
291 owned_keys: &BTreeSet<Key>,
292) -> Option<Key> {
293 match gate {
294 UnmetGate::Dependency {
295 predecessor, key, ..
296 }
297 | UnmetGate::BasisInvalidated {
298 predecessor, key, ..
299 } if owned.contains(predecessor) => Some(Key::new(key.clone())),
300 UnmetGate::Applicability {
301 applicability:
302 Applicability::Stranded { predecessor, .. }
303 | Applicability::AwaitingChoice { predecessor },
304 } if owned_keys.contains(predecessor) => Some(predecessor.clone()),
305 _ => None,
306 }
307}
308
309fn same_gate(a: &UnmetGate, b: &UnmetGate) -> bool {
311 match (a, b) {
312 (
313 UnmetGate::Dependency { predecessor: x, .. },
314 UnmetGate::Dependency { predecessor: y, .. },
315 ) => x == y,
316 (
317 UnmetGate::BasisInvalidated { dependency: x, .. },
318 UnmetGate::BasisInvalidated { dependency: y, .. },
319 ) => x == y,
320 (UnmetGate::Decision { key: x }, UnmetGate::Decision { key: y })
321 | (UnmetGate::Choice { key: x, .. }, UnmetGate::Choice { key: y, .. }) => x == y,
322 _ => std::mem::discriminant(a) == std::mem::discriminant(b),
323 }
324}
325
326fn key(plan: &Plan, id: WorkItemId) -> Key {
327 plan.work_items
328 .get(&id)
329 .map_or_else(|| Key::new(id.to_string()), |w| w.key.clone())
330}
331
332#[cfg(test)]
333mod tests;