codetether_agent/session/delegation/lambda.rs
1//! Forgetting factor `λ` resolution + range clamp (Phase C step 32).
2//!
3//! CADMAS-CTX recommends `λ ∈ [0.9, 1.0)` for cyclical-drift mitigation:
4//! tighter values forget too quickly, looser values are indistinguishable
5//! from no decay. `1.0` (and above) disables decay entirely; values below
6//! `0.9` collapse the posterior toward the latest observation.
7
8use super::config::DEFAULT_LAMBDA;
9
10/// Lower bound for the forgetting factor.
11pub const LAMBDA_MIN: f64 = 0.9;
12/// Upper bound (exclusive): `1.0` would mean "never forget" — handled by
13/// the caller, not by this clamp. We saturate at the largest representable
14/// value strictly below `1.0`.
15pub const LAMBDA_MAX_INCLUSIVE: f64 = 1.0;
16
17/// Read `CODETETHER_DELEGATION_LAMBDA` and clamp into `[0.9, 1.0)`.
18///
19/// Returns `None` when the env var is absent or unparseable so the caller
20/// can fall back to its persisted [`DelegationConfig::lambda`].
21///
22/// [`DelegationConfig::lambda`]: super::config::DelegationConfig::lambda
23pub fn env_lambda_override() -> Option<f64> {
24 let raw = std::env::var("CODETETHER_DELEGATION_LAMBDA").ok()?;
25 let value: f64 = raw.trim().parse().ok()?;
26 Some(clamp_lambda(value))
27}
28
29/// Clamp `raw` into the documented `[0.9, 1.0)` range.
30///
31/// `1.0` is preserved as-is — that signals "no decay" at the call site,
32/// matching the historical [`DEFAULT_LAMBDA`]. Values strictly below `0.9`
33/// snap up to `0.9`; values strictly above `1.0` (or non-finite) fall back
34/// to the default so misconfiguration cannot cancel the bandit out.
35pub fn clamp_lambda(raw: f64) -> f64 {
36 if !raw.is_finite() {
37 return DEFAULT_LAMBDA;
38 }
39 if raw <= 0.0 || raw > LAMBDA_MAX_INCLUSIVE {
40 return DEFAULT_LAMBDA;
41 }
42 if raw < LAMBDA_MIN {
43 return LAMBDA_MIN;
44 }
45 raw
46}
47
48#[cfg(test)]
49mod tests {
50 use super::*;
51
52 #[test]
53 fn clamps_below_min_to_min() {
54 assert!((clamp_lambda(0.5) - LAMBDA_MIN).abs() < 1e-12);
55 }
56
57 #[test]
58 fn one_passes_through_above_one_falls_back() {
59 assert!((clamp_lambda(1.0) - 1.0).abs() < 1e-12);
60 assert!((clamp_lambda(2.5) - DEFAULT_LAMBDA).abs() < 1e-12);
61 }
62
63 #[test]
64 fn passes_through_in_range() {
65 assert!((clamp_lambda(0.95) - 0.95).abs() < 1e-12);
66 }
67
68 #[test]
69 fn nan_falls_back_to_default() {
70 assert!((clamp_lambda(f64::NAN) - DEFAULT_LAMBDA).abs() < 1e-12);
71 }
72}