Skip to main content

oxicode_sdk/coordination/
consensus.rs

1//! Consensus — simple voting for inter-agent decision making.
2
3use parking_lot::RwLock;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Result of a vote.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct VoteResult {
10    /// Whether a decision has been reached.
11    pub decided: bool,
12    /// The winning value (if decided).
13    pub decision: Option<String>,
14    /// Tally: value → count.
15    pub tally: HashMap<String, usize>,
16    /// Number of votes received.
17    pub votes_received: usize,
18    /// Total number of voters.
19    pub total_voters: usize,
20}
21
22/// A vote session.
23struct VoteSession {
24    voters: Vec<String>,
25    threshold: f32,
26    votes: HashMap<String, String>,
27}
28
29/// Consensus manager — simple majority / unanimity voting.
30///
31/// For production use cases, replace with Raft or a distributed consensus protocol.
32pub struct Consensus {
33    sessions: RwLock<HashMap<String, VoteSession>>,
34}
35
36impl Consensus {
37    /// Create a new consensus manager.
38    pub fn new() -> Self {
39        Self {
40            sessions: RwLock::new(HashMap::new()),
41        }
42    }
43
44    /// Start a new vote session.
45    ///
46    /// `threshold`: fraction of votes required to decide (0.5 = majority, 1.0 = unanimity).
47    pub fn start(&self, vote_id: &str, voters: Vec<String>, threshold: f32) {
48        self.sessions.write().insert(
49            vote_id.to_string(),
50            VoteSession {
51                voters,
52                threshold,
53                votes: HashMap::new(),
54            },
55        );
56    }
57
58    /// Cast a vote. Returns the updated result.
59    ///
60    /// If the threshold is reached, `decided` becomes `true`.
61    pub fn vote(
62        &self,
63        vote_id: &str,
64        voter: &str,
65        value: String,
66    ) -> crate::error::SdkResult<VoteResult> {
67        let mut sessions = self.sessions.write();
68        let session =
69            sessions
70                .get_mut(vote_id)
71                .ok_or_else(|| crate::error::SdkError::VoteNotFound {
72                    vote_id: vote_id.to_string(),
73                })?;
74
75        // Verify voter is eligible
76        if !session.voters.iter().any(|v| v == voter) {
77            return Err(crate::error::SdkError::InvalidState {
78                entity: "vote".into(),
79                reason: format!("voter '{}' not in voter list", voter),
80            });
81        }
82
83        session.votes.insert(voter.to_string(), value);
84        Ok(self.compute_result(session))
85    }
86
87    /// Get the current status of a vote.
88    pub fn status(&self, vote_id: &str) -> Option<VoteResult> {
89        let sessions = self.sessions.read();
90        sessions.get(vote_id).map(|s| self.compute_result(s))
91    }
92
93    fn compute_result(&self, session: &VoteSession) -> VoteResult {
94        let mut tally: HashMap<String, usize> = HashMap::new();
95        for value in session.votes.values() {
96            *tally.entry(value.clone()).or_insert(0) += 1;
97        }
98
99        let votes_received = session.votes.len();
100        let total_voters = session.voters.len();
101        let required = (session.threshold * total_voters as f32).ceil() as usize;
102
103        // Find the value with the most votes
104        let (best_value, best_count) = tally
105            .iter()
106            .max_by_key(|(_, count)| *count)
107            .map(|(v, c)| (v.clone(), *c))
108            .unwrap_or_default();
109
110        let decided = best_count >= required && votes_received > 0;
111        let decision = if decided { Some(best_value) } else { None };
112
113        VoteResult {
114            decided,
115            decision,
116            tally,
117            votes_received,
118            total_voters,
119        }
120    }
121}
122
123impl Default for Consensus {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn majority_vote() {
135        let c = Consensus::new();
136        c.start("v1", vec!["a".into(), "b".into(), "c".into()], 0.5);
137
138        c.vote("v1", "a", "yes".into()).unwrap();
139        let r = c.vote("v1", "b", "yes".into()).unwrap();
140        assert!(r.decided);
141        assert_eq!(r.decision.unwrap(), "yes");
142    }
143
144    #[test]
145    fn unanimity_required() {
146        let c = Consensus::new();
147        c.start("v2", vec!["a".into(), "b".into()], 1.0);
148
149        c.vote("v2", "a", "yes".into()).unwrap();
150        let r = c.status("v2").unwrap();
151        assert!(!r.decided);
152
153        c.vote("v2", "b", "yes".into()).unwrap();
154        let r = c.status("v2").unwrap();
155        assert!(r.decided);
156    }
157
158    #[test]
159    fn split_vote_no_majority() {
160        let c = Consensus::new();
161        c.start("v3", vec!["a".into(), "b".into()], 0.6);
162
163        c.vote("v3", "a", "yes".into()).unwrap();
164        c.vote("v3", "b", "no".into()).unwrap();
165        let r = c.status("v3").unwrap();
166        assert!(!r.decided);
167    }
168
169    #[test]
170    fn invalid_voter() {
171        let c = Consensus::new();
172        c.start("v4", vec!["a".into()], 0.5);
173        let result = c.vote("v4", "intruder", "yes".into());
174        assert!(result.is_err());
175    }
176
177    #[test]
178    fn vote_not_found() {
179        let c = Consensus::new();
180        let result = c.vote("nonexistent", "a", "yes".into());
181        assert!(result.is_err());
182    }
183
184    #[test]
185    fn vote_result_tally() {
186        let c = Consensus::new();
187        c.start("v5", vec!["a".into(), "b".into(), "c".into()], 0.5);
188
189        c.vote("v5", "a", "x".into()).unwrap();
190        c.vote("v5", "b", "y".into()).unwrap();
191        c.vote("v5", "c", "x".into()).unwrap();
192
193        let r = c.status("v5").unwrap();
194        assert!(r.decided);
195        assert_eq!(r.decision.unwrap(), "x");
196        assert_eq!(*r.tally.get("x").unwrap(), 2);
197        assert_eq!(*r.tally.get("y").unwrap(), 1);
198    }
199}