1use std::collections::HashMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::EvalResult;
13
14#[derive(Debug, Clone)]
16pub struct LifecycleEndState {
17 pub lifecycle_phase: String, pub days_since_opening: u32,
19}
20
21#[derive(Debug, Clone)]
23pub struct TransitionRecord {
24 pub from_phase: String,
25 pub to_phase: String,
26 pub triggered_by_event: bool,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct LifecycleThresholds {
31 pub min_phase_diversity: usize,
33 pub min_progression_rate: f64,
35 pub min_event_driven_rate: f64,
37 pub max_stuck_new_rate: f64,
39}
40
41impl Default for LifecycleThresholds {
42 fn default() -> Self {
43 Self {
44 min_phase_diversity: 3,
45 min_progression_rate: 0.70,
46 min_event_driven_rate: 0.10,
47 max_stuck_new_rate: 0.05,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct LifecycleAnalysis {
54 pub total_accounts: usize,
55 pub phase_distribution: HashMap<String, f64>,
56 pub progression_rate: f64,
57 pub event_driven_rate: f64,
58 pub stuck_new_rate: f64,
59 pub phases_observed: usize,
60 pub total_transitions: usize,
61 pub passes: bool,
62 pub issues: Vec<String>,
63}
64
65pub struct LifecycleAnalyzer {
66 pub thresholds: LifecycleThresholds,
67}
68
69impl LifecycleAnalyzer {
70 pub fn new() -> Self {
71 Self {
72 thresholds: LifecycleThresholds::default(),
73 }
74 }
75
76 pub fn analyze(
77 &self,
78 end_states: &[LifecycleEndState],
79 transitions: &[TransitionRecord],
80 ) -> EvalResult<LifecycleAnalysis> {
81 let total = end_states.len();
82 if total == 0 {
83 return Ok(LifecycleAnalysis {
84 total_accounts: 0,
85 phase_distribution: HashMap::new(),
86 progression_rate: 0.0,
87 event_driven_rate: 0.0,
88 stuck_new_rate: 0.0,
89 phases_observed: 0,
90 total_transitions: 0,
91 passes: true,
92 issues: Vec::new(),
93 });
94 }
95
96 let mut phase_counts: HashMap<String, usize> = HashMap::new();
97 for s in end_states {
98 *phase_counts.entry(s.lifecycle_phase.clone()).or_insert(0) += 1;
99 }
100 let phase_distribution: HashMap<String, f64> = phase_counts
101 .iter()
102 .map(|(k, v)| (k.clone(), *v as f64 / total as f64))
103 .collect();
104 let phases_observed = phase_distribution.len();
105
106 let progressed = end_states
107 .iter()
108 .filter(|s| s.lifecycle_phase != "new")
109 .count();
110 let progression_rate = progressed as f64 / total as f64;
111
112 let stuck_new = end_states
114 .iter()
115 .filter(|s| s.lifecycle_phase == "new" && s.days_since_opening > 180)
116 .count();
117 let stuck_new_rate = stuck_new as f64 / total as f64;
118
119 let event_driven_rate = if !transitions.is_empty() {
120 let event_count = transitions.iter().filter(|t| t.triggered_by_event).count();
121 event_count as f64 / transitions.len() as f64
122 } else {
123 1.0
124 };
125
126 let mut issues = Vec::new();
127 if phases_observed < self.thresholds.min_phase_diversity {
128 issues.push(format!(
129 "Only {} phases observed — expected at least {}",
130 phases_observed, self.thresholds.min_phase_diversity,
131 ));
132 }
133 if progression_rate < self.thresholds.min_progression_rate {
134 issues.push(format!(
135 "Progression rate {:.1}% below minimum {:.1}%",
136 progression_rate * 100.0,
137 self.thresholds.min_progression_rate * 100.0,
138 ));
139 }
140 if stuck_new_rate > self.thresholds.max_stuck_new_rate {
141 issues.push(format!(
142 "{:.1}% of accounts stuck in New phase despite >180d age",
143 stuck_new_rate * 100.0,
144 ));
145 }
146 if !transitions.is_empty() && event_driven_rate < self.thresholds.min_event_driven_rate {
147 issues.push(format!(
148 "Event-driven transition rate {:.1}% below minimum {:.1}%",
149 event_driven_rate * 100.0,
150 self.thresholds.min_event_driven_rate * 100.0,
151 ));
152 }
153
154 Ok(LifecycleAnalysis {
155 total_accounts: total,
156 phase_distribution,
157 progression_rate,
158 event_driven_rate,
159 stuck_new_rate,
160 phases_observed,
161 total_transitions: transitions.len(),
162 passes: issues.is_empty(),
163 issues,
164 })
165 }
166}
167
168impl Default for LifecycleAnalyzer {
169 fn default() -> Self {
170 Self::new()
171 }
172}
173
174#[cfg(test)]
175#[allow(clippy::unwrap_used)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn test_realistic_lifecycle_passes() {
181 let states = vec![
182 LifecycleEndState {
184 lifecycle_phase: "new".into(),
185 days_since_opening: 20,
186 },
187 LifecycleEndState {
188 lifecycle_phase: "new".into(),
189 days_since_opening: 25,
190 },
191 LifecycleEndState {
193 lifecycle_phase: "ramp_up".into(),
194 days_since_opening: 50,
195 },
196 LifecycleEndState {
197 lifecycle_phase: "ramp_up".into(),
198 days_since_opening: 80,
199 },
200 LifecycleEndState {
201 lifecycle_phase: "ramp_up".into(),
202 days_since_opening: 70,
203 },
204 LifecycleEndState {
206 lifecycle_phase: "steady".into(),
207 days_since_opening: 200,
208 },
209 LifecycleEndState {
210 lifecycle_phase: "steady".into(),
211 days_since_opening: 300,
212 },
213 LifecycleEndState {
214 lifecycle_phase: "steady".into(),
215 days_since_opening: 350,
216 },
217 LifecycleEndState {
218 lifecycle_phase: "steady".into(),
219 days_since_opening: 250,
220 },
221 LifecycleEndState {
223 lifecycle_phase: "dormant".into(),
224 days_since_opening: 400,
225 },
226 ];
227 let transitions = vec![
228 TransitionRecord {
229 from_phase: "new".into(),
230 to_phase: "ramp_up".into(),
231 triggered_by_event: false,
232 },
233 TransitionRecord {
234 from_phase: "ramp_up".into(),
235 to_phase: "steady".into(),
236 triggered_by_event: false,
237 },
238 TransitionRecord {
239 from_phase: "steady".into(),
240 to_phase: "decline".into(),
241 triggered_by_event: true,
242 },
243 TransitionRecord {
244 from_phase: "decline".into(),
245 to_phase: "dormant".into(),
246 triggered_by_event: false,
247 },
248 ];
249 let a = LifecycleAnalyzer::new();
250 let r = a.analyze(&states, &transitions).unwrap();
251 assert!(r.passes, "Issues: {:?}", r.issues);
252 }
253
254 #[test]
255 fn test_all_stuck_in_new_flagged() {
256 let states: Vec<_> = (0..100)
257 .map(|_| LifecycleEndState {
258 lifecycle_phase: "new".into(),
259 days_since_opening: 300,
260 })
261 .collect();
262 let a = LifecycleAnalyzer::new();
263 let r = a.analyze(&states, &[]).unwrap();
264 assert!(!r.passes);
265 assert!(r
266 .issues
267 .iter()
268 .any(|i| i.contains("stuck") || i.contains("Progression") || i.contains("phases")));
269 }
270}