use crate::spec_ai_collective::capability::CapabilityTracker;
use crate::spec_ai_collective::types::{CollectiveError, Domain, InstanceId, ProposalId, Result};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProposalType {
StrategyAdoption,
PolicyChange,
ResourceAllocation,
ConflictResolution,
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProposalStatus {
Open,
Approved,
Rejected,
Expired,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Proposal {
pub proposal_id: ProposalId,
pub proposer_id: InstanceId,
pub proposal_type: ProposalType,
pub title: String,
pub description: String,
pub content: serde_json::Value,
pub deadline: DateTime<Utc>,
pub required_quorum: f32,
pub required_approval: f32,
pub relevant_domains: Vec<Domain>,
pub status: ProposalStatus,
pub created_at: DateTime<Utc>,
pub resolved_at: Option<DateTime<Utc>>,
}
impl Proposal {
pub fn new(
proposer_id: InstanceId,
proposal_type: ProposalType,
title: impl Into<String>,
description: impl Into<String>,
content: serde_json::Value,
duration: Duration,
) -> Self {
Self {
proposal_id: uuid::Uuid::new_v4().to_string(),
proposer_id,
proposal_type,
title: title.into(),
description: description.into(),
content,
deadline: Utc::now() + duration,
required_quorum: 0.5,
required_approval: 0.5,
relevant_domains: Vec::new(),
status: ProposalStatus::Open,
created_at: Utc::now(),
resolved_at: None,
}
}
pub fn with_quorum(mut self, quorum: f32) -> Self {
self.required_quorum = quorum.clamp(0.0, 1.0);
self
}
pub fn with_approval(mut self, approval: f32) -> Self {
self.required_approval = approval.clamp(0.0, 1.0);
self
}
pub fn with_domains(mut self, domains: Vec<String>) -> Self {
self.relevant_domains = domains;
self
}
pub fn is_open(&self) -> bool {
self.status == ProposalStatus::Open && Utc::now() < self.deadline
}
pub fn is_expired(&self) -> bool {
Utc::now() >= self.deadline && self.status == ProposalStatus::Open
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum VoteDecision {
Approve,
Reject,
Abstain,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vote {
pub voter_id: InstanceId,
pub proposal_id: ProposalId,
pub decision: VoteDecision,
pub weight: f32,
pub rationale: Option<String>,
pub voted_at: DateTime<Utc>,
}
impl Vote {
pub fn new(
voter_id: InstanceId,
proposal_id: ProposalId,
decision: VoteDecision,
weight: f32,
) -> Self {
Self {
voter_id,
proposal_id,
decision,
weight: weight.clamp(0.0, 1.0),
rationale: None,
voted_at: Utc::now(),
}
}
pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
self.rationale = Some(rationale.into());
self
}
}
#[derive(Debug, Clone)]
pub struct TallyResult {
pub weighted_approval: f32,
pub weighted_rejection: f32,
pub weighted_abstention: f32,
pub voter_count: usize,
pub eligible_voters: usize,
pub quorum_reached: bool,
pub approved: bool,
pub status: ProposalStatus,
}
#[derive(Debug)]
pub struct ConsensusCoordinator {
instance_id: InstanceId,
proposals: HashMap<ProposalId, Proposal>,
votes: HashMap<ProposalId, Vec<Vote>>,
eligible_voters: Vec<InstanceId>,
#[allow(dead_code)]
default_duration: Duration,
min_vote_weight: f32,
}
impl ConsensusCoordinator {
pub fn new(instance_id: InstanceId) -> Self {
Self {
instance_id,
proposals: HashMap::new(),
votes: HashMap::new(),
eligible_voters: Vec::new(),
default_duration: Duration::hours(24),
min_vote_weight: 0.1,
}
}
pub fn instance_id(&self) -> &str {
&self.instance_id
}
pub fn set_eligible_voters(&mut self, voters: Vec<InstanceId>) {
self.eligible_voters = voters;
}
pub fn add_eligible_voter(&mut self, voter: InstanceId) {
if !self.eligible_voters.contains(&voter) {
self.eligible_voters.push(voter);
}
}
pub fn create_proposal(&mut self, proposal: Proposal) -> ProposalId {
let proposal_id = proposal.proposal_id.clone();
self.proposals.insert(proposal_id.clone(), proposal);
self.votes.insert(proposal_id.clone(), Vec::new());
proposal_id
}
pub fn get_proposal(&self, proposal_id: &str) -> Option<&Proposal> {
self.proposals.get(proposal_id)
}
pub fn calculate_vote_weight(
&self,
voter_id: &str,
proposal: &Proposal,
tracker: &CapabilityTracker,
) -> f32 {
if proposal.relevant_domains.is_empty() {
return 1.0;
}
let profile = if voter_id == tracker.instance_id() {
Some(tracker.profile())
} else {
tracker.peers().get(voter_id)
};
match profile {
Some(profile) => {
let score = profile.match_score(&proposal.relevant_domains);
(0.5 + 0.5 * score).max(self.min_vote_weight)
}
None => self.min_vote_weight,
}
}
pub fn cast_vote(
&mut self,
proposal_id: &str,
decision: VoteDecision,
tracker: &CapabilityTracker,
rationale: Option<String>,
) -> Result<Vote> {
let proposal = self
.proposals
.get(proposal_id)
.ok_or_else(|| CollectiveError::ProposalNotFound(proposal_id.to_string()))?;
if !proposal.is_open() {
return Err(CollectiveError::ProposalExpired(proposal_id.to_string()));
}
let weight = self.calculate_vote_weight(&self.instance_id, proposal, tracker);
let mut vote = Vote::new(
self.instance_id.clone(),
proposal_id.to_string(),
decision,
weight,
);
if let Some(r) = rationale {
vote = vote.with_rationale(r);
}
if let Some(votes) = self.votes.get_mut(proposal_id) {
votes.retain(|v| v.voter_id != self.instance_id);
votes.push(vote.clone());
}
Ok(vote)
}
pub fn record_vote(&mut self, vote: Vote) -> Result<()> {
let proposal = self
.proposals
.get(&vote.proposal_id)
.ok_or_else(|| CollectiveError::ProposalNotFound(vote.proposal_id.clone()))?;
if !proposal.is_open() {
return Err(CollectiveError::ProposalExpired(vote.proposal_id.clone()));
}
if let Some(votes) = self.votes.get_mut(&vote.proposal_id) {
votes.retain(|v| v.voter_id != vote.voter_id);
votes.push(vote);
}
Ok(())
}
pub fn tally_votes(&self, proposal_id: &str) -> Result<TallyResult> {
let proposal = self
.proposals
.get(proposal_id)
.ok_or_else(|| CollectiveError::ProposalNotFound(proposal_id.to_string()))?;
let votes = self
.votes
.get(proposal_id)
.map(|v| v.as_slice())
.unwrap_or(&[]);
let mut weighted_approval = 0.0;
let mut weighted_rejection = 0.0;
let mut weighted_abstention = 0.0;
for vote in votes {
match vote.decision {
VoteDecision::Approve => weighted_approval += vote.weight,
VoteDecision::Reject => weighted_rejection += vote.weight,
VoteDecision::Abstain => weighted_abstention += vote.weight,
}
}
let voter_count = votes.len();
let eligible_voters = self.eligible_voters.len().max(1);
let quorum_ratio = voter_count as f32 / eligible_voters as f32;
let quorum_reached = quorum_ratio >= proposal.required_quorum;
let total_decisive = weighted_approval + weighted_rejection;
let approval_ratio = if total_decisive > 0.0 {
weighted_approval / total_decisive
} else {
0.0
};
let approved = quorum_reached && approval_ratio >= proposal.required_approval;
let status = if proposal.is_expired() {
if quorum_reached {
if approved {
ProposalStatus::Approved
} else {
ProposalStatus::Rejected
}
} else {
ProposalStatus::Expired
}
} else if quorum_reached {
if approval_ratio >= 0.9 {
ProposalStatus::Approved
} else if approval_ratio <= 0.1 {
ProposalStatus::Rejected
} else {
ProposalStatus::Open
}
} else {
ProposalStatus::Open
};
Ok(TallyResult {
weighted_approval,
weighted_rejection,
weighted_abstention,
voter_count,
eligible_voters,
quorum_reached,
approved,
status,
})
}
pub fn resolve_proposal(&mut self, proposal_id: &str) -> Result<TallyResult> {
let tally = self.tally_votes(proposal_id)?;
if let Some(proposal) = self.proposals.get_mut(proposal_id) {
proposal.status = tally.status.clone();
proposal.resolved_at = Some(Utc::now());
}
Ok(tally)
}
pub fn cancel_proposal(&mut self, proposal_id: &str) -> Result<()> {
let proposal = self
.proposals
.get_mut(proposal_id)
.ok_or_else(|| CollectiveError::ProposalNotFound(proposal_id.to_string()))?;
if proposal.proposer_id != self.instance_id {
return Err(CollectiveError::Internal(anyhow::anyhow!(
"Only the proposer can cancel a proposal"
)));
}
proposal.status = ProposalStatus::Cancelled;
proposal.resolved_at = Some(Utc::now());
Ok(())
}
pub fn open_proposals(&self) -> Vec<&Proposal> {
self.proposals.values().filter(|p| p.is_open()).collect()
}
pub fn check_expired(&mut self) -> Vec<(ProposalId, TallyResult)> {
let expired: Vec<_> = self
.proposals
.values()
.filter(|p| p.is_expired())
.map(|p| p.proposal_id.clone())
.collect();
let mut results = Vec::new();
for proposal_id in expired {
if let Ok(tally) = self.resolve_proposal(&proposal_id) {
results.push((proposal_id, tally));
}
}
results
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_proposal_creation() {
let proposal = Proposal::new(
"agent-1".to_string(),
ProposalType::StrategyAdoption,
"Adopt new code review strategy",
"Proposal to adopt the new code review strategy",
serde_json::json!({"strategy_id": "strat-123"}),
Duration::hours(24),
)
.with_quorum(0.5)
.with_approval(0.6);
assert!(proposal.is_open());
assert!(!proposal.is_expired());
assert_eq!(proposal.required_quorum, 0.5);
assert_eq!(proposal.required_approval, 0.6);
}
#[test]
fn test_voting() {
let mut coordinator = ConsensusCoordinator::new("agent-1".to_string());
coordinator.set_eligible_voters(vec![
"agent-1".to_string(),
"agent-2".to_string(),
"agent-3".to_string(),
]);
let proposal = Proposal::new(
"agent-1".to_string(),
ProposalType::PolicyChange,
"Test proposal",
"Description",
serde_json::json!({}),
Duration::hours(24),
)
.with_quorum(0.5);
let proposal_id = coordinator.create_proposal(proposal);
coordinator
.record_vote(Vote::new(
"agent-1".to_string(),
proposal_id.clone(),
VoteDecision::Approve,
1.0,
))
.unwrap();
coordinator
.record_vote(Vote::new(
"agent-2".to_string(),
proposal_id.clone(),
VoteDecision::Approve,
0.8,
))
.unwrap();
let tally = coordinator.tally_votes(&proposal_id).unwrap();
assert_eq!(tally.voter_count, 2);
assert!(tally.quorum_reached);
assert!(tally.weighted_approval > 0.0);
}
}