use chrono::NaiveDateTime;
use rand::Rng;
use rand_chacha::ChaCha8Rng;
use crate::generators::Value;
use crate::lifecycle::config::{TimeBucket, TrackedEntity};
#[derive(Debug, Clone, PartialEq)]
pub struct ChurnModel {
pub rate: f64,
pub grace_period: usize,
pub column: String,
pub value: Value,
pub cascade: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChurnEvent {
pub entity_id: i64,
pub bucket_index: usize,
pub churned_at: NaiveDateTime,
}
impl ChurnModel {
pub fn apply(
&self,
active_entities: &[TrackedEntity],
bucket: &TimeBucket,
rng: &mut ChaCha8Rng,
) -> Vec<ChurnEvent> {
let mut events = Vec::new();
for entity in active_entities {
if !entity.is_active {
continue;
}
if entity.age_in_buckets(bucket.index) < self.grace_period {
continue;
}
if self.churns(rng) {
events.push(ChurnEvent {
entity_id: entity.id,
bucket_index: bucket.index,
churned_at: bucket.random_datetime(rng),
});
}
}
events
}
fn churns(&self, rng: &mut ChaCha8Rng) -> bool {
if self.rate <= 0.0 {
false
} else if self.rate >= 1.0 {
true
} else {
rng.gen_bool(self.rate)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use rand::SeedableRng;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).expect("valid date")
}
fn bucket(index: usize) -> TimeBucket {
TimeBucket {
index,
start: date(2024, 1, 1),
end: date(2024, 2, 1),
}
}
fn entity(id: i64, created_bucket: usize) -> TrackedEntity {
TrackedEntity {
id,
created_at: date(2024, 1, 1).and_time(chrono::NaiveTime::MIN),
created_bucket,
is_active: true,
churned_at: None,
}
}
fn model(rate: f64, grace_period: usize) -> ChurnModel {
ChurnModel {
rate,
grace_period,
column: "is_active".into(),
value: Value::Bool(false),
cascade: true,
}
}
#[test]
fn test_churn_respects_grace_period() {
let churn = model(1.0, 3); let entities = vec![entity(1, 0), entity(2, 2)];
let mut rng = ChaCha8Rng::seed_from_u64(42);
let events = churn.apply(&entities, &bucket(2), &mut rng);
assert!(events.is_empty());
let events = churn.apply(&entities, &bucket(5), &mut rng);
assert_eq!(events.len(), 2);
}
#[test]
fn test_churn_full_rate_churns_all_eligible() {
let churn = model(1.0, 0);
let entities = vec![entity(1, 0), entity(2, 0), entity(3, 0)];
let mut rng = ChaCha8Rng::seed_from_u64(42);
let events = churn.apply(&entities, &bucket(4), &mut rng);
assert_eq!(events.len(), 3);
let ids: Vec<i64> = events.iter().map(|e| e.entity_id).collect();
assert_eq!(ids, vec![1, 2, 3]);
}
#[test]
fn test_churn_zero_rate_churns_none() {
let churn = model(0.0, 0);
let entities = vec![entity(1, 0), entity(2, 0)];
let mut rng = ChaCha8Rng::seed_from_u64(42);
let events = churn.apply(&entities, &bucket(4), &mut rng);
assert!(events.is_empty());
}
#[test]
fn test_churn_skips_already_inactive() {
let churn = model(1.0, 0);
let mut e = entity(1, 0);
e.is_active = false;
let mut rng = ChaCha8Rng::seed_from_u64(42);
let events = churn.apply(&[e], &bucket(4), &mut rng);
assert!(events.is_empty());
}
#[test]
fn test_churn_event_timestamp_within_bucket() {
let churn = model(1.0, 0);
let b = bucket(4);
let mut rng = ChaCha8Rng::seed_from_u64(42);
let events = churn.apply(&[entity(1, 0)], &b, &mut rng);
assert_eq!(events.len(), 1);
let ts = events[0].churned_at;
assert!(ts >= b.start_datetime());
assert!(ts < b.end_datetime());
}
#[test]
fn test_churn_partial_rate_is_deterministic() {
let churn = model(0.5, 0);
let entities: Vec<TrackedEntity> = (0..100).map(|i| entity(i, 0)).collect();
let mut r1 = ChaCha8Rng::seed_from_u64(7);
let mut r2 = ChaCha8Rng::seed_from_u64(7);
let e1 = churn.apply(&entities, &bucket(4), &mut r1);
let e2 = churn.apply(&entities, &bucket(4), &mut r2);
let ids1: Vec<i64> = e1.iter().map(|e| e.entity_id).collect();
let ids2: Vec<i64> = e2.iter().map(|e| e.entity_id).collect();
assert_eq!(ids1, ids2);
assert!(!e1.is_empty() && e1.len() < 100);
}
}