Skip to main content

dpm_engine/query/
explain.rs

1use super::{downstream_counts, simulation_config};
2use crate::{EngineError, GateReport, Transition, gates, readiness::show_with};
3use chrono::{DateTime, Utc};
4use dpm_model::{
5    Applicability, BasisStatus, Plan, Timeline, WorkItem, WorkItemId, basis_dependents,
6    basis_status,
7};
8use dpm_schedule::{
9    deterministic_remaining_at, drag_remaining_at, pert_approximation_remaining_at,
10    simulate_remaining_at,
11};
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, BTreeSet};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16/// Execution contract, prerequisites, and derived reason for a work item.
17pub struct WorkExplanation {
18    /// Claim eligibility, the same report `next` and `status` use.
19    pub gates: GateReport,
20    /// Eligibility of every lifecycle transition, so FF/SF finish gates are visible before a claim.
21    #[serde(default)]
22    pub transitions: BTreeMap<Transition, GateReport>,
23    /// Execution percentage, independent completion condition and completion time.
24    pub progress: crate::ProgressSummary,
25    /// Resolved requirements, gates, evidence and related work for the execution contract.
26    pub context: crate::ExecutionContext,
27    /// Work-item projection represented by this result, including recorded event times.
28    pub work: WorkItem,
29    /// Whether this task can be claimed now.
30    pub ready: bool,
31    /// Whether the work belongs to the active graph, derived from conditions, joins and choices.
32    #[serde(default = "applicable")]
33    pub applicability: Applicability,
34    /// Direct predecessor work items.
35    pub predecessors: Vec<WorkItem>,
36    /// Count of distinct transitively dependent work items.
37    pub downstream_count: usize,
38    /// Derived timing of this activity when available.
39    pub schedule: Option<dpm_schedule::ActivitySchedule>,
40    /// Whether the forecast counts this task as 0 h because it has no estimate, so `schedule`,
41    /// `criticality` and every forecast that includes it are optimistic; see
42    /// [`is_unestimated`](crate::is_unestimated).
43    #[serde(default)]
44    pub unestimated: bool,
45    /// Fraction of sampled schedules in which each activity is critical.
46    pub criticality: Option<f64>,
47    /// Hours the deterministic remaining finish would move if this work took no time: zero off
48    /// the critical path, negative when shortening it would delay the finish; absent for work
49    /// outside the active graph.
50    #[serde(default)]
51    pub drag_hours: Option<f64>,
52    /// Classical PERT variance `((P − O) / 6)²` of the remaining duration, in hours squared:
53    /// started work keeps only the range its elapsed time allows (`O` raised to the elapsed
54    /// hours), finished or unestimated work has none. It is the analytic approximation's input,
55    /// not the variance of the beta-PERT duration the simulation samples. Absent for work outside
56    /// the active graph.
57    #[serde(default)]
58    pub pert_variance_hours2: Option<f64>,
59    /// Readiness, blocker, gate, lifecycle, lag and schedule explanations.
60    pub why_now: Vec<String>,
61    /// Provisional bases this work relies on, and other work relying on its submission attempts.
62    #[serde(default)]
63    pub basis: BasisReport,
64}
65
66/// Derived validity of recorded provisional bases, read from the snapshot's attempt records.
67#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
68pub struct BasisReport {
69    /// Effective basis of this work on each provisional edge into it.
70    pub relies_on: Vec<BasisStatus>,
71    /// Effective bases of downstream work on this work's attempts, including rejected ones.
72    pub relied_on_by: Vec<BasisStatus>,
73}
74
75/// Explain a validated work item at an adapter-supplied time, including derived completion.
76pub fn explain_work(
77    plan: &Plan,
78    work: WorkItemId,
79    now: DateTime<Utc>,
80) -> Result<WorkExplanation, EngineError> {
81    plan.validate()?;
82    let timeline = Timeline::at(plan, now);
83    let item = show_with(plan, work, &timeline)?;
84    let schedule = deterministic_remaining_at(plan, &timeline)?;
85    let simulation = simulate_remaining_at(plan, simulation_config(), &timeline)?;
86    let predecessors = plan
87        .dependencies
88        .iter()
89        .filter(|dep| dep.successor == work)
90        .filter_map(|dep| plan.work_items.get(&dep.predecessor).cloned())
91        .collect::<Vec<_>>();
92    let downstream_count = downstream_counts(plan, &timeline, [work])
93        .get(&work)
94        .copied()
95        .unwrap_or(0);
96    let authoritative = plan
97        .work_items
98        .get(&work)
99        .ok_or(EngineError::MissingWorkItem(work))?;
100    let transitions: BTreeMap<_, _> = Transition::ALL
101        .into_iter()
102        .map(|t| (t, gates::evaluate(plan, authoritative, t, &timeline)))
103        .collect();
104    let gates = transitions
105        .get(&Transition::Claim)
106        .cloned()
107        .unwrap_or_else(|| gates::evaluate(plan, authoritative, Transition::Claim, &timeline));
108    let ready = gates.ready;
109    let criticality = simulation.criticality.get(&work).copied();
110    let (drag_hours, pert_variance_hours2) = risk(plan, &timeline, work)?;
111    let unestimated = super::is_unestimated(&timeline, authoritative);
112    let mut why_now = why_now(
113        plan,
114        authoritative,
115        &timeline,
116        &transitions,
117        downstream_count,
118        criticality,
119    );
120    if unestimated {
121        why_now.push(UNESTIMATED_REASON.into());
122    }
123
124    Ok(WorkExplanation {
125        gates,
126        transitions,
127        progress: crate::progress::progress_with(plan, &timeline)
128            .work
129            .get(&work)
130            .copied()
131            .ok_or(EngineError::MissingWorkItem(work))?,
132        context: crate::context::execution_context(plan, &item)?,
133        work: item,
134        ready,
135        applicability: timeline.applicability(work).clone(),
136        predecessors,
137        downstream_count,
138        schedule: schedule.activities.get(&work).cloned(),
139        unestimated,
140        criticality,
141        drag_hours,
142        pert_variance_hours2,
143        why_now,
144        basis: BasisReport {
145            relies_on: basis_status(plan, authoritative),
146            relied_on_by: basis_dependents(plan, work),
147        },
148    })
149}
150
151/// Drag and classical remaining variance of one work item in the remaining projection.
152fn risk(
153    plan: &Plan,
154    timeline: &Timeline,
155    work: WorkItemId,
156) -> Result<(Option<f64>, Option<f64>), EngineError> {
157    let drag = drag_remaining_at(plan, timeline, &BTreeSet::from([work]))?;
158    let approximation = pert_approximation_remaining_at(plan, timeline)?;
159    Ok((
160        drag.get(&work).copied(),
161        approximation.activity_variance_hours2.get(&work).copied(),
162    ))
163}
164
165/// Explanation shared by `explain` and `next` for a task the forecast counts as 0 h.
166pub(crate) const UNESTIMATED_REASON: &str = "no duration estimate: the forecast counts this task as 0 h, so its schedule, float and criticality are optimistic";
167
168fn applicable() -> Applicability {
169    Applicability::Applicable
170}
171
172fn why_now(
173    plan: &Plan,
174    work: &WorkItem,
175    timeline: &Timeline,
176    transitions: &BTreeMap<Transition, GateReport>,
177    downstream_count: usize,
178    criticality: Option<f64>,
179) -> Vec<String> {
180    let mut why_now = Vec::new();
181    let current = Transition::ALL
182        .into_iter()
183        .find(|t| t.lifecycle() == work.execution.status)
184        .and_then(|t| transitions.get(&t).map(|report| (t, report)));
185    if timeline.completed_at(work.id).is_some() {
186        why_now.push("work is complete".into());
187    } else if let Some((transition, report)) = current {
188        if report.ready {
189            why_now.push(match transition {
190                Transition::Claim => "work can be claimed now: every FS/SS predecessor event and positive lag has elapsed and no decision gates it".into(),
191                other => format!("work can {other} now: every gate for this transition is satisfied"),
192            });
193            why_now.extend(
194                report
195                    .provisional
196                    .iter()
197                    .map(crate::ProvisionalRelease::describe),
198            );
199        } else {
200            why_now.extend(report.reasons());
201        }
202    } else {
203        why_now.extend(
204            transitions
205                .get(&Transition::Claim)
206                .map(GateReport::reasons)
207                .unwrap_or_default(),
208        );
209    }
210    for dep in plan
211        .enforced_dependencies()
212        .filter(|d| d.successor == work.id && d.lag_hours < 0.0)
213    {
214        let key = plan
215            .work_items
216            .get(&dep.predecessor)
217            .map_or_else(String::new, |w| w.key.to_string());
218        why_now.push(format!(
219            "{} {}h lead from {key} shapes the schedule only; execution still waits for the predecessor event",
220            dep.kind.abbreviation(),
221            dep.lag_hours
222        ));
223    }
224    if downstream_count > 0 {
225        why_now.push(format!(
226            "completion affects {downstream_count} downstream work items"
227        ));
228    }
229    if let Some(value) = criticality {
230        why_now.push(format!("schedule criticality is {:.0}%", value * 100.0));
231    }
232    why_now
233}