Skip to main content

dpm_model/
timeline.rs

1use crate::applicability::{self, Derived};
2use crate::{
3    Applicability, Decision, Dependency, Endpoint, EventTime, Plan, Release, WorkItem, WorkItemId,
4    WorkKind, WorkStatus,
5};
6use chrono::{DateTime, Utc};
7use std::collections::{BTreeMap, BTreeSet};
8
9/// Execution events and derived completion times of every work item at one clock reading.
10///
11/// This is the single evaluator of relation gates: task readiness and lifecycle commands, milestone
12/// and package completion, progress and the remaining schedule all read edge releases from here, so
13/// they cannot disagree. It is derived from authoritative facts and never persisted.
14#[derive(Debug, Clone, PartialEq)]
15pub struct Timeline {
16    index: crate::graph_index::GraphIndex,
17    now: DateTime<Utc>,
18    complete: BTreeMap<WorkItemId, EventTime>,
19    applicability: BTreeMap<WorkItemId, Applicability>,
20    choice_at: BTreeMap<WorkItemId, EventTime>,
21}
22
23const APPLICABLE: Applicability = Applicability::Applicable;
24
25impl Timeline {
26    /// Derive completion from a validated plan and an adapter-supplied clock reading.
27    ///
28    /// Tasks complete when verified and their conditions are selected. A milestone is reached when
29    /// it is applicable and every unwaived incoming edge and every decision gating it or its
30    /// containers is released; its time is the latest of those releases and of the choices that
31    /// selected it or skipped its branches. A work package completes when every child that a
32    /// choice did not exclude is complete, and at least one is, at the latest child, choice or
33    /// gate time; a package whose children were all excluded or cannot proceed is not applicable,
34    /// so it is never complete.
35    #[must_use]
36    pub fn at(plan: &Plan, now: DateTime<Utc>) -> Self {
37        let index = crate::graph_index::GraphIndex::new(plan);
38        let Derived { states, choice_at } = applicability::derive_indexed(plan, &index);
39        let mut timeline = Self {
40            index,
41            now,
42            complete: plan
43                .work_items
44                .values()
45                .filter(|w| w.is_executable())
46                .filter(|w| {
47                    states
48                        .get(&w.id)
49                        .is_none_or(Applicability::condition_selected)
50                })
51                .filter_map(|w| task_event(w, Endpoint::Finish).map(|at| (w.id, at)))
52                .collect(),
53            applicability: states,
54            choice_at,
55        };
56        loop {
57            let reached: Vec<_> = plan
58                .work_items
59                .values()
60                .filter(|w| !timeline.complete.contains_key(&w.id))
61                .filter_map(|w| timeline.aggregate_completion(plan, w).map(|at| (w.id, at)))
62                .collect();
63            if reached.is_empty() {
64                return timeline;
65            }
66            timeline.complete.extend(reached);
67        }
68    }
69
70    /// Enforced incoming edges in source order, for the same immutable plan used by this timeline.
71    pub fn incoming<'a>(
72        &'a self,
73        plan: &'a Plan,
74        work: WorkItemId,
75    ) -> impl Iterator<Item = &'a Dependency> {
76        self.index
77            .incoming
78            .get(&work)
79            .into_iter()
80            .flatten()
81            .filter_map(|i| plan.dependencies.get(*i))
82    }
83
84    /// Clock reading this timeline was evaluated at.
85    #[must_use]
86    pub fn now(&self) -> DateTime<Utc> {
87        self.now
88    }
89
90    /// Completion time of verified tasks, reached milestones and completed packages.
91    #[must_use]
92    pub fn completed_at(&self, work: WorkItemId) -> Option<EventTime> {
93        self.complete.get(&work).copied()
94    }
95
96    /// Identities of all completed work.
97    #[must_use]
98    pub fn completed(&self) -> BTreeSet<WorkItemId> {
99        self.complete.keys().copied().collect()
100    }
101
102    /// Time of a work item's start or finish event, if it has occurred.
103    #[must_use]
104    pub fn event(&self, plan: &Plan, work: WorkItemId, endpoint: Endpoint) -> Option<EventTime> {
105        let item = plan.work_items.get(&work)?;
106        if item.is_executable() {
107            task_event(item, endpoint)
108        } else {
109            self.completed_at(work)
110        }
111    }
112
113    /// Whether work belongs to the active graph, and if not, why.
114    #[must_use]
115    pub fn applicability(&self, work: WorkItemId) -> &Applicability {
116        self.applicability.get(&work).unwrap_or(&APPLICABLE)
117    }
118
119    /// Whether an edge's required predecessor event plus positive lag has elapsed.
120    ///
121    /// A constraint from not-selected work is a skipped branch, released once the choice has been
122    /// made, only into an active-branch join; into any other successor it never releases.
123    #[must_use]
124    pub fn edge(&self, plan: &Plan, edge: &Dependency) -> Release {
125        if self.applicability(edge.predecessor).is_not_selected() {
126            let join = plan
127                .work_items
128                .get(&edge.successor)
129                .map(|w| w.contract.join);
130            return match (join, self.choice_at.get(&edge.predecessor)) {
131                // Before the choice was made, the branch was still open, not absent.
132                (Some(_), Some(EventTime::Recorded(at))) if *at > self.now => {
133                    Release::AwaitingEvent
134                }
135                (Some(join), Some(at)) if join.skips_unselected() => {
136                    Release::SkippedBranch { at: *at }
137                }
138                _ => Release::NotSelected,
139            };
140        }
141        let event = self.event(plan, edge.predecessor, edge.kind.predecessor_endpoint());
142        Release::evaluate_with(event, edge.lag_hours, self.now, |at, hours| {
143            plan.lag_end(edge, at, hours)
144        })
145    }
146
147    /// Decisions naming this work or a containing package as blocked, with their release state.
148    #[must_use]
149    pub fn decisions<'a>(&self, plan: &'a Plan, work: WorkItemId) -> Vec<(&'a Decision, Release)> {
150        let Some(scope) = ancestors(plan, work) else {
151            return Vec::new();
152        };
153        let ids: BTreeSet<_> = scope
154            .iter()
155            .flat_map(|id| self.index.decisions.get(id).into_iter().flatten())
156            .collect();
157        ids.into_iter()
158            .filter_map(|id| plan.decisions.get(id))
159            .map(|d| (d, Release::evaluate(gate_event(d), 0.0, self.now)))
160            .collect()
161    }
162
163    /// Decisions whose standing outcomes select applicable work through its own or its containers'
164    /// conditions, each with the time that outcome has held; empty for work that is not applicable.
165    ///
166    /// Work cannot have taken a transition before the choice that put it in the plan was made.
167    #[must_use]
168    pub fn selecting_choices<'a>(
169        &self,
170        plan: &'a Plan,
171        work: WorkItemId,
172    ) -> Vec<(&'a Decision, EventTime)> {
173        if !self.applicability(work).is_applicable() {
174            return Vec::new();
175        }
176        let Some(scope) = ancestors(plan, work) else {
177            return Vec::new();
178        };
179        let choices: BTreeMap<_, _> = scope
180            .iter()
181            .filter_map(|id| plan.work_items.get(id)?.contract.condition.as_ref())
182            .filter_map(|condition| applicability::effective_decision(plan, condition.decision))
183            .filter_map(|d| applicability::standing_since(plan, d).map(|at| (d.id, (d, at))))
184            .collect();
185        choices.into_values().collect()
186    }
187
188    fn aggregate_completion(&self, plan: &Plan, work: &WorkItem) -> Option<EventTime> {
189        if work.execution.status != WorkStatus::Planned
190            || !self.applicability(work.id).is_applicable()
191        {
192            return None;
193        }
194        let prerequisites: Vec<Release> = match work.kind {
195            WorkKind::Task => return None,
196            WorkKind::Milestone => self
197                .incoming(plan, work.id)
198                .map(|d| self.edge(plan, d))
199                .collect(),
200            WorkKind::WorkPackage => self
201                .index
202                .children
203                .get(&work.id)
204                .into_iter()
205                .flatten()
206                .filter_map(|id| plan.work_items.get(id))
207                .map(|w| match self.choice_at.get(&w.id) {
208                    Some(at) if self.applicability(w.id).is_not_selected() => {
209                        Release::SkippedBranch { at: *at }
210                    }
211                    _ => self
212                        .completed_at(w.id)
213                        .map_or(Release::AwaitingEvent, |at| Release::Released { at }),
214                })
215                .collect(),
216        };
217        // Without one branch that happened, completion would rest only on excluded work.
218        let permits_empty = matches!(
219            work.contract.join,
220            crate::JoinPolicy::ActiveBranches { allow_empty: true }
221        );
222        let happened = prerequisites
223            .iter()
224            .any(|r| !matches!(r, Release::SkippedBranch { .. }));
225        if prerequisites.is_empty() || (!happened && !permits_empty) {
226            return None;
227        }
228        let gates = self.decisions(plan, work.id).into_iter().map(|(_, r)| r);
229        let chosen = self
230            .choice_at
231            .get(&work.id)
232            .map(|at| Release::Released { at: *at });
233        prerequisites
234            .into_iter()
235            .chain(gates)
236            .chain(chosen)
237            .try_fold(None, |latest: Option<EventTime>, release| {
238                let at = release.released_at()?;
239                Some(Some(latest.map_or(at, |l| l.latest(at))))
240            })
241            .flatten()
242    }
243}
244
245/// Derive completed task, milestone and package identities at a clock reading.
246#[must_use]
247pub fn completion(plan: &Plan, now: DateTime<Utc>) -> BTreeSet<WorkItemId> {
248    Timeline::at(plan, now).completed()
249}
250
251/// A task's own event: its recorded time, or unknown when its lifecycle shows it occurred earlier.
252fn task_event(work: &WorkItem, endpoint: Endpoint) -> Option<EventTime> {
253    match endpoint {
254        Endpoint::Start => work.start_event(),
255        Endpoint::Finish => match work.execution.events.verified_at {
256            Some(at) => Some(EventTime::Recorded(at)),
257            None => work
258                .execution
259                .status
260                .satisfies_dependency()
261                .then_some(EventTime::Unrecorded),
262        },
263    }
264}
265
266/// A gate is released once it is no longer open, whether decided or superseded.
267fn gate_event(decision: &Decision) -> Option<EventTime> {
268    (decision.status != crate::DecisionStatus::Open).then(|| {
269        decision
270            .resolved_at
271            .map_or(EventTime::Unrecorded, EventTime::Recorded)
272    })
273}
274
275fn ancestors(plan: &Plan, work: WorkItemId) -> Option<BTreeSet<WorkItemId>> {
276    let mut ids = BTreeSet::new();
277    let mut next = Some(work);
278    while let Some(id) = next {
279        if !ids.insert(id) {
280            return None;
281        }
282        next = plan.work_items.get(&id)?.parent;
283    }
284    Some(ids)
285}
286
287#[cfg(test)]
288mod tests;