use serde::{Deserialize, Serialize};
use crate::types::{ActorId, VoteTopicId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsensusRule {
Majority,
Supermajority,
Unanimous,
LeadDecides,
AdvisoryOnly,
}
impl ConsensusRule {
#[must_use]
pub fn passes(self, yes_votes: usize, total_voters: usize) -> bool {
match self {
Self::Majority => yes_votes * 2 > total_voters,
Self::Supermajority => yes_votes * 3 >= total_voters * 2,
Self::Unanimous => yes_votes == total_voters,
Self::LeadDecides => yes_votes >= 1,
Self::AdvisoryOnly => true,
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Majority => "majority",
Self::Supermajority => "supermajority",
Self::Unanimous => "unanimous",
Self::LeadDecides => "lead_decides",
Self::AdvisoryOnly => "advisory_only",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoteDecision {
Yes,
No,
Abstain,
}
impl VoteDecision {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Yes => "yes",
Self::No => "no",
Self::Abstain => "abstain",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Vote {
pub topic: VoteTopicId,
pub voter: ActorId,
pub decision: VoteDecision,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Disagreement {
pub topic: VoteTopicId,
pub dissenter: ActorId,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsensusOutcome {
pub topic: VoteTopicId,
pub rule: ConsensusRule,
pub yes_votes: usize,
pub no_votes: usize,
pub abstain_votes: usize,
pub total_voters: usize,
pub passes: bool,
}
impl ConsensusOutcome {
#[must_use]
pub fn evaluate(
topic: VoteTopicId,
rule: ConsensusRule,
votes: &[Vote],
total_voters: usize,
) -> Self {
let mut latest: Vec<(&ActorId, VoteDecision)> = Vec::new();
for vote in votes.iter().filter(|v| v.topic == topic) {
if let Some(slot) = latest.iter_mut().find(|(voter, _)| *voter == &vote.voter) {
slot.1 = vote.decision;
} else {
latest.push((&vote.voter, vote.decision));
}
}
let mut yes_votes = 0usize;
let mut no_votes = 0usize;
let mut abstain_votes = 0usize;
for (_, decision) in &latest {
match decision {
VoteDecision::Yes => yes_votes += 1,
VoteDecision::No => no_votes += 1,
VoteDecision::Abstain => abstain_votes += 1,
}
}
Self {
topic,
rule,
yes_votes,
no_votes,
abstain_votes,
total_voters,
passes: rule.passes(yes_votes, total_voters),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn topic(s: &str) -> VoteTopicId {
VoteTopicId::new(s)
}
fn voter(s: &str) -> ActorId {
ActorId::new(s)
}
fn vote(t: &str, v: &str, d: VoteDecision) -> Vote {
Vote {
topic: topic(t),
voter: voter(v),
decision: d,
reason: None,
}
}
#[test]
fn rule_passes_thresholds_match_organism_baseline() {
assert!(ConsensusRule::Majority.passes(3, 4));
assert!(!ConsensusRule::Majority.passes(2, 4));
assert!(ConsensusRule::Supermajority.passes(2, 3));
assert!(!ConsensusRule::Supermajority.passes(1, 3));
assert!(ConsensusRule::Unanimous.passes(5, 5));
assert!(!ConsensusRule::Unanimous.passes(4, 5));
assert!(ConsensusRule::LeadDecides.passes(1, 9));
assert!(ConsensusRule::AdvisoryOnly.passes(0, 10));
}
#[test]
fn rule_label_is_stable_snake_case() {
assert_eq!(ConsensusRule::Majority.label(), "majority");
assert_eq!(ConsensusRule::Supermajority.label(), "supermajority");
assert_eq!(ConsensusRule::Unanimous.label(), "unanimous");
assert_eq!(ConsensusRule::LeadDecides.label(), "lead_decides");
assert_eq!(ConsensusRule::AdvisoryOnly.label(), "advisory_only");
}
#[test]
fn outcome_tallies_only_matching_topic() {
let votes = vec![
vote("t1", "alice", VoteDecision::Yes),
vote("t1", "bob", VoteDecision::No),
vote("t2", "carol", VoteDecision::Yes),
];
let outcome = ConsensusOutcome::evaluate(topic("t1"), ConsensusRule::Majority, &votes, 2);
assert_eq!(outcome.yes_votes, 1);
assert_eq!(outcome.no_votes, 1);
assert_eq!(outcome.total_voters, 2);
assert!(!outcome.passes);
}
#[test]
fn outcome_collapses_repeat_votes_per_actor_to_latest() {
let votes = vec![
vote("t1", "alice", VoteDecision::No),
vote("t1", "alice", VoteDecision::Yes),
vote("t1", "bob", VoteDecision::Yes),
];
let outcome = ConsensusOutcome::evaluate(topic("t1"), ConsensusRule::Unanimous, &votes, 2);
assert_eq!(outcome.yes_votes, 2);
assert_eq!(outcome.no_votes, 0);
assert!(outcome.passes);
}
#[test]
fn outcome_counts_abstentions_separately() {
let votes = vec![
vote("t", "a", VoteDecision::Yes),
vote("t", "b", VoteDecision::Abstain),
vote("t", "c", VoteDecision::Yes),
];
let outcome = ConsensusOutcome::evaluate(topic("t"), ConsensusRule::Majority, &votes, 3);
assert_eq!(outcome.yes_votes, 2);
assert_eq!(outcome.abstain_votes, 1);
assert!(outcome.passes);
}
#[test]
fn vote_serializes_camel_case_and_skips_empty_reason() {
let v = Vote {
topic: topic("done"),
voter: voter("alice"),
decision: VoteDecision::Yes,
reason: None,
};
let json = serde_json::to_string(&v).expect("vote should serialize");
assert_eq!(json, r#"{"topic":"done","voter":"alice","decision":"yes"}"#);
}
#[test]
fn disagreement_roundtrips_through_json() {
let d = Disagreement {
topic: topic("plan"),
dissenter: voter("bob"),
reason: "scope is too broad".into(),
};
let json = serde_json::to_string(&d).unwrap();
let parsed: Disagreement = serde_json::from_str(&json).unwrap();
assert_eq!(d, parsed);
}
}