Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica

eidetica/
height.rs

1//! Height calculation strategies for entries in Eidetica databases.
2//!
3//! The height of an entry determines its position in the causal ordering of the Merkle DAG.
4//! Different strategies provide different trade-offs between simplicity and time-awareness.
5
6use std::sync::Arc;
7
8use crate::clock::Clock;
9use serde::{Deserialize, Serialize};
10
11/// Height calculation strategy for entries in a database.
12///
13/// The height of an entry determines its position in the causal ordering
14/// of the Merkle DAG. Different strategies provide different trade-offs:
15///
16/// - [`Incremental`](HeightStrategy::Incremental): Simple monotonic counter, optimal for offline-first
17/// - [`Timestamp`](HeightStrategy::Timestamp): Time-aware ordering
18///
19/// # Database Configuration
20///
21/// Height strategy is configured at the database level and stored in
22/// `_settings.height_strategy`. All transactions from a database inherit
23/// the strategy. The strategy should be set at database creation time.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25#[serde(rename_all = "snake_case")]
26pub enum HeightStrategy {
27    /// Incremental height: `height = max(parent_heights) + 1`
28    ///
29    /// Root entries have height 0. Each subsequent entry has height
30    /// equal to the maximum height of its parents plus one.
31    ///
32    /// This is the default strategy and provides optimal behavior for
33    /// offline-first scenarios where entries may be created without
34    /// network connectivity.
35    #[default]
36    Incremental,
37
38    /// Timestamp-based height: `height = max(current_timestamp_ms, max(parent_heights) + 1)`
39    ///
40    /// Uses milliseconds since Unix epoch as the height value, ensuring
41    /// entries are ordered by creation time while maintaining monotonic
42    /// progression (never less than parent height + 1).
43    ///
44    /// This strategy is useful for:
45    /// - Time-series data where temporal ordering matters
46    /// - Debugging and auditing (heights indicate creation time)
47    /// - Scenarios where clock synchronization is reasonable
48    ///
49    /// **Note**: Requires reasonably synchronized clocks across clients.
50    /// Clock skew may cause entries to have heights slightly in the future
51    /// relative to other clients. Use caution.
52    Timestamp,
53}
54
55impl HeightStrategy {
56    /// Convert this strategy into a [`HeightCalculator`] with the given clock.
57    pub(crate) fn into_calculator(self, clock: Arc<dyn Clock>) -> HeightCalculator {
58        HeightCalculator::new(self, clock)
59    }
60}
61
62/// Runtime height calculator with a bound clock.
63///
64/// Created from a [`HeightStrategy`] via [`HeightStrategy::into_calculator`].
65/// This separates the serializable configuration (strategy) from the
66/// runtime behavior (strategy + clock).
67#[derive(Clone)]
68pub(crate) struct HeightCalculator {
69    strategy: HeightStrategy,
70    clock: Arc<dyn Clock>,
71}
72
73impl std::fmt::Debug for HeightCalculator {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("HeightCalculator")
76            .field("strategy", &self.strategy)
77            .finish_non_exhaustive()
78    }
79}
80
81impl HeightCalculator {
82    /// Create a new calculator with the given strategy and clock.
83    pub(crate) fn new(strategy: HeightStrategy, clock: Arc<dyn Clock>) -> Self {
84        Self { strategy, clock }
85    }
86
87    /// Calculate the height for an entry given its parent heights.
88    ///
89    /// # Arguments
90    /// * `max_parent_height` - The maximum height among all parent entries,
91    ///   or `None` if this is a root entry (no parents)
92    ///
93    /// # Returns
94    /// The calculated height for the new entry
95    pub(crate) fn calculate_height(&self, max_parent_height: Option<u64>) -> u64 {
96        match self.strategy {
97            HeightStrategy::Incremental => max_parent_height.map(|h| h + 1).unwrap_or(0),
98            HeightStrategy::Timestamp => {
99                let timestamp_ms = self.clock.now_millis();
100                let min_height = max_parent_height.map(|h| h + 1).unwrap_or(0);
101
102                // FIXME: Clock skew detection should be more sophisticated - track
103                // cumulative skew, alert on persistent drift, and potentially reject
104                // entries from peers with severely skewed clocks.
105                if min_height > timestamp_ms {
106                    let skew_ms = min_height - timestamp_ms;
107                    tracing::warn!(
108                        parent_height = max_parent_height.unwrap_or(0),
109                        current_timestamp_ms = timestamp_ms,
110                        skew_ms,
111                        "Clock skew detected: parent timestamp is {}ms ahead of local clock",
112                        skew_ms
113                    );
114                }
115
116                timestamp_ms.max(min_height)
117            }
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::clock::FixedClock;
126
127    #[test]
128    fn test_incremental_root() {
129        let clock = Arc::new(FixedClock::default());
130        let calculator = HeightStrategy::Incremental.into_calculator(clock);
131        assert_eq!(calculator.calculate_height(None), 0);
132    }
133
134    #[test]
135    fn test_incremental_with_parent() {
136        let clock = Arc::new(FixedClock::default());
137        let calculator = HeightStrategy::Incremental.into_calculator(clock);
138        assert_eq!(calculator.calculate_height(Some(0)), 1);
139        assert_eq!(calculator.calculate_height(Some(5)), 6);
140        assert_eq!(calculator.calculate_height(Some(100)), 101);
141    }
142
143    #[test]
144    fn test_timestamp_root() {
145        let clock = Arc::new(FixedClock::new(1704067200000)); // 2024-01-01 00:00:00 UTC
146        let _hold = clock.hold();
147        let calculator = HeightStrategy::Timestamp.into_calculator(clock.clone());
148        let height = calculator.calculate_height(None);
149        assert_eq!(height, 1704067200000);
150    }
151
152    #[test]
153    fn test_timestamp_with_low_parent() {
154        let clock = Arc::new(FixedClock::new(1704067200000)); // 2024-01-01 00:00:00 UTC
155        let _hold = clock.hold();
156        let calculator = HeightStrategy::Timestamp.into_calculator(clock.clone());
157        // Parent with low height - should use timestamp
158        let height = calculator.calculate_height(Some(100));
159        assert_eq!(height, 1704067200000);
160    }
161
162    #[test]
163    fn test_timestamp_with_high_parent() {
164        let clock = Arc::new(FixedClock::new(1704067200000)); // 2024-01-01 00:00:00 UTC
165        let _hold = clock.hold();
166        let calculator = HeightStrategy::Timestamp.into_calculator(clock.clone());
167        // Parent with very high height (future timestamp) - should use parent + 1
168        let future_height = 1704067200000 + 1_000_000; // 1000 seconds in future
169        let height = calculator.calculate_height(Some(future_height));
170        assert!(height > future_height);
171    }
172
173    #[test]
174    fn test_serialization() {
175        // Test incremental serialization
176        let json = serde_json::to_string(&HeightStrategy::Incremental).unwrap();
177        assert_eq!(json, "\"incremental\"");
178
179        // Test timestamp serialization
180        let json = serde_json::to_string(&HeightStrategy::Timestamp).unwrap();
181        assert_eq!(json, "\"timestamp\"");
182    }
183
184    #[test]
185    fn test_deserialization() {
186        let incremental: HeightStrategy = serde_json::from_str("\"incremental\"").unwrap();
187        assert_eq!(incremental, HeightStrategy::Incremental);
188
189        let timestamp: HeightStrategy = serde_json::from_str("\"timestamp\"").unwrap();
190        assert_eq!(timestamp, HeightStrategy::Timestamp);
191    }
192}