//! Semantic decay for assertion confidence. //! //! This module implements time-based confidence decay for assertions. //! Older assertions have their effective confidence reduced based on age, //! allowing recent evidence to outweigh stale claims. //! //! # The Problem //! //! Medical knowledge decays at different rates. A Reddit post from 2022 //! shouldn't compete equally with a 2024 RCT. Without decay, old assertions //! with high confidence can dominate over recent, more relevant evidence. //! //! # Formula //! //! ```text //! effective_confidence = original_confidence * 2^(-(age / halflife)) //! ``` //! //! Where: //! - `age` = now - assertion.timestamp (in seconds) //! - `halflife` = decay half-life (in seconds) //! //! # Example //! //! With a 1-year half-life (31,536,000 seconds): //! - 0 years old: 100% of original confidence //! - 1 year old: 50% of original confidence //! - 2 years old: 25% of original confidence //! - 3 years old: 12.5% of original confidence use stemedb_core::types::Assertion; /// Seconds per day (86,400). const SECONDS_PER_DAY: u64 = 86_400; /// Apply decay to a set of assertions based on age. /// /// Returns cloned assertions with their confidence scores reduced based on /// how old they are relative to the given timestamp. /// /// # Arguments /// /// * `assertions` - The assertions to decay /// * `halflife` - Decay half-life in seconds. After this duration, confidence is halved. /// * `now` - Reference timestamp (usually current time or `as_of` for time-travel) /// /// # Returns /// /// A new vector of assertions with decayed confidence scores. /// The original assertions are not modified. /// /// # Formula /// /// ```text /// age = now - assertion.timestamp /// decay_factor = 2^(-(age / halflife)) /// effective_confidence = confidence * decay_factor /// ``` /// /// # Edge Cases /// /// - If `halflife` is 0, returns assertions unchanged (no decay) /// - If assertion timestamp > now, no decay is applied (future assertions) /// - Confidence is clamped to [0.0, 1.0] pub fn apply_decay(assertions: &[Assertion], halflife: u64, now: u64) -> Vec { if halflife == 0 { return assertions.to_vec(); } assertions .iter() .map(|assertion| { let decayed_confidence = compute_decayed_confidence( assertion.confidence, assertion.timestamp, halflife, now, ); let mut decayed = assertion.clone(); decayed.confidence = decayed_confidence; decayed }) .collect() } /// Apply source-class-aware decay to assertions. /// /// Each assertion's decay half-life is determined by its `source_class` tier: /// - Tier 0 (Regulatory): No decay /// - Tier 1 (Clinical): 2-year half-life /// - Tier 2 (Observational): 1-year half-life /// - Tier 3 (Expert): 6-month half-life /// - Tier 4 (Community): 3-month half-life /// - Tier 5 (Anecdotal): 1-month half-life /// /// # Arguments /// /// * `assertions` - The assertions to decay /// * `fallback_halflife` - Half-life in seconds to use when source_class has no default /// * `now` - Reference timestamp /// /// # Returns /// /// A new vector of assertions with tier-appropriate decay applied. pub fn apply_source_class_decay( assertions: &[Assertion], fallback_halflife: u64, now: u64, ) -> Vec { assertions .iter() .map(|assertion| { // Get tier-specific half-life from SourceClass, convert days to seconds // If default_decay_days() returns None (e.g., Regulatory), no decay is applied. let halflife_opt = assertion .source_class .default_decay_days() .map(|days| u64::from(days) * SECONDS_PER_DAY); // If source class has no decay (None), return unchanged // Otherwise use tier-specific halflife (or fallback if zero) let halflife = match halflife_opt { None => { // Source class explicitly has no decay (e.g., Regulatory) return assertion.clone(); } Some(0) => fallback_halflife, // Shouldn't happen, but fallback to avoid div-by-zero Some(h) => h, }; let decayed_confidence = compute_decayed_confidence( assertion.confidence, assertion.timestamp, halflife, now, ); let mut decayed = assertion.clone(); decayed.confidence = decayed_confidence; decayed }) .collect() } /// Compute the decayed confidence for a single assertion. /// /// # Formula /// /// ```text /// age = now - timestamp /// decay_factor = 2^(-(age / halflife)) /// decayed_confidence = confidence * decay_factor /// ``` /// /// # Arguments /// /// * `confidence` - Original confidence score (0.0 to 1.0) /// * `timestamp` - When the assertion was created (Unix seconds) /// * `halflife` - Decay half-life in seconds /// * `now` - Reference timestamp (Unix seconds) /// /// # Returns /// /// The decayed confidence, clamped to [0.0, 1.0]. fn compute_decayed_confidence(confidence: f32, timestamp: u64, halflife: u64, now: u64) -> f32 { // No decay for future assertions if timestamp >= now { return confidence; } let age = now - timestamp; let age_f = age as f32; let halflife_f = halflife as f32; // decay_factor = 2^(-(age / halflife)) // This equals e^(-(age / halflife) * ln(2)) let decay_factor = 2_f32.powf(-age_f / halflife_f); // Clamp to valid confidence range (confidence * decay_factor).clamp(0.0, 1.0) } #[cfg(test)] mod tests { use super::*; use stemedb_core::testing::AssertionBuilder; use stemedb_core::types::SourceClass; /// One year in seconds (365 days). const ONE_YEAR_SECONDS: u64 = 365 * 24 * 60 * 60; /// One hour in seconds. const ONE_HOUR_SECONDS: u64 = 60 * 60; // ======================================================================== // Core Decay Tests // ======================================================================== #[test] fn test_decay_reduces_old_assertion_confidence() { // Assertion is 1 year old with 1-year half-life // Expected: ~50% of original confidence let now = 1_000_000_000_u64; // Some reference time let one_year_ago = now - ONE_YEAR_SECONDS; let assertion = AssertionBuilder::new() .subject("test") .predicate("value") .confidence(0.9) .timestamp(one_year_ago) .build(); let decayed = apply_decay(&[assertion], ONE_YEAR_SECONDS, now); assert_eq!(decayed.len(), 1); let decayed_conf = decayed[0].confidence; // Should be approximately 0.45 (0.9 * 0.5) // Allow 1% tolerance for floating point assert!((decayed_conf - 0.45).abs() < 0.01, "Expected ~0.45, got {}", decayed_conf); } #[test] fn test_decay_preserves_fresh_assertions() { // Assertion is 1 hour old with 1-year half-life // Expected: ~100% of original confidence (minimal decay) let now = 1_000_000_000_u64; let one_hour_ago = now - ONE_HOUR_SECONDS; let assertion = AssertionBuilder::new() .subject("test") .predicate("value") .confidence(0.9) .timestamp(one_hour_ago) .build(); let decayed = apply_decay(&[assertion], ONE_YEAR_SECONDS, now); assert_eq!(decayed.len(), 1); let decayed_conf = decayed[0].confidence; // Should be very close to original (99.99%+) assert!((decayed_conf - 0.9).abs() < 0.001, "Expected ~0.9, got {}", decayed_conf); } #[test] fn test_decay_interacts_with_lens() { // Two assertions: older has higher base confidence but should lose after decay let now = 1_000_000_000_u64; let two_years_ago = now - (2 * ONE_YEAR_SECONDS); let one_week_ago = now - (7 * 24 * 60 * 60); // Old assertion: high confidence (0.9), but 2 years old let old_assertion = AssertionBuilder::new() .subject("test") .predicate("value") .confidence(0.9) .timestamp(two_years_ago) .build(); // New assertion: lower confidence (0.6), but only 1 week old let new_assertion = AssertionBuilder::new() .subject("test") .predicate("value") .confidence(0.6) .timestamp(one_week_ago) .build(); let decayed = apply_decay(&[old_assertion, new_assertion], ONE_YEAR_SECONDS, now); assert_eq!(decayed.len(), 2); // Old assertion: 0.9 * 0.25 (2 half-lives) = ~0.225 let old_decayed = decayed[0].confidence; assert!( (old_decayed - 0.225).abs() < 0.02, "Old assertion expected ~0.225, got {}", old_decayed ); // New assertion: 0.6 * ~1.0 = ~0.6 (negligible decay) // 1 week = 604800 seconds, 1 year = 31536000 seconds // decay factor = 2^(-(604800/31536000)) = 2^(-0.0192) ≈ 0.9868 // 0.6 * 0.9868 ≈ 0.592 let new_decayed = decayed[1].confidence; assert!( (new_decayed - 0.6).abs() < 0.02, // Allow 2% tolerance "New assertion expected ~0.6, got {}", new_decayed ); // The newer assertion should now have higher effective confidence assert!( new_decayed > old_decayed, "Newer assertion ({}) should beat older ({}) after decay", new_decayed, old_decayed ); } // ======================================================================== // Source-Class-Aware Decay Tests // ======================================================================== #[test] fn test_source_aware_decay_tier0_no_decay() { // Regulatory (Tier 0) sources should never decay let now = 1_000_000_000_u64; let five_years_ago = now - (5 * ONE_YEAR_SECONDS); let assertion = AssertionBuilder::new() .subject("test") .predicate("value") .confidence(0.95) .timestamp(five_years_ago) .source_class(SourceClass::Regulatory) .build(); let decayed = apply_source_class_decay(&[assertion], ONE_YEAR_SECONDS, now); assert_eq!(decayed.len(), 1); // Regulatory sources have no decay (default_decay_days returns None) // So fallback is used, but since Regulatory returns None, we should handle this // Actually, looking at the code, None means no decay assert_eq!(decayed[0].confidence, 0.95, "Regulatory sources should not decay"); } #[test] fn test_source_aware_decay_tier5_rapid_decay() { // Anecdotal (Tier 5) sources decay rapidly (30-day half-life) let now = 1_000_000_000_u64; let sixty_days_ago = now - (60 * SECONDS_PER_DAY); let assertion = AssertionBuilder::new() .subject("test") .predicate("value") .confidence(0.8) .timestamp(sixty_days_ago) .source_class(SourceClass::Anecdotal) .build(); let decayed = apply_source_class_decay(&[assertion], ONE_YEAR_SECONDS, now); assert_eq!(decayed.len(), 1); // 60 days = 2 half-lives for Anecdotal (30-day half-life) // Expected: 0.8 * 0.25 = 0.2 let decayed_conf = decayed[0].confidence; assert!( (decayed_conf - 0.2).abs() < 0.02, "Anecdotal (60 days, 30-day halflife) expected ~0.2, got {}", decayed_conf ); } #[test] fn test_source_aware_decay_mixed_tiers() { // Compare Clinical (2yr halflife) vs Anecdotal (30-day halflife) let now = 1_000_000_000_u64; let one_year_ago = now - ONE_YEAR_SECONDS; // Clinical: 1 year = 0.5 half-lives → ~70% decay factor let clinical = AssertionBuilder::new() .subject("test") .predicate("value") .confidence(0.8) .timestamp(one_year_ago) .source_class(SourceClass::Clinical) .build(); // Anecdotal: 1 year = 12+ half-lives → ~0% decay factor let anecdotal = AssertionBuilder::new() .subject("test") .predicate("value") .confidence(0.8) .timestamp(one_year_ago) .source_class(SourceClass::Anecdotal) .build(); let decayed = apply_source_class_decay(&[clinical, anecdotal], ONE_YEAR_SECONDS, now); assert_eq!(decayed.len(), 2); // Clinical (2yr halflife): 1yr = 0.5 halflife → 2^(-0.5) ≈ 0.707 // 0.8 * 0.707 ≈ 0.566 let clinical_decayed = decayed[0].confidence; assert!( (clinical_decayed - 0.566).abs() < 0.02, "Clinical expected ~0.566, got {}", clinical_decayed ); // Anecdotal (30-day halflife): 365 days = ~12.2 half-lives → 2^(-12.2) ≈ 0.0002 // Should be near zero let anecdotal_decayed = decayed[1].confidence; assert!( anecdotal_decayed < 0.01, "Anecdotal expected near zero, got {}", anecdotal_decayed ); // Clinical should be much higher than Anecdotal after tier-aware decay assert!( clinical_decayed > anecdotal_decayed * 10.0, "Clinical ({}) should be much higher than Anecdotal ({})", clinical_decayed, anecdotal_decayed ); } // ======================================================================== // Edge Case Tests // ======================================================================== #[test] fn test_decay_zero_halflife_no_change() { let now = 1_000_000_000_u64; let one_year_ago = now - ONE_YEAR_SECONDS; let assertion = AssertionBuilder::new() .subject("test") .predicate("value") .confidence(0.9) .timestamp(one_year_ago) .build(); // Zero half-life means no decay let decayed = apply_decay(&[assertion], 0, now); assert_eq!(decayed.len(), 1); assert_eq!(decayed[0].confidence, 0.9, "Zero halflife should skip decay"); } #[test] fn test_decay_future_assertion_no_change() { let now = 1_000_000_000_u64; let future = now + ONE_YEAR_SECONDS; let assertion = AssertionBuilder::new() .subject("test") .predicate("value") .confidence(0.9) .timestamp(future) .build(); let decayed = apply_decay(&[assertion], ONE_YEAR_SECONDS, now); assert_eq!(decayed.len(), 1); assert_eq!(decayed[0].confidence, 0.9, "Future assertions should not decay"); } #[test] fn test_decay_empty_assertions() { let decayed = apply_decay(&[], ONE_YEAR_SECONDS, 1_000_000_000); assert!(decayed.is_empty()); } #[test] fn test_decay_confidence_clamps_to_valid_range() { // Very old assertion should decay to near-zero but never negative // Use a large enough `now` to avoid overflow with 100-year old assertion let now = 5_000_000_000_u64; // ~2128 in Unix time let ancient = now - (100 * ONE_YEAR_SECONDS); // 100 years ago let assertion = AssertionBuilder::new() .subject("test") .predicate("value") .confidence(0.9) .timestamp(ancient) .build(); let decayed = apply_decay(&[assertion], ONE_YEAR_SECONDS, now); assert_eq!(decayed.len(), 1); assert!(decayed[0].confidence >= 0.0, "Confidence should not be negative"); assert!(decayed[0].confidence <= 1.0, "Confidence should not exceed 1.0"); } #[test] fn test_decay_preserves_other_fields() { let now = 1_000_000_000_u64; let one_year_ago = now - ONE_YEAR_SECONDS; let assertion = AssertionBuilder::new() .subject("Tesla") .predicate("revenue") .object_number(96.7) .confidence(0.9) .timestamp(one_year_ago) .build(); let decayed = apply_decay(std::slice::from_ref(&assertion), ONE_YEAR_SECONDS, now); assert_eq!(decayed.len(), 1); assert_eq!(decayed[0].subject, assertion.subject); assert_eq!(decayed[0].predicate, assertion.predicate); assert_eq!(decayed[0].object, assertion.object); assert_eq!(decayed[0].timestamp, assertion.timestamp); // Only confidence should change assert_ne!(decayed[0].confidence, assertion.confidence); } }