Skip to main content

clawft_kernel/
mesh_heartbeat.rs

1//! SWIM-style heartbeat and failure detection for mesh (K6.5).
2//!
3//! Implements a simplified SWIM protocol for detecting node failures
4//! in the mesh network. Each tick, a random peer is pinged directly;
5//! if no response, indirect probes are sent via other peers.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13use crate::mesh_clock::{Clock, MonoTime, RealClock};
14
15/// Heartbeat configuration.
16#[derive(Debug, Clone)]
17pub struct HeartbeatConfig {
18    /// How often to probe a peer (default 1s).
19    pub probe_interval: Duration,
20    /// Direct ping timeout (default 500ms).
21    pub ping_timeout: Duration,
22    /// Indirect probe timeout (default 1s).
23    pub indirect_timeout: Duration,
24    /// Number of indirect probe witnesses (default 3).
25    pub indirect_witnesses: usize,
26    /// Time in suspect state before marking unreachable (default 5s).
27    pub suspect_timeout: Duration,
28}
29
30impl Default for HeartbeatConfig {
31    fn default() -> Self {
32        Self {
33            probe_interval: Duration::from_secs(1),
34            ping_timeout: Duration::from_millis(500),
35            indirect_timeout: Duration::from_secs(1),
36            indirect_witnesses: 3,
37            suspect_timeout: Duration::from_secs(5),
38        }
39    }
40}
41
42/// Ping request.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PingRequest {
45    /// Source node ID.
46    pub from_node: String,
47    /// Sequence number for correlation.
48    pub sequence: u64,
49    /// Whether this is an indirect ping (on behalf of another node).
50    pub indirect: bool,
51    /// If indirect, the original requester.
52    pub on_behalf_of: Option<String>,
53    /// Mesh-synchronized time from the sender (microseconds since epoch).
54    #[serde(default)]
55    pub mesh_time_us: u64,
56    /// Clock source quality of the sender.
57    #[serde(default)]
58    pub clock_source: ClockSource,
59}
60
61/// Ping response.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct PingResponse {
64    /// Responding node ID.
65    pub from_node: String,
66    /// Matching sequence number.
67    pub sequence: u64,
68    /// Process count on responding node.
69    pub process_count: u32,
70    /// Service count on responding node.
71    pub service_count: u32,
72    /// Mesh-synchronized time from the responder (microseconds since epoch).
73    #[serde(default)]
74    pub mesh_time_us: u64,
75    /// Clock source quality of the responder.
76    #[serde(default)]
77    pub clock_source: ClockSource,
78}
79
80// ── Time synchronization ──────────────────────────────────────────
81
82/// Clock source quality (higher = better, wins authority election).
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85#[derive(Default)]
86pub enum ClockSource {
87    /// Local monotonic clock only (no external sync).
88    #[default]
89    Local = 0,
90    /// Synchronized via mesh heartbeat from another node.
91    Mesh = 1,
92    /// NTP-synchronized system clock.
93    Ntp = 2,
94    /// WiFi TSF counter (sub-microsecond, same AP only).
95    Tsf = 3,
96    /// GPS PPS (pulse-per-second) disciplined clock.
97    Gps = 4,
98}
99
100/// Mesh time synchronization state for a node.
101#[derive(Debug, Clone)]
102pub struct MeshClockSync {
103    /// Current clock offset from authority (microseconds, signed).
104    pub offset_us: i64,
105    /// Smoothed offset (EMA).
106    smoothed_offset: f64,
107    /// ID of the current time authority node.
108    pub authority_id: Option<String>,
109    /// Clock source of the authority.
110    pub authority_source: ClockSource,
111    /// Our own clock source.
112    pub local_source: ClockSource,
113    /// Number of sync samples received.
114    pub sync_count: u64,
115    /// Estimated clock uncertainty (microseconds).
116    pub uncertainty_us: u64,
117}
118
119impl MeshClockSync {
120    /// Create a new clock sync state.
121    pub fn new(local_source: ClockSource) -> Self {
122        Self {
123            offset_us: 0,
124            smoothed_offset: 0.0,
125            authority_id: None,
126            authority_source: ClockSource::Local,
127            local_source,
128            sync_count: 0,
129            uncertainty_us: 1_000_000, // 1 second initial uncertainty
130        }
131    }
132
133    /// Get the current mesh-synchronized time in microseconds since epoch.
134    ///
135    /// Uses [`RealClock`]. Prefer [`Self::mesh_time_us_with`] when a
136    /// [`Clock`] is available (tests / DI).
137    pub fn mesh_time_us(&self) -> u64 {
138        self.mesh_time_us_with(&RealClock)
139    }
140
141    /// Mesh-synchronized time using an injectable [`Clock`].
142    pub fn mesh_time_us_with(&self, clock: &dyn Clock) -> u64 {
143        let local = clock.unix_time_us();
144        (local as i64 + self.offset_us) as u64
145    }
146
147    /// Process a time sync sample from a heartbeat.
148    ///
149    /// If the sender has a better clock source, update our offset.
150    /// Uses exponential moving average to smooth jitter.
151    pub fn process_sync(
152        &mut self,
153        sender_id: &str,
154        sender_time_us: u64,
155        sender_source: ClockSource,
156        local_receive_time_us: u64,
157    ) {
158        // Only sync from equal or better clock sources.
159        if sender_source < self.local_source && self.sync_count > 0 {
160            return;
161        }
162
163        // If this sender has a better source than current authority, switch.
164        if sender_source > self.authority_source
165            || self.authority_id.is_none()
166            || self.authority_id.as_deref() == Some(sender_id)
167        {
168            let raw_offset = sender_time_us as i64 - local_receive_time_us as i64;
169
170            // Reject outliers: >100ms jump is likely network delay, not drift.
171            if self.sync_count > 5 && (raw_offset - self.offset_us).unsigned_abs() > 100_000 {
172                return;
173            }
174
175            // EMA smoothing: alpha=0.1 for stability.
176            let alpha = if self.sync_count < 5 { 0.5 } else { 0.1 };
177            self.smoothed_offset = (1.0 - alpha) * self.smoothed_offset + alpha * raw_offset as f64;
178            self.offset_us = self.smoothed_offset as i64;
179
180            self.authority_id = Some(sender_id.to_string());
181            self.authority_source = sender_source;
182            self.sync_count += 1;
183
184            // Uncertainty decreases with more samples.
185            self.uncertainty_us = match self.sync_count {
186                0..=5 => 10_000, // 10ms
187                6..=20 => 1_000, // 1ms
188                21..=100 => 100, // 100µs
189                _ => 50,         // 50µs steady state
190            };
191        }
192    }
193
194    /// Check if this node should be the time authority.
195    pub fn is_authority(&self, our_node_id: &str) -> bool {
196        self.authority_id.as_deref() == Some(our_node_id) || self.authority_id.is_none()
197    }
198
199    /// Whether we have a better clock source than the given source.
200    pub fn is_better_source(&self, other: ClockSource) -> bool {
201        self.local_source > other
202    }
203}
204
205/// Get current system time in microseconds since Unix epoch.
206///
207/// Thin wrapper over [`RealClock`] for call sites that do not yet hold a
208/// [`Clock`] handle. Prefer injecting `Clock` in new code (WEFT-113).
209pub fn system_time_us() -> u64 {
210    RealClock.unix_time_us()
211}
212
213/// Node health state from heartbeat monitoring.
214#[non_exhaustive]
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum HeartbeatState {
217    /// Node is healthy (responding to pings).
218    Alive,
219    /// Node missed direct ping, awaiting indirect probe.
220    Suspect,
221    /// Node confirmed unreachable (indirect probes also failed).
222    Dead,
223}
224
225/// Heartbeat tracker for a single peer.
226#[derive(Debug, Clone)]
227pub struct PeerHeartbeat {
228    /// Current health state.
229    pub state: HeartbeatState,
230    /// Last successful heartbeat time (monotonic).
231    pub last_seen: MonoTime,
232    /// When suspect state was entered (if applicable).
233    pub suspect_since: Option<MonoTime>,
234    /// Consecutive missed pings.
235    pub missed_count: u32,
236    /// Last ping sequence number sent.
237    pub last_ping_seq: u64,
238}
239
240impl PeerHeartbeat {
241    /// Create a peer heartbeat snapshot at `clock.now()`.
242    pub fn new(clock: &dyn Clock) -> Self {
243        Self {
244            state: HeartbeatState::Alive,
245            last_seen: clock.now(),
246            suspect_since: None,
247            missed_count: 0,
248            last_ping_seq: 0,
249        }
250    }
251
252    /// Record a successful heartbeat.
253    pub fn record_alive(&mut self, clock: &dyn Clock) {
254        self.state = HeartbeatState::Alive;
255        self.last_seen = clock.now();
256        self.suspect_since = None;
257        self.missed_count = 0;
258    }
259
260    /// Record a missed heartbeat.
261    pub fn record_miss(&mut self, clock: &dyn Clock, config: &HeartbeatConfig) {
262        self.missed_count += 1;
263        match self.state {
264            HeartbeatState::Alive => {
265                self.state = HeartbeatState::Suspect;
266                self.suspect_since = Some(clock.now());
267            }
268            HeartbeatState::Suspect => {
269                // `>=`, not `>`: a peer that has been suspect for *at least*
270                // `suspect_timeout` is dead. With a zero `suspect_timeout`
271                // (tests: "transition immediately") `>` would require a
272                // strictly positive elapsed, which fails when two reads
273                // share the same mono tick. `>=` is deterministic.
274                if let Some(since) = self.suspect_since {
275                    let elapsed = clock.now().elapsed_since(since);
276                    if elapsed >= config.suspect_timeout {
277                        self.state = HeartbeatState::Dead;
278                    }
279                }
280            }
281            HeartbeatState::Dead => {} // already dead
282        }
283    }
284
285    /// Check if this peer should be considered unreachable.
286    pub fn is_unreachable(&self, clock: &dyn Clock, config: &HeartbeatConfig) -> bool {
287        self.state == HeartbeatState::Dead
288            || clock.now().elapsed_since(self.last_seen) > config.suspect_timeout * 3
289    }
290}
291
292impl Default for PeerHeartbeat {
293    fn default() -> Self {
294        Self::new(&RealClock)
295    }
296}
297
298/// Heartbeat tracker managing all peers.
299pub struct HeartbeatTracker {
300    config: HeartbeatConfig,
301    peers: HashMap<String, PeerHeartbeat>,
302    next_sequence: u64,
303    /// Injectable clock (production: [`RealClock`]).
304    clock: Arc<dyn Clock>,
305}
306
307impl HeartbeatTracker {
308    /// Create a tracker bound to the production [`RealClock`].
309    pub fn new(config: HeartbeatConfig) -> Self {
310        Self::with_clock(config, RealClock::shared())
311    }
312
313    /// Create a tracker with an injectable [`Clock`] (WEFT-113).
314    pub fn with_clock(config: HeartbeatConfig, clock: Arc<dyn Clock>) -> Self {
315        Self {
316            config,
317            peers: HashMap::new(),
318            next_sequence: 1,
319            clock,
320        }
321    }
322
323    /// Shared clock handle used by this tracker.
324    pub fn clock(&self) -> &Arc<dyn Clock> {
325        &self.clock
326    }
327
328    /// Start tracking a new peer.
329    pub fn add_peer(&mut self, node_id: String) {
330        self.peers
331            .entry(node_id)
332            .or_insert_with(|| PeerHeartbeat::new(self.clock.as_ref()));
333    }
334
335    /// Remove a peer from tracking.
336    pub fn remove_peer(&mut self, node_id: &str) {
337        self.peers.remove(node_id);
338    }
339
340    /// Record a successful ping response.
341    pub fn record_alive(&mut self, node_id: &str) {
342        if let Some(peer) = self.peers.get_mut(node_id) {
343            peer.record_alive(self.clock.as_ref());
344        }
345    }
346
347    /// Record a missed ping.
348    pub fn record_miss(&mut self, node_id: &str) {
349        if let Some(peer) = self.peers.get_mut(node_id) {
350            peer.record_miss(self.clock.as_ref(), &self.config);
351        }
352    }
353
354    /// Get the next ping sequence number.
355    pub fn next_sequence(&mut self) -> u64 {
356        let seq = self.next_sequence;
357        self.next_sequence += 1;
358        seq
359    }
360
361    /// Get all peers in suspect state.
362    pub fn suspect_peers(&self) -> Vec<&str> {
363        self.peers
364            .iter()
365            .filter(|(_, p)| p.state == HeartbeatState::Suspect)
366            .map(|(id, _)| id.as_str())
367            .collect()
368    }
369
370    /// Get all peers confirmed dead.
371    pub fn dead_peers(&self) -> Vec<&str> {
372        self.peers
373            .iter()
374            .filter(|(_, p)| p.state == HeartbeatState::Dead)
375            .map(|(id, _)| id.as_str())
376            .collect()
377    }
378
379    /// Number of tracked peers.
380    pub fn peer_count(&self) -> usize {
381        self.peers.len()
382    }
383
384    /// Get health state for a specific peer.
385    pub fn peer_state(&self, node_id: &str) -> Option<HeartbeatState> {
386        self.peers.get(node_id).map(|p| p.state)
387    }
388}
389
390/// Observability metrics for a mesh peer, used for affinity scoring.
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct PeerMetrics {
393    /// Node identifier.
394    pub node_id: String,
395    /// Smoothed round-trip time in milliseconds.
396    pub rtt_ms: f64,
397    /// Total messages sent to this peer.
398    pub messages_sent: u64,
399    /// Total messages received from this peer.
400    pub messages_received: u64,
401    /// Total bytes sent to this peer.
402    pub bytes_sent: u64,
403    /// Total bytes received from this peer.
404    pub bytes_received: u64,
405    /// Total errors encountered with this peer.
406    pub errors: u64,
407    /// Last update timestamp (epoch millis).
408    pub last_updated: u64,
409}
410
411impl PeerMetrics {
412    /// Create new zeroed metrics for a peer.
413    pub fn new(node_id: String) -> Self {
414        Self {
415            node_id,
416            rtt_ms: 0.0,
417            messages_sent: 0,
418            messages_received: 0,
419            bytes_sent: 0,
420            bytes_received: 0,
421            errors: 0,
422            last_updated: 0,
423        }
424    }
425
426    /// Affinity score (lower is better). Used for service resolution preference.
427    pub fn affinity_score(&self) -> f64 {
428        let error_rate = if self.messages_sent > 0 {
429            self.errors as f64 / self.messages_sent as f64
430        } else {
431            0.0
432        };
433        self.rtt_ms + error_rate * 1000.0
434    }
435
436    /// Record a successful message send.
437    pub fn record_send(&mut self, bytes: u64) {
438        self.messages_sent += 1;
439        self.bytes_sent += bytes;
440    }
441
442    /// Record a received message.
443    pub fn record_recv(&mut self, bytes: u64) {
444        self.messages_received += 1;
445        self.bytes_received += bytes;
446    }
447
448    /// Record an error.
449    pub fn record_error(&mut self) {
450        self.errors += 1;
451    }
452
453    /// Update RTT measurement using exponential moving average.
454    pub fn update_rtt(&mut self, rtt_ms: f64) {
455        self.rtt_ms = self.rtt_ms * 0.8 + rtt_ms * 0.2;
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::mesh_clock::MockClock;
463
464    #[test]
465    fn ping_request_serde_roundtrip() {
466        let req = PingRequest {
467            from_node: "node-a".to_string(),
468            sequence: 42,
469            indirect: true,
470            on_behalf_of: Some("node-b".to_string()),
471            mesh_time_us: system_time_us(),
472            clock_source: ClockSource::Ntp,
473        };
474        let json = serde_json::to_string(&req).unwrap();
475        let restored: PingRequest = serde_json::from_str(&json).unwrap();
476        assert_eq!(restored.from_node, "node-a");
477        assert_eq!(restored.sequence, 42);
478        assert!(restored.indirect);
479        assert_eq!(restored.on_behalf_of, Some("node-b".to_string()));
480    }
481
482    #[test]
483    fn ping_response_serde_roundtrip() {
484        let resp = PingResponse {
485            from_node: "node-b".to_string(),
486            sequence: 42,
487            process_count: 5,
488            service_count: 3,
489            mesh_time_us: system_time_us(),
490            clock_source: ClockSource::Local,
491        };
492        let json = serde_json::to_string(&resp).unwrap();
493        let restored: PingResponse = serde_json::from_str(&json).unwrap();
494        assert_eq!(restored.from_node, "node-b");
495        assert_eq!(restored.sequence, 42);
496        assert_eq!(restored.process_count, 5);
497        assert_eq!(restored.service_count, 3);
498    }
499
500    #[test]
501    fn heartbeat_config_defaults() {
502        let config = HeartbeatConfig::default();
503        assert_eq!(config.probe_interval, Duration::from_secs(1));
504        assert_eq!(config.ping_timeout, Duration::from_millis(500));
505        assert_eq!(config.indirect_timeout, Duration::from_secs(1));
506        assert_eq!(config.indirect_witnesses, 3);
507        assert_eq!(config.suspect_timeout, Duration::from_secs(5));
508    }
509
510    #[test]
511    fn peer_heartbeat_alive_to_suspect() {
512        let clock = MockClock::new();
513        let config = HeartbeatConfig::default();
514        let mut peer = PeerHeartbeat::new(&clock);
515        assert_eq!(peer.state, HeartbeatState::Alive);
516
517        peer.record_miss(&clock, &config);
518        assert_eq!(peer.state, HeartbeatState::Suspect);
519        assert_eq!(peer.missed_count, 1);
520        assert!(peer.suspect_since.is_some());
521    }
522
523    #[test]
524    fn peer_heartbeat_suspect_to_dead() {
525        // Use a zero-duration suspect timeout so the transition is immediate.
526        let clock = MockClock::new();
527        let config = HeartbeatConfig {
528            suspect_timeout: Duration::from_secs(0),
529            ..HeartbeatConfig::default()
530        };
531        let mut peer = PeerHeartbeat::new(&clock);
532
533        // First miss: Alive -> Suspect
534        peer.record_miss(&clock, &config);
535        assert_eq!(peer.state, HeartbeatState::Suspect);
536
537        // Second miss with zero timeout: Suspect -> Dead
538        peer.record_miss(&clock, &config);
539        assert_eq!(peer.state, HeartbeatState::Dead);
540    }
541
542    #[test]
543    fn record_alive_resets_state() {
544        let clock = MockClock::new();
545        let config = HeartbeatConfig::default();
546        let mut peer = PeerHeartbeat::new(&clock);
547
548        peer.record_miss(&clock, &config);
549        assert_eq!(peer.state, HeartbeatState::Suspect);
550
551        peer.record_alive(&clock);
552        assert_eq!(peer.state, HeartbeatState::Alive);
553        assert_eq!(peer.missed_count, 0);
554        assert!(peer.suspect_since.is_none());
555    }
556
557    #[test]
558    fn mock_clock_deterministically_advances_heartbeat_ticks() {
559        // WEFT-113 AC: MockClock drives suspect → dead without real sleeps.
560        let clock = MockClock::new();
561        let config = HeartbeatConfig {
562            suspect_timeout: Duration::from_secs(5),
563            ..HeartbeatConfig::default()
564        };
565        let mut tracker = HeartbeatTracker::with_clock(config, Arc::new(clock.clone()));
566        tracker.add_peer("peer-a".into());
567
568        tracker.record_miss("peer-a");
569        assert_eq!(
570            tracker.peer_state("peer-a"),
571            Some(HeartbeatState::Suspect)
572        );
573
574        // Still within suspect window.
575        clock.advance(Duration::from_secs(4));
576        tracker.record_miss("peer-a");
577        assert_eq!(
578            tracker.peer_state("peer-a"),
579            Some(HeartbeatState::Suspect)
580        );
581
582        // Advance past suspect_timeout; next miss → Dead.
583        clock.advance(Duration::from_secs(2));
584        tracker.record_miss("peer-a");
585        assert_eq!(tracker.peer_state("peer-a"), Some(HeartbeatState::Dead));
586        assert!(tracker.dead_peers().contains(&"peer-a"));
587    }
588
589    #[test]
590    fn tracker_add_remove_peers() {
591        let mut tracker = HeartbeatTracker::new(HeartbeatConfig::default());
592        tracker.add_peer("node-a".to_string());
593        tracker.add_peer("node-b".to_string());
594        assert_eq!(tracker.peer_count(), 2);
595
596        tracker.remove_peer("node-a");
597        assert_eq!(tracker.peer_count(), 1);
598        assert!(tracker.peer_state("node-a").is_none());
599        assert_eq!(tracker.peer_state("node-b"), Some(HeartbeatState::Alive));
600    }
601
602    #[test]
603    fn tracker_suspect_and_dead_peers() {
604        let config = HeartbeatConfig {
605            suspect_timeout: Duration::from_secs(0),
606            ..HeartbeatConfig::default()
607        };
608        let mut tracker = HeartbeatTracker::new(config);
609        tracker.add_peer("node-a".to_string());
610        tracker.add_peer("node-b".to_string());
611        tracker.add_peer("node-c".to_string());
612
613        // node-a: one miss -> suspect
614        tracker.record_miss("node-a");
615        // node-b: two misses with zero timeout -> dead
616        tracker.record_miss("node-b");
617        tracker.record_miss("node-b");
618        // node-c: stays alive
619
620        let suspects = tracker.suspect_peers();
621        assert_eq!(suspects.len(), 1);
622        assert!(suspects.contains(&"node-a"));
623
624        let dead = tracker.dead_peers();
625        assert_eq!(dead.len(), 1);
626        assert!(dead.contains(&"node-b"));
627    }
628
629    // ── PeerMetrics tests ─────────────────────────────────────────
630
631    #[test]
632    fn peer_metrics_affinity_score_zero_errors() {
633        let mut m = PeerMetrics::new("n1".into());
634        m.rtt_ms = 10.0;
635        m.messages_sent = 100;
636        m.errors = 0;
637        // score = rtt + 0 = 10.0
638        assert!((m.affinity_score() - 10.0).abs() < f64::EPSILON);
639    }
640
641    #[test]
642    fn peer_metrics_affinity_score_with_errors() {
643        let mut m = PeerMetrics::new("n1".into());
644        m.rtt_ms = 5.0;
645        m.messages_sent = 100;
646        m.errors = 10;
647        // error_rate = 10/100 = 0.1, score = 5.0 + 0.1*1000 = 105.0
648        assert!((m.affinity_score() - 105.0).abs() < f64::EPSILON);
649    }
650
651    #[test]
652    fn peer_metrics_affinity_no_messages() {
653        let m = PeerMetrics::new("n1".into());
654        // No messages sent: error_rate = 0, score = 0
655        assert!((m.affinity_score() - 0.0).abs() < f64::EPSILON);
656    }
657
658    #[test]
659    fn peer_metrics_record_send_recv() {
660        let mut m = PeerMetrics::new("n1".into());
661        m.record_send(100);
662        m.record_send(200);
663        assert_eq!(m.messages_sent, 2);
664        assert_eq!(m.bytes_sent, 300);
665
666        m.record_recv(50);
667        assert_eq!(m.messages_received, 1);
668        assert_eq!(m.bytes_received, 50);
669    }
670
671    #[test]
672    fn peer_metrics_record_error() {
673        let mut m = PeerMetrics::new("n1".into());
674        m.record_error();
675        m.record_error();
676        assert_eq!(m.errors, 2);
677    }
678
679    #[test]
680    fn peer_metrics_update_rtt_ema() {
681        let mut m = PeerMetrics::new("n1".into());
682        m.rtt_ms = 100.0;
683        m.update_rtt(50.0);
684        // EMA: 100*0.8 + 50*0.2 = 80 + 10 = 90
685        assert!((m.rtt_ms - 90.0).abs() < f64::EPSILON);
686    }
687
688    #[test]
689    fn peer_metrics_serde_roundtrip() {
690        let mut m = PeerMetrics::new("node-x".into());
691        m.rtt_ms = 15.5;
692        m.messages_sent = 42;
693        let json = serde_json::to_string(&m).unwrap();
694        let restored: PeerMetrics = serde_json::from_str(&json).unwrap();
695        assert_eq!(restored.node_id, "node-x");
696        assert!((restored.rtt_ms - 15.5).abs() < f64::EPSILON);
697        assert_eq!(restored.messages_sent, 42);
698    }
699
700    #[test]
701    fn next_sequence_increments() {
702        let mut tracker = HeartbeatTracker::new(HeartbeatConfig::default());
703        assert_eq!(tracker.next_sequence(), 1);
704        assert_eq!(tracker.next_sequence(), 2);
705        assert_eq!(tracker.next_sequence(), 3);
706    }
707
708    // ── Time sync tests ──────────────────────────────────────────
709
710    #[test]
711    fn clock_source_ordering() {
712        assert!(ClockSource::Gps > ClockSource::Ntp);
713        assert!(ClockSource::Ntp > ClockSource::Mesh);
714        assert!(ClockSource::Mesh > ClockSource::Local);
715        assert!(ClockSource::Tsf > ClockSource::Ntp);
716    }
717
718    #[test]
719    fn mesh_clock_sync_initial_state() {
720        let sync = MeshClockSync::new(ClockSource::Local);
721        assert_eq!(sync.offset_us, 0);
722        assert_eq!(sync.sync_count, 0);
723        assert!(sync.authority_id.is_none());
724        assert_eq!(sync.uncertainty_us, 1_000_000);
725    }
726
727    #[test]
728    fn mesh_clock_sync_from_ntp_authority() {
729        let mut sync = MeshClockSync::new(ClockSource::Local);
730        let now = system_time_us();
731
732        // Simulate NTP authority 5ms ahead.
733        let authority_time = now + 5_000;
734        sync.process_sync("authority-1", authority_time, ClockSource::Ntp, now);
735
736        assert_eq!(sync.authority_id.as_deref(), Some("authority-1"));
737        assert_eq!(sync.authority_source, ClockSource::Ntp);
738        assert_eq!(sync.sync_count, 1);
739        // First sample with alpha=0.5: offset ≈ 0.5 * 5000 = 2500µs.
740        assert!((sync.offset_us - 2_500).unsigned_abs() < 1_000);
741    }
742
743    #[test]
744    fn mesh_clock_sync_ignores_worse_source() {
745        let mut sync = MeshClockSync::new(ClockSource::Ntp);
746        let now = system_time_us();
747
748        // We have NTP, peer only has Local — should be ignored after warmup.
749        for i in 0..10 {
750            sync.process_sync("peer", now + i * 100, ClockSource::Local, now + i * 100);
751        }
752        // After warmup samples, local source should be ignored.
753        let offset_before = sync.offset_us;
754        sync.process_sync("peer", now + 50_000, ClockSource::Local, now);
755        assert_eq!(sync.offset_us, offset_before);
756    }
757
758    #[test]
759    fn mesh_clock_sync_smooths_jitter() {
760        let mut sync = MeshClockSync::new(ClockSource::Local);
761        let now = system_time_us();
762
763        // Send 20 samples with 1ms offset + random jitter.
764        for i in 0..20u64 {
765            let jitter = (i % 3) as i64 * 50 - 50; // -50, 0, +50µs
766            let authority_time = (now as i64 + 1_000 + jitter) as u64;
767            sync.process_sync("auth", authority_time, ClockSource::Ntp, now);
768        }
769
770        // Offset should be close to 1000µs (1ms).
771        assert!((sync.offset_us - 1_000).unsigned_abs() < 500);
772        // Uncertainty should have decreased.
773        assert!(sync.uncertainty_us < 10_000);
774    }
775
776    #[test]
777    fn system_time_us_returns_plausible_value() {
778        let t = system_time_us();
779        // Should be after 2020-01-01 in microseconds.
780        assert!(t > 1_577_836_800_000_000);
781        // Should be before 2050-01-01.
782        assert!(t < 2_524_608_000_000_000);
783    }
784}