Skip to main content

dpm_model/
provisional.rs

1use crate::{
2    ActorId, Dependency, DependencyId, EventTime, Plan, Release, StartBasis, Timeline, WorkItem,
3    WorkItemId,
4};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8/// One submission of a task, identified by its 1-based ordinal within that task.
9///
10/// The ordinal is deterministic, so every reload and replica of the same history agrees on it.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct SubmissionAttempt {
14    /// Position of this attempt in the task's submission history, starting at 1.
15    pub number: u32,
16    /// Time recorded by the submit command.
17    pub submitted_at: DateTime<Utc>,
18    /// Review result; only the latest attempt may still be pending.
19    pub outcome: AttemptOutcome,
20}
21
22/// Independent review result of one submission attempt.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
25pub enum AttemptOutcome {
26    /// Awaiting independent review.
27    Pending,
28    /// Returned for rework; the attempt stays in history and never becomes valid again.
29    Rejected {
30        /// Independent reviewer.
31        actor: ActorId,
32        /// Time recorded by the reject command.
33        at: DateTime<Utc>,
34        /// Concrete unmet acceptance or evidence requirement.
35        reason: String,
36    },
37    /// Accepted by an independent verifier.
38    Verified {
39        /// Independent verifier.
40        actor: ActorId,
41        /// Time recorded by the verify command.
42        at: DateTime<Utc>,
43    },
44}
45
46impl AttemptOutcome {
47    /// Whether the attempt still awaited review at `now`: a start recorded before a later review
48    /// relied on an unreviewed result, whatever the review has since decided.
49    #[must_use]
50    pub fn pending_at(&self, now: DateTime<Utc>) -> bool {
51        match self {
52            Self::Pending => true,
53            Self::Rejected { at, .. } | Self::Verified { at, .. } => *at > now,
54        }
55    }
56}
57
58/// Immutable reference to the predecessor attempt a successor's execution relies on.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct DependencyBasis {
62    /// Provisional edge whose start gate the attempt released.
63    pub dependency: DependencyId,
64    /// Predecessor that made the attempt.
65    pub predecessor: WorkItemId,
66    /// Ordinal of the relied-on predecessor attempt.
67    pub attempt: u32,
68    /// Time recorded by the operation that captured this basis.
69    pub recorded_at: DateTime<Utc>,
70    /// Operation that captured this basis.
71    pub source: BasisSource,
72}
73
74/// Operation that recorded a dependency basis.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
77pub enum BasisSource {
78    /// The successor's owner started on the attempt through the provisional start gate.
79    Start,
80    /// An independent human or service reviewed the successor against a newer attempt.
81    Revalidation {
82        /// Accountable reviewer.
83        actor: ActorId,
84        /// Why the successor's work remains valid on the new attempt.
85        reason: String,
86    },
87}
88
89impl WorkItem {
90    /// Attempt with the given ordinal.
91    #[must_use]
92    pub fn attempt(&self, number: u32) -> Option<&SubmissionAttempt> {
93        let index = usize::try_from(number).ok()?.checked_sub(1)?;
94        self.execution.attempts.get(index)
95    }
96
97    /// Latest attempt unless it was rejected: the pending or verified result a basis may rely on.
98    #[must_use]
99    pub fn current_attempt(&self) -> Option<&SubmissionAttempt> {
100        self.execution
101            .attempts
102            .last()
103            .filter(|a| !matches!(a.outcome, AttemptOutcome::Rejected { .. }))
104    }
105
106    /// Latest basis recorded for each edge, in first-recorded edge order.
107    #[must_use]
108    pub fn effective_basis(&self) -> Vec<&DependencyBasis> {
109        let mut latest: Vec<&DependencyBasis> = Vec::new();
110        for entry in &self.execution.basis {
111            match latest.iter_mut().find(|b| b.dependency == entry.dependency) {
112                Some(slot) => *slot = entry,
113                None => latest.push(entry),
114            }
115        }
116        latest
117    }
118}
119
120/// Derived validity of a recorded basis; never stored.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(tag = "state", rename_all = "snake_case")]
123pub enum BasisState {
124    /// The relied-on attempt still awaits review.
125    Pending,
126    /// The relied-on attempt was verified.
127    Verified,
128    /// The relied-on attempt was rejected; only an explicit revalidation records a new basis.
129    Invalidated {
130        /// Reviewer who rejected the attempt.
131        rejected_by: ActorId,
132        /// Time of the rejection.
133        rejected_at: DateTime<Utc>,
134        /// Rejection reason.
135        reason: String,
136    },
137}
138
139/// A successor's effective basis on one provisional edge, with its derived state.
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub struct BasisStatus {
142    /// Work whose execution relies on the attempt.
143    pub successor: WorkItemId,
144    /// Human-readable successor key.
145    pub successor_key: String,
146    /// Provisional edge carrying the basis.
147    pub dependency: DependencyId,
148    /// Work that made the attempt.
149    pub predecessor: WorkItemId,
150    /// Human-readable predecessor key.
151    pub predecessor_key: String,
152    /// The immutable recorded reference.
153    pub basis: DependencyBasis,
154    /// Derived from the predecessor's attempt record.
155    pub state: BasisState,
156    /// Predecessor's pending or verified attempt a revalidation may name, if any.
157    pub current_attempt: Option<u32>,
158    /// Whether the edge gates execution; a waived edge's basis is context only.
159    pub enforced: bool,
160}
161
162impl BasisStatus {
163    /// Whether an enforced basis rests on a rejected attempt and so gates finishing the successor.
164    #[must_use]
165    pub fn gates(&self) -> bool {
166        self.enforced && matches!(self.state, BasisState::Invalidated { .. })
167    }
168}
169
170/// Effective basis of a work item on each provisional edge, derived from attempt records.
171#[must_use]
172pub fn basis_status(plan: &Plan, work: &WorkItem) -> Vec<BasisStatus> {
173    work.effective_basis()
174        .into_iter()
175        .filter_map(|basis| status_of(plan, work, basis))
176        .collect()
177}
178
179/// Effective bases of other work that rely on attempts of this predecessor.
180#[must_use]
181pub fn basis_dependents(plan: &Plan, predecessor: WorkItemId) -> Vec<BasisStatus> {
182    plan.work_items
183        .values()
184        .flat_map(|work| basis_status(plan, work))
185        .filter(|status| status.predecessor == predecessor)
186        .collect()
187}
188
189fn status_of(plan: &Plan, work: &WorkItem, basis: &DependencyBasis) -> Option<BasisStatus> {
190    let predecessor = plan.work_items.get(&basis.predecessor)?;
191    let state = match &predecessor.attempt(basis.attempt)?.outcome {
192        AttemptOutcome::Pending => BasisState::Pending,
193        AttemptOutcome::Verified { .. } => BasisState::Verified,
194        AttemptOutcome::Rejected { actor, at, reason } => BasisState::Invalidated {
195            rejected_by: actor.clone(),
196            rejected_at: *at,
197            reason: reason.clone(),
198        },
199    };
200    Some(BasisStatus {
201        successor: work.id,
202        successor_key: work.key.to_string(),
203        dependency: basis.dependency,
204        predecessor: predecessor.id,
205        predecessor_key: predecessor.key.to_string(),
206        basis: basis.clone(),
207        state,
208        current_attempt: predecessor.current_attempt().map(|a| a.number),
209        enforced: plan
210            .find_dependency(basis.dependency)
211            .is_some_and(|edge| !edge.is_waived()),
212    })
213}
214
215/// Release of an edge for the successor's start, and the predecessor attempt it was measured from.
216#[derive(Debug, Clone, Copy, PartialEq)]
217pub struct StartRelease {
218    /// Release state at the timeline's clock reading.
219    pub release: Release,
220    /// Predecessor attempt whose submission time a provisional release is measured from.
221    pub attempt: Option<u32>,
222    /// Whether that attempt awaited review at the clock reading, so a start then relied on an
223    /// unverified result.
224    pub provisional: bool,
225}
226
227impl Timeline {
228    /// Evaluate an edge for the successor's claim or start.
229    ///
230    /// A provisional edge releases on the predecessor's current attempt's submission plus positive
231    /// lag until its verified finish releases it, so verifying that attempt never pushes an
232    /// elapsing start gate later. Every other evaluation, including the successor's verification
233    /// and milestone reach, uses [`Timeline::edge`], so a submission never counts as a finish
234    /// there. A legacy submission without an attempt record has nothing a basis could reference,
235    /// so its edge waits for verification.
236    #[must_use]
237    pub fn start_edge(&self, plan: &Plan, edge: &Dependency) -> StartRelease {
238        let release = self.edge(plan, edge);
239        // Only an edge still waiting on the predecessor's own event may use its attempt; a
240        // not-selected branch stays governed by the choice, never by a submission.
241        let current = (edge.start_basis == StartBasis::Provisional
242            && matches!(release, Release::AwaitingEvent | Release::Elapsing { .. })
243            && !self.applicability(edge.predecessor).is_not_selected())
244        .then(|| plan.work_items.get(&edge.predecessor))
245        .flatten()
246        .and_then(WorkItem::current_attempt);
247        match current {
248            Some(attempt) => StartRelease {
249                release: Release::evaluate_with(
250                    Some(EventTime::Recorded(attempt.submitted_at)),
251                    edge.lag_hours,
252                    self.now(),
253                    |at, hours| plan.lag_end(edge, at, hours),
254                ),
255                attempt: Some(attempt.number),
256                provisional: attempt.outcome.pending_at(self.now()),
257            },
258            None => StartRelease {
259                release,
260                attempt: None,
261                provisional: false,
262            },
263        }
264    }
265
266    /// The earliest instant after this timeline's clock reading at which a pending gate opens with
267    /// no change to the plan: an elapsing dependency lag (including a provisional start lag) or a
268    /// decision gate released with elapsing lag.
269    ///
270    /// Time passing is the only change, so an adapter that evaluated at [`Timeline::now`] knows
271    /// when to evaluate again. The instant is the exact opening boundary: a reading strictly
272    /// before it still sees the gate elapsing, a reading at it sees it released. A fact recorded
273    /// with a future time is not reported as elapsing, so it is not included.
274    #[must_use]
275    pub fn next_release(&self, plan: &Plan) -> Option<DateTime<Utc>> {
276        let edges = plan
277            .dependencies
278            .iter()
279            .flat_map(|edge| [self.edge(plan, edge), self.start_edge(plan, edge).release]);
280        let decisions = plan.work_items.keys().flat_map(|id| {
281            self.decisions(plan, *id)
282                .into_iter()
283                .map(|(_, release)| release)
284        });
285        edges
286            .chain(decisions)
287            .filter_map(|release| match release {
288                Release::Elapsing { opens_at, .. } => Some(opens_at),
289                _ => None,
290            })
291            .min()
292    }
293}
294
295#[cfg(test)]
296mod tests;