defi_tracker_lifecycle/lifecycle/
mod.rs1pub mod adapters;
2pub mod mapping;
3
4#[derive(
6 Debug,
7 Clone,
8 Copy,
9 PartialEq,
10 Eq,
11 strum_macros::Display,
12 strum_macros::EnumString,
13 strum_macros::AsRefStr,
14)]
15#[strum(serialize_all = "lowercase")]
16pub enum TerminalStatus {
17 Completed,
19 Cancelled,
21 Expired,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum LifecycleTransition {
28 Create,
30 FillDelta,
32 Close { status: TerminalStatus },
34 MetadataOnly,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TransitionDecision {
42 Apply,
44 IgnoreTerminalViolation,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct SnapshotDelta {
54 pub delta: i64,
56 pub regression: bool,
58}
59
60pub struct LifecycleEngine;
62
63impl LifecycleEngine {
64 pub fn decide_transition(
69 current_terminal: Option<TerminalStatus>,
70 transition: LifecycleTransition,
71 ) -> TransitionDecision {
72 if current_terminal.is_none() {
73 return TransitionDecision::Apply;
74 }
75
76 match transition {
77 LifecycleTransition::MetadataOnly => TransitionDecision::Apply,
78 LifecycleTransition::Create
79 | LifecycleTransition::FillDelta
80 | LifecycleTransition::Close { .. } => TransitionDecision::IgnoreTerminalViolation,
81 }
82 }
83
84 pub fn normalize_snapshot_to_delta(stored_total: i64, snapshot_total: i64) -> SnapshotDelta {
88 let delta = snapshot_total.saturating_sub(stored_total).max(0);
89 SnapshotDelta {
90 delta,
91 regression: snapshot_total < stored_total,
92 }
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::{
99 LifecycleEngine, LifecycleTransition, SnapshotDelta, TerminalStatus, TransitionDecision,
100 };
101
102 fn lcg_next(state: &mut u64) -> u64 {
103 *state = state
104 .wrapping_mul(6_364_136_223_846_793_005)
105 .wrapping_add(1);
106 *state
107 }
108
109 fn random_transition(state: &mut u64) -> LifecycleTransition {
110 match lcg_next(state) % 6 {
111 0 => LifecycleTransition::Create,
112 1 => LifecycleTransition::FillDelta,
113 2 => LifecycleTransition::Close {
114 status: TerminalStatus::Completed,
115 },
116 3 => LifecycleTransition::Close {
117 status: TerminalStatus::Cancelled,
118 },
119 4 => LifecycleTransition::Close {
120 status: TerminalStatus::Expired,
121 },
122 _ => LifecycleTransition::MetadataOnly,
123 }
124 }
125
126 #[test]
127 fn terminal_status_roundtrip() {
128 assert_eq!(
129 "completed".parse::<TerminalStatus>().ok(),
130 Some(TerminalStatus::Completed)
131 );
132 assert_eq!(
133 "cancelled".parse::<TerminalStatus>().ok(),
134 Some(TerminalStatus::Cancelled)
135 );
136 assert_eq!(
137 "expired".parse::<TerminalStatus>().ok(),
138 Some(TerminalStatus::Expired)
139 );
140 assert_eq!("active".parse::<TerminalStatus>().ok(), None);
141 assert_eq!(TerminalStatus::Completed.to_string(), "completed");
142 }
143
144 #[test]
145 fn terminal_orders_reject_state_mutating_transitions() {
146 let current = Some(TerminalStatus::Completed);
147 assert_eq!(
148 LifecycleEngine::decide_transition(current, LifecycleTransition::Create),
149 TransitionDecision::IgnoreTerminalViolation
150 );
151 assert_eq!(
152 LifecycleEngine::decide_transition(current, LifecycleTransition::FillDelta),
153 TransitionDecision::IgnoreTerminalViolation
154 );
155 assert_eq!(
156 LifecycleEngine::decide_transition(
157 current,
158 LifecycleTransition::Close {
159 status: TerminalStatus::Cancelled
160 }
161 ),
162 TransitionDecision::IgnoreTerminalViolation
163 );
164 assert_eq!(
165 LifecycleEngine::decide_transition(current, LifecycleTransition::MetadataOnly),
166 TransitionDecision::Apply
167 );
168 }
169
170 #[test]
171 fn snapshot_to_delta_never_regresses() {
172 assert_eq!(
173 LifecycleEngine::normalize_snapshot_to_delta(300, 450),
174 SnapshotDelta {
175 delta: 150,
176 regression: false
177 }
178 );
179 assert_eq!(
180 LifecycleEngine::normalize_snapshot_to_delta(300, 300),
181 SnapshotDelta {
182 delta: 0,
183 regression: false
184 }
185 );
186 assert_eq!(
187 LifecycleEngine::normalize_snapshot_to_delta(300, 200),
188 SnapshotDelta {
189 delta: 0,
190 regression: true
191 }
192 );
193 }
194
195 #[test]
196 fn snapshot_to_delta_property_holds_for_randomized_inputs() {
197 let mut seed = 0x00C0_FFEE_u64;
198 for _ in 0..20_000 {
199 let stored_total = (lcg_next(&mut seed) as i64 % 2_000_000) - 1_000_000;
200 let snapshot_total = (lcg_next(&mut seed) as i64 % 2_000_000) - 1_000_000;
201 let normalized =
202 LifecycleEngine::normalize_snapshot_to_delta(stored_total, snapshot_total);
203
204 assert!(normalized.delta >= 0);
205 assert_eq!(normalized.regression, snapshot_total < stored_total);
206
207 if snapshot_total >= stored_total {
208 assert_eq!(normalized.delta, snapshot_total - stored_total);
209 } else {
210 assert_eq!(normalized.delta, 0);
211 }
212 }
213 }
214
215 #[test]
216 fn terminal_immutability_property_holds_for_all_terminals() {
217 let terminal_statuses = [
218 TerminalStatus::Completed,
219 TerminalStatus::Cancelled,
220 TerminalStatus::Expired,
221 ];
222 let mut seed = 0xDEAD_BEEF_u64;
223
224 for status in terminal_statuses {
225 for _ in 0..5_000 {
226 let transition = random_transition(&mut seed);
227 let decision = LifecycleEngine::decide_transition(Some(status), transition);
228 match transition {
229 LifecycleTransition::MetadataOnly => {
230 assert_eq!(decision, TransitionDecision::Apply);
231 }
232 LifecycleTransition::Create
233 | LifecycleTransition::FillDelta
234 | LifecycleTransition::Close { .. } => {
235 assert_eq!(decision, TransitionDecision::IgnoreTerminalViolation);
236 }
237 }
238 }
239 }
240 }
241
242 #[test]
243 fn non_terminal_statuses_do_not_block_transitions() {
244 let mut seed = 0xA11CE_u64;
245
246 for _ in 0..12_000 {
247 let transition = random_transition(&mut seed);
248 let decision = LifecycleEngine::decide_transition(None, transition);
249 assert_eq!(decision, TransitionDecision::Apply);
250 }
251 }
252
253 fn apply_sequence(steps: &[(LifecycleTransition, TransitionDecision)]) {
254 let mut current_terminal: Option<TerminalStatus> = None;
255
256 for (i, (transition, expected_decision)) in steps.iter().enumerate() {
257 let decision = LifecycleEngine::decide_transition(current_terminal, *transition);
258 assert_eq!(
259 decision, *expected_decision,
260 "step {i}: expected {expected_decision:?} for {transition:?} with terminal {current_terminal:?}"
261 );
262
263 if decision == TransitionDecision::Apply
264 && let LifecycleTransition::Close { status } = transition
265 {
266 current_terminal = Some(*status);
267 }
268 }
269 }
270
271 #[test]
272 fn lifecycle_sequence_dca_happy_path() {
273 apply_sequence(&[
274 (LifecycleTransition::Create, TransitionDecision::Apply),
275 (LifecycleTransition::FillDelta, TransitionDecision::Apply),
276 (LifecycleTransition::FillDelta, TransitionDecision::Apply),
277 (
278 LifecycleTransition::Close {
279 status: TerminalStatus::Completed,
280 },
281 TransitionDecision::Apply,
282 ),
283 (
284 LifecycleTransition::FillDelta,
285 TransitionDecision::IgnoreTerminalViolation,
286 ),
287 ]);
288 }
289
290 #[test]
291 fn lifecycle_sequence_limit_cancel() {
292 apply_sequence(&[
293 (LifecycleTransition::Create, TransitionDecision::Apply),
294 (LifecycleTransition::FillDelta, TransitionDecision::Apply),
295 (
296 LifecycleTransition::Close {
297 status: TerminalStatus::Cancelled,
298 },
299 TransitionDecision::Apply,
300 ),
301 (
302 LifecycleTransition::Create,
303 TransitionDecision::IgnoreTerminalViolation,
304 ),
305 ]);
306 }
307
308 #[test]
309 fn lifecycle_sequence_limit_expired() {
310 apply_sequence(&[
311 (LifecycleTransition::Create, TransitionDecision::Apply),
312 (
313 LifecycleTransition::Close {
314 status: TerminalStatus::Expired,
315 },
316 TransitionDecision::Apply,
317 ),
318 (
319 LifecycleTransition::FillDelta,
320 TransitionDecision::IgnoreTerminalViolation,
321 ),
322 ]);
323 }
324
325 #[test]
326 fn lifecycle_sequence_terminal_still_accepts_metadata() {
327 apply_sequence(&[
328 (LifecycleTransition::Create, TransitionDecision::Apply),
329 (
330 LifecycleTransition::Close {
331 status: TerminalStatus::Completed,
332 },
333 TransitionDecision::Apply,
334 ),
335 (LifecycleTransition::MetadataOnly, TransitionDecision::Apply),
336 (LifecycleTransition::MetadataOnly, TransitionDecision::Apply),
337 (
338 LifecycleTransition::Close {
339 status: TerminalStatus::Cancelled,
340 },
341 TransitionDecision::IgnoreTerminalViolation,
342 ),
343 ]);
344 }
345}