Skip to main content

dpm_model/
dependency.rs

1use crate::{ActorId, DependencyId, WorkItemId};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
5/// Temporal lower bound relating two activity endpoints.
6pub enum DependencyKind {
7    /// Successor start is bounded by predecessor finish plus lag.
8    FinishStart,
9    /// Successor start is bounded by predecessor start plus lag.
10    StartStart,
11    /// Successor finish is bounded by predecessor finish plus lag.
12    FinishFinish,
13    /// Successor finish is bounded by predecessor start plus lag.
14    StartFinish,
15}
16
17impl DependencyKind {
18    /// Stable two-letter relation name, independent of declaration order.
19    #[must_use]
20    pub fn abbreviation(self) -> &'static str {
21        match self {
22            Self::FinishStart => "FS",
23            Self::StartStart => "SS",
24            Self::FinishFinish => "FF",
25            Self::StartFinish => "SF",
26        }
27    }
28
29    /// Predecessor event the relation waits for: its start (SS, SF) or its finish (FS, FF).
30    #[must_use]
31    pub fn predecessor_endpoint(self) -> crate::Endpoint {
32        match self {
33            Self::FinishStart | Self::FinishFinish => crate::Endpoint::Finish,
34            Self::StartStart | Self::StartFinish => crate::Endpoint::Start,
35        }
36    }
37
38    /// Successor event the relation constrains: its start (FS, SS) or its finish (FF, SF).
39    #[must_use]
40    pub fn successor_endpoint(self) -> crate::Endpoint {
41        match self {
42            Self::FinishStart | Self::StartStart => crate::Endpoint::Start,
43            Self::FinishFinish | Self::StartFinish => crate::Endpoint::Finish,
44        }
45    }
46}
47
48#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49/// Whether an authorized reviewer may temporarily set a constraint aside.
50pub enum DependencyPolicy {
51    /// Always enforced; only a reviewed plan change can alter or remove it.
52    #[default]
53    Hard,
54    /// Enforced unless a human or service records an explicit waiver.
55    Soft,
56}
57
58#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59/// Which predecessor result releases a finish-to-start edge for the successor's start.
60///
61/// Only the start gates (claim and start) differ: the successor's verification, milestone reach
62/// and the remaining forecast always wait for the predecessor's verified finish.
63pub enum StartBasis {
64    /// The successor starts only after the predecessor is verified.
65    #[default]
66    Verified,
67    /// The successor may start on the predecessor's pending submission attempt, and its start
68    /// records that attempt as its basis; a rejection of the attempt invalidates the basis.
69    Provisional,
70}
71
72impl StartBasis {
73    /// Whether this is the default verified-finish basis, which serialization omits.
74    #[must_use]
75    pub fn is_verified(&self) -> bool {
76        *self == Self::Verified
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81/// Recorded decision to stop enforcing a soft constraint.
82#[serde(deny_unknown_fields)]
83pub struct DependencyWaiver {
84    /// Human or service principal accountable for the waiver.
85    pub actor: ActorId,
86    /// Caller-supplied UTC waiver time.
87    pub at: chrono::DateTime<chrono::Utc>,
88    /// Non-empty justification.
89    pub reason: String,
90}
91
92#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93/// Whether a lag counts elapsed hours or working hours of the successor's calendar.
94pub enum LagBasis {
95    /// Wall-clock hours, such as curing or waiting time.
96    #[default]
97    Elapsed,
98    /// Working hours of the successor's calendar; elapsed hours in a plan without calendars.
99    Working,
100}
101
102impl LagBasis {
103    /// Whether this is the default elapsed basis, which serialization omits.
104    #[must_use]
105    pub fn is_elapsed(&self) -> bool {
106        *self == Self::Elapsed
107    }
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111/// Independently addressable temporal constraint between two work items.
112#[serde(deny_unknown_fields)]
113pub struct Dependency {
114    /// Stable edge identity; unique within the plan.
115    pub id: DependencyId,
116    /// Activity providing the constrained start or finish.
117    pub predecessor: WorkItemId,
118    /// Activity whose start or finish has the lower bound.
119    pub successor: WorkItemId,
120    /// Domain category of this value.
121    pub kind: DependencyKind,
122    /// Positive values add delay; negative values are lead time.
123    pub lag_hours: f64,
124    /// Whether the lag counts elapsed or working hours.
125    #[serde(default, skip_serializing_if = "LagBasis::is_elapsed")]
126    pub lag_basis: LagBasis,
127    /// Whether the constraint may be waived.
128    #[serde(default)]
129    pub policy: DependencyPolicy,
130    /// Predecessor result that releases the successor's start; changed only by reviewed plan change.
131    #[serde(default, skip_serializing_if = "StartBasis::is_verified")]
132    pub start_basis: StartBasis,
133    /// Optional author explanation of why the constraint and its policy exist.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub rationale: Option<String>,
136    /// Present while a soft constraint is set aside; changed only by waive/restore commands.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub waiver: Option<DependencyWaiver>,
139}
140
141const FNV_OFFSET: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d;
142const FNV_PRIME: u128 = 0x0000_0000_0100_0000_0000_0000_0000_013b;
143const IDENTITY_DOMAIN: &[u8] = b"dpm.dependency.v1\0";
144
145/// MurmurHash3's 64-bit finalizer: a bijection in which every input bit affects every output bit.
146fn avalanche(mut value: u64) -> u64 {
147    value ^= value >> 33;
148    value = value.wrapping_mul(0xff51_afd7_ed55_8ccd);
149    value ^= value >> 33;
150    value = value.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
151    value ^ (value >> 33)
152}
153
154impl Dependency {
155    /// Deterministic identity for a newly constructed constraint.
156    ///
157    /// Constructors and interchange adapters must agree on a new edge's identity. Persisted edges
158    /// carry that identity explicitly and retain it when edited. Validation allows
159    /// one relation of each kind per ordered pair; a hash collision would surface as a
160    /// duplicate-identity validation error rather than merging two edges.
161    #[must_use]
162    pub fn derived_id(
163        predecessor: WorkItemId,
164        successor: WorkItemId,
165        kind: DependencyKind,
166    ) -> DependencyId {
167        let mut hash = FNV_OFFSET;
168        let bytes = IDENTITY_DOMAIN
169            .iter()
170            .chain(predecessor.0.as_bytes())
171            .chain(successor.0.as_bytes())
172            .chain(kind.abbreviation().as_bytes());
173        for byte in bytes {
174            hash ^= u128::from(*byte);
175            hash = hash.wrapping_mul(FNV_PRIME);
176        }
177        // FNV alone leaves inputs that differ only near the end sharing most high bits; this
178        // bijective cross-mix of both halves spreads every input byte over the whole identity.
179        let (high, low) = ((hash >> 64) as u64, hash as u64);
180        let high = avalanche(high ^ low.rotate_left(29));
181        let low = avalanche(low ^ high);
182        let high = avalanche(high ^ low);
183        let mixed = (u128::from(high) << 64) | u128::from(low);
184        DependencyId(uuid::Builder::from_custom_bytes(mixed.to_be_bytes()).into_uuid())
185    }
186
187    /// Hard constraint with a derived identity and no rationale.
188    #[must_use]
189    pub fn new(
190        predecessor: WorkItemId,
191        successor: WorkItemId,
192        kind: DependencyKind,
193        lag_hours: f64,
194    ) -> Self {
195        Self {
196            id: Self::derived_id(predecessor, successor, kind),
197            predecessor,
198            successor,
199            kind,
200            lag_hours,
201            lag_basis: LagBasis::Elapsed,
202            policy: DependencyPolicy::Hard,
203            start_basis: StartBasis::Verified,
204            rationale: None,
205            waiver: None,
206        }
207    }
208
209    /// Whether a recorded waiver currently sets this constraint aside.
210    #[must_use]
211    pub fn is_waived(&self) -> bool {
212        self.waiver.is_some()
213    }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
217/// Contextual relationship between work items; never an execution or schedule constraint.
218pub enum WorkLinkKind {
219    /// The items share context.
220    RelatesTo,
221    /// The source repeats the target's scope.
222    Duplicates,
223    /// The source was derived from the target.
224    DerivedFrom,
225    /// The source replaces the target's scope.
226    Supersedes,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230/// Typed non-gating link, identified by its kind and unordered endpoint pair.
231#[serde(deny_unknown_fields)]
232pub struct WorkLink {
233    /// Meaning of the relationship.
234    pub kind: WorkLinkKind,
235    /// Work item making the statement.
236    pub source: WorkItemId,
237    /// Work item the statement refers to.
238    pub target: WorkItemId,
239    /// Optional non-empty explanation.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub note: Option<String>,
242}
243
244#[cfg(test)]
245mod tests;