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>
297 lines
9.5 KiB
Rust
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
|
|
);
|
|
}
|