Skip to main content

datasynth_eval/banking/
account_lifecycle.rs

1//! Account lifecycle quality evaluator.
2//!
3//! Validates that account lifecycle phases are distributed realistically:
4//! - Multiple phases represented (not all stuck in one)
5//! - Transitions occur at reasonable rates
6//! - Life events contribute to transitions
7
8use std::collections::HashMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::EvalResult;
13
14/// Per-account lifecycle end-state observation.
15#[derive(Debug, Clone)]
16pub struct LifecycleEndState {
17    pub lifecycle_phase: String, // "new" | "ramp_up" | "steady" | "decline" | "dormant"
18    pub days_since_opening: u32,
19}
20
21/// Transition record.
22#[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    /// Minimum number of distinct phases that should be represented
32    pub min_phase_diversity: usize,
33    /// Minimum fraction of accounts that moved beyond New
34    pub min_progression_rate: f64,
35    /// Minimum fraction of transitions that are event-driven
36    pub min_event_driven_rate: f64,
37    /// Maximum fraction of accounts stuck in New despite age > 180 days
38    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        // Accounts older than 180d still stuck in New
113        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            // 20% new (young accounts)
183            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            // 30% ramp_up
192            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            // 40% steady
205            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            // 10% decline/dormant
222            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}