use std::collections::HashMap;
use crate::error::FingerprintResult;
use crate::models::{AccountClassStats, DistributionParams, DistributionType, NumericStats};
pub fn fit_to_stats(stats: &NumericStats) -> (DistributionType, DistributionParams) {
if stats.distribution != DistributionType::Unknown {
return (stats.distribution, stats.distribution_params.clone());
}
let mean = stats.mean;
let std_dev = stats.std_dev;
let min = stats.min;
let max = stats.max;
let range = max - min;
if range > 0.0 {
let expected_std_uniform = range / (12.0_f64).sqrt();
if (std_dev - expected_std_uniform).abs() / expected_std_uniform < 0.15 {
return (
DistributionType::Uniform,
DistributionParams::uniform(min, max),
);
}
}
if mean > 0.0 && min >= 0.0 && (std_dev / mean - 1.0).abs() < 0.2 {
return (
DistributionType::Exponential,
DistributionParams::exponential(1.0 / mean),
);
}
if min > 0.0 && mean > 0.0 {
let log_values_mean = mean.ln();
let cv = std_dev / mean; let sigma = (1.0 + cv.powi(2)).ln().sqrt();
let mu = log_values_mean - sigma.powi(2) / 2.0;
return (
DistributionType::LogNormal,
DistributionParams::log_normal(mu, sigma),
);
}
(
DistributionType::Normal,
DistributionParams::normal(mean, std_dev),
)
}
pub fn estimate_lognormal_params(mean: f64, variance: f64) -> (f64, f64) {
if mean <= 0.0 {
return (0.0, 1.0);
}
let sigma_sq = (1.0 + variance / mean.powi(2)).ln();
let mu = mean.ln() - sigma_sq / 2.0;
(mu, sigma_sq.sqrt())
}
pub fn estimate_normal_params(values: &[f64]) -> (f64, f64) {
if values.is_empty() {
return (0.0, 1.0);
}
let n = values.len() as f64;
let mean: f64 = values.iter().sum::<f64>() / n;
let variance: f64 = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
(mean, variance.sqrt())
}
#[derive(Debug, Clone)]
pub struct FittedDistribution {
pub distribution_type: DistributionType,
pub params: DistributionParams,
pub mean: f64,
pub std_dev: f64,
pub min: f64,
pub max: f64,
}
pub fn fit_account_class_distributions(
class_stats: &[AccountClassStats],
) -> FingerprintResult<HashMap<String, FittedDistribution>> {
let mut result = HashMap::new();
for class in class_stats {
if let Some(ref numeric) = class.numeric {
let fitted = FittedDistribution {
distribution_type: numeric.distribution,
params: numeric.distribution_params.clone(),
mean: numeric.mean,
std_dev: numeric.std_dev,
min: numeric.min,
max: numeric.max,
};
result.insert(class.class_pattern.clone(), fitted);
}
}
Ok(result)
}
pub fn goodness_of_fit(
observed: &[f64],
dist_type: DistributionType,
params: &DistributionParams,
) -> f64 {
if observed.is_empty() {
return 0.0;
}
let mut sorted = observed.to_vec();
sorted.sort_by(f64::total_cmp);
let n = sorted.len();
let mut max_diff = 0.0;
for (i, &x) in sorted.iter().enumerate() {
let empirical_cdf = (i + 1) as f64 / n as f64;
let theoretical_cdf = theoretical_cdf(x, dist_type, params);
let diff = (empirical_cdf - theoretical_cdf).abs();
if diff > max_diff {
max_diff = diff;
}
}
1.0 - max_diff.min(1.0)
}
fn theoretical_cdf(x: f64, dist_type: DistributionType, params: &DistributionParams) -> f64 {
match dist_type {
DistributionType::Normal => {
let mean = params.param1.unwrap_or(0.0);
let std_dev = params.param2.unwrap_or(1.0);
normal_cdf(x, mean, std_dev)
}
DistributionType::LogNormal => {
if x <= 0.0 {
return 0.0;
}
let mu = params.param1.unwrap_or(0.0);
let sigma = params.param2.unwrap_or(1.0);
normal_cdf(x.ln(), mu, sigma)
}
DistributionType::Uniform => {
let a = params.param1.unwrap_or(0.0);
let b = params.param2.unwrap_or(1.0);
if x < a {
0.0
} else if x > b {
1.0
} else {
(x - a) / (b - a)
}
}
DistributionType::Exponential => {
let rate = params.param1.unwrap_or(1.0);
if x < 0.0 {
0.0
} else {
1.0 - (-rate * x).exp()
}
}
DistributionType::Gamma => {
let shape = params.param1.unwrap_or(1.0);
let rate = params.param2.unwrap_or(1.0);
if x <= 0.0 || shape <= 0.0 || rate <= 0.0 {
0.0
} else {
regularized_gamma_p(shape, rate * x)
}
}
DistributionType::Pareto => {
let x_m = params.param1.unwrap_or(1.0);
let alpha = params.param2.unwrap_or(1.0);
if x < x_m {
0.0
} else {
1.0 - (x_m / x).powf(alpha)
}
}
DistributionType::PointMass => {
let point = params.param1.unwrap_or(0.0);
if x < point {
0.0
} else {
1.0
}
}
DistributionType::Mixture => {
if let Some(ref components) = params.mixture_components {
let mut cdf_val = 0.0;
for comp in components {
cdf_val += comp.weight * theoretical_cdf(x, comp.distribution, &comp.params);
}
cdf_val
} else {
0.5
}
}
_ => 0.5, }
}
fn normal_cdf(x: f64, mean: f64, std_dev: f64) -> f64 {
if std_dev == 0.0 {
return if x >= mean { 1.0 } else { 0.0 };
}
let z = (x - mean) / std_dev;
0.5 * (1.0 + erf(z / std::f64::consts::SQRT_2))
}
fn erf(x: f64) -> f64 {
let a1 = 0.254829592;
let a2 = -0.284496736;
let a3 = 1.421413741;
let a4 = -1.453152027;
let a5 = 1.061405429;
let p = 0.3275911;
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let t = 1.0 / (1.0 + p * x);
let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
sign * y
}
fn ln_gamma(x: f64) -> f64 {
if x < 0.5 {
let reflected = ln_gamma(1.0 - x);
return (std::f64::consts::PI / (std::f64::consts::PI * x).sin()).ln() - reflected;
}
const COEFFICIENTS: [f64; 9] = [
0.999_999_999_999_809_9,
676.520_368_121_885_1,
-1_259.139_216_722_402_9,
771.323_428_777_653_1,
-176.615_029_162_140_6,
12.507_343_278_686_905,
-0.138_571_095_265_720_12,
9.984_369_578_019_572e-6,
1.505_632_735_149_311_6e-7,
];
const G: f64 = 7.0;
let x = x - 1.0;
let mut sum = COEFFICIENTS[0];
for (i, &coeff) in COEFFICIENTS.iter().enumerate().skip(1) {
sum += coeff / (x + i as f64);
}
let t = x + G + 0.5;
0.5 * (2.0 * std::f64::consts::PI).ln() + (t.ln() * (x + 0.5)) - t + sum.ln()
}
fn regularized_gamma_p(a: f64, x: f64) -> f64 {
if x < 0.0 {
return 0.0;
}
if x == 0.0 {
return 0.0;
}
if a <= 0.0 {
return 1.0;
}
if x > a + 1.0 {
return 1.0 - regularized_gamma_q_cf(a, x);
}
regularized_gamma_p_series(a, x)
}
fn regularized_gamma_p_series(a: f64, x: f64) -> f64 {
let max_iterations = 200;
let epsilon = 1e-14;
let ln_prefix = a * x.ln() - x - ln_gamma(a);
if ln_prefix < -700.0 {
return 0.0;
}
let prefix = ln_prefix.exp();
let mut sum = 1.0 / a;
let mut term = 1.0 / a;
for n in 1..max_iterations {
term *= x / (a + n as f64);
sum += term;
if term.abs() < epsilon * sum.abs() {
break;
}
}
(prefix * sum).clamp(0.0, 1.0)
}
fn regularized_gamma_q_cf(a: f64, x: f64) -> f64 {
let max_iterations = 200;
let epsilon = 1e-14;
let tiny = 1e-30;
let ln_prefix = a * x.ln() - x - ln_gamma(a);
if ln_prefix < -700.0 {
return 1.0; }
let prefix = ln_prefix.exp();
let b0 = x + 1.0 - a;
let mut f = if b0.abs() < tiny { tiny } else { b0 };
let mut c = f;
let mut d = 0.0;
for n in 1..max_iterations {
let an = n as f64 * (a - n as f64);
let bn = x + 2.0 * n as f64 + 1.0 - a;
d = bn + an * d;
if d.abs() < tiny {
d = tiny;
}
d = 1.0 / d;
c = bn + an / c;
if c.abs() < tiny {
c = tiny;
}
let delta = c * d;
f *= delta;
if (delta - 1.0).abs() < epsilon {
break;
}
}
let result = prefix / f;
result.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::MixtureComponent;
#[test]
fn test_estimate_lognormal() {
let (mu, sigma) = estimate_lognormal_params(100.0, 2500.0);
assert!(mu > 0.0);
assert!(sigma > 0.0);
}
#[test]
fn test_normal_cdf() {
assert!((normal_cdf(0.0, 0.0, 1.0) - 0.5).abs() < 0.01);
assert!(normal_cdf(3.0, 0.0, 1.0) > 0.99);
assert!(normal_cdf(-3.0, 0.0, 1.0) < 0.01);
}
#[test]
fn test_ln_gamma_known_values() {
assert!(
ln_gamma(1.0).abs() < 1e-10,
"ln_gamma(1) should be 0, got {}",
ln_gamma(1.0)
);
assert!(
ln_gamma(2.0).abs() < 1e-10,
"ln_gamma(2) should be 0, got {}",
ln_gamma(2.0)
);
let expected = 2.0_f64.ln();
assert!(
(ln_gamma(3.0) - expected).abs() < 1e-8,
"ln_gamma(3) should be {}, got {}",
expected,
ln_gamma(3.0)
);
let expected = 24.0_f64.ln();
assert!(
(ln_gamma(5.0) - expected).abs() < 1e-8,
"ln_gamma(5) should be {}, got {}",
expected,
ln_gamma(5.0)
);
let expected = (std::f64::consts::PI).sqrt().ln();
assert!(
(ln_gamma(0.5) - expected).abs() < 1e-8,
"ln_gamma(0.5) should be {}, got {}",
expected,
ln_gamma(0.5)
);
}
#[test]
fn test_ln_gamma_large_values() {
let expected = 362880.0_f64.ln();
assert!(
(ln_gamma(10.0) - expected).abs() < 1e-6,
"ln_gamma(10) should be ~{}, got {}",
expected,
ln_gamma(10.0)
);
}
#[test]
fn test_gamma_cdf_exponential_special_case() {
let params = DistributionParams {
param1: Some(1.0), param2: Some(2.0), ..DistributionParams::empty()
};
let cdf0 = theoretical_cdf(0.0, DistributionType::Gamma, ¶ms);
assert!(
cdf0.abs() < 1e-10,
"Gamma CDF at 0 should be 0, got {}",
cdf0
);
let expected = 1.0 - (-1.0_f64).exp();
let cdf_half = theoretical_cdf(0.5, DistributionType::Gamma, ¶ms);
assert!(
(cdf_half - expected).abs() < 0.01,
"Gamma(1,2) CDF at 0.5 should be ~{}, got {}",
expected,
cdf_half
);
let cdf_large = theoretical_cdf(10.0, DistributionType::Gamma, ¶ms);
assert!(
cdf_large > 0.99,
"Gamma CDF at large x should be ~1, got {}",
cdf_large
);
}
#[test]
fn test_gamma_cdf_shape_2() {
let params = DistributionParams {
param1: Some(2.0), param2: Some(1.0), ..DistributionParams::empty()
};
let expected = 1.0 - 2.0 * (-1.0_f64).exp();
let cdf_val = theoretical_cdf(1.0, DistributionType::Gamma, ¶ms);
assert!(
(cdf_val - expected).abs() < 0.01,
"Gamma(2,1) CDF at 1.0 should be ~{}, got {}",
expected,
cdf_val
);
let expected3 = 1.0 - 4.0 * (-3.0_f64).exp();
let cdf_val3 = theoretical_cdf(3.0, DistributionType::Gamma, ¶ms);
assert!(
(cdf_val3 - expected3).abs() < 0.01,
"Gamma(2,1) CDF at 3.0 should be ~{}, got {}",
expected3,
cdf_val3
);
}
#[test]
fn test_gamma_cdf_negative_x() {
let params = DistributionParams {
param1: Some(2.0),
param2: Some(1.0),
..DistributionParams::empty()
};
let cdf_neg = theoretical_cdf(-1.0, DistributionType::Gamma, ¶ms);
assert!(
cdf_neg.abs() < 1e-10,
"Gamma CDF for negative x should be 0, got {}",
cdf_neg
);
}
#[test]
fn test_gamma_cdf_monotonically_increasing() {
let params = DistributionParams {
param1: Some(3.0),
param2: Some(0.5),
..DistributionParams::empty()
};
let mut prev = 0.0;
for i in 0..=20 {
let x = i as f64 * 0.5;
let cdf_val = theoretical_cdf(x, DistributionType::Gamma, ¶ms);
assert!(
cdf_val >= prev - 1e-10,
"Gamma CDF should be monotonically increasing: at x={}, cdf={} < prev={}",
x,
cdf_val,
prev
);
prev = cdf_val;
}
}
#[test]
fn test_pareto_cdf_basic() {
let params = DistributionParams {
param1: Some(1.0), param2: Some(2.0), ..DistributionParams::empty()
};
let cdf_below = theoretical_cdf(0.5, DistributionType::Pareto, ¶ms);
assert!(
cdf_below.abs() < 1e-10,
"Pareto CDF below x_m should be 0, got {}",
cdf_below
);
let cdf_at = theoretical_cdf(1.0, DistributionType::Pareto, ¶ms);
assert!(
cdf_at.abs() < 1e-10,
"Pareto CDF at x_m should be 0, got {}",
cdf_at
);
let cdf_2 = theoretical_cdf(2.0, DistributionType::Pareto, ¶ms);
assert!(
(cdf_2 - 0.75).abs() < 1e-10,
"Pareto(1,2) CDF at 2.0 should be 0.75, got {}",
cdf_2
);
let cdf_4 = theoretical_cdf(4.0, DistributionType::Pareto, ¶ms);
assert!(
(cdf_4 - 0.9375).abs() < 1e-10,
"Pareto(1,2) CDF at 4.0 should be 0.9375, got {}",
cdf_4
);
}
#[test]
fn test_pareto_cdf_different_params() {
let params = DistributionParams {
param1: Some(5.0),
param2: Some(3.0),
..DistributionParams::empty()
};
let cdf_10 = theoretical_cdf(10.0, DistributionType::Pareto, ¶ms);
assert!(
(cdf_10 - 0.875).abs() < 1e-10,
"Pareto(5,3) CDF at 10.0 should be 0.875, got {}",
cdf_10
);
}
#[test]
fn test_pareto_cdf_monotonically_increasing() {
let params = DistributionParams {
param1: Some(1.0),
param2: Some(1.5),
..DistributionParams::empty()
};
let mut prev = 0.0;
for i in 1..=20 {
let x = i as f64;
let cdf_val = theoretical_cdf(x, DistributionType::Pareto, ¶ms);
assert!(
cdf_val >= prev - 1e-10,
"Pareto CDF should be monotonically increasing"
);
prev = cdf_val;
}
}
#[test]
fn test_point_mass_cdf() {
let params = DistributionParams {
param1: Some(5.0), ..DistributionParams::empty()
};
let cdf_below = theoretical_cdf(4.99, DistributionType::PointMass, ¶ms);
assert!(
cdf_below.abs() < 1e-10,
"PointMass CDF below point should be 0, got {}",
cdf_below
);
let cdf_at = theoretical_cdf(5.0, DistributionType::PointMass, ¶ms);
assert!(
(cdf_at - 1.0).abs() < 1e-10,
"PointMass CDF at point should be 1, got {}",
cdf_at
);
let cdf_above = theoretical_cdf(5.01, DistributionType::PointMass, ¶ms);
assert!(
(cdf_above - 1.0).abs() < 1e-10,
"PointMass CDF above point should be 1, got {}",
cdf_above
);
}
#[test]
fn test_point_mass_cdf_at_zero() {
let params = DistributionParams {
param1: Some(0.0),
..DistributionParams::empty()
};
let cdf_neg = theoretical_cdf(-0.001, DistributionType::PointMass, ¶ms);
assert!(cdf_neg.abs() < 1e-10);
let cdf_zero = theoretical_cdf(0.0, DistributionType::PointMass, ¶ms);
assert!((cdf_zero - 1.0).abs() < 1e-10);
}
#[test]
fn test_mixture_cdf_single_component() {
let params = DistributionParams {
mixture_components: Some(vec![MixtureComponent {
weight: 1.0,
distribution: DistributionType::Normal,
params: DistributionParams::normal(0.0, 1.0),
}]),
..DistributionParams::empty()
};
let mix_cdf = theoretical_cdf(0.0, DistributionType::Mixture, ¶ms);
let normal_cdf_val = normal_cdf(0.0, 0.0, 1.0);
assert!(
(mix_cdf - normal_cdf_val).abs() < 1e-10,
"Single-component mixture should equal component CDF: {} vs {}",
mix_cdf,
normal_cdf_val
);
}
#[test]
fn test_mixture_cdf_two_components() {
let params = DistributionParams {
mixture_components: Some(vec![
MixtureComponent {
weight: 0.5,
distribution: DistributionType::Normal,
params: DistributionParams::normal(0.0, 1.0),
},
MixtureComponent {
weight: 0.5,
distribution: DistributionType::Normal,
params: DistributionParams::normal(5.0, 1.0),
},
]),
..DistributionParams::empty()
};
let mix_cdf_0 = theoretical_cdf(0.0, DistributionType::Mixture, ¶ms);
assert!(
(mix_cdf_0 - 0.25).abs() < 0.01,
"Mixture CDF at 0 should be ~0.25, got {}",
mix_cdf_0
);
let mix_cdf_5 = theoretical_cdf(5.0, DistributionType::Mixture, ¶ms);
assert!(
(mix_cdf_5 - 0.75).abs() < 0.01,
"Mixture CDF at 5 should be ~0.75, got {}",
mix_cdf_5
);
}
#[test]
fn test_mixture_cdf_monotonically_increasing() {
let params = DistributionParams {
mixture_components: Some(vec![
MixtureComponent {
weight: 0.3,
distribution: DistributionType::Normal,
params: DistributionParams::normal(-2.0, 1.0),
},
MixtureComponent {
weight: 0.7,
distribution: DistributionType::Normal,
params: DistributionParams::normal(3.0, 2.0),
},
]),
..DistributionParams::empty()
};
let mut prev = 0.0;
for i in -50..=50 {
let x = i as f64 * 0.2;
let cdf_val = theoretical_cdf(x, DistributionType::Mixture, ¶ms);
assert!(
cdf_val >= prev - 1e-10,
"Mixture CDF should be monotonically increasing: at x={}, cdf={} < prev={}",
x,
cdf_val,
prev
);
prev = cdf_val;
}
}
#[test]
fn test_mixture_cdf_no_components_fallback() {
let params = DistributionParams::empty();
let cdf_val = theoretical_cdf(0.0, DistributionType::Mixture, ¶ms);
assert!(
(cdf_val - 0.5).abs() < 1e-10,
"Mixture with no components should return 0.5, got {}",
cdf_val
);
}
#[test]
fn test_goodness_of_fit_gamma() {
let data: Vec<f64> = (1..=100).map(|i| (i as f64) * 0.1).collect();
let params = DistributionParams {
param1: Some(2.0),
param2: Some(1.0),
..DistributionParams::empty()
};
let score = goodness_of_fit(&data, DistributionType::Gamma, ¶ms);
assert!(
(0.0..=1.0).contains(&score),
"Score should be in [0,1], got {}",
score
);
}
#[test]
fn test_goodness_of_fit_pareto() {
let data: Vec<f64> = (1..=50).map(|i| 1.0 + i as f64 * 0.1).collect();
let params = DistributionParams {
param1: Some(1.0),
param2: Some(2.0),
..DistributionParams::empty()
};
let score = goodness_of_fit(&data, DistributionType::Pareto, ¶ms);
assert!(
(0.0..=1.0).contains(&score),
"Score should be in [0,1], got {}",
score
);
}
#[test]
fn test_regularized_gamma_p_known_values() {
let p_1_1 = regularized_gamma_p(1.0, 1.0);
let expected = 1.0 - (-1.0_f64).exp();
assert!(
(p_1_1 - expected).abs() < 1e-8,
"P(1,1) should be ~{}, got {}",
expected,
p_1_1
);
let p_1_0 = regularized_gamma_p(1.0, 0.0);
assert!(p_1_0.abs() < 1e-10, "P(1,0) should be 0, got {}", p_1_0);
}
#[test]
fn test_regularized_gamma_p_large_x() {
let p = regularized_gamma_p(2.0, 50.0);
assert!((p - 1.0).abs() < 1e-6, "P(2, 50) should be ~1.0, got {}", p);
}
#[test]
fn test_regularized_gamma_p_bounds() {
for &a in &[0.5, 1.0, 2.0, 5.0, 10.0] {
for &x in &[0.0, 0.1, 1.0, 5.0, 20.0, 100.0] {
let p = regularized_gamma_p(a, x);
assert!(
(0.0..=1.0).contains(&p),
"P({}, {}) = {} out of bounds",
a,
x,
p
);
}
}
}
#[test]
fn test_regularized_gamma_p_monotonic_in_x() {
let a = 3.0;
let mut prev = 0.0;
for i in 0..=50 {
let x = i as f64 * 0.5;
let p = regularized_gamma_p(a, x);
assert!(
p >= prev - 1e-10,
"P({}, {}) = {} should be >= prev {}",
a,
x,
p,
prev
);
prev = p;
}
}
fn make_class_stats(
pattern: &str,
label: &str,
numeric: Option<NumericStats>,
row_count: u64,
) -> AccountClassStats {
let mut s = AccountClassStats::new(pattern.to_string(), label.to_string());
s.numeric = numeric;
s.row_count = row_count;
s
}
#[test]
fn test_fit_account_class_distributions_empty_input() {
let result = fit_account_class_distributions(&[]).unwrap();
assert!(
result.is_empty(),
"Empty input should produce empty map, got {} entries",
result.len()
);
}
#[test]
fn test_fit_account_class_distributions_correct_count() {
let classes = vec![
make_class_stats(
"1XXX",
"Assets",
Some(NumericStats::new(50, 100.0, 50000.0, 5000.0, 3000.0)),
50,
),
make_class_stats(
"4XXX",
"Revenue",
Some(NumericStats::new(200, 500.0, 99000.0, 12000.0, 8000.0)),
200,
),
make_class_stats(
"5XXX",
"Expenses",
Some(NumericStats::new(150, 10.0, 30000.0, 4000.0, 2500.0)),
150,
),
];
let result = fit_account_class_distributions(&classes).unwrap();
assert_eq!(
result.len(),
3,
"Should have 3 fitted distributions, got {}",
result.len()
);
assert!(result.contains_key("1XXX"));
assert!(result.contains_key("4XXX"));
assert!(result.contains_key("5XXX"));
}
#[test]
fn test_fit_account_class_distributions_skips_no_numeric() {
let classes = vec![
make_class_stats(
"1XXX",
"Assets",
Some(NumericStats::new(50, 100.0, 50000.0, 5000.0, 3000.0)),
50,
),
make_class_stats("2XXX", "Liabilities", None, 0),
make_class_stats(
"4XXX",
"Revenue",
Some(NumericStats::new(200, 500.0, 99000.0, 12000.0, 8000.0)),
200,
),
];
let result = fit_account_class_distributions(&classes).unwrap();
assert_eq!(
result.len(),
2,
"Should have 2 entries (skipping class without numeric), got {}",
result.len()
);
assert!(result.contains_key("1XXX"));
assert!(!result.contains_key("2XXX"), "2XXX should be skipped");
assert!(result.contains_key("4XXX"));
}
#[test]
fn test_fit_account_class_distributions_propagates_params() {
let mut numeric = NumericStats::new(100, 500.0, 99000.0, 12345.67, 5000.0);
numeric.distribution = DistributionType::LogNormal;
numeric.distribution_params = DistributionParams::log_normal(8.5, 1.2);
let classes = vec![make_class_stats("4XXX", "Revenue", Some(numeric), 100)];
let result = fit_account_class_distributions(&classes).unwrap();
let fitted = result.get("4XXX").expect("4XXX should be present");
assert_eq!(fitted.distribution_type, DistributionType::LogNormal);
assert_eq!(fitted.params.param1, Some(8.5));
assert_eq!(fitted.params.param2, Some(1.2));
assert!((fitted.mean - 12345.67).abs() < 1e-10);
assert!((fitted.std_dev - 5000.0).abs() < 1e-10);
assert!((fitted.min - 500.0).abs() < 1e-10);
assert!((fitted.max - 99000.0).abs() < 1e-10);
}
#[test]
fn test_fit_account_class_distributions_all_without_numeric() {
let classes = vec![
make_class_stats("1XXX", "Assets", None, 0),
make_class_stats("2XXX", "Liabilities", None, 0),
];
let result = fit_account_class_distributions(&classes).unwrap();
assert!(
result.is_empty(),
"All classes without numeric should produce empty map"
);
}
}