use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq)]
pub struct ScoringWeights {
pub churn: f64,
pub touch: f64,
pub recency: f64,
pub fan_in: f64,
pub scc: f64,
pub depth: f64,
pub neighbor_churn: f64,
}
impl Default for ScoringWeights {
fn default() -> Self {
ScoringWeights {
churn: 0.5,
touch: 0.3,
recency: 0.2,
fan_in: 0.4,
scc: 0.3,
depth: 0.1,
neighbor_churn: 0.2,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct RiskFactors {
pub complexity: f64,
pub churn: f64,
pub activity: f64,
pub recency: f64,
pub fan_in: f64,
pub cyclic_dependency: f64,
pub depth: f64,
pub neighbor_churn: f64,
}
pub struct ActivityRiskInput {
pub lrs: f64,
pub churn: Option<(usize, usize)>,
pub touch_count_30d: Option<usize>,
pub days_since_last_change: Option<u32>,
pub fan_in: Option<usize>,
pub scc_size: Option<usize>,
pub dependency_depth: Option<usize>,
pub neighbor_churn: Option<usize>,
}
pub fn compute_activity_risk(
input: &ActivityRiskInput,
weights: &ScoringWeights,
) -> (f64, RiskFactors) {
let complexity_score = input.lrs;
let churn_score = if let Some((added, deleted)) = input.churn {
((added + deleted) as f64 / 100.0) * weights.churn
} else {
0.0
};
let touch_score = if let Some(touches) = input.touch_count_30d {
((touches as f64 / 10.0).min(5.0)) * weights.touch
} else {
0.0
};
let recency_score = if let Some(days) = input.days_since_last_change {
((5.0 - (days as f64 / 7.0)).max(0.0)) * weights.recency
} else {
0.0
};
let fan_in_score = if let Some(fi) = input.fan_in {
((fi as f64 / 5.0).min(10.0)) * weights.fan_in
} else {
0.0
};
let scc_score = if let Some(size) = input.scc_size {
if size > 1 {
(size as f64) * weights.scc
} else {
0.0
}
} else {
0.0
};
let depth_score = if let Some(depth) = input.dependency_depth {
((depth as f64 / 3.0).min(5.0)) * weights.depth
} else {
0.0
};
let neighbor_churn_score = if let Some(nc) = input.neighbor_churn {
(nc as f64 / 500.0) * weights.neighbor_churn
} else {
0.0
};
let activity_risk = complexity_score
+ churn_score
+ touch_score
+ recency_score
+ fan_in_score
+ scc_score
+ depth_score
+ neighbor_churn_score;
let risk_factors = RiskFactors {
complexity: complexity_score,
churn: churn_score,
activity: touch_score,
recency: recency_score,
fan_in: fan_in_score,
cyclic_dependency: scc_score,
depth: depth_score,
neighbor_churn: neighbor_churn_score,
};
(activity_risk, risk_factors)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_activity_risk_base_lrs_only() {
let (risk, factors) = compute_activity_risk(
&ActivityRiskInput {
lrs: 10.0,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
fan_in: None,
scc_size: None,
dependency_depth: None,
neighbor_churn: None,
},
&ScoringWeights::default(),
);
assert_eq!(risk, 10.0);
assert_eq!(factors.complexity, 10.0);
assert_eq!(factors.churn, 0.0);
assert_eq!(factors.activity, 0.0);
}
#[test]
fn test_compute_activity_risk_with_churn() {
let (risk, factors) = compute_activity_risk(
&ActivityRiskInput {
lrs: 10.0,
churn: Some((50, 50)), touch_count_30d: None,
days_since_last_change: None,
fan_in: None,
scc_size: None,
dependency_depth: None,
neighbor_churn: None,
},
&ScoringWeights::default(),
);
assert_eq!(risk, 10.5);
assert_eq!(factors.churn, 0.5);
}
#[test]
fn test_compute_activity_risk_with_all_factors() {
let (risk, factors) = compute_activity_risk(
&ActivityRiskInput {
lrs: 10.0,
churn: Some((50, 50)), touch_count_30d: Some(20), days_since_last_change: Some(1), fan_in: Some(25), scc_size: Some(3), dependency_depth: Some(9), neighbor_churn: Some(1000), },
&ScoringWeights::default(),
);
assert!(risk > 15.0); assert_eq!(factors.complexity, 10.0);
assert_eq!(factors.churn, 0.5);
assert_eq!(factors.activity, 0.6);
assert!((factors.cyclic_dependency - 0.9).abs() < 0.001); }
}