Skip to main content

dpm_model/
applicability.rs

1use crate::{
2    Decision, DecisionId, DecisionStatus, DependencyId, EventTime, Key, Plan, WorkItem, WorkItemId,
3    WorkKind,
4};
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, VecDeque};
7
8mod packages;
9
10/// Whether work belongs to the active execution graph, derived from decisions and constraints.
11///
12/// Only `Applicable` work can take lifecycle transitions or enter the remaining schedule. The
13/// state is recomputed from authoritative decisions, conditions, join policies and unwaived
14/// constraints; it is never persisted.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "state", rename_all = "snake_case")]
17pub enum Applicability {
18    /// Every condition names the selected option and every prerequisite can proceed.
19    Applicable,
20    /// A condition on the work or a containing package awaits an open decision.
21    Undecided {
22        /// Decision that has not selected an option.
23        decision: Key,
24        /// Option the work requires.
25        option: String,
26    },
27    /// A decision selected a different option than a condition on the work or a container.
28    NotSelected {
29        /// Decision that excluded the work.
30        decision: Key,
31        /// Option the work requires.
32        option: String,
33        /// Option the decision selected.
34        selected: String,
35    },
36    /// A prerequisite is undecided or itself waits for a choice, so the work's place in the plan
37    /// is not yet committed. For a work package with no applicable child, the prerequisite is a
38    /// child that still waits for a choice.
39    AwaitingChoice {
40        /// Prerequisite, or package child, whose applicability is still unknown.
41        predecessor: Key,
42    },
43    /// An ordinary constraint from not-selected work, or from work that can never proceed, never
44    /// releases; only a reviewed plan change can let this work proceed.
45    Stranded {
46        /// Constraint that can never be released.
47        dependency: DependencyId,
48        /// Predecessor of that constraint.
49        predecessor: Key,
50    },
51    /// Every branch into an active-branch join was excluded and the join does not permit that.
52    EmptyJoin,
53    /// Every child of this work package was excluded by a choice, so the package is excluded as
54    /// well: it holds no work that could complete it.
55    AllChildrenExcluded {
56        /// Decisions whose selected options excluded the children, in key order.
57        decisions: Vec<Key>,
58    },
59    /// No child of this work package is applicable and at least one that a choice did not exclude
60    /// is stranded or an empty join, so the package can never complete without a plan change.
61    ChildrenStranded {
62        /// First such child, in key order.
63        child: Key,
64    },
65}
66
67impl Applicability {
68    /// Whether the work belongs to the active graph.
69    #[must_use]
70    pub fn is_applicable(&self) -> bool {
71        matches!(self, Self::Applicable)
72    }
73
74    /// Whether a choice excluded the work from the plan's scope, directly or through every child
75    /// of a work package.
76    #[must_use]
77    pub fn is_not_selected(&self) -> bool {
78        matches!(
79            self,
80            Self::NotSelected { .. } | Self::AllChildrenExcluded { .. }
81        )
82    }
83
84    /// Whether the work's own conditions are selected, so its lifecycle may count as completion.
85    #[must_use]
86    pub fn condition_selected(&self) -> bool {
87        !matches!(self, Self::NotSelected { .. } | Self::Undecided { .. })
88    }
89}
90
91/// Applicability of every work item, with the time each choice that affects it was made.
92pub(crate) struct Derived {
93    pub(crate) states: BTreeMap<WorkItemId, Applicability>,
94    /// For excluded work, when it was excluded; for selected conditional work, when the latest of
95    /// its conditions was decided.
96    pub(crate) choice_at: BTreeMap<WorkItemId, EventTime>,
97}
98
99/// Derive applicability: conditions through containment first, then constraints in dependency
100/// order, then work packages from their children.
101pub(crate) fn derive(plan: &Plan) -> Derived {
102    derive_indexed(plan, &crate::graph_index::GraphIndex::new(plan))
103}
104
105pub(crate) fn derive_indexed(plan: &Plan, index: &crate::graph_index::GraphIndex) -> Derived {
106    let mut derived = Derived {
107        states: BTreeMap::new(),
108        choice_at: BTreeMap::new(),
109    };
110    for work in plan.work_items.values() {
111        let (state, at) = conditions(plan, work);
112        if let Some(at) = at {
113            derived.choice_at.insert(work.id, at);
114        }
115        derived.states.insert(work.id, state);
116    }
117    for id in dependency_order(plan) {
118        let Some(work) = plan.work_items.get(&id) else {
119            continue;
120        };
121        // A selected task already verified has taken every transition; its prerequisites no longer
122        // decide whether it can proceed, so a later choice change cannot strand it.
123        let finished = work.is_executable() && work.execution.status.satisfies_dependency();
124        if work.kind == WorkKind::WorkPackage
125            || finished
126            || !derived
127                .states
128                .get(&id)
129                .is_some_and(Applicability::is_applicable)
130        {
131            continue;
132        }
133        let state = through_constraints(plan, index, work, &derived.states);
134        derived.states.insert(id, state);
135    }
136    packages::derive(plan, index, &mut derived);
137    derived
138}
139
140impl Plan {
141    /// Applicability of every work item; time-independent, so reviews can compare two plans.
142    #[must_use]
143    pub fn applicability(&self) -> BTreeMap<WorkItemId, Applicability> {
144        derive(self).states
145    }
146
147    /// The decision whose outcome currently stands for `id`, following replacements.
148    #[must_use]
149    pub fn effective_decision(&self, id: DecisionId) -> Option<&Decision> {
150        effective_decision(self, id)
151    }
152}
153
154/// The decision whose outcome currently stands for `id`, following replacements.
155pub(crate) fn effective_decision(plan: &Plan, id: DecisionId) -> Option<&Decision> {
156    let mut current = plan.decisions.get(&id)?;
157    let mut steps = 0_usize;
158    while let Some(next) = plan
159        .decisions
160        .values()
161        .find(|d| d.supersedes == Some(current.id))
162    {
163        current = next;
164        steps = steps.wrapping_add(1);
165        if steps > plan.decisions.len() {
166            break;
167        }
168    }
169    Some(current)
170}
171
172/// Since when the standing outcome of a resolved decision has held; `None` while open or withdrawn
173/// without replacement.
174///
175/// A replacement that reaffirms the outcome it supersedes does not re-make the choice: work the
176/// outcome selected or excluded was already released at the earlier resolution, and moving that
177/// time would re-close gates that execution has relied on. The time is therefore the earliest
178/// resolution in the unbroken run of equal outcomes ending at `decision`; a changed outcome starts
179/// a new run at its own resolution.
180pub(crate) fn standing_since(plan: &Plan, decision: &Decision) -> Option<EventTime> {
181    if decision.status != DecisionStatus::Decided {
182        return None;
183    }
184    let mut since = own_time(decision);
185    let mut current = decision;
186    let mut steps = 0_usize;
187    while let Some(previous) = current.supersedes.and_then(|id| plan.decisions.get(&id)) {
188        steps = steps.wrapping_add(1);
189        if previous.outcome != decision.outcome || steps > plan.decisions.len() {
190            break;
191        }
192        since = earliest(since, own_time(previous));
193        current = previous;
194    }
195    Some(since)
196}
197
198fn own_time(decision: &Decision) -> EventTime {
199    decision
200        .resolved_at
201        .map_or(EventTime::Unrecorded, EventTime::Recorded)
202}
203
204/// An unrecorded time predates recording, so it is the earlier of the two.
205fn earliest(a: EventTime, b: EventTime) -> EventTime {
206    match (a, b) {
207        (EventTime::Recorded(a), EventTime::Recorded(b)) => EventTime::Recorded(a.min(b)),
208        _ => EventTime::Unrecorded,
209    }
210}
211
212fn conditions(plan: &Plan, work: &WorkItem) -> (Applicability, Option<EventTime>) {
213    let mut undecided = None;
214    let mut decided: Option<EventTime> = None;
215    let mut next = Some(work);
216    let mut depth = 0_usize;
217    while let Some(item) = next {
218        if let Some(condition) = &item.contract.condition
219            && let Some(decision) = effective_decision(plan, condition.decision)
220        {
221            match (standing_since(plan, decision), decision.outcome.as_deref()) {
222                (Some(at), Some(selected)) if selected != condition.option => {
223                    let state = Applicability::NotSelected {
224                        decision: decision.key.clone(),
225                        option: condition.option.clone(),
226                        selected: selected.into(),
227                    };
228                    return (state, Some(at));
229                }
230                (Some(at), Some(_)) => decided = Some(decided.map_or(at, |d| d.latest(at))),
231                _ => {
232                    undecided.get_or_insert_with(|| Applicability::Undecided {
233                        decision: decision.key.clone(),
234                        option: condition.option.clone(),
235                    });
236                }
237            }
238        }
239        depth = depth.wrapping_add(1);
240        next = item
241            .parent
242            .and_then(|id| plan.work_items.get(&id))
243            .filter(|_| depth <= plan.work_items.len());
244    }
245    match undecided {
246        Some(state) => (state, None),
247        None => (Applicability::Applicable, decided),
248    }
249}
250
251fn through_constraints(
252    plan: &Plan,
253    index: &crate::graph_index::GraphIndex,
254    work: &WorkItem,
255    states: &BTreeMap<WorkItemId, Applicability>,
256) -> Applicability {
257    let mut awaiting = None;
258    let (mut incoming, mut active) = (0_usize, 0_usize);
259    for edge in index
260        .incoming
261        .get(&work.id)
262        .into_iter()
263        .flatten()
264        .filter_map(|i| plan.dependencies.get(*i))
265    {
266        incoming = incoming.wrapping_add(1);
267        let Some(predecessor) = plan.work_items.get(&edge.predecessor) else {
268            continue;
269        };
270        match states.get(&edge.predecessor) {
271            Some(state) if state.is_not_selected() && work.contract.join.skips_unselected() => {}
272            Some(
273                Applicability::NotSelected { .. }
274                | Applicability::AllChildrenExcluded { .. }
275                | Applicability::Stranded { .. }
276                | Applicability::ChildrenStranded { .. }
277                | Applicability::EmptyJoin,
278            ) => {
279                return Applicability::Stranded {
280                    dependency: edge.id,
281                    predecessor: predecessor.key.clone(),
282                };
283            }
284            Some(Applicability::Undecided { .. } | Applicability::AwaitingChoice { .. }) => {
285                awaiting.get_or_insert_with(|| predecessor.key.clone());
286                active = active.wrapping_add(1);
287            }
288            Some(Applicability::Applicable) | None => active = active.wrapping_add(1),
289        }
290    }
291    if let Some(predecessor) = awaiting {
292        return Applicability::AwaitingChoice { predecessor };
293    }
294    let permits_empty = matches!(
295        work.contract.join,
296        crate::JoinPolicy::ActiveBranches { allow_empty: true }
297    );
298    if work.contract.join.skips_unselected() && incoming > 0 && active == 0 && !permits_empty {
299        return Applicability::EmptyJoin;
300    }
301    Applicability::Applicable
302}
303
304/// Work in dependency order; items on a cycle (rejected by validation) are omitted.
305fn dependency_order(plan: &Plan) -> Vec<WorkItemId> {
306    let mut indegree: BTreeMap<_, usize> = plan.work_items.keys().map(|id| (*id, 0)).collect();
307    let mut outgoing: BTreeMap<_, Vec<_>> = BTreeMap::new();
308    for edge in &plan.dependencies {
309        if let Some(count) = indegree.get_mut(&edge.successor) {
310            *count = count.wrapping_add(1);
311            outgoing
312                .entry(edge.predecessor)
313                .or_default()
314                .push(edge.successor);
315        }
316    }
317    let mut queue: VecDeque<_> = indegree
318        .iter()
319        .filter_map(|(id, n)| (*n == 0).then_some(*id))
320        .collect();
321    let mut order = Vec::with_capacity(plan.work_items.len());
322    while let Some(id) = queue.pop_front() {
323        order.push(id);
324        for child in outgoing.get(&id).into_iter().flatten() {
325            if let Some(count) = indegree.get_mut(child) {
326                *count = count.saturating_sub(1);
327                if *count == 0 {
328                    queue.push_back(*child);
329                }
330            }
331        }
332    }
333    order
334}
335
336#[cfg(test)]
337mod tests;