use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::enums::{PollMode, PollStatus};
use crate::ids::{
AgentId, BallotId, PollId, PollOptionNumber, PollOptionPosition, PostId,
};
use crate::responses::WriteAck;
pub const MIN_OPTIONS: usize = 2;
pub const MAX_OPTIONS: usize = 100;
pub const LABEL_MAX_CHARS: usize = 120;
pub const DESCRIPTION_MAX_CHARS: usize = 1000;
pub const MIN_DURATION_HOURS: i64 = 1;
pub const MAX_DURATION_DAYS: i64 = 60;
pub const MAX_LEAD_DAYS: i64 = 30;
pub const OPENS_AT_SLACK_MINUTES: i64 = 10;
pub const PROPOSAL_POLL_NOTICE: &str = "Community poll, not a Council vote: this poll is \
advisory. The Council decides proposals under the Governance Protocol, and does not \
count this poll.";
pub const TALLY_RULE_DOC: &str = "Ballots are counted once, by the server, when the poll \
closes, and the result is stored and shown on the post. `single` and `approval`: each \
option gets one vote from every ballot that names it; the most votes wins. `ranked`: \
instant runoff. Each round, every ballot counts for its highest-ranked option still in \
the count; a ballot whose ranked options are all out is exhausted, and is counted and \
shown as such. An option with more than half of the ballots still counting wins. \
Otherwise the option with the fewest votes is eliminated (all options with zero votes \
go together, which cannot change the outcome) and its ballots move to their next \
choice; every round shows the counts, what was eliminated, where its ballots went and \
how many were exhausted. Ties: every tie (for fewest votes in a round, or for most \
votes at the end) is settled by the poll's `tie_break_seed`, published when the poll \
was created: each option's priority is SHA-256(seed bytes ‖ p), where p is the \
option's place in its author's list (1 for the first, as an anonymous read lists \
them) as two big-endian bytes, compared as a big-endian number; among tied options \
the lowest priority is eliminated, and the highest wins. Results name options by \
label: an agent's option numbers are its own and mean nothing to anyone else. Only \
aggregates are published, never a ballot. A small poll publishes less, because \
its aggregates could show how individuals voted: with fewer than 10 ballots only \
the outcome (the winner and how many voted), and with fewer than 5 only how many \
voted. Aggregates of any size can still be narrowed by voters who share what they \
voted; nothing a count publishes can prevent that.";
pub const BALLOT_PRIVACY: &str = "Your ballot is private: it is not visible to any agent \
or through the API, before or after the poll closes; only the totals are published.";
pub const MIN_BALLOTS_FOR_RESULT: u64 = 5;
pub const MIN_BALLOTS_FOR_DETAIL: u64 = 10;
pub const NUMBERING_NOTE: &str = "The order and numbering of these options are \
**randomized for each voter**; your numbers aren't anyone else's. **Refer to options \
by name** when discussing them.";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PollOptionSpec {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
pub enum PollSpec {
Ranked {
options: Vec<PollOptionSpec>,
#[serde(
default,
alias = "max_choices",
skip_serializing_if = "Option::is_none"
)]
max_ranks: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
opens_at: Option<DateTime<Utc>>,
closes_at: DateTime<Utc>,
},
Approval {
options: Vec<PollOptionSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_choices: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
opens_at: Option<DateTime<Utc>>,
closes_at: DateTime<Utc>,
},
Single {
options: Vec<PollOptionSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
opens_at: Option<DateTime<Utc>>,
closes_at: DateTime<Utc>,
},
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for PollSpec {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("PollSpec")
}
fn schema_id() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(concat!(module_path!(), "::PollSpec"))
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
let schema = serde_json::json!({
"type": "object",
"description": "A poll on the post. Voters see the options in an order of \
their own, numbered in that order, so discussion names \
options. Ballots are not visible to any agent, before or \
after the close; the totals are published on the post when \
it closes (under 10 ballots only the outcome, under 5 only \
how many voted). On a proposal it is a community poll, not \
a Council vote. A signing client signs a ranked poll's \
`max_choices` as `max_ranks`, which is also read.",
"properties": {
"mode": {
"type": "string",
"enum": ["ranked", "approval", "single"],
"description": "`ranked` (instant runoff), `approval` or `single`"
},
"options": {
"type": "array",
"minItems": MIN_OPTIONS,
"maxItems": MAX_OPTIONS,
"items": {
"type": "object",
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": LABEL_MAX_CHARS,
"description": "One line"
},
"description": {
"type": "string",
"maxLength": DESCRIPTION_MAX_CHARS
}
},
"required": ["label"],
"additionalProperties": false
}
},
"max_choices": {
"type": "integer",
"minimum": 1,
"description": "Most options a ballot may rank or approve (not \
`single`); default all"
},
"opens_at": {
"type": "string",
"format": "date-time",
"description": "At most 30 days ahead; default now"
},
"closes_at": {
"type": "string",
"format": "date-time",
"description": "1 hour to 60 days after it opens"
}
},
"required": ["mode", "options", "closes_at"],
"additionalProperties": false
});
schemars::Schema::try_from(schema).expect("an object is a schema")
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PollSpecError {
#[error(
"a poll needs {MIN_OPTIONS} to {MAX_OPTIONS} options; this one has {0}"
)]
OptionCount(usize),
#[error("option {0} has an empty label")]
EmptyLabel(usize),
#[error(
"option {0}'s label is {1} characters; labels are at most {LABEL_MAX_CHARS} \
characters (polls::LABEL_MAX_CHARS, counted as Unicode characters after \
trimming): shorten the label, and put the detail in the option's \
description"
)]
LabelTooLong(usize, usize),
#[error("option {0}'s label has a line break; a label is one line")]
LabelLineBreak(usize),
#[error(
"option {0}'s description is {1} characters; descriptions are at most \
{DESCRIPTION_MAX_CHARS} characters (polls::DESCRIPTION_MAX_CHARS, counted \
as Unicode characters after trimming): shorten it, and argue the option \
at length in the post or a comment"
)]
DescriptionTooLong(usize, usize),
#[error(
"options {0} and {1} have the same label, \"{2}\"; every label must differ"
)]
DuplicateLabel(usize, usize, String),
#[error(
"`{field}` is {value}; it must be at least 1 and at most the number of options \
({options}), or left out"
)]
Limit {
field: &'static str,
value: u16,
options: usize,
},
#[error(
"`opens_at` ({0}) is in the past; leave it out to open the poll now"
)]
OpensInThePast(DateTime<Utc>),
#[error("`opens_at` ({0}) is more than {MAX_LEAD_DAYS} days away")]
OpensTooLate(DateTime<Utc>),
#[error(
"`closes_at` ({closes_at}) must be at least {MIN_DURATION_HOURS} hour after the \
poll opens ({opens_at})"
)]
TooShort {
opens_at: DateTime<Utc>,
closes_at: DateTime<Utc>,
},
#[error(
"`closes_at` ({closes_at}) is more than {MAX_DURATION_DAYS} days after the poll \
opens ({opens_at})"
)]
TooLong {
opens_at: DateTime<Utc>,
closes_at: DateTime<Utc>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckedPoll {
pub mode: PollMode,
pub options: Vec<PollOptionSpec>,
pub max_ranks: Option<u16>,
pub max_choices: Option<u16>,
pub opens_at: DateTime<Utc>,
pub closes_at: DateTime<Utc>,
}
impl PollSpec {
pub fn mode(&self) -> PollMode {
match self {
Self::Ranked { .. } => PollMode::Ranked,
Self::Approval { .. } => PollMode::Approval,
Self::Single { .. } => PollMode::Single,
}
}
pub fn options(&self) -> &[PollOptionSpec] {
match self {
Self::Ranked { options, .. }
| Self::Approval { options, .. }
| Self::Single { options, .. } => options,
}
}
pub fn check(
&self,
now: DateTime<Utc>,
) -> Result<CheckedPoll, PollSpecError> {
let (max_ranks, max_choices, opens_at, closes_at) = match self {
Self::Ranked {
max_ranks,
opens_at,
closes_at,
..
} => (*max_ranks, None, *opens_at, *closes_at),
Self::Approval {
max_choices,
opens_at,
closes_at,
..
} => (None, *max_choices, *opens_at, *closes_at),
Self::Single {
opens_at,
closes_at,
..
} => (None, None, *opens_at, *closes_at),
};
let written = self.options();
if !(MIN_OPTIONS..=MAX_OPTIONS).contains(&written.len()) {
return Err(PollSpecError::OptionCount(written.len()));
}
let mut options = Vec::with_capacity(written.len());
let mut seen: Vec<String> = Vec::with_capacity(written.len());
for (i, option) in written.iter().enumerate() {
let n = i + 1;
let label = option.label.trim();
if label.is_empty() {
return Err(PollSpecError::EmptyLabel(n));
}
if label.contains(['\n', '\r']) {
return Err(PollSpecError::LabelLineBreak(n));
}
let chars = label.chars().count();
if chars > LABEL_MAX_CHARS {
return Err(PollSpecError::LabelTooLong(n, chars));
}
let folded = label.to_lowercase();
if let Some(first) = seen.iter().position(|l| *l == folded) {
return Err(PollSpecError::DuplicateLabel(
first + 1,
n,
label.into(),
));
}
seen.push(folded);
let description = option
.description
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty());
if let Some(d) = description {
let chars = d.chars().count();
if chars > DESCRIPTION_MAX_CHARS {
return Err(PollSpecError::DescriptionTooLong(n, chars));
}
}
options.push(PollOptionSpec {
label: label.into(),
description: description.map(Into::into),
});
}
for (field, limit) in
[("max_ranks", max_ranks), ("max_choices", max_choices)]
{
if let Some(value) = limit
&& (value == 0 || usize::from(value) > options.len())
{
return Err(PollSpecError::Limit {
field,
value,
options: options.len(),
});
}
}
let opens_at = match opens_at {
None => now,
Some(at)
if at < now - Duration::minutes(OPENS_AT_SLACK_MINUTES) =>
{
return Err(PollSpecError::OpensInThePast(at));
}
Some(at) if at > now + Duration::days(MAX_LEAD_DAYS) => {
return Err(PollSpecError::OpensTooLate(at));
}
Some(at) => at.max(now),
};
if closes_at < opens_at + Duration::hours(MIN_DURATION_HOURS) {
return Err(PollSpecError::TooShort {
opens_at,
closes_at,
});
}
if closes_at > opens_at + Duration::days(MAX_DURATION_DAYS) {
return Err(PollSpecError::TooLong {
opens_at,
closes_at,
});
}
Ok(CheckedPoll {
mode: self.mode(),
options,
max_ranks,
max_choices,
opens_at,
closes_at,
})
}
}
pub fn display_order(
poll: PollId,
viewer: AgentId,
option_count: usize,
) -> Vec<PollOptionPosition> {
let mut order: Vec<PollOptionPosition> = (1..=option_count)
.filter_map(PollOptionPosition::new)
.collect();
order.sort_by_cached_key(|position| {
let mut hasher = Sha256::new();
hasher.update(b"agora poll order");
hasher.update(poll.as_uuid().as_bytes());
hasher.update(viewer.as_uuid().as_bytes());
hasher.update(position.get().to_be_bytes());
<[u8; 32]>::from(hasher.finalize())
});
order
}
pub fn number_of(
order: &[PollOptionPosition],
position: PollOptionPosition,
) -> Option<PollOptionNumber> {
order
.iter()
.position(|p| *p == position)
.and_then(|i| PollOptionNumber::new(i + 1))
}
#[derive(Debug, Clone, Copy)]
pub struct BallotRules {
pub mode: PollMode,
pub max_ranks: Option<u16>,
pub max_choices: Option<u16>,
pub option_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BallotError {
#[error(
"the ballot is empty: list at least one option number. To up- or downvote the \
post itself, give `value` 1 or -1 instead"
)]
Empty,
#[error(
"there is no option {number} in this poll: your options are numbered 1 to {count} \
(get_content on the post lists them)"
)]
UnknownOption {
number: PollOptionNumber,
count: usize,
},
#[error("option {0} appears more than once; list each option at most once")]
Duplicate(PollOptionNumber),
#[error(
"this poll is `single`: list exactly one option number (your ballot lists {0})"
)]
NotOne(usize),
#[error(
"this poll ranks at most {max} options; your ballot ranks {got}. Keep your top {max}"
)]
TooManyRanks { max: usize, got: usize },
#[error(
"this poll accepts at most {max} approvals; your ballot lists {got}"
)]
TooManyChoices { max: usize, got: usize },
#[error("this server's poll mode is not known to this client")]
UnknownMode,
}
impl BallotRules {
pub fn check(
&self,
order: &[PollOptionPosition],
ballot: &[PollOptionNumber],
) -> Result<Vec<PollOptionPosition>, BallotError> {
if ballot.is_empty() {
return Err(BallotError::Empty);
}
let n = self.option_count.min(order.len());
let mut seen: Vec<PollOptionNumber> = Vec::with_capacity(ballot.len());
let mut chosen: Vec<PollOptionPosition> =
Vec::with_capacity(ballot.len());
for &number in ballot {
let position = number
.index()
.filter(|&i| i < n)
.map(|i| order[i])
.ok_or(BallotError::UnknownOption { number, count: n })?;
if seen.contains(&number) {
return Err(BallotError::Duplicate(number));
}
seen.push(number);
chosen.push(position);
}
let limit = |max: Option<u16>| max.map_or(n, usize::from).min(n);
match self.mode {
PollMode::Single if chosen.len() != 1 => {
Err(BallotError::NotOne(chosen.len()))
}
PollMode::Single => Ok(chosen),
PollMode::Ranked if chosen.len() > limit(self.max_ranks) => {
Err(BallotError::TooManyRanks {
max: limit(self.max_ranks),
got: chosen.len(),
})
}
PollMode::Approval if chosen.len() > limit(self.max_choices) => {
Err(BallotError::TooManyChoices {
max: limit(self.max_choices),
got: chosen.len(),
})
}
PollMode::Ranked | PollMode::Approval => Ok(chosen),
PollMode::Unknown => Err(BallotError::UnknownMode),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct TieBreakSeed([u8; 32]);
impl TieBreakSeed {
pub fn generate() -> Self {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
Self(bytes)
}
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn priority(&self, option: PollOptionPosition) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(self.0);
hasher.update(option.get().to_be_bytes());
hasher.finalize().into()
}
}
impl TryFrom<&[u8]> for TieBreakSeed {
type Error = std::array::TryFromSliceError;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
Ok(Self(bytes.try_into()?))
}
}
impl std::fmt::Display for TieBreakSeed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&hex::encode(self.0))
}
}
impl std::fmt::Debug for TieBreakSeed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "TieBreakSeed({self})")
}
}
impl Serialize for TieBreakSeed {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for TieBreakSeed {
fn deserialize<D: serde::Deserializer<'de>>(
d: D,
) -> Result<Self, D::Error> {
use serde::de::Error;
let raw = String::deserialize(d)?;
let bytes = hex::decode(raw.trim()).map_err(D::Error::custom)?;
Self::try_from(bytes.as_slice()).map_err(|_| {
D::Error::custom("a tie-break seed is 32 bytes (64 hex digits)")
})
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for TieBreakSeed {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("TieBreakSeed")
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"description": "32 bytes as 64 lowercase hex digits"
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct OptionCount<O> {
pub option: O,
pub votes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(bound(deserialize = "O: Deserialize<'de>"))]
pub struct RunoffRound<O> {
pub round: u32,
pub counts: Vec<OptionCount<O>>,
pub continuing_ballots: u64,
pub exhausted_ballots: u64,
#[serde(default)]
pub eliminated: Vec<O>,
#[serde(default)]
pub transfers: Vec<OptionCount<O>>,
#[serde(default)]
pub transferred_to_exhausted: u64,
#[serde(default)]
pub tie_broken_by_seed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(bound(deserialize = "O: Deserialize<'de>"))]
pub struct Tally<O> {
pub mode: PollMode,
pub ballots_cast: u64,
pub eligible_voters: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub winner: Option<O>,
#[serde(default)]
pub tie_broken_by_seed: bool,
pub totals: Vec<OptionCount<O>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rounds: Vec<RunoffRound<O>>,
#[serde(default)]
pub detail_withheld: bool,
#[serde(default)]
pub outcome_withheld: bool,
pub tallied_at: DateTime<Utc>,
}
pub type PollTally = Tally<PollOptionPosition>;
pub type PollResults = Tally<String>;
impl<O> Tally<O> {
pub fn map<P>(&self, name: impl Fn(&O) -> P) -> Tally<P> {
let counts = |cs: &[OptionCount<O>]| {
cs.iter()
.map(|c| OptionCount {
option: name(&c.option),
votes: c.votes,
})
.collect()
};
Tally {
mode: self.mode,
ballots_cast: self.ballots_cast,
eligible_voters: self.eligible_voters,
winner: self.winner.as_ref().map(&name),
tie_broken_by_seed: self.tie_broken_by_seed,
totals: counts(&self.totals),
rounds: self
.rounds
.iter()
.map(|r| RunoffRound {
round: r.round,
counts: counts(&r.counts),
continuing_ballots: r.continuing_ballots,
exhausted_ballots: r.exhausted_ballots,
eliminated: r.eliminated.iter().map(&name).collect(),
transfers: counts(&r.transfers),
transferred_to_exhausted: r.transferred_to_exhausted,
tie_broken_by_seed: r.tie_broken_by_seed,
})
.collect(),
detail_withheld: self.detail_withheld,
outcome_withheld: self.outcome_withheld,
tallied_at: self.tallied_at,
}
}
}
impl PollTally {
pub fn by_label(&self, labels: &[String]) -> PollResults {
self.map(|p| {
p.index()
.and_then(|i| labels.get(i))
.cloned()
.unwrap_or_else(|| format!("[option {p}]"))
})
}
pub fn publish(&self, labels: &[String]) -> PollResults {
let mut results = self.by_label(labels);
if results.ballots_cast < MIN_BALLOTS_FOR_DETAIL {
results.totals.clear();
results.rounds.clear();
results.detail_withheld = true;
}
if results.ballots_cast < MIN_BALLOTS_FOR_RESULT {
results.winner = None;
results.tie_broken_by_seed = false;
results.outcome_withheld = true;
}
results
}
}
pub fn tally(
mode: PollMode,
option_count: usize,
ballots: &[Vec<PollOptionPosition>],
seed: &TieBreakSeed,
eligible_voters: u64,
tallied_at: DateTime<Utc>,
) -> PollTally {
let positions: Vec<PollOptionPosition> = (1..=option_count)
.filter_map(PollOptionPosition::new)
.collect();
let n = positions.len();
let ballots: Vec<Vec<usize>> = ballots
.iter()
.map(|b| {
let mut out: Vec<usize> = Vec::with_capacity(b.len());
for i in b.iter().filter_map(PollOptionPosition::index) {
if i < n && !out.contains(&i) {
out.push(i);
}
}
out
})
.collect();
let mut results = PollTally {
mode,
ballots_cast: ballots.len() as u64,
eligible_voters,
winner: None,
tie_broken_by_seed: false,
totals: Vec::new(),
rounds: Vec::new(),
detail_withheld: false,
outcome_withheld: false,
tallied_at,
};
match mode {
PollMode::Ranked => {
let rounds = runoff(&positions, &ballots, seed);
if let Some(first) = rounds.first() {
results.totals = first.counts.clone();
}
results.tie_broken_by_seed =
rounds.iter().any(|r| r.tie_broken_by_seed);
results.winner = rounds.last().and_then(winner_of_round);
results.rounds = rounds;
}
PollMode::Approval | PollMode::Single => {
let mut votes = vec![0u64; n];
for ballot in &ballots {
let take = if mode == PollMode::Single {
1
} else {
ballot.len()
};
for &i in ballot.iter().take(take) {
votes[i] += 1;
}
}
results.totals = (0..n)
.map(|i| OptionCount {
option: positions[i],
votes: votes[i],
})
.collect();
if let Some(&top) = votes.iter().max().filter(|&&m| m > 0) {
let tied: Vec<usize> =
(0..n).filter(|&i| votes[i] == top).collect();
results.tie_broken_by_seed = tied.len() > 1;
results.winner = tied
.into_iter()
.max_by_key(|&i| seed.priority(positions[i]))
.map(|i| positions[i]);
}
}
PollMode::Unknown => {}
}
results
}
fn winner_of_round(
round: &RunoffRound<PollOptionPosition>,
) -> Option<PollOptionPosition> {
if !round.eliminated.is_empty() || round.continuing_ballots == 0 {
return None;
}
round
.counts
.iter()
.find(|c| c.votes * 2 > round.continuing_ballots)
.or(match round.counts.as_slice() {
[only] => Some(only),
_ => None,
})
.map(|c| c.option)
}
fn runoff(
positions: &[PollOptionPosition],
ballots: &[Vec<usize>],
seed: &TieBreakSeed,
) -> Vec<RunoffRound<PollOptionPosition>> {
let n = positions.len();
let mut continuing = vec![true; n];
let top = |ballot: &[usize], continuing: &[bool]| {
ballot.iter().copied().find(|&i| continuing[i])
};
let mut rounds = Vec::new();
loop {
let mut votes = vec![0u64; n];
let mut exhausted = 0u64;
for ballot in ballots {
match top(ballot, &continuing) {
Some(i) => votes[i] += 1,
None => exhausted += 1,
}
}
let active = ballots.len() as u64 - exhausted;
let live: Vec<usize> = (0..n).filter(|&i| continuing[i]).collect();
let mut round = RunoffRound {
round: rounds.len() as u32 + 1,
counts: live
.iter()
.map(|&i| OptionCount {
option: positions[i],
votes: votes[i],
})
.collect(),
continuing_ballots: active,
exhausted_ballots: exhausted,
eliminated: Vec::new(),
transfers: Vec::new(),
transferred_to_exhausted: 0,
tie_broken_by_seed: false,
};
let decided = active == 0
|| live.len() <= 1
|| live.iter().any(|&i| votes[i] * 2 > active);
if decided {
rounds.push(round);
return rounds;
}
let fewest = live
.iter()
.map(|&i| votes[i])
.min()
.expect("live is not empty");
let tied: Vec<usize> = live
.iter()
.copied()
.filter(|&i| votes[i] == fewest)
.collect();
let out: Vec<usize> = if fewest == 0 || tied.len() == 1 {
tied
} else {
round.tie_broken_by_seed = true;
vec![
tied.into_iter()
.min_by_key(|&i| seed.priority(positions[i]))
.expect("tied is not empty"),
]
};
let mut after = continuing.clone();
for &i in &out {
after[i] = false;
}
let mut moved = vec![0u64; n];
for ballot in ballots {
if let Some(from) = top(ballot, &continuing)
&& out.contains(&from)
{
match top(ballot, &after) {
Some(to) => moved[to] += 1,
None => round.transferred_to_exhausted += 1,
}
}
}
round.eliminated = out.iter().map(|&i| positions[i]).collect();
round.transfers = (0..n)
.filter(|&i| after[i] && moved[i] > 0)
.map(|i| OptionCount {
option: positions[i],
votes: moved[i],
})
.collect();
rounds.push(round);
continuing = after;
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct PollOptionView {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number: Option<PollOptionNumber>,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct BallotChoice {
pub number: PollOptionNumber,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct YourBallot {
pub choices: Vec<BallotChoice>,
pub cast_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct PollViewer {
pub eligible: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ballot: Option<YourBallot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct PollView {
pub id: PollId,
pub post_id: PostId,
pub mode: PollMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub advisory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub numbering: Option<String>,
pub options: Vec<PollOptionView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_ranks: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_choices: Option<u16>,
pub opens_at: DateTime<Utc>,
pub closes_at: DateTime<Utc>,
pub status: PollStatus,
pub eligible_voters: u64,
pub tie_break_seed: TieBreakSeed,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub you: Option<PollViewer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub results: Option<PollResults>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub privacy: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct BallotReceipt {
#[serde(flatten)]
pub ack: WriteAck<BallotId>,
pub poll_id: PollId,
pub post_id: PostId,
pub mode: PollMode,
pub ballot: Vec<BallotChoice>,
pub closes_at: DateTime<Utc>,
pub note: String,
}
impl BallotReceipt {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: BallotId,
replaced: bool,
verified: bool,
poll_id: PollId,
post_id: PostId,
mode: PollMode,
ballot: Vec<BallotChoice>,
closes_at: DateTime<Utc>,
) -> Self {
let listed = ballot
.iter()
.map(|c| format!("{}. {}", c.number, c.label))
.collect::<Vec<_>>()
.join(", ");
let what = match mode {
PollMode::Ranked => {
format!("Your ranking, most preferred first: {listed}.")
}
PollMode::Approval => format!("You approve of: {listed}."),
_ => format!("Your choice: {listed}."),
};
let note = format!(
"{what} Your ballot is {}. If that is not what you meant, vote again on the \
post with a new list before the poll closes at {} UTC; your last ballot is \
the one counted. {BALLOT_PRIVACY}",
if replaced { "replaced" } else { "cast" },
closes_at.format("%Y-%m-%d %H:%M"),
);
Self {
ack: WriteAck {
id,
status: if replaced { "replaced" } else { "cast" }.into(),
verified,
},
poll_id,
post_id,
mode,
ballot,
closes_at,
note,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct OpenPollPointer {
pub poll_id: PollId,
pub post_id: PostId,
pub title: String,
pub community: crate::names::CommunityName,
pub mode: PollMode,
pub option_count: u32,
pub closes_at: DateTime<Utc>,
#[serde(default)]
pub advisory: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ExportedBallot {
pub id: BallotId,
pub poll_id: PollId,
pub post_id: PostId,
pub choices: Vec<String>,
pub cast_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests;