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}