Skip to main content

dpm_engine/gates/
transition.rs

1use dpm_model::{Applicability, DependencyKind, DependencyPolicy, Endpoint, Release, WorkStatus};
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5/// Task lifecycle transition whose relation gates are evaluated.
6///
7/// | Transition | From        | Relations  | Decisions |
8/// |------------|-------------|------------|-----------|
9/// | `Claim`    | `Planned`   | FS, SS     | yes       |
10/// | `Start`    | `Claimed`   | FS, SS     | yes       |
11/// | `Submit`   | `InProgress`| FF, SF     | no        |
12/// | `Verify`   | `Submitted` | all four   | yes       |
13///
14/// A claim reserves only work that could start now, so it shares the start gates. Submission is the
15/// owner's finish claim and verification the accepted finish: both must respect FF/SF. Verification
16/// also re-checks FS/SS and decisions, so a restored waiver or gate applies again before acceptance.
17#[derive(
18    Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
19)]
20#[serde(rename_all = "snake_case")]
21pub enum Transition {
22    /// Reserve planned work for an owner.
23    #[default]
24    Claim,
25    /// Begin executing claimed work; records the start event.
26    Start,
27    /// Request independent verification; records the submission event.
28    Submit,
29    /// Accept the submitted result; records the finish event.
30    Verify,
31}
32
33impl Transition {
34    /// Every transition, in lifecycle order.
35    pub const ALL: [Self; 4] = [Self::Claim, Self::Start, Self::Submit, Self::Verify];
36
37    /// Lifecycle state the transition leaves.
38    #[must_use]
39    pub fn lifecycle(self) -> WorkStatus {
40        match self {
41            Self::Claim => WorkStatus::Planned,
42            Self::Start => WorkStatus::Claimed,
43            Self::Submit => WorkStatus::InProgress,
44            Self::Verify => WorkStatus::Submitted,
45        }
46    }
47
48    /// Whether a relation constrains this transition of its successor.
49    #[must_use]
50    pub fn governs(self, kind: DependencyKind) -> bool {
51        match self {
52            Self::Claim | Self::Start => kind.successor_endpoint() == Endpoint::Start,
53            Self::Submit => kind.successor_endpoint() == Endpoint::Finish,
54            Self::Verify => true,
55        }
56    }
57
58    /// Whether this transition is gated as the successor's start, where a provisional edge may
59    /// accept a pending predecessor attempt.
60    #[must_use]
61    pub fn starts(self) -> bool {
62        matches!(self, Self::Claim | Self::Start)
63    }
64
65    /// Whether a basis on a rejected predecessor attempt prevents this transition: finishing work
66    /// built on a rejected result would hide the rejection.
67    #[must_use]
68    pub fn checks_basis(self) -> bool {
69        matches!(self, Self::Submit | Self::Verify)
70    }
71
72    /// Whether open decisions on the work or its containers prevent this transition.
73    #[must_use]
74    pub fn checks_decisions(self) -> bool {
75        !matches!(self, Self::Submit)
76    }
77}
78
79impl fmt::Display for Transition {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.write_str(match self {
82            Self::Claim => "claim",
83            Self::Start => "start",
84            Self::Submit => "submit",
85            Self::Verify => "verify",
86        })
87    }
88}
89
90pub(super) fn describe_release(
91    key: &str,
92    requires: Endpoint,
93    release: Release,
94    edge: &str,
95    policy: DependencyPolicy,
96) -> String {
97    let event = match requires {
98        Endpoint::Start => "start",
99        Endpoint::Finish => "verified finish",
100    };
101    match release {
102        Release::AwaitingEvent => format!("awaits the {event} of {key} ({edge})"),
103        Release::Elapsing { event_at, opens_at } => {
104            format!("{key} {event} recorded at {event_at}; lag elapses at {opens_at} ({edge})")
105        }
106        Release::UnrecordedEventTime => {
107            let remedy = match policy {
108                DependencyPolicy::Soft => "a human or service may waive this Soft edge",
109                DependencyPolicy::Hard => {
110                    "a Hard edge cannot be waived; a reviewed plan change may alter its lag only while neither the successor nor any work depending on it has been claimed, and otherwise the gate stays closed"
111                }
112            };
113            format!(
114                "the {event} time of {key} was not recorded, so its positive lag cannot be shown to have elapsed ({edge}); {remedy}"
115            )
116        }
117        Release::LagOutOfRange { event_at } => format!(
118            "{key} {event} recorded at {event_at}, but the lag exceeds the supported time range ({edge})"
119        ),
120        Release::Released { .. } => format!("{key} {event} released ({edge})"),
121        Release::SkippedBranch { .. } => {
122            format!(
123                "{key} was not selected; this active-branch join treats it as an absent branch ({edge})"
124            )
125        }
126        Release::NotSelected => format!(
127            "{key} was not selected, and an ordinary dependency on skipped work never releases ({edge}); declare an active-branch join or change the plan"
128        ),
129    }
130}
131
132/// Human-readable reason work is outside the active graph, naming decisions and work by key.
133#[must_use]
134pub fn describe_applicability(applicability: &Applicability) -> String {
135    match applicability {
136        Applicability::Applicable => "work is applicable".into(),
137        Applicability::Undecided { decision, option } => {
138            format!("work applies only if {decision} selects {option}, and it is undecided")
139        }
140        Applicability::NotSelected {
141            decision,
142            option,
143            selected,
144        } => format!(
145            "work applies only if {decision} selects {option}, but it selected {selected}; it cannot be claimed or completed"
146        ),
147        Applicability::AwaitingChoice { predecessor } => format!(
148            "prerequisite {predecessor} depends on an undecided choice, so this work is not yet committed"
149        ),
150        Applicability::Stranded { predecessor, .. } => format!(
151            "prerequisite {predecessor} was not selected or can never proceed, and an ordinary dependency never releases from it; only a reviewed plan change that rewires the dependency, or a waiver of a Soft dependency, lets this work proceed"
152        ),
153        Applicability::EmptyJoin => "every branch into this join was not selected, and the join does not permit an empty result".into(),
154        Applicability::AllChildrenExcluded { decisions } => format!(
155            "every child of this work package was excluded by {}; the package is excluded with them",
156            join_keys(decisions)
157        ),
158        Applicability::ChildrenStranded { child } => format!(
159            "no child of this work package is applicable and {child} can never proceed, so the package cannot complete without a reviewed plan change"
160        ),
161    }
162}
163
164fn join_keys(keys: &[dpm_model::Key]) -> String {
165    keys.iter()
166        .map(|k| k.0.as_str())
167        .collect::<Vec<_>>()
168        .join(", ")
169}