1use serde::{Deserialize, Serialize};
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct DecayParams {
47 pub half_life: f32,
50
51 pub shape: f32,
58}
59
60impl DecayParams {
61 #[inline]
63 pub fn exponential(half_life: f32) -> Self {
64 Self {
65 half_life,
66 shape: 1.0,
67 }
68 }
69
70 #[inline]
72 pub fn heavy_tail(half_life: f32) -> Self {
73 Self {
74 half_life,
75 shape: 0.65,
76 }
77 }
78
79 #[inline]
81 pub fn sharp_cutoff(half_life: f32) -> Self {
82 Self {
83 half_life,
84 shape: 2.0,
85 }
86 }
87
88 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#[inline]
123pub fn parametric_decay(x: f32, params: &DecayParams) -> f32 {
124 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 let ratio = x / params.half_life;
141 let exponent = -(core::f32::consts::LN_2) * ratio.powf(params.shape);
142
143 exponent.exp().clamp(0.0, 1.0)
145}
146
147#[inline]
153pub fn exponential_decay(x: f32, half_life: f32) -> f32 {
154 parametric_decay(x, &DecayParams::exponential(half_life))
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct ScoringDecayConfig {
164 pub recency_notes: DecayParams,
166
167 pub recency_artifacts: DecayParams,
169
170 pub warmth: DecayParams,
172
173 pub graph_proximity: DecayParams,
177
178 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 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 #[test]
215 fn test_identity_at_zero() {
216 let params = DecayParams::exponential(7.0);
217 assert_eq!(parametric_decay(0.0, ¶ms), 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, ¶ms);
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, ¶ms);
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, ¶ms);
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, ¶ms);
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, ¶ms);
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 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 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 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, ¶ms);
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 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, ¶ms);
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, ¶ms), 1.0);
327 }
328
329 #[test]
330 fn test_very_small_half_life() {
331 let params = DecayParams::exponential(0.001);
332 let result = parametric_decay(1.0, ¶ms);
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 let result = parametric_decay(1.0, ¶ms);
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 #[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, ¶ms), 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, ¶ms), 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, ¶ms), 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, ¶ms), 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, ¶ms), 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, ¶ms), 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, ¶ms), 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, ¶ms), 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, ¶ms), 0.0);
478 }
479
480 mod prop {
483 use super::*;
484 use proptest::prelude::*;
485
486 proptest! {
487 #![proptest_config(ProptestConfig::with_cases(2000))]
488
489 #[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, ¶ms);
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 #[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, ¶ms);
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 #[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, ¶ms);
529 let s2 = parametric_decay(x2, ¶ms);
530 prop_assert!(
531 s1 >= s2,
532 "P3 violated: S({}) = {} < S({}) = {} with τ={}, β={}",
533 x1, s1, x2, s2, half_life, shape
534 );
535 }
536
537 #[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, ¶ms);
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 #[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, ¶ms);
565 prop_assert!(
566 result < 0.01,
567 "P5 violated: S(1000τ, τ={}, β={}) = {} ≥ 0.01",
568 half_life, shape, result
569 );
570 }
571
572 #[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, ¶ms);
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 #[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, ) {
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 #[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 #[test]
623 fn test_decay_exponential_at_half_life_integration() {
624 let params = DecayParams::exponential(7.0);
625 let d = parametric_decay(7.0, ¶ms);
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 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 let gentle_at_3 = parametric_decay(3.0, ¶ms_gentle);
647 let sharp_at_3 = parametric_decay(3.0, ¶ms_sharp);
648 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 #[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, ¶ms);
665 let s2 = parametric_decay(d2 as f32, ¶ms);
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 #[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, ¶ms);
678 let score_at_n = parametric_decay(events_ago as f32, ¶ms);
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}