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;