stemedb/crates/stemedb-query/tests/battery/battery3_decay_math.rs
jordan 42d4e09508 feat: Index persistence (Phase 5C) - vector hot/cold, visual checkpoint
Phase 5C (Index Persistence) implementation:
- PersistentVectorIndex with hot/cold architecture
  - Hot: in-memory HNSW for recent vectors
  - Cold: memory-mapped HNSW loaded from disk
  - Background builder for WAL replay and atomic swap
  - BLAKE3 integrity verification
- PersistentVisualIndex with checkpoint persistence
  - BkTreeSnapshot with rkyv serialization
  - CRC32C corruption detection
  - Atomic write pattern (temp → fsync → rename)
- Key codec additions for vector index metadata
- Split large files into modules (<500 lines each)
  - battery_pre_sentinel.rs → battery/ directory
  - visual_index.rs → visual_index/ directory
  - persistent.rs → persistent/ directory
- Refactored ingest worker tests for clarity
- Updated roadmap to mark Phase 5 complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:43:18 -07:00

297 lines
9.5 KiB
Rust

//! Battery 3: Decay Math Precision.
//!
//! Tests confidence decay calculations across different source class tiers.
//!
//! # Test Coverage
//!
//! | Test | Feature | Validates |
//! |------|---------|-----------|
//! | `test_decay_tier0_never_decays` | Tier 0 | Regulatory never decays |
//! | `test_decay_tier1_exact_halflife` | Tier 1 | Clinical at 730d = 0.5x |
//! | `test_decay_tier1_two_halflives` | Tier 1 | Clinical at 1460d = 0.25x |
//! | `test_decay_tier5_exact_halflife` | Tier 5 | Anecdotal at 30d = 0.5x |
//! | `test_decay_tier5_three_halflives` | Tier 5 | Anecdotal at 90d = 0.125x |
//! | `test_decay_zero_confidence_stays_zero` | Edge case | 0 * decay = 0 |
//! | `test_decay_never_goes_negative` | Edge case | No negative confidence |
//! | `test_decay_uses_as_of_for_age_calculation` | Time travel | Uses `now` param |
#![allow(clippy::expect_used)] // Test code uses expect() for clear failure messages
use super::helpers::*;
/// Test 3.1: Regulatory assertions never decay.
///
/// Tier 0 (Regulatory) has no decay halflife.
/// A Regulatory assertion with confidence 0.95, timestamped 10 years ago,
/// should maintain its original confidence after decay application.
#[test]
fn test_decay_tier0_never_decays() {
let now: u64 = 1_000_000_000;
let ten_years_ago = now - (10 * 365 * 86_400);
let assertion = AssertionBuilder::new()
.subject("test")
.predicate("decay_test")
.object_number(1.0)
.source_class(SourceClass::Regulatory)
.confidence(0.95)
.timestamp(ten_years_ago)
.build();
let fallback_halflife: u64 = 365 * 86_400; // 1 year
let decayed = apply_source_class_decay(&[assertion], fallback_halflife, now);
assert_eq!(decayed.len(), 1);
assert_eq!(
decayed[0].confidence, 0.95,
"Regulatory assertions should never decay, confidence should remain exactly 0.95"
);
}
/// Test 3.2: Clinical assertion decays to 0.5 at exactly one half-life.
///
/// Tier 1 (Clinical) has 730-day half-life.
/// An assertion with confidence 1.0, timestamped exactly 730 days ago,
/// should decay to 0.5 (within tolerance of 0.02).
#[test]
fn test_decay_tier1_exact_halflife() {
let now: u64 = 1_000_000_000;
let days_730 = 730 * 86_400;
let past = now - days_730;
let assertion = AssertionBuilder::new()
.subject("test")
.predicate("decay_test")
.object_number(1.0)
.source_class(SourceClass::Clinical)
.confidence(1.0)
.timestamp(past)
.build();
let fallback_halflife: u64 = 365 * 86_400;
let decayed = apply_source_class_decay(&[assertion], fallback_halflife, now);
assert_eq!(decayed.len(), 1);
let effective_conf = decayed[0].confidence;
assert!(
(effective_conf - 0.5).abs() < 0.02,
"Clinical assertion at 730 days (1 half-life) should decay to ~0.5, got {}",
effective_conf
);
}
/// Test 3.3: Clinical assertion decays to 0.25 at exactly two half-lives.
///
/// Tier 1 (Clinical) has 730-day half-life.
/// An assertion with confidence 1.0, timestamped exactly 1460 days ago,
/// should decay to 0.25 (within tolerance of 0.02).
#[test]
fn test_decay_tier1_two_halflives() {
let now: u64 = 1_000_000_000;
let days_1460 = 1460 * 86_400;
let past = now - days_1460;
let assertion = AssertionBuilder::new()
.subject("test")
.predicate("decay_test")
.object_number(1.0)
.source_class(SourceClass::Clinical)
.confidence(1.0)
.timestamp(past)
.build();
let fallback_halflife: u64 = 365 * 86_400;
let decayed = apply_source_class_decay(&[assertion], fallback_halflife, now);
assert_eq!(decayed.len(), 1);
let effective_conf = decayed[0].confidence;
assert!(
(effective_conf - 0.25).abs() < 0.02,
"Clinical assertion at 1460 days (2 half-lives) should decay to ~0.25, got {}",
effective_conf
);
}
/// Test 3.4: Anecdotal assertion decays to 0.5 at exactly one half-life.
///
/// Tier 5 (Anecdotal) has 30-day half-life.
/// An assertion with confidence 1.0, timestamped exactly 30 days ago,
/// should decay to 0.5 (within tolerance of 0.02).
#[test]
fn test_decay_tier5_exact_halflife() {
let now: u64 = 1_000_000_000;
let days_30 = 30 * 86_400;
let past = now - days_30;
let assertion = AssertionBuilder::new()
.subject("test")
.predicate("decay_test")
.object_number(1.0)
.source_class(SourceClass::Anecdotal)
.confidence(1.0)
.timestamp(past)
.build();
let fallback_halflife: u64 = 365 * 86_400;
let decayed = apply_source_class_decay(&[assertion], fallback_halflife, now);
assert_eq!(decayed.len(), 1);
let effective_conf = decayed[0].confidence;
assert!(
(effective_conf - 0.5).abs() < 0.02,
"Anecdotal assertion at 30 days (1 half-life) should decay to ~0.5, got {}",
effective_conf
);
}
/// Test 3.5: Anecdotal assertion decays to 0.125 at exactly three half-lives.
///
/// Tier 5 (Anecdotal) has 30-day half-life.
/// An assertion with confidence 1.0, timestamped exactly 90 days ago,
/// should decay to 0.125 (within tolerance of 0.02).
#[test]
fn test_decay_tier5_three_halflives() {
let now: u64 = 1_000_000_000;
let days_90 = 90 * 86_400;
let past = now - days_90;
let assertion = AssertionBuilder::new()
.subject("test")
.predicate("decay_test")
.object_number(1.0)
.source_class(SourceClass::Anecdotal)
.confidence(1.0)
.timestamp(past)
.build();
let fallback_halflife: u64 = 365 * 86_400;
let decayed = apply_source_class_decay(&[assertion], fallback_halflife, now);
assert_eq!(decayed.len(), 1);
let effective_conf = decayed[0].confidence;
assert!(
(effective_conf - 0.125).abs() < 0.02,
"Anecdotal assertion at 90 days (3 half-lives) should decay to ~0.125, got {}",
effective_conf
);
}
/// Test 3.6: Assertion with zero confidence stays zero after decay.
///
/// Decay formula: confidence * 2^(-age/halflife)
/// If confidence = 0.0, then 0 * anything = 0.
#[test]
fn test_decay_zero_confidence_stays_zero() {
let now: u64 = 1_000_000_000;
let days_365 = 365 * 86_400;
let past = now - days_365;
let assertion = AssertionBuilder::new()
.subject("test")
.predicate("decay_test")
.object_number(1.0)
.source_class(SourceClass::Clinical)
.confidence(0.0)
.timestamp(past)
.build();
let fallback_halflife: u64 = 365 * 86_400;
let decayed = apply_source_class_decay(&[assertion], fallback_halflife, now);
assert_eq!(decayed.len(), 1);
assert_eq!(
decayed[0].confidence, 0.0,
"Zero confidence should remain zero after decay (0 * anything = 0)"
);
}
/// Test 3.7: Decay never produces negative confidence.
///
/// Even with very low initial confidence and extreme age (12+ half-lives),
/// the effective confidence should never go below 0.0.
#[test]
fn test_decay_never_goes_negative() {
let now: u64 = 1_000_000_000;
let days_365 = 365 * 86_400;
let past = now - days_365;
let assertion = AssertionBuilder::new()
.subject("test")
.predicate("decay_test")
.object_number(1.0)
.source_class(SourceClass::Anecdotal) // 30-day half-life
.confidence(0.01)
.timestamp(past) // 365 days ago = 12+ half-lives
.build();
let fallback_halflife: u64 = 365 * 86_400;
let decayed = apply_source_class_decay(&[assertion], fallback_halflife, now);
assert_eq!(decayed.len(), 1);
assert!(
decayed[0].confidence >= 0.0,
"Confidence should never go negative, got {}",
decayed[0].confidence
);
}
/// Test 3.8: Decay uses `now` parameter for age calculation.
///
/// Two assertions at T=1000, both with confidence 0.9:
/// - Assertion A: Clinical (730-day half-life)
/// - Assertion B: Anecdotal (30-day half-life)
///
/// Apply decay with `now = T + 730*86400` (exactly 730 days later).
/// Assert:
/// - A's effective confidence ~ 0.45 (0.9 * 0.5, one half-life)
/// - B's effective confidence ~ near zero (0.9 * 2^(-24), 24 half-lives)
#[test]
fn test_decay_uses_as_of_for_age_calculation() {
let base_ts: u64 = 1000;
let days_730 = 730 * 86_400;
let now = base_ts + days_730;
let clinical_assertion = AssertionBuilder::new()
.subject("test")
.predicate("decay_test")
.object_number(1.0)
.source_class(SourceClass::Clinical)
.confidence(0.9)
.timestamp(base_ts)
.build();
let anecdotal_assertion = AssertionBuilder::new()
.subject("test")
.predicate("decay_test")
.object_number(2.0)
.source_class(SourceClass::Anecdotal)
.confidence(0.9)
.timestamp(base_ts)
.build();
let fallback_halflife: u64 = 365 * 86_400;
let decayed = apply_source_class_decay(
&[clinical_assertion, anecdotal_assertion],
fallback_halflife,
now,
);
assert_eq!(decayed.len(), 2);
// Clinical: 0.9 * 2^(-1) = 0.45
let clinical_conf = decayed[0].confidence;
assert!(
(clinical_conf - 0.45).abs() < 0.02,
"Clinical assertion at 730 days (1 half-life) should decay to ~0.45, got {}",
clinical_conf
);
// Anecdotal: 0.9 * 2^(-24) ≈ 0.9 * 5.96e-8 ≈ near zero
let anecdotal_conf = decayed[1].confidence;
assert!(
anecdotal_conf < 0.001,
"Anecdotal assertion at 730 days (24 half-lives @ 30-day rate) should decay to near zero, got {}",
anecdotal_conf
);
}