1use super::{downstream_counts, simulation_config};
2use crate::{EngineError, GateReport, Transition, gates, readiness::show_with};
3use chrono::{DateTime, Utc};
4use dpm_model::{
5 Applicability, BasisStatus, Plan, Timeline, WorkItem, WorkItemId, basis_dependents,
6 basis_status,
7};
8use dpm_schedule::{
9 deterministic_remaining_at, drag_remaining_at, pert_approximation_remaining_at,
10 simulate_remaining_at,
11};
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, BTreeSet};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct WorkExplanation {
18 pub gates: GateReport,
20 #[serde(default)]
22 pub transitions: BTreeMap<Transition, GateReport>,
23 pub progress: crate::ProgressSummary,
25 pub context: crate::ExecutionContext,
27 pub work: WorkItem,
29 pub ready: bool,
31 #[serde(default = "applicable")]
33 pub applicability: Applicability,
34 pub predecessors: Vec<WorkItem>,
36 pub downstream_count: usize,
38 pub schedule: Option<dpm_schedule::ActivitySchedule>,
40 #[serde(default)]
44 pub unestimated: bool,
45 pub criticality: Option<f64>,
47 #[serde(default)]
51 pub drag_hours: Option<f64>,
52 #[serde(default)]
58 pub pert_variance_hours2: Option<f64>,
59 pub why_now: Vec<String>,
61 #[serde(default)]
63 pub basis: BasisReport,
64}
65
66#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
68pub struct BasisReport {
69 pub relies_on: Vec<BasisStatus>,
71 pub relied_on_by: Vec<BasisStatus>,
73}
74
75pub fn explain_work(
77 plan: &Plan,
78 work: WorkItemId,
79 now: DateTime<Utc>,
80) -> Result<WorkExplanation, EngineError> {
81 plan.validate()?;
82 let timeline = Timeline::at(plan, now);
83 let item = show_with(plan, work, &timeline)?;
84 let schedule = deterministic_remaining_at(plan, &timeline)?;
85 let simulation = simulate_remaining_at(plan, simulation_config(), &timeline)?;
86 let predecessors = plan
87 .dependencies
88 .iter()
89 .filter(|dep| dep.successor == work)
90 .filter_map(|dep| plan.work_items.get(&dep.predecessor).cloned())
91 .collect::<Vec<_>>();
92 let downstream_count = downstream_counts(plan, &timeline, [work])
93 .get(&work)
94 .copied()
95 .unwrap_or(0);
96 let authoritative = plan
97 .work_items
98 .get(&work)
99 .ok_or(EngineError::MissingWorkItem(work))?;
100 let transitions: BTreeMap<_, _> = Transition::ALL
101 .into_iter()
102 .map(|t| (t, gates::evaluate(plan, authoritative, t, &timeline)))
103 .collect();
104 let gates = transitions
105 .get(&Transition::Claim)
106 .cloned()
107 .unwrap_or_else(|| gates::evaluate(plan, authoritative, Transition::Claim, &timeline));
108 let ready = gates.ready;
109 let criticality = simulation.criticality.get(&work).copied();
110 let (drag_hours, pert_variance_hours2) = risk(plan, &timeline, work)?;
111 let unestimated = super::is_unestimated(&timeline, authoritative);
112 let mut why_now = why_now(
113 plan,
114 authoritative,
115 &timeline,
116 &transitions,
117 downstream_count,
118 criticality,
119 );
120 if unestimated {
121 why_now.push(UNESTIMATED_REASON.into());
122 }
123
124 Ok(WorkExplanation {
125 gates,
126 transitions,
127 progress: crate::progress::progress_with(plan, &timeline)
128 .work
129 .get(&work)
130 .copied()
131 .ok_or(EngineError::MissingWorkItem(work))?,
132 context: crate::context::execution_context(plan, &item)?,
133 work: item,
134 ready,
135 applicability: timeline.applicability(work).clone(),
136 predecessors,
137 downstream_count,
138 schedule: schedule.activities.get(&work).cloned(),
139 unestimated,
140 criticality,
141 drag_hours,
142 pert_variance_hours2,
143 why_now,
144 basis: BasisReport {
145 relies_on: basis_status(plan, authoritative),
146 relied_on_by: basis_dependents(plan, work),
147 },
148 })
149}
150
151fn risk(
153 plan: &Plan,
154 timeline: &Timeline,
155 work: WorkItemId,
156) -> Result<(Option<f64>, Option<f64>), EngineError> {
157 let drag = drag_remaining_at(plan, timeline, &BTreeSet::from([work]))?;
158 let approximation = pert_approximation_remaining_at(plan, timeline)?;
159 Ok((
160 drag.get(&work).copied(),
161 approximation.activity_variance_hours2.get(&work).copied(),
162 ))
163}
164
165pub(crate) const UNESTIMATED_REASON: &str = "no duration estimate: the forecast counts this task as 0 h, so its schedule, float and criticality are optimistic";
167
168fn applicable() -> Applicability {
169 Applicability::Applicable
170}
171
172fn why_now(
173 plan: &Plan,
174 work: &WorkItem,
175 timeline: &Timeline,
176 transitions: &BTreeMap<Transition, GateReport>,
177 downstream_count: usize,
178 criticality: Option<f64>,
179) -> Vec<String> {
180 let mut why_now = Vec::new();
181 let current = Transition::ALL
182 .into_iter()
183 .find(|t| t.lifecycle() == work.execution.status)
184 .and_then(|t| transitions.get(&t).map(|report| (t, report)));
185 if timeline.completed_at(work.id).is_some() {
186 why_now.push("work is complete".into());
187 } else if let Some((transition, report)) = current {
188 if report.ready {
189 why_now.push(match transition {
190 Transition::Claim => "work can be claimed now: every FS/SS predecessor event and positive lag has elapsed and no decision gates it".into(),
191 other => format!("work can {other} now: every gate for this transition is satisfied"),
192 });
193 why_now.extend(
194 report
195 .provisional
196 .iter()
197 .map(crate::ProvisionalRelease::describe),
198 );
199 } else {
200 why_now.extend(report.reasons());
201 }
202 } else {
203 why_now.extend(
204 transitions
205 .get(&Transition::Claim)
206 .map(GateReport::reasons)
207 .unwrap_or_default(),
208 );
209 }
210 for dep in plan
211 .enforced_dependencies()
212 .filter(|d| d.successor == work.id && d.lag_hours < 0.0)
213 {
214 let key = plan
215 .work_items
216 .get(&dep.predecessor)
217 .map_or_else(String::new, |w| w.key.to_string());
218 why_now.push(format!(
219 "{} {}h lead from {key} shapes the schedule only; execution still waits for the predecessor event",
220 dep.kind.abbreviation(),
221 dep.lag_hours
222 ));
223 }
224 if downstream_count > 0 {
225 why_now.push(format!(
226 "completion affects {downstream_count} downstream work items"
227 ));
228 }
229 if let Some(value) = criticality {
230 why_now.push(format!("schedule criticality is {:.0}%", value * 100.0));
231 }
232 why_now
233}