Skip to main content

radiate_core/stats/
expr.rs

1use crate::metric_names;
2use crate::{Expr, stats::metric_fields};
3
4const KP: f32 = 0.05_f32;
5const KI: f32 = 0.005_f32;
6const KD: f32 = 0.02_f32;
7
8pub fn species_error_signal(count: usize) -> Expr {
9    Expr::select(metric_names::SPECIES_COUNT).error(count as f32)
10}
11
12pub fn species_target_control(target: usize, base_val: f32) -> Expr {
13    let target_f32 = target as f32;
14    let raw_error = species_error_signal(target);
15
16    // Proportional: smoothed count so single-gen bursts don't cause hard jumps
17    let proportional = Expr::select(metric_names::SPECIES_COUNT)
18        .rolling(3)
19        .mean()
20        .error(target_f32)
21        * KP;
22
23    // Integral: accumulated recent error over a rolling window
24    // Derivative: velocity of the error — anticipates rising/falling count
25    let integral = raw_error.clone().rolling(20).sum() * KI;
26    let derivative = raw_error.rolling(5).slope() * KD;
27
28    Expr::when(Expr::select(metric_names::INDEX).lt(2_i32))
29        .then(base_val)
30        .otherwise(
31            Expr::select(metric_names::SPECIES_THRESHOLD) + proportional + integral + derivative,
32        )
33        .clamp(0.0_f32, target_f32 * 2.5_f32)
34        .alias(metric_names::SPECIES_THRESHOLD)
35}
36
37// Rolling slope of best score — useful for limits and convergence detection
38pub fn score_trend_signal(window: usize) -> Expr {
39    Expr::select(metric_names::BEST_SCORES)
40        .rolling(window)
41        .slope()
42        .alias(format!("{}.[{}]", metric_names::SCORES_TREND, window))
43}
44
45// Coefficient of variation — normalized score spread
46pub fn score_cv_signal(window: usize) -> Expr {
47    Expr::select(metric_names::BEST_SCORES)
48        .rolling(window)
49        .stddev()
50        .div(
51            Expr::select(metric_names::BEST_SCORES)
52                .rolling(window)
53                .mean(),
54        )
55}
56
57// Throttles add-vertex/add-edge rates as genome grows past target
58pub fn genome_size_throttle(base_rate: impl Into<Expr>, target_size: usize) -> Expr {
59    let pressure = Expr::select(metric_names::GENOME_SIZE)
60        .rolling(10)
61        .mean()
62        .div(target_size as f32)
63        .clamp(1.0_f32, 5.0_f32);
64    base_rate.into().div(pressure)
65}
66
67// Higher when diversity is low, lower when healthy
68pub fn diversity_signal(window: usize, min: f32, max: f32) -> Expr {
69    let diversity = Expr::select(metric_names::PCT_DIVERSITY)
70        .rolling(window)
71        .mean();
72    (Expr::lit(1.0_f32) - diversity)
73        .mul(max - min)
74        .add(min)
75        .clamp(min, max)
76        .alias(format!("{}.[{}]", metric_names::PCT_DIVERSITY, window))
77}
78
79// True when best score hasn't meaningfully moved in `window` generations
80pub fn stagnation_expr(window: usize, epsilon: f32) -> Expr {
81    Expr::select(metric_names::BEST_SCORES)
82        .rolling(window)
83        .slope()
84        .abs()
85        .lt(epsilon)
86}
87
88// Bloat pressure: throttle growth mutation only when genome size is
89// growing WITHOUT a corresponding fitness payoff. Distinguishes genuine
90// bloat (size↑, corr weak) from justified growth (size↑, corr strong) —
91pub fn bloat_pressure_signal(base_rate: impl Into<Expr>, corr_floor: f32) -> Expr {
92    let base_rate = base_rate.into();
93    let growing = Expr::select(metric_names::GENOME_SIZE)
94        .attr(metric_fields::MEAN)
95        .rolling(10)
96        .slope()
97        .gt(0.0_f32);
98
99    let weak_payoff = Expr::select(metric_names::SIZE_SCORE_CORR)
100        .rolling(10)
101        .mean()
102        .abs()
103        .lt(corr_floor);
104
105    Expr::warmup(1)
106        .then(
107            Expr::when(growing.and(weak_payoff))
108                .then(base_rate.clone() * 0.5_f32)
109                .otherwise(base_rate.clone()),
110        )
111        .otherwise(base_rate)
112}