use crate::gates::{UnmetGate, evaluate};
use crate::{EngineError, Transition};
use chrono::{DateTime, Utc};
use dpm_model::{
ActorId, Applicability, Dependency, DependencyId, DependencyKind, DependencyPolicy, Key, Plan,
StartBasis, Timeline, WorkItemId, WorkKind,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EdgeRelaxation {
Removed,
Policy,
Lag,
Kind,
StartBasis,
Other,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RelaxedConstraint {
Dependency {
dependency: DependencyId,
predecessor: Key,
successor: Key,
relaxation: EdgeRelaxation,
},
Gate {
work: Key,
transition: Transition,
gate: UnmetGate,
},
}
impl fmt::Display for RelaxedConstraint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Dependency {
dependency,
predecessor,
successor,
relaxation,
} => write!(
f,
"dependency {dependency} ({predecessor} -> {successor}): {relaxation:?}"
),
Self::Gate {
work,
transition,
gate,
} => write!(
f,
"the {transition} gate of {work}: {}",
crate::gates::describe(gate)
),
}
}
}
pub(crate) fn refuse_own_relaxation(
current: &Plan,
proposed: &Plan,
actor: &ActorId,
at: DateTime<Utc>,
) -> Result<(), EngineError> {
let owned: BTreeSet<WorkItemId> = current
.work_items
.values()
.filter(|w| w.held_by(actor))
.map(|w| w.id)
.collect();
if owned.is_empty() {
return Ok(());
}
let downstream = through_milestones(current, &owned, Direction::Downstream);
let upstream = through_milestones(current, &owned, Direction::Upstream);
let refuse = |work: Key, relaxed| EngineError::OwnGateRelaxed {
actor: actor.clone(),
work,
relaxed: Box::new(relaxed),
};
for edge in current.dependencies.iter().filter(|e| {
owned.contains(&e.predecessor)
|| owned.contains(&e.successor)
|| downstream.contains(&e.predecessor)
|| upstream.contains(&e.successor)
}) {
if let Some(relaxation) = edge_relaxation(edge, proposed) {
let mine =
if owned.contains(&edge.predecessor) || downstream.contains(&edge.predecessor) {
edge.predecessor
} else {
edge.successor
};
let relaxed = RelaxedConstraint::Dependency {
dependency: edge.id,
predecessor: key(current, edge.predecessor),
successor: key(current, edge.successor),
relaxation,
};
return Err(refuse(key(current, mine), relaxed));
}
}
let forwarding: BTreeSet<WorkItemId> = owned.union(&downstream).copied().collect();
match relaxed_gate(current, proposed, &owned, &forwarding, at) {
Some((mine, relaxed)) => Err(refuse(mine, relaxed)),
None => Ok(()),
}
}
#[derive(Clone, Copy)]
enum Direction {
Downstream,
Upstream,
}
fn through_milestones(
plan: &Plan,
owned: &BTreeSet<WorkItemId>,
direction: Direction,
) -> BTreeSet<WorkItemId> {
let is_milestone = |id: &WorkItemId| {
plan.work_items
.get(id)
.is_some_and(|w| w.kind == WorkKind::Milestone)
};
let mut reached = BTreeSet::new();
let mut frontier: Vec<WorkItemId> = owned.iter().copied().collect();
while let Some(node) = frontier.pop() {
for edge in &plan.dependencies {
let next = match direction {
Direction::Downstream if edge.predecessor == node => edge.successor,
Direction::Upstream if edge.successor == node => edge.predecessor,
_ => continue,
};
if is_milestone(&next) && reached.insert(next) {
frontier.push(next);
}
}
}
reached
}
fn edge_relaxation(old: &Dependency, proposed: &Plan) -> Option<EdgeRelaxation> {
let same_pair: Vec<_> = proposed
.dependencies
.iter()
.filter(|e| e.predecessor == old.predecessor && e.successor == old.successor)
.collect();
let weakenings: Vec<_> = same_pair.iter().map(|e| weakening(old, e)).collect();
if weakenings.iter().any(Option::is_none) {
return None;
}
let nearest = same_pair
.iter()
.position(|e| e.id == old.id)
.or((!same_pair.is_empty()).then_some(0));
Some(
nearest
.and_then(|i| weakenings.get(i).copied().flatten())
.unwrap_or(EdgeRelaxation::Removed),
)
}
fn weakening(old: &Dependency, new: &Dependency) -> Option<EdgeRelaxation> {
if old.policy == DependencyPolicy::Hard && new.policy == DependencyPolicy::Soft {
return Some(EdgeRelaxation::Policy);
}
if new.lag_hours < old.lag_hours {
return Some(EdgeRelaxation::Lag);
}
if !implies(new.kind, old.kind) {
return Some(EdgeRelaxation::Kind);
}
if old.start_basis == StartBasis::Verified && new.start_basis == StartBasis::Provisional {
return Some(EdgeRelaxation::StartBasis);
}
let normalized = Dependency {
id: old.id,
kind: old.kind,
lag_hours: old.lag_hours,
policy: old.policy,
start_basis: old.start_basis,
rationale: old.rationale.clone(),
..new.clone()
};
(normalized != *old).then_some(EdgeRelaxation::Other)
}
fn implies(new: DependencyKind, old: DependencyKind) -> bool {
use DependencyKind::{FinishFinish, FinishStart, StartFinish, StartStart};
new == old
|| new == FinishStart
|| (old == StartFinish && matches!(new, StartStart | FinishFinish))
}
fn relaxed_gate(
current: &Plan,
proposed: &Plan,
owned: &BTreeSet<WorkItemId>,
forwarding: &BTreeSet<WorkItemId>,
at: DateTime<Utc>,
) -> Option<(Key, RelaxedConstraint)> {
let (before, after) = (Timeline::at(current, at), Timeline::at(proposed, at));
let forwarding_keys: BTreeSet<Key> = forwarding.iter().map(|id| key(current, *id)).collect();
let successors = current
.dependencies
.iter()
.filter(|e| forwarding.contains(&e.predecessor))
.map(|e| e.successor);
let scope: BTreeSet<_> = forwarding.iter().copied().chain(successors).collect();
for id in scope {
let (Some(old), Some(new)) = (current.work_items.get(&id), proposed.work_items.get(&id))
else {
continue;
};
for transition in Transition::ALL {
let was = evaluate(current, old, transition, &before).unmet;
let now = evaluate(proposed, new, transition, &after).unmet;
for gate in was {
let concerns = if owned.contains(&id) {
Some(old.key.clone())
} else {
owned_subject(&gate, forwarding, &forwarding_keys)
};
if let Some(mine) = concerns
&& !now.iter().any(|g| same_gate(g, &gate))
{
let relaxed = RelaxedConstraint::Gate {
work: old.key.clone(),
transition,
gate,
};
return Some((mine, relaxed));
}
}
}
}
None
}
fn owned_subject(
gate: &UnmetGate,
owned: &BTreeSet<WorkItemId>,
owned_keys: &BTreeSet<Key>,
) -> Option<Key> {
match gate {
UnmetGate::Dependency {
predecessor, key, ..
}
| UnmetGate::BasisInvalidated {
predecessor, key, ..
} if owned.contains(predecessor) => Some(Key::new(key.clone())),
UnmetGate::Applicability {
applicability:
Applicability::Stranded { predecessor, .. }
| Applicability::AwaitingChoice { predecessor },
} if owned_keys.contains(predecessor) => Some(predecessor.clone()),
_ => None,
}
}
fn same_gate(a: &UnmetGate, b: &UnmetGate) -> bool {
match (a, b) {
(
UnmetGate::Dependency { predecessor: x, .. },
UnmetGate::Dependency { predecessor: y, .. },
) => x == y,
(
UnmetGate::BasisInvalidated { dependency: x, .. },
UnmetGate::BasisInvalidated { dependency: y, .. },
) => x == y,
(UnmetGate::Decision { key: x }, UnmetGate::Decision { key: y })
| (UnmetGate::Choice { key: x, .. }, UnmetGate::Choice { key: y, .. }) => x == y,
_ => std::mem::discriminant(a) == std::mem::discriminant(b),
}
}
fn key(plan: &Plan, id: WorkItemId) -> Key {
plan.work_items
.get(&id)
.map_or_else(|| Key::new(id.to_string()), |w| w.key.clone())
}
#[cfg(test)]
mod tests;