Skip to main content

ipfrs_tensorlogic/
consensus.rs

1//! Federated Learning Round Consensus Tracker
2//!
3//! Production-grade consensus mechanism for federated learning rounds.
4//! A quorum of participating nodes must commit their gradient updates
5//! before a round can proceed to aggregation.
6//!
7//! # Overview
8//!
9//! [`RoundConsensusTracker`] manages the lifecycle of federated learning
10//! rounds. Each round is identified by a [`RoundId`] (a monotonically
11//! increasing `u64`). Peers cast [`Vote`]s, and the [`QuorumPolicy`]
12//! determines whether consensus has been reached.
13//!
14//! ## Quorum Logic
15//!
16//! A round commits when:
17//! - At least `min_peers` votes have been received, AND
18//! - The fraction of `Commit` votes meets `commit_threshold`
19//!
20//! A round aborts when:
21//! - It is impossible to reach the commit threshold even if all remaining
22//!   expected peers vote Commit (i.e. enough Abort/Abstain votes exist), OR
23//! - The timeout elapses
24//!
25//! ## Gradient CID Collection
26//!
27//! On commit the [`QuorumResult::Commit`] variant carries the `gradient_cid`
28//! values of every committing peer, ready for aggregation.
29//!
30//! # Examples
31//!
32//! ```
33//! use ipfrs_tensorlogic::consensus::{
34//!     RoundConsensusTracker, RoundId, PeerVote, Vote, QuorumPolicy, QuorumResult,
35//! };
36//! use std::time::Duration;
37//!
38//! let policy = QuorumPolicy::default();
39//! let tracker = RoundConsensusTracker::new(policy);
40//!
41//! let round_id = RoundId::from(1_u64);
42//! let peers = vec!["peer-a".to_string(), "peer-b".to_string(), "peer-c".to_string()];
43//! tracker.begin_round(round_id.clone(), peers).expect("example: should succeed in docs");
44//!
45//! for (i, peer) in ["peer-a", "peer-b", "peer-c"].iter().enumerate() {
46//!     let vote = PeerVote::new(
47//!         peer.to_string(),
48//!         round_id.clone(),
49//!         Vote::Commit,
50//!         Some(format!("bafyreic{}", i)),
51//!     );
52//!     let result = tracker.cast_vote(vote).expect("example: should succeed in docs");
53//!     if let QuorumResult::Commit { gradient_cids } = result {
54//!         assert_eq!(gradient_cids.len(), 3);
55//!         break;
56//!     }
57//! }
58//! ```
59
60use parking_lot::RwLock;
61use std::collections::HashMap;
62use std::fmt;
63use std::sync::atomic::{AtomicU64, Ordering};
64use std::time::{Duration, Instant};
65use thiserror::Error;
66
67// ─── RoundId ─────────────────────────────────────────────────────────────────
68
69/// Monotonically-increasing federated learning round identifier.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
71pub struct RoundId(pub u64);
72
73impl RoundId {
74    /// Return the next `RoundId`.
75    pub fn next(&self) -> Self {
76        Self(self.0 + 1)
77    }
78}
79
80impl fmt::Display for RoundId {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        write!(f, "round-{}", self.0)
83    }
84}
85
86impl From<u64> for RoundId {
87    fn from(v: u64) -> Self {
88        Self(v)
89    }
90}
91
92// ─── Vote ─────────────────────────────────────────────────────────────────────
93
94/// A peer's vote for a federated learning round.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum Vote {
97    /// The peer has committed its gradient update.
98    Commit,
99    /// The peer requests the round be aborted.
100    Abort,
101    /// The peer abstains (e.g. due to resource constraints).
102    Abstain,
103}
104
105// ─── PeerVote ────────────────────────────────────────────────────────────────
106
107/// A single vote cast by a peer for a specific round.
108#[derive(Debug, Clone)]
109pub struct PeerVote {
110    /// Identifier of the voting peer.
111    pub peer_id: String,
112    /// Round this vote belongs to.
113    pub round_id: RoundId,
114    /// The peer's decision.
115    pub vote: Vote,
116    /// Wall-clock time at which the vote was cast.
117    pub voted_at: Instant,
118    /// CID of the gradient contribution uploaded by the peer (present only on
119    /// `Commit` votes).
120    pub gradient_cid: Option<String>,
121}
122
123impl PeerVote {
124    /// Construct a new `PeerVote` using the current instant as `voted_at`.
125    pub fn new(
126        peer_id: String,
127        round_id: RoundId,
128        vote: Vote,
129        gradient_cid: Option<String>,
130    ) -> Self {
131        Self {
132            peer_id,
133            round_id,
134            vote,
135            voted_at: Instant::now(),
136            gradient_cid,
137        }
138    }
139}
140
141// ─── QuorumResult ────────────────────────────────────────────────────────────
142
143/// Outcome of evaluating the quorum policy against the current vote set.
144#[derive(Debug, Clone, PartialEq)]
145pub enum QuorumResult {
146    /// Enough peers committed — aggregation may proceed.
147    Commit {
148        /// CIDs of gradient contributions from committing peers.
149        gradient_cids: Vec<String>,
150    },
151    /// The round must be abandoned.
152    Abort {
153        /// Human-readable explanation.
154        reason: String,
155    },
156    /// Quorum has not yet been reached; more votes are needed.
157    Pending {
158        /// How many votes have been received so far.
159        votes_received: usize,
160        /// How many more votes are required.
161        votes_needed: usize,
162    },
163    /// The policy timeout elapsed before quorum was reached.
164    TimedOut,
165}
166
167// ─── QuorumPolicy ────────────────────────────────────────────────────────────
168
169/// Policy parameters governing when a round can proceed to aggregation.
170#[derive(Debug, Clone)]
171pub struct QuorumPolicy {
172    /// Minimum number of peers that must vote for quorum to be considered.
173    pub min_peers: usize,
174    /// Fraction of votes (0.0–1.0) that must be `Commit` for the round to
175    /// commit.  E.g. `0.67` means at least 67 % of votes must be `Commit`.
176    pub commit_threshold: f64,
177    /// Maximum time allowed before the round is automatically timed out.
178    pub timeout: Duration,
179}
180
181impl Default for QuorumPolicy {
182    fn default() -> Self {
183        Self {
184            min_peers: 3,
185            commit_threshold: 0.67,
186            timeout: Duration::from_secs(30),
187        }
188    }
189}
190
191impl QuorumPolicy {
192    /// Evaluate the current vote set and elapsed time against this policy.
193    ///
194    /// `votes` is the full slice of votes cast so far.
195    /// `elapsed` is the time since the round started.
196    pub fn check(&self, votes: &[PeerVote], elapsed: Duration) -> QuorumResult {
197        // Timeout takes priority.
198        if elapsed >= self.timeout {
199            return QuorumResult::TimedOut;
200        }
201
202        let total = votes.len();
203        let commit_count = votes.iter().filter(|v| v.vote == Vote::Commit).count();
204        let abort_or_abstain = votes.iter().filter(|v| v.vote != Vote::Commit).count();
205
206        // Check whether quorum is already met.
207        if total >= self.min_peers {
208            let fraction = commit_count as f64 / total as f64;
209            if fraction >= self.commit_threshold {
210                let gradient_cids = votes
211                    .iter()
212                    .filter(|v| v.vote == Vote::Commit)
213                    .filter_map(|v| v.gradient_cid.clone())
214                    .collect();
215                return QuorumResult::Commit { gradient_cids };
216            }
217        }
218
219        // Determine whether it is still mathematically possible to reach the
220        // commit threshold.  If abort/abstain votes already make it impossible
221        // (assuming every remaining expected peer votes Commit), abort early.
222        //
223        // We use `total` as the lower-bound denominator: at minimum, the
224        // peers who have already voted have determined the fraction.  If
225        // even with infinite additional Commit votes the threshold can never
226        // be satisfied given the current non-commit count, we abort.
227        //
228        // Concretely: threshold_commits_needed(n) = ceil(threshold * n).
229        // As n grows, threshold_commits_needed grows too, but non_commit_count
230        // stays fixed.  So we check: can any n >= total satisfy
231        //   (n - abort_or_abstain) / n >= commit_threshold
232        // => 1 - abort_or_abstain/n >= commit_threshold
233        // => abort_or_abstain/n <= 1 - commit_threshold
234        // => n >= abort_or_abstain / (1 - commit_threshold)
235        //
236        // That is always satisfiable for large enough n (unless
237        // commit_threshold == 1.0), so we only abort early when we know the
238        // expected_peers count makes it impossible.  Since QuorumPolicy
239        // doesn't know expected_peers, we use total as a proxy: if the
240        // current fraction of non-commits already exceeds (1 - threshold)
241        // and we have at least min_peers votes, abort.
242        if total >= self.min_peers {
243            let non_commit_fraction = abort_or_abstain as f64 / total as f64;
244            if non_commit_fraction > (1.0 - self.commit_threshold) {
245                return QuorumResult::Abort {
246                    reason: format!(
247                        "commit threshold unreachable: {}/{} votes are non-commit ({:.0}% > {:.0}% max)",
248                        abort_or_abstain,
249                        total,
250                        non_commit_fraction * 100.0,
251                        (1.0 - self.commit_threshold) * 100.0,
252                    ),
253                };
254            }
255        }
256
257        // Still pending.
258        let votes_needed = if total < self.min_peers {
259            self.min_peers - total
260        } else {
261            // Need more commits to push fraction over threshold.
262            let commits_needed = (self.commit_threshold * (total + 1) as f64).ceil() as usize;
263            commits_needed.saturating_sub(commit_count)
264        };
265
266        QuorumResult::Pending {
267            votes_received: total,
268            votes_needed,
269        }
270    }
271}
272
273// ─── RoundStatus ─────────────────────────────────────────────────────────────
274
275/// Internal lifecycle state of a single round.
276#[derive(Debug, Clone)]
277pub enum RoundStatus {
278    /// Votes are being collected.
279    Active,
280    /// Quorum was reached and the round committed.
281    Committed,
282    /// The round was aborted (either by vote or by `abort_round`).
283    Aborted {
284        /// Human-readable reason for the abort.
285        reason: String,
286    },
287}
288
289impl RoundStatus {
290    /// Returns `true` if this is a terminal state.
291    fn is_terminal(&self) -> bool {
292        matches!(self, RoundStatus::Committed | RoundStatus::Aborted { .. })
293    }
294}
295
296// ─── RoundState ──────────────────────────────────────────────────────────────
297
298/// Internal state for a single federated learning round.
299struct RoundState {
300    round_id: u64,
301    /// Peers that were expected to vote in this round (stored for future
302    /// use, e.g. computing quorum against a known membership set).
303    #[allow(dead_code)]
304    expected_peers: Vec<String>,
305    votes: Vec<PeerVote>,
306    started_at: Instant,
307    status: RoundStatus,
308}
309
310impl RoundState {
311    fn new(round_id: u64, expected_peers: Vec<String>) -> Self {
312        Self {
313            round_id,
314            expected_peers,
315            votes: Vec::new(),
316            started_at: Instant::now(),
317            status: RoundStatus::Active,
318        }
319    }
320
321    fn elapsed(&self) -> Duration {
322        self.started_at.elapsed()
323    }
324
325    fn has_voted(&self, peer_id: &str) -> bool {
326        self.votes.iter().any(|v| v.peer_id == peer_id)
327    }
328}
329
330// ─── ConsensusStats ──────────────────────────────────────────────────────────
331
332/// Atomic counters tracking aggregate consensus activity.
333pub struct ConsensusStats {
334    /// Total number of rounds started.
335    pub total_rounds_started: AtomicU64,
336    /// Total number of rounds that reached commit quorum.
337    pub total_committed: AtomicU64,
338    /// Total number of rounds that were aborted or timed out.
339    pub total_aborted: AtomicU64,
340    /// Total number of individual votes cast across all rounds.
341    pub total_votes_cast: AtomicU64,
342}
343
344impl ConsensusStats {
345    fn new() -> Self {
346        Self {
347            total_rounds_started: AtomicU64::new(0),
348            total_committed: AtomicU64::new(0),
349            total_aborted: AtomicU64::new(0),
350            total_votes_cast: AtomicU64::new(0),
351        }
352    }
353
354    /// Take a consistent snapshot of all counters.
355    pub fn snapshot(&self) -> ConsensusStatsSnapshot {
356        ConsensusStatsSnapshot {
357            total_rounds_started: self.total_rounds_started.load(Ordering::Relaxed),
358            total_committed: self.total_committed.load(Ordering::Relaxed),
359            total_aborted: self.total_aborted.load(Ordering::Relaxed),
360            total_votes_cast: self.total_votes_cast.load(Ordering::Relaxed),
361        }
362    }
363}
364
365impl fmt::Debug for ConsensusStats {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        f.debug_struct("ConsensusStats")
368            .field(
369                "total_rounds_started",
370                &self.total_rounds_started.load(Ordering::Relaxed),
371            )
372            .field(
373                "total_committed",
374                &self.total_committed.load(Ordering::Relaxed),
375            )
376            .field("total_aborted", &self.total_aborted.load(Ordering::Relaxed))
377            .field(
378                "total_votes_cast",
379                &self.total_votes_cast.load(Ordering::Relaxed),
380            )
381            .finish()
382    }
383}
384
385/// Immutable snapshot of [`ConsensusStats`].
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub struct ConsensusStatsSnapshot {
388    /// Total number of rounds started.
389    pub total_rounds_started: u64,
390    /// Total number of rounds that reached commit quorum.
391    pub total_committed: u64,
392    /// Total number of rounds that were aborted or timed out.
393    pub total_aborted: u64,
394    /// Total number of individual votes cast across all rounds.
395    pub total_votes_cast: u64,
396}
397
398// ─── ConsensusError ──────────────────────────────────────────────────────────
399
400/// Errors that can be returned by [`RoundConsensusTracker`] operations.
401#[derive(Debug, Error, Clone, PartialEq, Eq)]
402pub enum ConsensusError {
403    /// No round with the given ID exists.
404    #[error("round {0} not found")]
405    RoundNotFound(u64),
406
407    /// A round with this ID was already registered.
408    #[error("round {0} already exists")]
409    RoundAlreadyExists(u64),
410
411    /// The operation requires the round to be `Active` but it is in a
412    /// terminal state.
413    #[error("round {0} is not active")]
414    RoundNotActive(u64),
415
416    /// This peer has already cast a vote in this round.
417    #[error("duplicate vote from peer '{peer_id}' in round {round_id}")]
418    DuplicateVote {
419        /// Peer that attempted to vote twice.
420        peer_id: String,
421        /// Round ID in which the duplicate was detected.
422        round_id: u64,
423    },
424}
425
426// ─── RoundConsensusTracker ───────────────────────────────────────────────────
427
428/// Central registry for federated learning round consensus.
429///
430/// All public methods are safe to call from multiple threads concurrently.
431/// Internal state is protected by a single `RwLock`; the design keeps
432/// critical sections short to minimise contention.
433pub struct RoundConsensusTracker {
434    rounds: RwLock<HashMap<u64, RoundState>>,
435    /// Policy applied to every quorum evaluation.
436    pub policy: QuorumPolicy,
437    /// Aggregate statistics.
438    pub stats: ConsensusStats,
439}
440
441impl RoundConsensusTracker {
442    /// Create a new tracker with the given quorum policy.
443    pub fn new(policy: QuorumPolicy) -> Self {
444        Self {
445            rounds: RwLock::new(HashMap::new()),
446            policy,
447            stats: ConsensusStats::new(),
448        }
449    }
450
451    // ── Round lifecycle ───────────────────────────────────────────────────
452
453    /// Register a new round.  Returns [`ConsensusError::RoundAlreadyExists`]
454    /// if a round with the same ID already exists.
455    pub fn begin_round(
456        &self,
457        round_id: RoundId,
458        expected_peers: Vec<String>,
459    ) -> Result<(), ConsensusError> {
460        let key = round_id.0;
461        let mut guard = self.rounds.write();
462        if guard.contains_key(&key) {
463            return Err(ConsensusError::RoundAlreadyExists(key));
464        }
465        guard.insert(key, RoundState::new(key, expected_peers));
466        drop(guard);
467        self.stats
468            .total_rounds_started
469            .fetch_add(1, Ordering::Relaxed);
470        Ok(())
471    }
472
473    /// Cast a vote for a round.
474    ///
475    /// After recording the vote the quorum policy is evaluated.  If the
476    /// policy reaches a terminal verdict (`Commit` or `Abort`) the round's
477    /// status is updated atomically and the corresponding stat counter is
478    /// incremented.
479    ///
480    /// # Errors
481    ///
482    /// - [`ConsensusError::RoundNotFound`] — no such round
483    /// - [`ConsensusError::RoundNotActive`] — round is already committed/aborted
484    /// - [`ConsensusError::DuplicateVote`] — peer already voted in this round
485    pub fn cast_vote(&self, vote: PeerVote) -> Result<QuorumResult, ConsensusError> {
486        let key = vote.round_id.0;
487        let peer_id = vote.peer_id.clone();
488
489        let mut guard = self.rounds.write();
490        let state = guard
491            .get_mut(&key)
492            .ok_or(ConsensusError::RoundNotFound(key))?;
493
494        if state.status.is_terminal() {
495            return Err(ConsensusError::RoundNotActive(key));
496        }
497        if state.has_voted(&peer_id) {
498            return Err(ConsensusError::DuplicateVote {
499                peer_id,
500                round_id: key,
501            });
502        }
503
504        state.votes.push(vote);
505        self.stats.total_votes_cast.fetch_add(1, Ordering::Relaxed);
506
507        let elapsed = state.elapsed();
508        let result = self.policy.check(&state.votes, elapsed);
509
510        match &result {
511            QuorumResult::Commit { .. } => {
512                state.status = RoundStatus::Committed;
513                drop(guard);
514                self.stats.total_committed.fetch_add(1, Ordering::Relaxed);
515            }
516            QuorumResult::Abort { reason } => {
517                state.status = RoundStatus::Aborted {
518                    reason: reason.clone(),
519                };
520                drop(guard);
521                self.stats.total_aborted.fetch_add(1, Ordering::Relaxed);
522            }
523            QuorumResult::TimedOut => {
524                state.status = RoundStatus::Aborted {
525                    reason: "timeout".to_string(),
526                };
527                drop(guard);
528                self.stats.total_aborted.fetch_add(1, Ordering::Relaxed);
529            }
530            QuorumResult::Pending { .. } => {
531                drop(guard);
532            }
533        }
534
535        Ok(result)
536    }
537
538    /// Evaluate the current quorum state of a round without casting a new
539    /// vote.  Returns `None` if the round does not exist.
540    pub fn check_round(&self, round_id: &RoundId) -> Option<QuorumResult> {
541        let key = round_id.0;
542        let guard = self.rounds.read();
543        let state = guard.get(&key)?;
544
545        match &state.status {
546            RoundStatus::Committed => {
547                let gradient_cids = state
548                    .votes
549                    .iter()
550                    .filter(|v| v.vote == Vote::Commit)
551                    .filter_map(|v| v.gradient_cid.clone())
552                    .collect();
553                Some(QuorumResult::Commit { gradient_cids })
554            }
555            RoundStatus::Aborted { reason } => Some(QuorumResult::Abort {
556                reason: reason.clone(),
557            }),
558            RoundStatus::Active => {
559                let elapsed = state.elapsed();
560                Some(self.policy.check(&state.votes, elapsed))
561            }
562        }
563    }
564
565    /// Forcibly abort a round.
566    ///
567    /// # Errors
568    ///
569    /// - [`ConsensusError::RoundNotFound`] — no such round
570    /// - [`ConsensusError::RoundNotActive`] — round already in terminal state
571    pub fn abort_round(&self, round_id: &RoundId, reason: &str) -> Result<(), ConsensusError> {
572        let key = round_id.0;
573        let mut guard = self.rounds.write();
574        let state = guard
575            .get_mut(&key)
576            .ok_or(ConsensusError::RoundNotFound(key))?;
577
578        if state.status.is_terminal() {
579            return Err(ConsensusError::RoundNotActive(key));
580        }
581
582        state.status = RoundStatus::Aborted {
583            reason: reason.to_string(),
584        };
585        drop(guard);
586        self.stats.total_aborted.fetch_add(1, Ordering::Relaxed);
587        Ok(())
588    }
589
590    /// Return the IDs of all rounds currently in a terminal state
591    /// (committed or aborted).
592    pub fn complete_rounds(&self) -> Vec<u64> {
593        let guard = self.rounds.read();
594        guard
595            .values()
596            .filter(|s| s.status.is_terminal())
597            .map(|s| s.round_id)
598            .collect()
599    }
600
601    /// Return the number of rounds currently in the `Active` state.
602    pub fn active_round_count(&self) -> usize {
603        let guard = self.rounds.read();
604        guard
605            .values()
606            .filter(|s| matches!(s.status, RoundStatus::Active))
607            .count()
608    }
609}
610
611impl fmt::Debug for RoundConsensusTracker {
612    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
613        let guard = self.rounds.read();
614        f.debug_struct("RoundConsensusTracker")
615            .field("total_rounds", &guard.len())
616            .field("active_rounds", &self.active_round_count())
617            .field("stats", &self.stats)
618            .finish()
619    }
620}
621
622// ─── Tests ───────────────────────────────────────────────────────────────────
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    // ── helpers ──────────────────────────────────────────────────────────────
629
630    fn default_tracker() -> RoundConsensusTracker {
631        RoundConsensusTracker::new(QuorumPolicy::default())
632    }
633
634    fn make_commit_vote(peer: &str, round: u64, cid: Option<&str>) -> PeerVote {
635        PeerVote::new(
636            peer.to_string(),
637            RoundId::from(round),
638            Vote::Commit,
639            cid.map(|s| s.to_string()),
640        )
641    }
642
643    fn make_abort_vote(peer: &str, round: u64) -> PeerVote {
644        PeerVote::new(peer.to_string(), RoundId::from(round), Vote::Abort, None)
645    }
646
647    fn make_abstain_vote(peer: &str, round: u64) -> PeerVote {
648        PeerVote::new(peer.to_string(), RoundId::from(round), Vote::Abstain, None)
649    }
650
651    fn three_peers() -> Vec<String> {
652        vec!["p1".to_string(), "p2".to_string(), "p3".to_string()]
653    }
654
655    // ── 1. Begin round, cast a vote (pending) ─────────────────────────────
656
657    #[test]
658    fn test_begin_round_and_single_vote_pending() {
659        let tracker = default_tracker();
660        let rid = RoundId::from(1);
661        tracker
662            .begin_round(rid, three_peers())
663            .expect("test: should succeed");
664
665        let result = tracker
666            .cast_vote(make_commit_vote("p1", 1, None))
667            .expect("test: should succeed");
668        assert!(
669            matches!(result, QuorumResult::Pending { .. }),
670            "expected Pending after 1/3 votes, got {result:?}"
671        );
672    }
673
674    // ── 2. Commit quorum reached ──────────────────────────────────────────
675
676    #[test]
677    fn test_commit_quorum_reached_on_third_vote() {
678        let tracker = default_tracker();
679        let rid = RoundId::from(2);
680        tracker
681            .begin_round(rid, three_peers())
682            .expect("test: should succeed");
683
684        tracker
685            .cast_vote(make_commit_vote("p1", 2, Some("cid1")))
686            .expect("test: should succeed");
687        tracker
688            .cast_vote(make_commit_vote("p2", 2, Some("cid2")))
689            .expect("test: should succeed");
690        let result = tracker
691            .cast_vote(make_commit_vote("p3", 2, Some("cid3")))
692            .expect("test: should succeed");
693
694        match result {
695            QuorumResult::Commit { gradient_cids } => {
696                assert_eq!(gradient_cids.len(), 3);
697                assert!(gradient_cids.contains(&"cid1".to_string()));
698                assert!(gradient_cids.contains(&"cid2".to_string()));
699                assert!(gradient_cids.contains(&"cid3".to_string()));
700            }
701            other => panic!("expected Commit, got {other:?}"),
702        }
703    }
704
705    // ── 3. Abort when majority votes Abort ───────────────────────────────
706
707    #[test]
708    fn test_abort_when_majority_votes_abort() {
709        let tracker = default_tracker();
710        let rid = RoundId::from(3);
711        tracker
712            .begin_round(rid, three_peers())
713            .expect("test: should succeed");
714
715        tracker
716            .cast_vote(make_abort_vote("p1", 3))
717            .expect("test: should succeed");
718        tracker
719            .cast_vote(make_abort_vote("p2", 3))
720            .expect("test: should succeed");
721        let result = tracker
722            .cast_vote(make_abort_vote("p3", 3))
723            .expect("test: should succeed");
724
725        assert!(
726            matches!(result, QuorumResult::Abort { .. }),
727            "expected Abort, got {result:?}"
728        );
729    }
730
731    // ── 4. Timeout detection ─────────────────────────────────────────────
732
733    #[test]
734    fn test_timeout_detection() {
735        let policy = QuorumPolicy {
736            min_peers: 1,
737            commit_threshold: 0.67,
738            timeout: Duration::from_millis(1), // very short timeout
739        };
740        let tracker = RoundConsensusTracker::new(policy);
741        let rid = RoundId::from(4);
742        tracker
743            .begin_round(rid, vec!["p1".to_string()])
744            .expect("test: should succeed");
745
746        // Sleep past the timeout.
747        std::thread::sleep(Duration::from_millis(10));
748
749        let result = tracker
750            .cast_vote(make_commit_vote("p1", 4, None))
751            .expect("test: should succeed");
752
753        assert_eq!(result, QuorumResult::TimedOut);
754    }
755
756    // ── 5. Duplicate vote rejection ───────────────────────────────────────
757
758    #[test]
759    fn test_duplicate_vote_rejected() {
760        let tracker = default_tracker();
761        tracker
762            .begin_round(RoundId::from(5), three_peers())
763            .expect("test: should succeed");
764        tracker
765            .cast_vote(make_commit_vote("p1", 5, None))
766            .expect("test: should succeed");
767
768        let err = tracker
769            .cast_vote(make_commit_vote("p1", 5, None))
770            .unwrap_err();
771
772        assert!(
773            matches!(err, ConsensusError::DuplicateVote { ref peer_id, round_id } if peer_id == "p1" && round_id == 5),
774            "unexpected error: {err}"
775        );
776    }
777
778    // ── 6. Round not found error ──────────────────────────────────────────
779
780    #[test]
781    fn test_round_not_found() {
782        let tracker = default_tracker();
783        let err = tracker
784            .cast_vote(make_commit_vote("p1", 99, None))
785            .unwrap_err();
786        assert_eq!(err, ConsensusError::RoundNotFound(99));
787    }
788
789    // ── 7. abort_round manual override ───────────────────────────────────
790
791    #[test]
792    fn test_abort_round_manual() {
793        let tracker = default_tracker();
794        let rid = RoundId::from(7);
795        tracker
796            .begin_round(rid, three_peers())
797            .expect("test: should succeed");
798        tracker
799            .abort_round(&rid, "coordinator decision")
800            .expect("test: should succeed");
801
802        let result = tracker.check_round(&rid).expect("test: should succeed");
803        match result {
804            QuorumResult::Abort { reason } => {
805                assert!(reason.contains("coordinator decision"));
806            }
807            other => panic!("expected Abort, got {other:?}"),
808        }
809    }
810
811    // ── 8. complete_rounds list ───────────────────────────────────────────
812
813    #[test]
814    fn test_complete_rounds_list() {
815        let tracker = default_tracker();
816
817        // Round 10 — will commit.
818        tracker
819            .begin_round(RoundId::from(10), three_peers())
820            .expect("test: should succeed");
821        tracker
822            .cast_vote(make_commit_vote("p1", 10, None))
823            .expect("test: should succeed");
824        tracker
825            .cast_vote(make_commit_vote("p2", 10, None))
826            .expect("test: should succeed");
827        tracker
828            .cast_vote(make_commit_vote("p3", 10, None))
829            .expect("test: should succeed");
830
831        // Round 11 — still active.
832        tracker
833            .begin_round(RoundId::from(11), three_peers())
834            .expect("test: should succeed");
835
836        // Round 12 — will be aborted manually.
837        tracker
838            .begin_round(RoundId::from(12), three_peers())
839            .expect("test: should succeed");
840        tracker
841            .abort_round(&RoundId::from(12), "test")
842            .expect("test: should succeed");
843
844        let complete = tracker.complete_rounds();
845        assert!(
846            complete.contains(&10),
847            "committed round 10 missing: {complete:?}"
848        );
849        assert!(
850            complete.contains(&12),
851            "aborted round 12 missing: {complete:?}"
852        );
853        assert!(
854            !complete.contains(&11),
855            "active round 11 should not be complete"
856        );
857    }
858
859    // ── 9. Gradient CIDs collected on commit ─────────────────────────────
860
861    #[test]
862    fn test_gradient_cids_collected_on_commit() {
863        let tracker = default_tracker();
864        let rid = RoundId::from(9);
865        tracker
866            .begin_round(rid, three_peers())
867            .expect("test: should succeed");
868
869        // p2 commits without a CID (e.g. gradient already known).
870        tracker
871            .cast_vote(make_commit_vote("p1", 9, Some("bafy1")))
872            .expect("test: should succeed");
873        tracker
874            .cast_vote(make_commit_vote("p2", 9, None))
875            .expect("test: should succeed");
876        let result = tracker
877            .cast_vote(make_commit_vote("p3", 9, Some("bafy3")))
878            .expect("test: should succeed");
879
880        match result {
881            QuorumResult::Commit { gradient_cids } => {
882                // Only p1 and p3 supplied CIDs.
883                assert_eq!(gradient_cids.len(), 2);
884                assert!(gradient_cids.contains(&"bafy1".to_string()));
885                assert!(gradient_cids.contains(&"bafy3".to_string()));
886            }
887            other => panic!("expected Commit, got {other:?}"),
888        }
889    }
890
891    // ── 10. Stats accumulation ────────────────────────────────────────────
892
893    #[test]
894    fn test_stats_accumulation() {
895        let tracker = default_tracker();
896
897        // Start 2 rounds, commit one, abort the other.
898        tracker
899            .begin_round(RoundId::from(20), three_peers())
900            .expect("test: should succeed");
901        tracker
902            .cast_vote(make_commit_vote("p1", 20, None))
903            .expect("test: should succeed");
904        tracker
905            .cast_vote(make_commit_vote("p2", 20, None))
906            .expect("test: should succeed");
907        tracker
908            .cast_vote(make_commit_vote("p3", 20, None))
909            .expect("test: should succeed"); // commits
910
911        tracker
912            .begin_round(RoundId::from(21), three_peers())
913            .expect("test: should succeed");
914        tracker
915            .abort_round(&RoundId::from(21), "test")
916            .expect("test: should succeed");
917
918        let snap = tracker.stats.snapshot();
919        assert_eq!(snap.total_rounds_started, 2);
920        assert_eq!(snap.total_committed, 1);
921        assert_eq!(snap.total_aborted, 1);
922        assert_eq!(snap.total_votes_cast, 3);
923    }
924
925    // ── 11. RoundId newtype and next() ────────────────────────────────────
926
927    #[test]
928    fn test_round_id_next_and_display() {
929        let r = RoundId::from(5_u64);
930        assert_eq!(r.next(), RoundId::from(6_u64));
931        assert_eq!(r.to_string(), "round-5");
932    }
933
934    // ── 12. active_round_count ────────────────────────────────────────────
935
936    #[test]
937    fn test_active_round_count() {
938        let tracker = default_tracker();
939        assert_eq!(tracker.active_round_count(), 0);
940
941        tracker
942            .begin_round(RoundId::from(30), three_peers())
943            .expect("test: should succeed");
944        tracker
945            .begin_round(RoundId::from(31), three_peers())
946            .expect("test: should succeed");
947        assert_eq!(tracker.active_round_count(), 2);
948
949        tracker
950            .abort_round(&RoundId::from(30), "x")
951            .expect("test: should succeed");
952        assert_eq!(tracker.active_round_count(), 1);
953    }
954
955    // ── 13. check_round returns None for unknown round ────────────────────
956
957    #[test]
958    fn test_check_round_unknown() {
959        let tracker = default_tracker();
960        assert!(tracker.check_round(&RoundId::from(999)).is_none());
961    }
962
963    // ── 14. RoundAlreadyExists on duplicate begin ─────────────────────────
964
965    #[test]
966    fn test_begin_round_duplicate_returns_error() {
967        let tracker = default_tracker();
968        tracker
969            .begin_round(RoundId::from(40), three_peers())
970            .expect("test: should succeed");
971        let err = tracker
972            .begin_round(RoundId::from(40), three_peers())
973            .unwrap_err();
974        assert_eq!(err, ConsensusError::RoundAlreadyExists(40));
975    }
976
977    // ── 15. Vote on completed round returns RoundNotActive ────────────────
978
979    #[test]
980    fn test_vote_on_committed_round_returns_not_active() {
981        let tracker = default_tracker();
982        tracker
983            .begin_round(RoundId::from(50), three_peers())
984            .expect("test: should succeed");
985        tracker
986            .cast_vote(make_commit_vote("p1", 50, None))
987            .expect("test: should succeed");
988        tracker
989            .cast_vote(make_commit_vote("p2", 50, None))
990            .expect("test: should succeed");
991        tracker
992            .cast_vote(make_commit_vote("p3", 50, None))
993            .expect("test: should succeed"); // commits
994
995        // A 4th peer tries to vote on an already-committed round.
996        let err = tracker
997            .cast_vote(make_commit_vote("p4", 50, None))
998            .unwrap_err();
999        assert_eq!(err, ConsensusError::RoundNotActive(50));
1000    }
1001
1002    // ── 16. Abstain votes count against commit threshold ─────────────────
1003
1004    #[test]
1005    fn test_abstain_counts_against_commit_threshold() {
1006        // 3 peers required, threshold 0.67 (2 out of 3 must commit).
1007        let tracker = default_tracker();
1008        tracker
1009            .begin_round(RoundId::from(60), three_peers())
1010            .expect("test: should succeed");
1011
1012        tracker
1013            .cast_vote(make_commit_vote("p1", 60, None))
1014            .expect("test: should succeed");
1015        tracker
1016            .cast_vote(make_commit_vote("p2", 60, None))
1017            .expect("test: should succeed");
1018        // p3 abstains — 2/3 ≈ 0.667 which is >= 0.67 threshold.
1019        let result = tracker
1020            .cast_vote(make_abstain_vote("p3", 60))
1021            .expect("test: should succeed");
1022
1023        // 2/3 = 0.666... which is strictly less than 0.67, so still pending
1024        // OR aborted depending on rounding. Let's verify the logic:
1025        // fraction = 2/3 = 0.6667; threshold = 0.67 → NOT >= threshold → pending/abort
1026        // non_commit_fraction = 1/3 ≈ 0.333; 1 - threshold = 0.33
1027        // 0.333 > 0.33 → Abort
1028        assert!(
1029            matches!(
1030                result,
1031                QuorumResult::Abort { .. } | QuorumResult::Pending { .. }
1032            ),
1033            "unexpected result {result:?}"
1034        );
1035    }
1036
1037    // ── 17. abort_round on already-aborted round returns RoundNotActive ───
1038
1039    #[test]
1040    fn test_abort_already_aborted_round() {
1041        let tracker = default_tracker();
1042        let rid = RoundId::from(70);
1043        tracker
1044            .begin_round(rid, three_peers())
1045            .expect("test: should succeed");
1046        tracker
1047            .abort_round(&rid, "first")
1048            .expect("test: should succeed");
1049
1050        let err = tracker.abort_round(&rid, "second").unwrap_err();
1051        assert_eq!(err, ConsensusError::RoundNotActive(70));
1052    }
1053
1054    // ── 18. Custom quorum policy with 100 % threshold ─────────────────────
1055
1056    #[test]
1057    fn test_custom_policy_unanimous_commit() {
1058        let policy = QuorumPolicy {
1059            min_peers: 3,
1060            commit_threshold: 1.0,
1061            timeout: Duration::from_secs(60),
1062        };
1063        let tracker = RoundConsensusTracker::new(policy);
1064        let rid = RoundId::from(80);
1065        tracker
1066            .begin_round(rid, three_peers())
1067            .expect("test: should succeed");
1068
1069        tracker
1070            .cast_vote(make_commit_vote("p1", 80, None))
1071            .expect("test: should succeed");
1072        tracker
1073            .cast_vote(make_commit_vote("p2", 80, None))
1074            .expect("test: should succeed");
1075        let result = tracker
1076            .cast_vote(make_commit_vote("p3", 80, None))
1077            .expect("test: should succeed");
1078
1079        assert!(
1080            matches!(result, QuorumResult::Commit { .. }),
1081            "unanimous policy should commit: {result:?}"
1082        );
1083    }
1084}