//! HLC-based Recency Lens: Hybrid Logical Clock timestamp wins. //! //! This lens provides distributed-consistent recency ordering using HLC timestamps, //! which handle clock skew between nodes better than Unix timestamps alone. //! //! # Why HLC over Unix timestamp? //! //! - **Clock skew tolerance**: Two nodes with drifted clocks will still produce //! consistent ordering because HLC combines physical time with logical counters. //! - **Total ordering**: HLC + node_id provides deterministic ordering even for //! concurrent events on different nodes. //! - **Causal consistency**: HLC preserves happens-before relationships across //! distributed nodes. //! //! # Resolution Strategy //! //! 1. Compare by `hlc_timestamp` (includes NTP64 time + logical counter) //! 2. If HLC times are equal (concurrent events), compare by `node_id` //! 3. Final tiebreaker: `source_hash` for determinism use crate::traits::{compute_conflict_score, Lens, Resolution}; use stemedb_core::types::Assertion; use tracing::instrument; /// HLC-based Recency Lens: Returns the assertion with the highest HLC timestamp. /// /// # Resolution Strategy /// /// 1. Find assertion with maximum `hlc_timestamp` /// 2. If HLC tie: HLC's `node_id` provides tiebreaker /// 3. Final tiebreaker: `source_hash` for determinism across identical HLCs /// /// # Confidence Calculation /// /// - Single candidate: 1.0 (trivial resolution) /// - Multiple candidates: Based on HLC timestamp gap (in milliseconds) to next candidate /// - > 1 day gap: 0.95 /// - > 1 hour gap: 0.8 /// - > 1 minute gap: 0.6 /// - Otherwise: 0.5 #[derive(Debug, Clone, Copy, Default)] pub struct HlcRecencyLens; impl Lens for HlcRecencyLens { #[instrument(skip(self, candidates), fields(candidates_count = candidates.len(), lens = "HlcRecency"))] fn resolve(&self, candidates: &[Assertion]) -> Resolution { if candidates.is_empty() { return Resolution::empty(); } if candidates.len() == 1 { return Resolution::with_winner(candidates[0].clone(), 1, 1.0, 0.0); } // Find the assertion with the highest HLC timestamp // HLC's Ord implementation compares time_ntp64 first, then node_id let winner = candidates .iter() .max_by(|a, b| { // Primary: highest HLC timestamp (includes NTP64 time + node_id tiebreaker) // Final tiebreaker: source_hash for determinism a.hlc_timestamp .cmp(&b.hlc_timestamp) .then_with(|| a.source_hash.cmp(&b.source_hash)) }) .cloned(); match winner { Some(w) => { // Calculate confidence based on how much newer the winner is let max_hlc = &w.hlc_timestamp; let max_ms = max_hlc.millis(); // Find the second-highest HLC timestamp let second_max_ms = candidates .iter() .filter(|a| a.hlc_timestamp < *max_hlc) .map(|a| a.hlc_timestamp.millis()) .max() .unwrap_or(0); // Confidence is higher when the gap is larger let gap_ms = max_ms.saturating_sub(second_max_ms); let confidence = if gap_ms > 86_400_000 { // More than a day: high confidence 0.95 } else if gap_ms > 3_600_000 { // More than an hour: good confidence 0.8 } else if gap_ms > 60_000 { // More than a minute: moderate confidence 0.6 } else { // Very close: low confidence 0.5 }; let conflict = compute_conflict_score(candidates); Resolution::with_winner(w, candidates.len(), confidence, conflict) } None => Resolution::empty(), } } fn name(&self) -> &'static str { "HlcRecency" } } #[cfg(test)] mod tests { use super::*; use stemedb_core::testing::AssertionBuilder; use stemedb_core::types::HlcTimestamp; fn create_assertion_with_hlc(subject: &str, time_ntp64: u64, node_id: [u8; 16]) -> Assertion { AssertionBuilder::new() .subject(subject) .hlc_timestamp(HlcTimestamp::new(time_ntp64, node_id)) .build() } #[test] fn test_empty_candidates() { let lens = HlcRecencyLens; let resolution = lens.resolve(&[]); assert!(resolution.winner.is_none()); assert_eq!(resolution.candidates_count, 0); } #[test] fn test_single_candidate() { let lens = HlcRecencyLens; let assertion = create_assertion_with_hlc("Tesla", 1000, [1u8; 16]); let resolution = lens.resolve(std::slice::from_ref(&assertion)); assert!(resolution.winner.is_some()); assert_eq!(resolution.winner.as_ref().map(|a| &a.subject), Some(&"Tesla".to_string())); assert_eq!(resolution.candidates_count, 1); assert!((resolution.resolution_confidence - 1.0).abs() < f32::EPSILON); } #[test] fn test_hlc_ordering_beats_unix_timestamp() { // Test that HLC ordering is used, not Unix timestamp let lens = HlcRecencyLens; // Create two assertions with same Unix timestamp but different HLC let mut older = AssertionBuilder::new() .subject("Older") .timestamp(1000) // Same Unix timestamp .hlc_timestamp(HlcTimestamp::new(1000, [1u8; 16])) .build(); older.source_hash = [1u8; 32]; let mut newer = AssertionBuilder::new() .subject("Newer") .timestamp(1000) // Same Unix timestamp .hlc_timestamp(HlcTimestamp::new(2000, [1u8; 16])) // Higher HLC .build(); newer.source_hash = [2u8; 32]; let resolution = lens.resolve(&[older, newer.clone()]); assert!(resolution.winner.is_some()); assert_eq!(resolution.winner.as_ref().map(|a| &a.subject), Some(&"Newer".to_string())); } #[test] fn test_deterministic_tiebreaker_same_hlc_time() { // When HLC time is equal, node_id should break the tie let lens = HlcRecencyLens; let mut a1 = create_assertion_with_hlc("A", 1000, [1u8; 16]); a1.source_hash = [1u8; 32]; let mut a2 = create_assertion_with_hlc("B", 1000, [2u8; 16]); // Higher node_id a2.source_hash = [2u8; 32]; // Same HLC time, should use node_id as tiebreaker let resolution1 = lens.resolve(&[a1.clone(), a2.clone()]); let resolution2 = lens.resolve(&[a2.clone(), a1.clone()]); // Should be deterministic regardless of input order // Higher node_id wins assert_eq!( resolution1.winner.as_ref().map(|a| &a.subject), resolution2.winner.as_ref().map(|a| &a.subject) ); // Node B has higher node_id [2u8; 16] > [1u8; 16] assert_eq!(resolution1.winner.as_ref().map(|a| &a.subject), Some(&"B".to_string())); } #[test] fn test_clock_skew_scenario() { // Scenario: Node A's wall clock is ahead, but Node B's assertion is causally later // In HLC, the causally later assertion should have a higher HLC timestamp let lens = HlcRecencyLens; // Node A: wall clock ahead (higher NTP64 base), but logically older event let node_a_ahead = create_assertion_with_hlc("NodeA_Ahead", 5000, [1u8; 16]); // Node B: wall clock behind, but received Node A's timestamp and incremented // In real HLC, this would be: max(local_time, received_time) + 1 let node_b_later = create_assertion_with_hlc("NodeB_CausallyLater", 5001, [2u8; 16]); let resolution = lens.resolve(&[node_a_ahead, node_b_later.clone()]); // Node B's assertion should win because it's causally later (higher HLC) assert_eq!( resolution.winner.as_ref().map(|a| &a.subject), Some(&"NodeB_CausallyLater".to_string()) ); } #[test] fn test_source_hash_final_tiebreaker() { // When HLC timestamps are completely identical, source_hash is final tiebreaker let lens = HlcRecencyLens; let mut a1 = AssertionBuilder::new() .subject("A") .hlc_timestamp(HlcTimestamp::new(1000, [1u8; 16])) .build(); a1.source_hash = [1u8; 32]; let mut a2 = AssertionBuilder::new() .subject("B") .hlc_timestamp(HlcTimestamp::new(1000, [1u8; 16])) // Identical HLC! .build(); a2.source_hash = [2u8; 32]; // Higher source_hash let resolution = lens.resolve(&[a1.clone(), a2.clone()]); // Higher source_hash should win assert_eq!(resolution.winner.as_ref().map(|a| &a.subject), Some(&"B".to_string())); } #[test] fn test_confidence_calculation() { let lens = HlcRecencyLens; // Create assertions with large time gap (> 1 day in milliseconds) // NTP64 seconds are in upper 32 bits, so 1 second = 1 << 32 // For a 2-day gap: 2 * 86400 seconds = 172800 seconds const NTP_UNIX_OFFSET: u64 = 2_208_988_800; let base_seconds = NTP_UNIX_OFFSET + 1000; let ntp64_base = base_seconds << 32; let ntp64_later = (base_seconds + 172800) << 32; // 2 days later let old = create_assertion_with_hlc("Old", ntp64_base, [1u8; 16]); let new = create_assertion_with_hlc("New", ntp64_later, [1u8; 16]); let resolution = lens.resolve(&[old, new]); assert!(resolution.winner.is_some()); // With > 1 day gap, confidence should be 0.95 assert!( resolution.resolution_confidence > 0.9, "Expected high confidence for large gap, got {}", resolution.resolution_confidence ); } #[test] fn test_multiple_candidates_selects_newest() { let lens = HlcRecencyLens; let old = create_assertion_with_hlc("Old", 1000, [1u8; 16]); let newer = create_assertion_with_hlc("Newer", 2000, [1u8; 16]); let newest = create_assertion_with_hlc("Newest", 3000, [1u8; 16]); let resolution = lens.resolve(&[old, newer, newest.clone()]); assert!(resolution.winner.is_some()); assert_eq!(resolution.winner.as_ref().map(|a| &a.subject), Some(&"Newest".to_string())); assert_eq!(resolution.candidates_count, 3); } #[test] fn test_lens_name() { let lens = HlcRecencyLens; assert_eq!(lens.name(), "HlcRecency"); } }