Skip to main content

dpm_engine/query/
choices.rs

1//! Projections of conditional work: what is outside the active graph, and one forecast per
2//! combination of open choices instead of a single percentile over mutually exclusive branches.
3
4use super::simulation_config;
5use crate::EngineError;
6use chrono::{DateTime, Utc};
7use dpm_model::{Applicability, DecisionId, DecisionStatus, Key, Plan, Timeline};
8use dpm_schedule::{RemainingOptions, deterministic_remaining_with, simulate_remaining_with};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11
12/// Scenario forecasts are computed only up to this many option combinations.
13pub const MAX_SCENARIOS: usize = 16;
14
15/// Work outside the active graph, with the derived reason.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct InapplicableWork {
18    /// Human-readable work key.
19    pub key: Key,
20    /// Why the work cannot take transitions or enter the forecast.
21    pub applicability: Applicability,
22}
23
24/// Open decisions whose options decide which conditional work applies.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct OpenChoices {
27    /// Open decisions, by key, that condition work.
28    pub decisions: Vec<Key>,
29    /// Number of option combinations across those decisions.
30    pub scenario_count: usize,
31    /// One forecast per combination; empty when `scenario_count` exceeds [`MAX_SCENARIOS`].
32    pub scenarios: Vec<ScenarioForecast>,
33}
34
35/// Remaining forecast if the open decisions select these options now.
36///
37/// No probability is assigned to a scenario, so scenarios are never merged into one percentile.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct ScenarioForecast {
40    /// Option key selected for each open decision.
41    pub choices: BTreeMap<Key, String>,
42    /// Deterministic remaining duration of this scenario's active graph, in elapsed hours.
43    pub expected_finish_hours: f64,
44    /// Median sampled completion of this scenario, when requested.
45    pub p50_finish_hours: Option<f64>,
46    /// 80th percentile sampled completion of this scenario, when requested.
47    pub p80_finish_hours: Option<f64>,
48    /// 95th percentile sampled completion of this scenario, when requested.
49    pub p95_finish_hours: Option<f64>,
50    /// Work that could not proceed in this scenario without a plan change.
51    pub stranded: Vec<Key>,
52    /// Tasks this scenario's forecast counts as 0 h because they have no estimate, in key order.
53    #[serde(default)]
54    pub unestimated: Vec<Key>,
55}
56
57pub(crate) fn not_applicable(plan: &Plan, timeline: &Timeline) -> Vec<InapplicableWork> {
58    let mut work: Vec<_> = plan
59        .work_items
60        .values()
61        .filter(|w| !timeline.applicability(w.id).is_applicable())
62        .map(|w| InapplicableWork {
63            key: w.key.clone(),
64            applicability: timeline.applicability(w.id).clone(),
65        })
66        .collect();
67    work.sort_by(|a, b| a.key.natural_cmp(&b.key));
68    work
69}
70
71/// Open decisions that condition work, in key order.
72fn open_decisions(plan: &Plan) -> Vec<(DecisionId, Key, Vec<String>)> {
73    let ids: BTreeSet<_> = plan
74        .work_items
75        .values()
76        .filter_map(|w| w.contract.condition.as_ref())
77        .filter_map(|c| plan.effective_decision(c.decision))
78        .filter(|d| d.status == DecisionStatus::Open)
79        .map(|d| d.id)
80        .collect();
81    let mut open: Vec<_> = ids
82        .into_iter()
83        .filter_map(|id| plan.decisions.get(&id))
84        .map(|d| {
85            let options = d.options.iter().map(|o| o.key.clone()).collect();
86            (d.id, d.key.clone(), options)
87        })
88        .collect();
89    open.sort_by(|a, b| a.1.cmp(&b.1));
90    open
91}
92
93/// Whether an open decision conditions work, which is when `open_choices` forecasts scenarios.
94pub(crate) fn has_open_choices(plan: &Plan) -> bool {
95    !open_decisions(plan).is_empty()
96}
97
98/// Forecast each combination of open choices, or `None` when no open choice conditions work.
99pub(crate) fn open_choices(
100    plan: &Plan,
101    probabilistic: bool,
102    now: DateTime<Utc>,
103    options: RemainingOptions,
104) -> Result<Option<OpenChoices>, EngineError> {
105    let open = open_decisions(plan);
106    if open.is_empty() {
107        return Ok(None);
108    }
109    let scenario_count = open.iter().fold(1_usize, |n, (_, _, options)| {
110        n.saturating_mul(options.len())
111    });
112    let mut scenarios = Vec::new();
113    if scenario_count <= MAX_SCENARIOS {
114        for index in 0..scenario_count {
115            let mut rest = index;
116            let choices: Vec<_> = open
117                .iter()
118                .map(|(id, key, options)| {
119                    let count = options.len().max(1);
120                    let option = options.get(rest % count).cloned().unwrap_or_default();
121                    rest /= count;
122                    (*id, key.clone(), option)
123                })
124                .collect();
125            scenarios.push(forecast(plan, &choices, (probabilistic, options), now)?);
126        }
127    }
128    Ok(Some(OpenChoices {
129        decisions: open.into_iter().map(|(_, key, _)| key).collect(),
130        scenario_count,
131        scenarios,
132    }))
133}
134
135fn forecast(
136    plan: &Plan,
137    choices: &[(DecisionId, Key, String)],
138    (probabilistic, options): (bool, RemainingOptions),
139    now: DateTime<Utc>,
140) -> Result<ScenarioForecast, EngineError> {
141    let mut hypothetical = plan.clone();
142    for (id, _, option) in choices {
143        if let Some(decision) = hypothetical.decisions.get_mut(id) {
144            decision.status = DecisionStatus::Decided;
145            decision.outcome = Some(option.clone());
146            decision.resolved_at = Some(now);
147        }
148    }
149    let timeline = Timeline::at(&hypothetical, now);
150    let schedule = deterministic_remaining_with(&hypothetical, &timeline, options)?;
151    let simulation = if probabilistic
152        && hypothetical
153            .work_items
154            .values()
155            .any(|w| w.schedule.estimate.is_some())
156    {
157        Some(simulate_remaining_with(
158            &hypothetical,
159            simulation_config(),
160            &timeline,
161            options,
162        )?)
163    } else {
164        None
165    };
166    let mut stranded: Vec<_> = hypothetical
167        .applicability()
168        .into_iter()
169        .filter(|(_, a)| {
170            matches!(
171                a,
172                Applicability::Stranded { .. }
173                    | Applicability::ChildrenStranded { .. }
174                    | Applicability::EmptyJoin
175            )
176        })
177        .filter_map(|(id, _)| hypothetical.work_items.get(&id).map(|w| w.key.clone()))
178        .collect();
179    stranded.sort_by(dpm_model::Key::natural_cmp);
180    Ok(ScenarioForecast {
181        choices: choices
182            .iter()
183            .map(|(_, key, option)| (key.clone(), option.clone()))
184            .collect(),
185        expected_finish_hours: schedule.project_finish_hours,
186        p50_finish_hours: simulation.as_ref().map(|s| s.p50_finish_hours),
187        p80_finish_hours: simulation.as_ref().map(|s| s.p80_finish_hours),
188        p95_finish_hours: simulation.as_ref().map(|s| s.p95_finish_hours),
189        stranded,
190        unestimated: super::unestimated(&hypothetical, &timeline),
191    })
192}