Skip to main content

dpm_engine/
query.rs

1use crate::{EngineError, Transition, gates};
2use chrono::{DateTime, Utc};
3use dpm_model::{
4    Applicability, BasisStatus, DecisionStatus, Plan, Timeline, WorkItem, WorkItemId, WorkStatus,
5    basis_status,
6};
7use dpm_schedule::{
8    RemainingOptions, SimulationConfig, SimulationSummary, deterministic_remaining_with,
9    simulate_remaining_with,
10};
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet, VecDeque};
13
14pub(crate) mod choices;
15pub use choices::{InapplicableWork, MAX_SCENARIOS, OpenChoices, ScenarioForecast};
16mod estimates;
17pub use estimates::{is_unestimated, unestimated};
18mod explain;
19pub use explain::{BasisReport, WorkExplanation, explain_work};
20mod advisory;
21pub use advisory::{Advisory, advisories};
22mod calibration;
23pub use calibration::{
24    AppliedCalibration, AppliedFactor, AppliedReviewDelay, BULK_WINDOW_SECONDS, CalibrationReport,
25    CalibrationRules, EstimateCalibration, EstimateSample, Exclusion, ExclusionReason,
26    HistoryCoverage, HourBasis, MAX_APPLIED_RATIO, MIN_ACTUAL_SECONDS, MIN_AGENT_APPLIED_RATIO,
27    MIN_APPLIED_RATIO, MIN_SAMPLES, REVIEW_FLOOR_SECONDS, RatioGroup, ReasonCount, WaitGroup,
28    WaitReport, calibration, min_applied_ratio,
29};
30mod forecast;
31pub use forecast::{
32    ForecastSpread, MilestoneForecast, PathStep, PertApproximationReport, RiskForecast,
33};
34mod next;
35pub use next::{CriticalityBasis, NextWorkCandidate, NextWorkQuery, next_work};
36mod flow;
37pub use flow::{
38    AgingWork, ClaimOutcomes, Durations, FlowReport, HolderOutcomes, Reliability, THROUGHPUT_WEEKS,
39    Throughput, WeekCount,
40};
41#[derive(Debug, Clone, Serialize, Deserialize)]
42/// Execution counts and remaining schedule projections.
43pub struct StatusSummary {
44    /// Claim eligibility of each leaf task, using the same policy as commands and next.
45    pub gates: BTreeMap<WorkItemId, crate::GateReport>,
46    /// Reported execution progress, separate from verified completion.
47    pub progress: crate::ProgressSummary,
48    /// Authoritative wrapping revision used for this projection.
49    pub revision: u64,
50    /// Count of all tasks, milestones, and containers.
51    pub total_work: usize,
52    /// Number of tasks that can be claimed now.
53    pub ready: usize,
54    /// Number of explicitly blocked tasks within the counted scope.
55    pub blocked: usize,
56    /// Number of claimed or in-progress tasks within the counted scope.
57    pub in_flight: usize,
58    /// Number of tasks awaiting independent verification within the counted scope.
59    pub awaiting_verification: usize,
60    /// Claimed, in-progress, blocked or submitted tasks outside the counted scope: a reviewed
61    /// choice change excluded them, so they take no transition until the plan changes again.
62    ///
63    /// The counted scope is the one progress and `complete` use, so no lifecycle count includes
64    /// work that no longer counts toward completion.
65    #[serde(default)]
66    pub excluded_in_flight: usize,
67    /// Number of tasks whose enforced provisional basis rests on a rejected predecessor attempt.
68    #[serde(default)]
69    pub basis_invalidated: usize,
70    /// Count of completed tasks and derived aggregate completions.
71    pub complete: usize,
72    /// Number of unresolved decisions, whether contextual or blocking.
73    pub open_decisions: usize,
74    /// Deterministic remaining duration of the active graph in elapsed hours; while choices are
75    /// open it covers committed work only, and `open_choices` forecasts each scenario.
76    pub expected_finish_hours: f64,
77    /// Median sampled completion time in elapsed hours; withheld while choices are open.
78    pub p50_finish_hours: Option<f64>,
79    /// 80th percentile sampled completion time in elapsed hours; withheld while choices are open.
80    pub p80_finish_hours: Option<f64>,
81    /// 95th percentile sampled completion time in elapsed hours; withheld while choices are open.
82    pub p95_finish_hours: Option<f64>,
83    /// Milestone percentiles, horizon probabilities and the analytic PERT approximation; null
84    /// whenever the percentiles are withheld.
85    #[serde(default)]
86    pub forecast: Option<RiskForecast>,
87    /// Tasks the headline forecast counts as 0 h because they have no estimate, in key order
88    /// ([`is_unestimated`](crate::is_unestimated)); the forecast is optimistic by their durations.
89    #[serde(default)]
90    pub unestimated: Vec<dpm_model::Key>,
91    /// Work outside the active graph, with the reason; it is in no forecast and never ready.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub not_applicable: Vec<InapplicableWork>,
94    /// Open decisions that condition work, with one forecast per option combination.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub open_choices: Option<OpenChoices>,
97    /// Measured factors the forecasts applied, present only for a calibrated status.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub calibration: Option<AppliedCalibration>,
100}
101
102pub(crate) fn simulation_config() -> SimulationConfig {
103    SimulationConfig {
104        iterations: 2_000,
105        ..SimulationConfig::default()
106    }
107}
108
109/// Claim readiness of every leaf task, evaluated once for one timeline.
110fn claim_reports(plan: &Plan, timeline: &Timeline) -> BTreeMap<WorkItemId, crate::GateReport> {
111    plan.work_items
112        .values()
113        .filter(|w| w.is_executable())
114        .map(|w| (w.id, gates::evaluate(plan, w, Transition::Claim, timeline)))
115        .collect()
116}
117
118/// Project validated lifecycle counts and remaining duration at an adapter-supplied time.
119pub fn status(
120    plan: &Plan,
121    probabilistic: bool,
122    now: DateTime<Utc>,
123) -> Result<StatusSummary, EngineError> {
124    status_with(plan, probabilistic, now, None, None)
125}
126
127/// [`status`] whose forecast also states the probability of finishing the project, and of
128/// reaching each milestone, within `horizon_hours` elapsed hours of `now`.
129pub fn status_at_horizon(
130    plan: &Plan,
131    probabilistic: bool,
132    now: DateTime<Utc>,
133    horizon_hours: Option<f64>,
134) -> Result<StatusSummary, EngineError> {
135    status_with(plan, probabilistic, now, None, horizon_hours)
136}
137
138/// [`status`] whose forecasts scale each unfinished task's estimate by its executor kind's
139/// measured ratio and add the verifier kind's measured review wait, each only when enough samples
140/// support it; `calibration` in the result states what was applied and why. The plan itself is
141/// never changed.
142pub fn status_calibrated(
143    plan: &Plan,
144    probabilistic: bool,
145    now: DateTime<Utc>,
146    report: &CalibrationReport,
147) -> Result<StatusSummary, EngineError> {
148    status_with(plan, probabilistic, now, Some(report), None)
149}
150
151/// [`status_calibrated`] with the horizon probabilities of [`status_at_horizon`].
152pub fn status_calibrated_at_horizon(
153    plan: &Plan,
154    probabilistic: bool,
155    now: DateTime<Utc>,
156    report: &CalibrationReport,
157    horizon_hours: Option<f64>,
158) -> Result<StatusSummary, EngineError> {
159    status_with(plan, probabilistic, now, Some(report), horizon_hours)
160}
161
162/// The seeded simulation of `forecast`, unless it is not requested, no work of `plan` is
163/// estimated, or open choices leave mutually exclusive branches without a probability model.
164fn sampled(
165    (plan, forecast): (&Plan, &Plan),
166    timeline: &Timeline,
167    options: RemainingOptions,
168    probabilistic: bool,
169    open_choices: Option<&OpenChoices>,
170) -> Result<Option<SimulationSummary>, EngineError> {
171    let estimated = plan
172        .work_items
173        .values()
174        .any(|w| w.schedule.estimate.is_some());
175    if !probabilistic || open_choices.is_some() || !estimated {
176        return Ok(None);
177    }
178    Ok(Some(simulate_remaining_with(
179        forecast,
180        simulation_config(),
181        timeline,
182        options,
183    )?))
184}
185
186fn status_with(
187    plan: &Plan,
188    probabilistic: bool,
189    now: DateTime<Utc>,
190    report: Option<&CalibrationReport>,
191    horizon_hours: Option<f64>,
192) -> Result<StatusSummary, EngineError> {
193    plan.validate()?;
194    forecast::validate_horizon(horizon_hours)?;
195    let timeline = Timeline::at(plan, now);
196    let calibrated = report.map(|r| calibration::calibrated_forecast(plan, r));
197    let (forecast, options) = match &calibrated {
198        Some((scaled, options, _)) => (scaled, *options),
199        None => (plan, RemainingOptions::default()),
200    };
201    let forecast_timeline = Timeline::at(forecast, now);
202    let schedule = deterministic_remaining_with(forecast, &forecast_timeline, options)?;
203    let open_choices = choices::open_choices(forecast, probabilistic, now, options)?;
204    let simulation = sampled(
205        (plan, forecast),
206        &forecast_timeline,
207        options,
208        probabilistic,
209        open_choices.as_ref(),
210    )?;
211    let risk = simulation
212        .as_ref()
213        .map(|sampled| {
214            let inputs = (forecast, &forecast_timeline, options);
215            forecast::risk_forecast(plan, inputs, sampled, horizon_hours)
216        })
217        .transpose()?;
218    let gates = claim_reports(plan, &timeline);
219
220    Ok(StatusSummary {
221        ready: gates.values().filter(|report| report.ready).count(),
222        gates,
223        progress: crate::progress::progress_with(plan, &timeline).overall,
224        revision: plan.revision,
225        total_work: plan.work_items.len(),
226        blocked: count(plan, &timeline, true, &[WorkStatus::Blocked]),
227        in_flight: count(
228            plan,
229            &timeline,
230            true,
231            &[WorkStatus::Claimed, WorkStatus::InProgress],
232        ),
233        awaiting_verification: count(plan, &timeline, true, &[WorkStatus::Submitted]),
234        excluded_in_flight: excluded_in_flight(plan, &timeline).len(),
235        basis_invalidated: plan
236            .work_items
237            .values()
238            .filter(|w| basis_status(plan, w).iter().any(BasisStatus::gates))
239            .count(),
240        complete: timeline.completed().len(),
241        open_decisions: plan
242            .decisions
243            .values()
244            .filter(|decision| decision.status == DecisionStatus::Open)
245            .count(),
246        expected_finish_hours: schedule.project_finish_hours,
247        p50_finish_hours: simulation.as_ref().map(|s| s.p50_finish_hours),
248        p80_finish_hours: simulation.as_ref().map(|s| s.p80_finish_hours),
249        p95_finish_hours: simulation.as_ref().map(|s| s.p95_finish_hours),
250        forecast: risk,
251        unestimated: unestimated(plan, &timeline),
252        not_applicable: choices::not_applicable(plan, &timeline),
253        open_choices,
254        calibration: calibrated.map(|(_, _, applied)| applied),
255    })
256}
257
258/// Tasks in one of `statuses`, inside (`counted`) or outside the status scope.
259fn count(plan: &Plan, timeline: &Timeline, counted: bool, statuses: &[WorkStatus]) -> usize {
260    plan.work_items
261        .values()
262        .filter(|w| statuses.contains(&w.execution.status))
263        .filter(|w| in_status_scope(timeline, w.id) == counted)
264        .count()
265}
266
267/// Whether work's lifecycle counts in `status` lifecycle counts, `complete` and progress: its own
268/// conditions are selected. Views that list work behind those counts use this scope, so a list
269/// never shows work its count leaves out.
270#[must_use]
271pub fn in_status_scope(timeline: &Timeline, work: WorkItemId) -> bool {
272    timeline.applicability(work).condition_selected()
273}
274
275/// Claimed, started, blocked or submitted work outside the status scope: a reviewed choice change
276/// excluded it, so it takes no transition until the plan changes again. `status` counts it as
277/// `excluded_in_flight`.
278#[must_use]
279pub fn excluded_in_flight<'a>(plan: &'a Plan, timeline: &Timeline) -> Vec<&'a WorkItem> {
280    const IN_FLIGHT: [WorkStatus; 4] = [
281        WorkStatus::Claimed,
282        WorkStatus::InProgress,
283        WorkStatus::Blocked,
284        WorkStatus::Submitted,
285    ];
286    plan.work_items
287        .values()
288        .filter(|w| IN_FLIGHT.contains(&w.execution.status) && !in_status_scope(timeline, w.id))
289        .collect()
290}
291
292/// Transitive successors that completing work could still release; excluded or stranded work
293/// is not counted because no completion releases it. Only enforced edges count: a waived edge
294/// gates nothing, so ranking must not reward releasing it.
295fn downstream_counts(
296    plan: &Plan,
297    timeline: &Timeline,
298    starts: impl IntoIterator<Item = WorkItemId>,
299) -> BTreeMap<WorkItemId, usize> {
300    let mut outgoing = BTreeMap::<WorkItemId, Vec<WorkItemId>>::new();
301    for dep in plan.enforced_dependencies().filter(|d| {
302        let state = timeline.applicability(d.successor);
303        !(state.is_not_selected()
304            || matches!(
305                state,
306                Applicability::Stranded { .. }
307                    | Applicability::ChildrenStranded { .. }
308                    | Applicability::EmptyJoin
309            ))
310    }) {
311        outgoing
312            .entry(dep.predecessor)
313            .or_default()
314            .push(dep.successor);
315    }
316
317    let mut result = BTreeMap::new();
318    for start in starts {
319        let mut seen = BTreeSet::new();
320        let mut queue = VecDeque::from([start]);
321        while let Some(node) = queue.pop_front() {
322            if let Some(next) = outgoing.get(&node) {
323                for child in next {
324                    if seen.insert(*child) {
325                        queue.push_back(*child);
326                    }
327                }
328            }
329        }
330        result.insert(start, seen.len());
331    }
332    result
333}