cellstate_core/
decay.rs

1//! Unified parametric decay module.
2//!
3//! All temporal and spatial decay in CELLSTATE is unified under the **Weibull
4//! survival function**, a single parametric family that subsumes exponential
5//! decay, heavy-tail decay, and sharp-cutoff decay:
6//!
7//! ```text
8//! S(x; τ, β) = exp( -ln(2) · (x / τ)^β )
9//! ```
10//!
11//! where:
12//! - `x ≥ 0` — independent variable (time in days, graph depth in hops, …)
13//! - `τ > 0` — **half-life parameter**: `S(τ; τ, 1) = 0.5`
14//! - `β > 0` — **shape parameter**:
15//!   - `β = 1.0` → standard memoryless exponential (current recency default)
16//!   - `β > 1.0` → super-exponential with sharp sigmoid-like cutoff
17//!   - `β < 1.0` → sub-exponential with heavy tail (graph proximity, etc.)
18//!
19//! # Mathematical Properties (all verified by property tests)
20//!
21//! - **P1 (Identity)**: `S(0; τ, β) = 1` for all `τ, β > 0`
22//! - **P2 (Half-life)**: `S(τ; τ, 1) = 0.5`
23//! - **P3 (Monotonicity)**: `x₁ ≤ x₂ ⟹ S(x₁) ≥ S(x₂)`
24//! - **P4 (Bounds)**: `0 ≤ S(x) ≤ 1` for all `x ≥ 0`
25//! - **P5 (Asymptotic)**: `lim_{x→∞} S(x) = 0`
26//! - **P6 (Smoothness)**: `S` is C^∞ for `x > 0`
27//!
28//! # Subsumption of Legacy Decay Functions
29//!
30//! | Legacy function               | Equivalent call                           |
31//! |-------------------------------|-------------------------------------------|
32//! | `0.5^(t/7)` (note recency)   | `parametric_decay(t, &{7.0, 1.0})`       |
33//! | `0.5^(t/30)` (artifact rec.) | `parametric_decay(t, &{30.0, 1.0})`      |
34//! | `0.5^(t/7)` (warmth decay)   | `parametric_decay(t, &{7.0, 1.0})`       |
35//! | `1 - d/D` (graph linear)     | Replaced by `parametric_decay(d, &{…})`  |
36//!
37//! Re-export path: `cellstate_core::decay::*`
38
39use serde::{Deserialize, Serialize};
40
41/// Configuration for a parametric decay function.
42///
43/// Encapsulates the two parameters of the Weibull survival function.
44/// Builders configure these per scoring factor via `ScoringDecayConfig`.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct DecayParams {
47    /// Half-life parameter τ: the value of `x` at which decay reaches 0.5
48    /// when `shape = 1.0`. Must be positive.
49    pub half_life: f32,
50
51    /// Shape parameter β controlling the decay curve:
52    /// - `1.0` = standard exponential (memoryless)
53    /// - `> 1.0` = super-exponential (sharp cutoff near τ)
54    /// - `< 1.0` = sub-exponential (heavy tail past τ)
55    ///
56    /// Must be positive.
57    pub shape: f32,
58}
59
60impl DecayParams {
61    /// Create params for standard exponential decay with the given half-life.
62    #[inline]
63    pub fn exponential(half_life: f32) -> Self {
64        Self {
65            half_life,
66            shape: 1.0,
67        }
68    }
69
70    /// Create params for heavy-tail decay (useful for graph proximity).
71    #[inline]
72    pub fn heavy_tail(half_life: f32) -> Self {
73        Self {
74            half_life,
75            shape: 0.65,
76        }
77    }
78
79    /// Create params for sharp cutoff decay (useful for time-sensitive windows).
80    #[inline]
81    pub fn sharp_cutoff(half_life: f32) -> Self {
82        Self {
83            half_life,
84            shape: 2.0,
85        }
86    }
87
88    /// Validate that parameters are in the domain.
89    pub fn validate(&self) -> bool {
90        self.half_life > 0.0
91            && self.shape > 0.0
92            && self.half_life.is_finite()
93            && self.shape.is_finite()
94    }
95}
96
97impl Default for DecayParams {
98    fn default() -> Self {
99        Self::exponential(7.0)
100    }
101}
102
103/// Weibull survival function: `S(x; τ, β) = exp(-ln(2) · (x/τ)^β)`
104///
105/// Returns a value in `[0.0, 1.0]` representing the decay of a signal
106/// at distance/time `x` from origin.
107///
108/// # Panics
109///
110/// Debug-asserts that `params.half_life > 0` and `params.shape > 0`.
111/// In release builds, invalid params produce `NaN` or `0.0`.
112///
113/// # Properties
114///
115/// All properties below are verified by the property test suite:
116///
117/// - **P1**: `parametric_decay(0, _) == 1.0`
118/// - **P2**: `parametric_decay(τ, {τ, 1.0}) ≈ 0.5`
119/// - **P3**: Monotone decreasing in `x`
120/// - **P4**: Output ∈ `[0.0, 1.0]`
121/// - **P5**: Approaches `0` as `x → ∞`
122#[inline]
123pub fn parametric_decay(x: f32, params: &DecayParams) -> f32 {
124    // Runtime guards: return 0.0 (fully decayed) for invalid / non-finite params
125    // so that invalid configurations fail safe instead of silently producing NaN.
126    if params.half_life <= 0.0
127        || params.shape <= 0.0
128        || !params.half_life.is_finite()
129        || !params.shape.is_finite()
130        || !x.is_finite()
131    {
132        return 0.0;
133    }
134
135    if x <= 0.0 {
136        return 1.0;
137    }
138
139    // S(x; τ, β) = exp(-ln(2) · (x/τ)^β)
140    let ratio = x / params.half_life;
141    let exponent = -(core::f32::consts::LN_2) * ratio.powf(params.shape);
142
143    // Clamp to [0, 1] to guard against floating-point edge cases
144    exponent.exp().clamp(0.0, 1.0)
145}
146
147/// Convenience: standard exponential decay with the given half-life.
148///
149/// Equivalent to `parametric_decay(x, &DecayParams::exponential(half_life))`.
150///
151/// This is the direct replacement for the legacy `0.5_f32.powf(x / half_life)`.
152#[inline]
153pub fn exponential_decay(x: f32, half_life: f32) -> f32 {
154    parametric_decay(x, &DecayParams::exponential(half_life))
155}
156
157/// Per-factor decay configuration.
158///
159/// Each scoring factor can use the unified parametric decay with its own
160/// half-life and shape. Builders override these via `ScoringDecayConfig`
161/// in the pack bundle manifest or environment variables.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct ScoringDecayConfig {
164    /// Recency decay for notes (default: τ=7 days, β=1.0 exponential)
165    pub recency_notes: DecayParams,
166
167    /// Recency decay for artifacts (default: τ=30 days, β=1.0 exponential)
168    pub recency_artifacts: DecayParams,
169
170    /// Warmth (access frequency) decay (default: τ=7 days, β=1.0 exponential)
171    pub warmth: DecayParams,
172
173    /// Graph proximity decay over hop distance (default: τ=5 hops, β=1.0)
174    ///
175    /// Replaces the legacy linear decay `1 - d/D` with smooth exponential.
176    pub graph_proximity: DecayParams,
177
178    /// Causal proximity decay over event recency (default: τ=10 events, β=0.8 heavy-tail)
179    pub causal_proximity: DecayParams,
180}
181
182impl Default for ScoringDecayConfig {
183    fn default() -> Self {
184        Self {
185            recency_notes: DecayParams::exponential(7.0),
186            recency_artifacts: DecayParams::exponential(30.0),
187            warmth: DecayParams::exponential(7.0),
188            graph_proximity: DecayParams::exponential(5.0),
189            causal_proximity: DecayParams {
190                half_life: 10.0,
191                shape: 0.8,
192            },
193        }
194    }
195}
196
197impl ScoringDecayConfig {
198    /// Validate all decay parameters.
199    pub fn validate(&self) -> bool {
200        self.recency_notes.validate()
201            && self.recency_artifacts.validate()
202            && self.warmth.validate()
203            && self.graph_proximity.validate()
204            && self.causal_proximity.validate()
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    // ── Deterministic unit tests ──────────────────────────────────────
213
214    #[test]
215    fn test_identity_at_zero() {
216        let params = DecayParams::exponential(7.0);
217        assert_eq!(parametric_decay(0.0, &params), 1.0);
218    }
219
220    #[test]
221    fn test_half_life_exponential() {
222        let params = DecayParams::exponential(7.0);
223        let result = parametric_decay(7.0, &params);
224        assert!((result - 0.5).abs() < 1e-6, "Expected ~0.5, got {}", result);
225    }
226
227    #[test]
228    fn test_half_life_large() {
229        let params = DecayParams::exponential(365.0);
230        let result = parametric_decay(365.0, &params);
231        assert!((result - 0.5).abs() < 1e-5, "Expected ~0.5, got {}", result);
232    }
233
234    #[test]
235    fn test_double_half_life() {
236        let params = DecayParams::exponential(7.0);
237        let result = parametric_decay(14.0, &params);
238        assert!(
239            (result - 0.25).abs() < 1e-5,
240            "Expected ~0.25, got {}",
241            result
242        );
243    }
244
245    #[test]
246    fn test_triple_half_life() {
247        let params = DecayParams::exponential(7.0);
248        let result = parametric_decay(21.0, &params);
249        assert!(
250            (result - 0.125).abs() < 1e-5,
251            "Expected ~0.125, got {}",
252            result
253        );
254    }
255
256    #[test]
257    fn test_asymptotic_very_large_x() {
258        let params = DecayParams::exponential(7.0);
259        let result = parametric_decay(10000.0, &params);
260        assert!(result < 1e-10, "Expected ~0, got {}", result);
261    }
262
263    #[test]
264    fn test_heavy_tail_decays_slower_than_exponential() {
265        let exp = DecayParams::exponential(5.0);
266        let heavy = DecayParams::heavy_tail(5.0);
267        // At x > τ, heavy tail should be higher (decays slower)
268        let x = 10.0;
269        assert!(
270            parametric_decay(x, &heavy) > parametric_decay(x, &exp),
271            "Heavy tail should decay slower at x={}",
272            x
273        );
274    }
275
276    #[test]
277    fn test_sharp_cutoff_decays_faster_past_half_life() {
278        let exp = DecayParams::exponential(5.0);
279        let sharp = DecayParams::sharp_cutoff(5.0);
280        // At x > τ, sharp cutoff should be lower (decays faster)
281        let x = 8.0;
282        assert!(
283            parametric_decay(x, &sharp) < parametric_decay(x, &exp),
284            "Sharp cutoff should decay faster at x={}",
285            x
286        );
287    }
288
289    #[test]
290    fn test_backward_compat_with_legacy_exponential() {
291        // Legacy: 0.5_f32.powf(days / 7.0)
292        let params = DecayParams::exponential(7.0);
293        for days in [0.0, 1.0, 3.5, 7.0, 14.0, 30.0, 100.0] {
294            let new_val = parametric_decay(days, &params);
295            let old_val = 0.5_f32.powf(days / 7.0);
296            assert!(
297                (new_val - old_val).abs() < 1e-5,
298                "Mismatch at days={}: new={}, old={}",
299                days,
300                new_val,
301                old_val
302            );
303        }
304    }
305
306    #[test]
307    fn test_backward_compat_artifact_recency() {
308        // Legacy: 0.5_f32.powf(days / 30.0)
309        let params = DecayParams::exponential(30.0);
310        for days in [0.0, 7.0, 15.0, 30.0, 60.0, 90.0] {
311            let new_val = parametric_decay(days, &params);
312            let old_val = 0.5_f32.powf(days / 30.0);
313            assert!(
314                (new_val - old_val).abs() < 1e-5,
315                "Mismatch at days={}: new={}, old={}",
316                days,
317                new_val,
318                old_val
319            );
320        }
321    }
322
323    #[test]
324    fn test_negative_x_returns_one() {
325        let params = DecayParams::exponential(7.0);
326        assert_eq!(parametric_decay(-5.0, &params), 1.0);
327    }
328
329    #[test]
330    fn test_very_small_half_life() {
331        let params = DecayParams::exponential(0.001);
332        // At x=1.0, this should be essentially zero
333        let result = parametric_decay(1.0, &params);
334        assert!(
335            result < 1e-10,
336            "Expected ~0 for tiny half-life, got {}",
337            result
338        );
339    }
340
341    #[test]
342    fn test_very_large_half_life() {
343        let params = DecayParams::exponential(1_000_000.0);
344        // At x=1.0, this should be essentially 1.0
345        let result = parametric_decay(1.0, &params);
346        assert!(
347            (result - 1.0).abs() < 0.001,
348            "Expected ~1.0 for huge half-life, got {}",
349            result
350        );
351    }
352
353    #[test]
354    fn test_convenience_exponential_decay() {
355        let direct = parametric_decay(5.0, &DecayParams::exponential(7.0));
356        let convenience = exponential_decay(5.0, 7.0);
357        assert_eq!(direct, convenience);
358    }
359
360    #[test]
361    fn test_decay_params_validate() {
362        assert!(DecayParams::exponential(7.0).validate());
363        assert!(DecayParams::heavy_tail(5.0).validate());
364        assert!(DecayParams::sharp_cutoff(3.0).validate());
365        assert!(!DecayParams {
366            half_life: 0.0,
367            shape: 1.0
368        }
369        .validate());
370        assert!(!DecayParams {
371            half_life: -1.0,
372            shape: 1.0
373        }
374        .validate());
375        assert!(!DecayParams {
376            half_life: 1.0,
377            shape: 0.0
378        }
379        .validate());
380        assert!(!DecayParams {
381            half_life: 1.0,
382            shape: -1.0
383        }
384        .validate());
385        assert!(!DecayParams {
386            half_life: f32::NAN,
387            shape: 1.0
388        }
389        .validate());
390        assert!(!DecayParams {
391            half_life: 1.0,
392            shape: f32::INFINITY
393        }
394        .validate());
395    }
396
397    #[test]
398    fn test_scoring_decay_config_defaults_valid() {
399        let config = ScoringDecayConfig::default();
400        assert!(config.validate());
401    }
402
403    // ── NaN / Infinity production guard tests ────────────────────────
404
405    #[test]
406    fn test_nan_half_life_returns_zero() {
407        let params = DecayParams {
408            half_life: f32::NAN,
409            shape: 1.0,
410        };
411        assert_eq!(parametric_decay(5.0, &params), 0.0);
412    }
413
414    #[test]
415    fn test_nan_shape_returns_zero() {
416        let params = DecayParams {
417            half_life: 7.0,
418            shape: f32::NAN,
419        };
420        assert_eq!(parametric_decay(5.0, &params), 0.0);
421    }
422
423    #[test]
424    fn test_nan_age_returns_zero() {
425        let params = DecayParams::exponential(7.0);
426        assert_eq!(parametric_decay(f32::NAN, &params), 0.0);
427    }
428
429    #[test]
430    fn test_infinity_half_life_returns_zero() {
431        let params = DecayParams {
432            half_life: f32::INFINITY,
433            shape: 1.0,
434        };
435        assert_eq!(parametric_decay(5.0, &params), 0.0);
436    }
437
438    #[test]
439    fn test_neg_infinity_half_life_returns_zero() {
440        let params = DecayParams {
441            half_life: f32::NEG_INFINITY,
442            shape: 1.0,
443        };
444        assert_eq!(parametric_decay(5.0, &params), 0.0);
445    }
446
447    #[test]
448    fn test_infinity_shape_returns_zero() {
449        let params = DecayParams {
450            half_life: 7.0,
451            shape: f32::INFINITY,
452        };
453        assert_eq!(parametric_decay(5.0, &params), 0.0);
454    }
455
456    #[test]
457    fn test_infinity_age_returns_zero() {
458        let params = DecayParams::exponential(7.0);
459        assert_eq!(parametric_decay(f32::INFINITY, &params), 0.0);
460    }
461
462    #[test]
463    fn test_negative_shape_returns_zero() {
464        let params = DecayParams {
465            half_life: 7.0,
466            shape: -1.0,
467        };
468        assert_eq!(parametric_decay(5.0, &params), 0.0);
469    }
470
471    #[test]
472    fn test_zero_half_life_returns_zero() {
473        let params = DecayParams {
474            half_life: 0.0,
475            shape: 1.0,
476        };
477        assert_eq!(parametric_decay(5.0, &params), 0.0);
478    }
479
480    // ── Property tests (proptest) ─────────────────────────────────────
481
482    mod prop {
483        use super::*;
484        use proptest::prelude::*;
485
486        proptest! {
487            #![proptest_config(ProptestConfig::with_cases(2000))]
488
489            /// P1: S(0; τ, β) = 1.0 for all valid τ, β
490            #[test]
491            fn prop_identity_at_zero(
492                half_life in 0.001f32..10000.0,
493                shape in 0.01f32..10.0,
494            ) {
495                let params = DecayParams { half_life, shape };
496                let result = parametric_decay(0.0, &params);
497                prop_assert!(
498                    (result - 1.0).abs() < 1e-6,
499                    "P1 violated: decay(0, τ={}, β={}) = {} ≠ 1.0",
500                    half_life, shape, result
501                );
502            }
503
504            /// P2: S(τ; τ, 1) = 0.5 (half-life semantics for exponential shape)
505            #[test]
506            fn prop_half_life_semantics(
507                half_life in 0.01f32..10000.0,
508            ) {
509                let params = DecayParams { half_life, shape: 1.0 };
510                let result = parametric_decay(half_life, &params);
511                prop_assert!(
512                    (result - 0.5).abs() < 1e-4,
513                    "P2 violated: decay(τ={}, τ={}, β=1) = {} ≠ 0.5",
514                    half_life, half_life, result
515                );
516            }
517
518            /// P3: S(x₁) ≥ S(x₂) when x₁ ≤ x₂ (monotone decreasing)
519            #[test]
520            fn prop_monotone_decreasing(
521                half_life in 0.01f32..10000.0,
522                shape in 0.01f32..10.0,
523                x1 in 0.0f32..10000.0,
524                delta in 0.001f32..1000.0,
525            ) {
526                let params = DecayParams { half_life, shape };
527                let x2 = x1 + delta;
528                let s1 = parametric_decay(x1, &params);
529                let s2 = parametric_decay(x2, &params);
530                prop_assert!(
531                    s1 >= s2,
532                    "P3 violated: S({}) = {} < S({}) = {} with τ={}, β={}",
533                    x1, s1, x2, s2, half_life, shape
534                );
535            }
536
537            /// P4: 0 ≤ S(x) ≤ 1 for all x ≥ 0
538            #[test]
539            fn prop_bounded_unit_interval(
540                half_life in 0.001f32..10000.0,
541                shape in 0.01f32..10.0,
542                x in 0.0f32..100000.0,
543            ) {
544                let params = DecayParams { half_life, shape };
545                let result = parametric_decay(x, &params);
546                prop_assert!(
547                    (0.0..=1.0).contains(&result),
548                    "P4 violated: S({}, τ={}, β={}) = {} not in [0, 1]",
549                    x, half_life, shape, result
550                );
551            }
552
553            /// P5: S(k·τ) ≈ 0 for sufficiently large k (asymptotic zero)
554            ///
555            /// The required multiplier depends on shape β. For heavy tails (β < 1),
556            /// convergence to zero is slow: we need k^β > ln(1/ε)/ln(2).
557            /// We restrict β ≥ 0.3 here so k=1000 suffices for ε=0.01.
558            #[test]
559            fn prop_asymptotic_zero(
560                half_life in 0.01f32..100.0,
561                shape in 0.3f32..10.0,
562            ) {
563                let params = DecayParams { half_life, shape };
564                let result = parametric_decay(1000.0 * half_life, &params);
565                prop_assert!(
566                    result < 0.01,
567                    "P5 violated: S(1000τ, τ={}, β={}) = {} ≥ 0.01",
568                    half_life, shape, result
569                );
570            }
571
572            /// Subsumption: parametric_decay(x, {τ, 1.0}) ≡ 0.5^(x/τ)
573            #[test]
574            fn prop_backward_compat_exponential(
575                half_life in 0.01f32..10000.0,
576                x in 0.0f32..10000.0,
577            ) {
578                let params = DecayParams { half_life, shape: 1.0 };
579                let new_result = parametric_decay(x, &params);
580                let old_result = 0.5_f32.powf(x / half_life);
581                prop_assert!(
582                    (new_result - old_result).abs() < 1e-4,
583                    "Subsumption violated at x={}, τ={}: new={} vs old={}",
584                    x, half_life, new_result, old_result
585                );
586            }
587
588            /// Shape ordering: at x > τ, lower β (heavier tail) yields higher score
589            #[test]
590            fn prop_shape_ordering_past_half_life(
591                half_life in 1.0f32..100.0,
592                beta_low in 0.1f32..0.9,
593                beta_high in 1.1f32..5.0,
594                x_ratio in 1.5f32..10.0,  // x = x_ratio * τ, so x > τ
595            ) {
596                let x = x_ratio * half_life;
597                let heavy = DecayParams { half_life, shape: beta_low };
598                let sharp = DecayParams { half_life, shape: beta_high };
599                let s_heavy = parametric_decay(x, &heavy);
600                let s_sharp = parametric_decay(x, &sharp);
601                prop_assert!(
602                    s_heavy >= s_sharp,
603                    "Shape ordering violated at x={}: heavy(β={})={} < sharp(β={})={}",
604                    x, beta_low, s_heavy, beta_high, s_sharp
605                );
606            }
607
608            /// DecayParams::validate rejects all invalid configurations
609            #[test]
610            fn prop_validate_rejects_nonpositive(
611                half_life in -1000.0f32..0.0,
612                shape in 0.01f32..10.0,
613            ) {
614                let params = DecayParams { half_life, shape };
615                prop_assert!(!params.validate());
616            }
617        }
618    }
619
620    // ── Moved from integration tests (scoring_property_tests.rs) ─────
621
622    #[test]
623    fn test_decay_exponential_at_half_life_integration() {
624        let params = DecayParams::exponential(7.0);
625        let d = parametric_decay(7.0, &params);
626        assert!(
627            (d - 0.5).abs() < 1e-4,
628            "exponential decay at half_life should be 0.5, got {}",
629            d
630        );
631    }
632
633    #[test]
634    fn test_decay_weibull_shape_effect() {
635        // Higher shape = sharper cliff
636        let params_gentle = DecayParams {
637            half_life: 10.0,
638            shape: 0.5,
639        };
640        let params_sharp = DecayParams {
641            half_life: 10.0,
642            shape: 3.0,
643        };
644
645        // At x < half_life, gentle decays faster (concave up vs sigmoid)
646        let gentle_at_3 = parametric_decay(3.0, &params_gentle);
647        let sharp_at_3 = parametric_decay(3.0, &params_sharp);
648        // Both should be > 0.5 since x < half_life
649        assert!(gentle_at_3 > 0.5);
650        assert!(sharp_at_3 > 0.5);
651    }
652
653    mod prop_integration {
654        use super::super::*;
655        use proptest::prelude::*;
656
657        proptest! {
658            #![proptest_config(ProptestConfig::with_cases(500))]
659
660            /// Property: graph proximity -- deeper depth produces lower or equal score.
661            #[test]
662            fn prop_graph_proximity_monotone(d1 in 0u32..10, d2 in 0u32..10) {
663                let params = DecayParams::exponential(5.0);
664                let s1 = parametric_decay(d1 as f32, &params);
665                let s2 = parametric_decay(d2 as f32, &params);
666                if d1 <= d2 {
667                    prop_assert!(s1 >= s2 - 1e-6,
668                        "depth {} score {} should be >= depth {} score {}",
669                        d1, s1, d2, s2);
670                }
671            }
672
673            /// Property: causal proximity -- events_ago=0 yields max score.
674            #[test]
675            fn prop_causal_entry_decay(events_ago in 0u32..200) {
676                let params = DecayParams { half_life: 10.0, shape: 0.8 };
677                let score_at_0 = parametric_decay(0.0, &params);
678                let score_at_n = parametric_decay(events_ago as f32, &params);
679                prop_assert!(score_at_0 >= score_at_n - 1e-6,
680                    "events_ago=0 score {} should be >= events_ago={} score {}",
681                    score_at_0, events_ago, score_at_n);
682            }
683        }
684    }
685}