Skip to main content

dpm_engine/
progress.rs

1use crate::EngineError;
2use chrono::{DateTime, Utc};
3use dpm_model::{
4    Applicability, EventTime, Plan, Timeline, WorkItem, WorkItemId, WorkKind, WorkStatus,
5};
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, BTreeSet};
8
9/// Execution progress is distinct from independent acceptance of the result. The default is no
10/// reported progress and no acceptance.
11#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
12pub struct ProgressSummary {
13    /// Execution percentage; packages/workspace equally weight descendant leaf tasks.
14    pub percent_complete: f64,
15    /// Whether all applicable verification and aggregate gate conditions are satisfied.
16    pub verified: bool,
17    /// Verification time of a task, or the latest prerequisite or decision release of a milestone
18    /// or package; unrecorded when any contributing event predates recorded event times.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub completed_at: Option<EventTime>,
21    /// Whether a choice keeps this work out of every percentage and completion condition.
22    #[serde(default, skip_serializing_if = "ProgressScope::is_counted")]
23    pub scope: ProgressScope,
24}
25
26/// Whether a work item's own conditions let it count toward progress.
27#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ProgressScope {
30    /// The work counts: it is unconditional or every condition names the selected option.
31    #[default]
32    Counted,
33    /// A decision selected a different option, for the work or for every child of a package; the
34    /// work is excluded, not completed.
35    NotSelected,
36    /// A condition awaits an open decision; the work is not counted, and its container or
37    /// workspace cannot be complete until the choice is made.
38    Undecided,
39}
40
41impl ProgressScope {
42    fn is_counted(&self) -> bool {
43        *self == Self::Counted
44    }
45
46    fn of(applicability: &Applicability) -> Self {
47        match applicability {
48            Applicability::NotSelected { .. } | Applicability::AllChildrenExcluded { .. } => {
49                Self::NotSelected
50            }
51            Applicability::Undecided { .. } => Self::Undecided,
52            _ => Self::Counted,
53        }
54    }
55}
56
57/// Derived progress values, never persisted as replacement domain objects.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ProgressProjection {
60    /// Equal-weight task progress for the workspace; milestones do not inflate the denominator.
61    pub overall: ProgressSummary,
62    /// Per-work task, container or milestone projection.
63    pub work: BTreeMap<WorkItemId, ProgressSummary>,
64}
65
66/// Compute execution percentages without inferring work from elapsed time or changing the schedule.
67///
68/// Percentages count only tasks whose conditions are selected: excluded work is not completed
69/// work, and undecided alternatives are not averaged into one figure. The workspace is verified
70/// only when every item is complete or excluded by a choice, so an open choice is not completion.
71///
72/// `now` is the adapter's clock reading; it decides only whether lagged milestone gates are reached.
73pub fn progress(plan: &Plan, now: DateTime<Utc>) -> Result<ProgressProjection, EngineError> {
74    plan.validate()?;
75    Ok(progress_with(plan, &Timeline::at(plan, now)))
76}
77
78pub(crate) fn progress_with(plan: &Plan, timeline: &Timeline) -> ProgressProjection {
79    let done = timeline.completed();
80    let scope = |id: WorkItemId| ProgressScope::of(timeline.applicability(id));
81    let tasks: Vec<_> = plan
82        .work_items
83        .values()
84        .filter(|w| w.is_executable() && scope(w.id).is_counted())
85        .collect();
86    let mut work = BTreeMap::new();
87    for item in plan.work_items.values() {
88        let verified = done.contains(&item.id);
89        let percent_complete = match item.kind {
90            WorkKind::Task => task_percent(item),
91            WorkKind::Milestone => {
92                if verified {
93                    100.0
94                } else {
95                    0.0
96                }
97            }
98            WorkKind::WorkPackage => {
99                let descendants: Vec<_> = tasks
100                    .iter()
101                    .copied()
102                    .filter(|t| within(plan, t, item.id))
103                    .collect();
104                average(&descendants, verified)
105            }
106        };
107        work.insert(
108            item.id,
109            ProgressSummary {
110                percent_complete,
111                verified,
112                completed_at: timeline.completed_at(item.id),
113                scope: scope(item.id),
114            },
115        );
116    }
117    let verified = !done.is_empty()
118        && plan
119            .work_items
120            .keys()
121            .all(|id| done.contains(id) || scope(*id) == ProgressScope::NotSelected);
122    let completed_at = verified
123        .then(|| {
124            plan.work_items
125                .keys()
126                .filter_map(|id| timeline.completed_at(*id))
127                .reduce(EventTime::latest)
128        })
129        .flatten();
130    ProgressProjection {
131        overall: ProgressSummary {
132            percent_complete: average(&tasks, verified),
133            verified,
134            completed_at,
135            scope: ProgressScope::Counted,
136        },
137        work,
138    }
139}
140
141fn task_percent(work: &WorkItem) -> f64 {
142    if matches!(
143        work.execution.status,
144        WorkStatus::Submitted | WorkStatus::Verified | WorkStatus::Done
145    ) {
146        100.0
147    } else {
148        f64::from(work.execution.reported_progress_percent)
149    }
150}
151
152fn average(tasks: &[&WorkItem], verified: bool) -> f64 {
153    if tasks.is_empty() {
154        return if verified { 100.0 } else { 0.0 };
155    }
156    tasks
157        .iter()
158        .map(|w| task_percent(w) / tasks.len() as f64)
159        .sum::<f64>()
160        .clamp(0.0, 100.0)
161}
162
163fn within(plan: &Plan, task: &WorkItem, container: WorkItemId) -> bool {
164    let mut parent = task.parent;
165    let mut seen = BTreeSet::new();
166    while let Some(id) = parent {
167        if id == container {
168            return true;
169        }
170        if !seen.insert(id) {
171            return false;
172        }
173        parent = plan.work_items.get(&id).and_then(|w| w.parent);
174    }
175    false
176}
177
178#[cfg(test)]
179mod tests;