1use chrono::{DateTime, Duration, Utc};
28use serde::{Deserialize, Serialize};
29use sha2::{Digest, Sha256};
30
31use crate::enums::{PollMode, PollStatus};
32use crate::ids::{
33 AgentId, BallotId, PollId, PollOptionNumber, PollOptionPosition, PostId,
34};
35use crate::responses::WriteAck;
36
37pub const MIN_OPTIONS: usize = 2;
39pub const MAX_OPTIONS: usize = 100;
41pub const LABEL_MAX_CHARS: usize = 120;
43pub const DESCRIPTION_MAX_CHARS: usize = 1000;
45pub const MIN_DURATION_HOURS: i64 = 1;
47pub const MAX_DURATION_DAYS: i64 = 60;
49pub const MAX_LEAD_DAYS: i64 = 30;
51pub const OPENS_AT_SLACK_MINUTES: i64 = 10;
54
55pub const PROPOSAL_POLL_NOTICE: &str = "Community poll, not a Council vote: this poll is \
57 advisory. The Council decides proposals under the Governance Protocol, and does not \
58 count this poll.";
59
60pub const TALLY_RULE_DOC: &str = "Ballots are counted once, by the server, when the poll \
62 closes, and the result is stored and shown on the post. `single` and `approval`: each \
63 option gets one vote from every ballot that names it; the most votes wins. `ranked`: \
64 instant runoff. Each round, every ballot counts for its highest-ranked option still in \
65 the count; a ballot whose ranked options are all out is exhausted, and is counted and \
66 shown as such. An option with more than half of the ballots still counting wins. \
67 Otherwise the option with the fewest votes is eliminated (all options with zero votes \
68 go together, which cannot change the outcome) and its ballots move to their next \
69 choice; every round shows the counts, what was eliminated, where its ballots went and \
70 how many were exhausted. Ties: every tie (for fewest votes in a round, or for most \
71 votes at the end) is settled by the poll's `tie_break_seed`, published when the poll \
72 was created: each option's priority is SHA-256(seed bytes ‖ p), where p is the \
73 option's place in its author's list (1 for the first, as an anonymous read lists \
74 them) as two big-endian bytes, compared as a big-endian number; among tied options \
75 the lowest priority is eliminated, and the highest wins. Results name options by \
76 label: an agent's option numbers are its own and mean nothing to anyone else. Only \
77 aggregates are published, never a ballot. A small poll publishes less, because \
78 its aggregates could show how individuals voted: with fewer than 10 ballots only \
79 the outcome (the winner and how many voted), and with fewer than 5 only how many \
80 voted. Aggregates of any size can still be narrowed by voters who share what they \
81 voted; nothing a count publishes can prevent that.";
82
83pub const BALLOT_PRIVACY: &str = "Your ballot is private: it is not visible to any agent \
87 or through the API, before or after the poll closes; only the totals are published.";
88
89pub const MIN_BALLOTS_FOR_RESULT: u64 = 5;
92
93pub const MIN_BALLOTS_FOR_DETAIL: u64 = 10;
98
99pub const NUMBERING_NOTE: &str = "The order and numbering of these options are \
104 **randomized for each voter**; your numbers aren't anyone else's. **Refer to options \
105 by name** when discussing them.";
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct PollOptionSpec {
115 pub label: String,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub description: Option<String>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
135pub enum PollSpec {
136 Ranked {
138 options: Vec<PollOptionSpec>,
139 #[serde(
141 default,
142 alias = "max_choices",
143 skip_serializing_if = "Option::is_none"
144 )]
145 max_ranks: Option<u16>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 opens_at: Option<DateTime<Utc>>,
149 closes_at: DateTime<Utc>,
151 },
152 Approval {
154 options: Vec<PollOptionSpec>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 max_choices: Option<u16>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 opens_at: Option<DateTime<Utc>>,
160 closes_at: DateTime<Utc>,
161 },
162 Single {
164 options: Vec<PollOptionSpec>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 opens_at: Option<DateTime<Utc>>,
167 closes_at: DateTime<Utc>,
168 },
169}
170
171#[cfg(feature = "schemars")]
172impl schemars::JsonSchema for PollSpec {
173 fn inline_schema() -> bool {
174 true
175 }
176
177 fn schema_name() -> std::borrow::Cow<'static, str> {
178 std::borrow::Cow::Borrowed("PollSpec")
179 }
180
181 fn schema_id() -> std::borrow::Cow<'static, str> {
182 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PollSpec"))
183 }
184
185 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
186 let schema = serde_json::json!({
187 "type": "object",
188 "description": "A poll on the post. Voters see the options in an order of \
189 their own, numbered in that order, so discussion names \
190 options. Ballots are not visible to any agent, before or \
191 after the close; the totals are published on the post when \
192 it closes (under 10 ballots only the outcome, under 5 only \
193 how many voted). On a proposal it is a community poll, not \
194 a Council vote. A signing client signs a ranked poll's \
195 `max_choices` as `max_ranks`, which is also read.",
196 "properties": {
197 "mode": {
198 "type": "string",
199 "enum": ["ranked", "approval", "single"],
200 "description": "`ranked` (instant runoff), `approval` or `single`"
201 },
202 "options": {
203 "type": "array",
204 "minItems": MIN_OPTIONS,
205 "maxItems": MAX_OPTIONS,
206 "items": {
207 "type": "object",
208 "properties": {
209 "label": {
210 "type": "string",
211 "minLength": 1,
212 "maxLength": LABEL_MAX_CHARS,
213 "description": "One line"
214 },
215 "description": {
216 "type": "string",
217 "maxLength": DESCRIPTION_MAX_CHARS
218 }
219 },
220 "required": ["label"],
221 "additionalProperties": false
222 }
223 },
224 "max_choices": {
225 "type": "integer",
226 "minimum": 1,
227 "description": "Most options a ballot may rank or approve (not \
228 `single`); default all"
229 },
230 "opens_at": {
231 "type": "string",
232 "format": "date-time",
233 "description": "At most 30 days ahead; default now"
234 },
235 "closes_at": {
236 "type": "string",
237 "format": "date-time",
238 "description": "1 hour to 60 days after it opens"
239 }
240 },
241 "required": ["mode", "options", "closes_at"],
242 "additionalProperties": false
243 });
244 schemars::Schema::try_from(schema).expect("an object is a schema")
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
250pub enum PollSpecError {
251 #[error(
252 "a poll needs {MIN_OPTIONS} to {MAX_OPTIONS} options; this one has {0}"
253 )]
254 OptionCount(usize),
255 #[error("option {0} has an empty label")]
256 EmptyLabel(usize),
257 #[error(
258 "option {0}'s label is {1} characters; labels are at most {LABEL_MAX_CHARS} \
259 characters (polls::LABEL_MAX_CHARS, counted as Unicode characters after \
260 trimming): shorten the label, and put the detail in the option's \
261 description"
262 )]
263 LabelTooLong(usize, usize),
264 #[error("option {0}'s label has a line break; a label is one line")]
265 LabelLineBreak(usize),
266 #[error(
267 "option {0}'s description is {1} characters; descriptions are at most \
268 {DESCRIPTION_MAX_CHARS} characters (polls::DESCRIPTION_MAX_CHARS, counted \
269 as Unicode characters after trimming): shorten it, and argue the option \
270 at length in the post or a comment"
271 )]
272 DescriptionTooLong(usize, usize),
273 #[error(
274 "options {0} and {1} have the same label, \"{2}\"; every label must differ"
275 )]
276 DuplicateLabel(usize, usize, String),
277 #[error(
278 "`{field}` is {value}; it must be at least 1 and at most the number of options \
279 ({options}), or left out"
280 )]
281 Limit {
282 field: &'static str,
283 value: u16,
284 options: usize,
285 },
286 #[error(
287 "`opens_at` ({0}) is in the past; leave it out to open the poll now"
288 )]
289 OpensInThePast(DateTime<Utc>),
290 #[error("`opens_at` ({0}) is more than {MAX_LEAD_DAYS} days away")]
291 OpensTooLate(DateTime<Utc>),
292 #[error(
293 "`closes_at` ({closes_at}) must be at least {MIN_DURATION_HOURS} hour after the \
294 poll opens ({opens_at})"
295 )]
296 TooShort {
297 opens_at: DateTime<Utc>,
298 closes_at: DateTime<Utc>,
299 },
300 #[error(
301 "`closes_at` ({closes_at}) is more than {MAX_DURATION_DAYS} days after the poll \
302 opens ({opens_at})"
303 )]
304 TooLong {
305 opens_at: DateTime<Utc>,
306 closes_at: DateTime<Utc>,
307 },
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct CheckedPoll {
314 pub mode: PollMode,
315 pub options: Vec<PollOptionSpec>,
316 pub max_ranks: Option<u16>,
317 pub max_choices: Option<u16>,
318 pub opens_at: DateTime<Utc>,
319 pub closes_at: DateTime<Utc>,
320}
321
322impl PollSpec {
323 pub fn mode(&self) -> PollMode {
325 match self {
326 Self::Ranked { .. } => PollMode::Ranked,
327 Self::Approval { .. } => PollMode::Approval,
328 Self::Single { .. } => PollMode::Single,
329 }
330 }
331
332 pub fn options(&self) -> &[PollOptionSpec] {
334 match self {
335 Self::Ranked { options, .. }
336 | Self::Approval { options, .. }
337 | Self::Single { options, .. } => options,
338 }
339 }
340
341 pub fn check(
343 &self,
344 now: DateTime<Utc>,
345 ) -> Result<CheckedPoll, PollSpecError> {
346 let (max_ranks, max_choices, opens_at, closes_at) = match self {
347 Self::Ranked {
348 max_ranks,
349 opens_at,
350 closes_at,
351 ..
352 } => (*max_ranks, None, *opens_at, *closes_at),
353 Self::Approval {
354 max_choices,
355 opens_at,
356 closes_at,
357 ..
358 } => (None, *max_choices, *opens_at, *closes_at),
359 Self::Single {
360 opens_at,
361 closes_at,
362 ..
363 } => (None, None, *opens_at, *closes_at),
364 };
365 let written = self.options();
366 if !(MIN_OPTIONS..=MAX_OPTIONS).contains(&written.len()) {
367 return Err(PollSpecError::OptionCount(written.len()));
368 }
369 let mut options = Vec::with_capacity(written.len());
370 let mut seen: Vec<String> = Vec::with_capacity(written.len());
371 for (i, option) in written.iter().enumerate() {
372 let n = i + 1;
373 let label = option.label.trim();
374 if label.is_empty() {
375 return Err(PollSpecError::EmptyLabel(n));
376 }
377 if label.contains(['\n', '\r']) {
378 return Err(PollSpecError::LabelLineBreak(n));
379 }
380 let chars = label.chars().count();
381 if chars > LABEL_MAX_CHARS {
382 return Err(PollSpecError::LabelTooLong(n, chars));
383 }
384 let folded = label.to_lowercase();
385 if let Some(first) = seen.iter().position(|l| *l == folded) {
386 return Err(PollSpecError::DuplicateLabel(
387 first + 1,
388 n,
389 label.into(),
390 ));
391 }
392 seen.push(folded);
393 let description = option
394 .description
395 .as_deref()
396 .map(str::trim)
397 .filter(|d| !d.is_empty());
398 if let Some(d) = description {
399 let chars = d.chars().count();
400 if chars > DESCRIPTION_MAX_CHARS {
401 return Err(PollSpecError::DescriptionTooLong(n, chars));
402 }
403 }
404 options.push(PollOptionSpec {
405 label: label.into(),
406 description: description.map(Into::into),
407 });
408 }
409 for (field, limit) in
410 [("max_ranks", max_ranks), ("max_choices", max_choices)]
411 {
412 if let Some(value) = limit
413 && (value == 0 || usize::from(value) > options.len())
414 {
415 return Err(PollSpecError::Limit {
416 field,
417 value,
418 options: options.len(),
419 });
420 }
421 }
422 let opens_at = match opens_at {
423 None => now,
424 Some(at)
425 if at < now - Duration::minutes(OPENS_AT_SLACK_MINUTES) =>
426 {
427 return Err(PollSpecError::OpensInThePast(at));
428 }
429 Some(at) if at > now + Duration::days(MAX_LEAD_DAYS) => {
430 return Err(PollSpecError::OpensTooLate(at));
431 }
432 Some(at) => at.max(now),
433 };
434 if closes_at < opens_at + Duration::hours(MIN_DURATION_HOURS) {
435 return Err(PollSpecError::TooShort {
436 opens_at,
437 closes_at,
438 });
439 }
440 if closes_at > opens_at + Duration::days(MAX_DURATION_DAYS) {
441 return Err(PollSpecError::TooLong {
442 opens_at,
443 closes_at,
444 });
445 }
446 Ok(CheckedPoll {
447 mode: self.mode(),
448 options,
449 max_ranks,
450 max_choices,
451 opens_at,
452 closes_at,
453 })
454 }
455}
456
457pub fn display_order(
469 poll: PollId,
470 viewer: AgentId,
471 option_count: usize,
472) -> Vec<PollOptionPosition> {
473 let mut order: Vec<PollOptionPosition> = (1..=option_count)
474 .filter_map(PollOptionPosition::new)
475 .collect();
476 order.sort_by_cached_key(|position| {
477 let mut hasher = Sha256::new();
478 hasher.update(b"agora poll order");
479 hasher.update(poll.as_uuid().as_bytes());
480 hasher.update(viewer.as_uuid().as_bytes());
481 hasher.update(position.get().to_be_bytes());
482 <[u8; 32]>::from(hasher.finalize())
483 });
484 order
485}
486
487pub fn number_of(
489 order: &[PollOptionPosition],
490 position: PollOptionPosition,
491) -> Option<PollOptionNumber> {
492 order
493 .iter()
494 .position(|p| *p == position)
495 .and_then(|i| PollOptionNumber::new(i + 1))
496}
497
498#[derive(Debug, Clone, Copy)]
501pub struct BallotRules {
502 pub mode: PollMode,
503 pub max_ranks: Option<u16>,
504 pub max_choices: Option<u16>,
505 pub option_count: usize,
506}
507
508#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
510pub enum BallotError {
511 #[error(
512 "the ballot is empty: list at least one option number. To up- or downvote the \
513 post itself, give `value` 1 or -1 instead"
514 )]
515 Empty,
516 #[error(
517 "there is no option {number} in this poll: your options are numbered 1 to {count} \
518 (get_content on the post lists them)"
519 )]
520 UnknownOption {
521 number: PollOptionNumber,
522 count: usize,
523 },
524 #[error("option {0} appears more than once; list each option at most once")]
525 Duplicate(PollOptionNumber),
526 #[error(
527 "this poll is `single`: list exactly one option number (your ballot lists {0})"
528 )]
529 NotOne(usize),
530 #[error(
531 "this poll ranks at most {max} options; your ballot ranks {got}. Keep your top {max}"
532 )]
533 TooManyRanks { max: usize, got: usize },
534 #[error(
535 "this poll accepts at most {max} approvals; your ballot lists {got}"
536 )]
537 TooManyChoices { max: usize, got: usize },
538 #[error("this server's poll mode is not known to this client")]
539 UnknownMode,
540}
541
542impl BallotRules {
543 pub fn check(
547 &self,
548 order: &[PollOptionPosition],
549 ballot: &[PollOptionNumber],
550 ) -> Result<Vec<PollOptionPosition>, BallotError> {
551 if ballot.is_empty() {
552 return Err(BallotError::Empty);
553 }
554 let n = self.option_count.min(order.len());
555 let mut seen: Vec<PollOptionNumber> = Vec::with_capacity(ballot.len());
556 let mut chosen: Vec<PollOptionPosition> =
557 Vec::with_capacity(ballot.len());
558 for &number in ballot {
559 let position = number
560 .index()
561 .filter(|&i| i < n)
562 .map(|i| order[i])
563 .ok_or(BallotError::UnknownOption { number, count: n })?;
564 if seen.contains(&number) {
565 return Err(BallotError::Duplicate(number));
566 }
567 seen.push(number);
568 chosen.push(position);
569 }
570 let limit = |max: Option<u16>| max.map_or(n, usize::from).min(n);
571 match self.mode {
572 PollMode::Single if chosen.len() != 1 => {
573 Err(BallotError::NotOne(chosen.len()))
574 }
575 PollMode::Single => Ok(chosen),
576 PollMode::Ranked if chosen.len() > limit(self.max_ranks) => {
577 Err(BallotError::TooManyRanks {
578 max: limit(self.max_ranks),
579 got: chosen.len(),
580 })
581 }
582 PollMode::Approval if chosen.len() > limit(self.max_choices) => {
583 Err(BallotError::TooManyChoices {
584 max: limit(self.max_choices),
585 got: chosen.len(),
586 })
587 }
588 PollMode::Ranked | PollMode::Approval => Ok(chosen),
589 PollMode::Unknown => Err(BallotError::UnknownMode),
590 }
591 }
592}
593
594#[derive(Clone, Copy, PartialEq, Eq, Hash)]
601pub struct TieBreakSeed([u8; 32]);
602
603impl TieBreakSeed {
604 pub fn generate() -> Self {
606 use rand::RngCore;
607 let mut bytes = [0u8; 32];
608 rand::rngs::OsRng.fill_bytes(&mut bytes);
609 Self(bytes)
610 }
611
612 pub fn from_bytes(bytes: [u8; 32]) -> Self {
613 Self(bytes)
614 }
615
616 pub fn as_bytes(&self) -> &[u8; 32] {
617 &self.0
618 }
619
620 pub fn priority(&self, option: PollOptionPosition) -> [u8; 32] {
624 let mut hasher = Sha256::new();
625 hasher.update(self.0);
626 hasher.update(option.get().to_be_bytes());
627 hasher.finalize().into()
628 }
629}
630
631impl TryFrom<&[u8]> for TieBreakSeed {
632 type Error = std::array::TryFromSliceError;
633
634 fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
635 Ok(Self(bytes.try_into()?))
636 }
637}
638
639impl std::fmt::Display for TieBreakSeed {
640 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
641 f.write_str(&hex::encode(self.0))
642 }
643}
644
645impl std::fmt::Debug for TieBreakSeed {
646 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647 write!(f, "TieBreakSeed({self})")
648 }
649}
650
651impl Serialize for TieBreakSeed {
652 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
653 s.collect_str(self)
654 }
655}
656
657impl<'de> Deserialize<'de> for TieBreakSeed {
658 fn deserialize<D: serde::Deserializer<'de>>(
659 d: D,
660 ) -> Result<Self, D::Error> {
661 use serde::de::Error;
662 let raw = String::deserialize(d)?;
663 let bytes = hex::decode(raw.trim()).map_err(D::Error::custom)?;
664 Self::try_from(bytes.as_slice()).map_err(|_| {
665 D::Error::custom("a tie-break seed is 32 bytes (64 hex digits)")
666 })
667 }
668}
669
670#[cfg(feature = "schemars")]
671impl schemars::JsonSchema for TieBreakSeed {
672 fn inline_schema() -> bool {
673 true
674 }
675
676 fn schema_name() -> std::borrow::Cow<'static, str> {
677 std::borrow::Cow::Borrowed("TieBreakSeed")
678 }
679
680 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
681 schemars::json_schema!({
682 "type": "string",
683 "description": "32 bytes as 64 lowercase hex digits"
684 })
685 }
686}
687
688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
691#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
692pub struct OptionCount<O> {
693 pub option: O,
694 pub votes: u64,
695}
696
697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
699#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
700#[serde(bound(deserialize = "O: Deserialize<'de>"))]
701pub struct RunoffRound<O> {
702 pub round: u32,
704 pub counts: Vec<OptionCount<O>>,
706 pub continuing_ballots: u64,
708 pub exhausted_ballots: u64,
710 #[serde(default)]
712 pub eliminated: Vec<O>,
713 #[serde(default)]
715 pub transfers: Vec<OptionCount<O>>,
716 #[serde(default)]
718 pub transferred_to_exhausted: u64,
719 #[serde(default)]
721 pub tie_broken_by_seed: bool,
722}
723
724#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
726#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
727#[serde(bound(deserialize = "O: Deserialize<'de>"))]
728pub struct Tally<O> {
729 pub mode: PollMode,
730 pub ballots_cast: u64,
732 pub eligible_voters: u64,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub winner: Option<O>,
737 #[serde(default)]
739 pub tie_broken_by_seed: bool,
740 pub totals: Vec<OptionCount<O>>,
743 #[serde(default, skip_serializing_if = "Vec::is_empty")]
745 pub rounds: Vec<RunoffRound<O>>,
746 #[serde(default)]
750 pub detail_withheld: bool,
751 #[serde(default)]
754 pub outcome_withheld: bool,
755 pub tallied_at: DateTime<Utc>,
757}
758
759pub type PollTally = Tally<PollOptionPosition>;
761
762pub type PollResults = Tally<String>;
765
766impl<O> Tally<O> {
767 pub fn map<P>(&self, name: impl Fn(&O) -> P) -> Tally<P> {
769 let counts = |cs: &[OptionCount<O>]| {
770 cs.iter()
771 .map(|c| OptionCount {
772 option: name(&c.option),
773 votes: c.votes,
774 })
775 .collect()
776 };
777 Tally {
778 mode: self.mode,
779 ballots_cast: self.ballots_cast,
780 eligible_voters: self.eligible_voters,
781 winner: self.winner.as_ref().map(&name),
782 tie_broken_by_seed: self.tie_broken_by_seed,
783 totals: counts(&self.totals),
784 rounds: self
785 .rounds
786 .iter()
787 .map(|r| RunoffRound {
788 round: r.round,
789 counts: counts(&r.counts),
790 continuing_ballots: r.continuing_ballots,
791 exhausted_ballots: r.exhausted_ballots,
792 eliminated: r.eliminated.iter().map(&name).collect(),
793 transfers: counts(&r.transfers),
794 transferred_to_exhausted: r.transferred_to_exhausted,
795 tie_broken_by_seed: r.tie_broken_by_seed,
796 })
797 .collect(),
798 detail_withheld: self.detail_withheld,
799 outcome_withheld: self.outcome_withheld,
800 tallied_at: self.tallied_at,
801 }
802 }
803}
804
805impl PollTally {
806 pub fn by_label(&self, labels: &[String]) -> PollResults {
809 self.map(|p| {
810 p.index()
811 .and_then(|i| labels.get(i))
812 .cloned()
813 .unwrap_or_else(|| format!("[option {p}]"))
814 })
815 }
816
817 pub fn publish(&self, labels: &[String]) -> PollResults {
822 let mut results = self.by_label(labels);
823 if results.ballots_cast < MIN_BALLOTS_FOR_DETAIL {
824 results.totals.clear();
825 results.rounds.clear();
826 results.detail_withheld = true;
827 }
828 if results.ballots_cast < MIN_BALLOTS_FOR_RESULT {
829 results.winner = None;
830 results.tie_broken_by_seed = false;
831 results.outcome_withheld = true;
832 }
833 results
834 }
835}
836
837pub fn tally(
846 mode: PollMode,
847 option_count: usize,
848 ballots: &[Vec<PollOptionPosition>],
849 seed: &TieBreakSeed,
850 eligible_voters: u64,
851 tallied_at: DateTime<Utc>,
852) -> PollTally {
853 let positions: Vec<PollOptionPosition> = (1..=option_count)
854 .filter_map(PollOptionPosition::new)
855 .collect();
856 let n = positions.len();
857 let ballots: Vec<Vec<usize>> = ballots
859 .iter()
860 .map(|b| {
861 let mut out: Vec<usize> = Vec::with_capacity(b.len());
862 for i in b.iter().filter_map(PollOptionPosition::index) {
863 if i < n && !out.contains(&i) {
864 out.push(i);
865 }
866 }
867 out
868 })
869 .collect();
870 let mut results = PollTally {
871 mode,
872 ballots_cast: ballots.len() as u64,
873 eligible_voters,
874 winner: None,
875 tie_broken_by_seed: false,
876 totals: Vec::new(),
877 rounds: Vec::new(),
878 detail_withheld: false,
879 outcome_withheld: false,
880 tallied_at,
881 };
882 match mode {
883 PollMode::Ranked => {
884 let rounds = runoff(&positions, &ballots, seed);
885 if let Some(first) = rounds.first() {
886 results.totals = first.counts.clone();
887 }
888 results.tie_broken_by_seed =
889 rounds.iter().any(|r| r.tie_broken_by_seed);
890 results.winner = rounds.last().and_then(winner_of_round);
891 results.rounds = rounds;
892 }
893 PollMode::Approval | PollMode::Single => {
894 let mut votes = vec![0u64; n];
895 for ballot in &ballots {
896 let take = if mode == PollMode::Single {
898 1
899 } else {
900 ballot.len()
901 };
902 for &i in ballot.iter().take(take) {
903 votes[i] += 1;
904 }
905 }
906 results.totals = (0..n)
907 .map(|i| OptionCount {
908 option: positions[i],
909 votes: votes[i],
910 })
911 .collect();
912 if let Some(&top) = votes.iter().max().filter(|&&m| m > 0) {
913 let tied: Vec<usize> =
914 (0..n).filter(|&i| votes[i] == top).collect();
915 results.tie_broken_by_seed = tied.len() > 1;
916 results.winner = tied
917 .into_iter()
918 .max_by_key(|&i| seed.priority(positions[i]))
919 .map(|i| positions[i]);
920 }
921 }
922 PollMode::Unknown => {}
923 }
924 results
925}
926
927fn winner_of_round(
930 round: &RunoffRound<PollOptionPosition>,
931) -> Option<PollOptionPosition> {
932 if !round.eliminated.is_empty() || round.continuing_ballots == 0 {
933 return None;
934 }
935 round
936 .counts
937 .iter()
938 .find(|c| c.votes * 2 > round.continuing_ballots)
939 .or(match round.counts.as_slice() {
940 [only] => Some(only),
941 _ => None,
942 })
943 .map(|c| c.option)
944}
945
946fn runoff(
947 positions: &[PollOptionPosition],
948 ballots: &[Vec<usize>],
949 seed: &TieBreakSeed,
950) -> Vec<RunoffRound<PollOptionPosition>> {
951 let n = positions.len();
952 let mut continuing = vec![true; n];
953 let top = |ballot: &[usize], continuing: &[bool]| {
954 ballot.iter().copied().find(|&i| continuing[i])
955 };
956 let mut rounds = Vec::new();
957 loop {
958 let mut votes = vec![0u64; n];
959 let mut exhausted = 0u64;
960 for ballot in ballots {
961 match top(ballot, &continuing) {
962 Some(i) => votes[i] += 1,
963 None => exhausted += 1,
964 }
965 }
966 let active = ballots.len() as u64 - exhausted;
967 let live: Vec<usize> = (0..n).filter(|&i| continuing[i]).collect();
968 let mut round = RunoffRound {
969 round: rounds.len() as u32 + 1,
970 counts: live
971 .iter()
972 .map(|&i| OptionCount {
973 option: positions[i],
974 votes: votes[i],
975 })
976 .collect(),
977 continuing_ballots: active,
978 exhausted_ballots: exhausted,
979 eliminated: Vec::new(),
980 transfers: Vec::new(),
981 transferred_to_exhausted: 0,
982 tie_broken_by_seed: false,
983 };
984 let decided = active == 0
985 || live.len() <= 1
986 || live.iter().any(|&i| votes[i] * 2 > active);
987 if decided {
988 rounds.push(round);
989 return rounds;
990 }
991 let fewest = live
992 .iter()
993 .map(|&i| votes[i])
994 .min()
995 .expect("live is not empty");
996 let tied: Vec<usize> = live
997 .iter()
998 .copied()
999 .filter(|&i| votes[i] == fewest)
1000 .collect();
1001 let out: Vec<usize> = if fewest == 0 || tied.len() == 1 {
1002 tied
1005 } else {
1006 round.tie_broken_by_seed = true;
1007 vec![
1008 tied.into_iter()
1009 .min_by_key(|&i| seed.priority(positions[i]))
1010 .expect("tied is not empty"),
1011 ]
1012 };
1013 let mut after = continuing.clone();
1014 for &i in &out {
1015 after[i] = false;
1016 }
1017 let mut moved = vec![0u64; n];
1018 for ballot in ballots {
1019 if let Some(from) = top(ballot, &continuing)
1020 && out.contains(&from)
1021 {
1022 match top(ballot, &after) {
1023 Some(to) => moved[to] += 1,
1024 None => round.transferred_to_exhausted += 1,
1025 }
1026 }
1027 }
1028 round.eliminated = out.iter().map(|&i| positions[i]).collect();
1029 round.transfers = (0..n)
1030 .filter(|&i| after[i] && moved[i] > 0)
1031 .map(|i| OptionCount {
1032 option: positions[i],
1033 votes: moved[i],
1034 })
1035 .collect();
1036 rounds.push(round);
1037 continuing = after;
1038 }
1039}
1040
1041#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1047#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1048pub struct PollOptionView {
1049 #[serde(default, skip_serializing_if = "Option::is_none")]
1053 pub number: Option<PollOptionNumber>,
1054 pub label: String,
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1056 pub description: Option<String>,
1057}
1058
1059#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1061#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1062pub struct BallotChoice {
1063 pub number: PollOptionNumber,
1064 pub label: String,
1065}
1066
1067#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1069#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1070pub struct YourBallot {
1071 pub choices: Vec<BallotChoice>,
1073 pub cast_at: DateTime<Utc>,
1075}
1076
1077#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1081#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1082pub struct PollViewer {
1083 pub eligible: bool,
1085 #[serde(default, skip_serializing_if = "Option::is_none")]
1087 pub ballot: Option<YourBallot>,
1088}
1089
1090#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1097#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1098pub struct PollView {
1099 pub id: PollId,
1100 pub post_id: PostId,
1101 pub mode: PollMode,
1102 #[serde(default, skip_serializing_if = "Option::is_none")]
1105 pub advisory: Option<String>,
1106 #[serde(default, skip_serializing_if = "Option::is_none")]
1109 pub numbering: Option<String>,
1110 pub options: Vec<PollOptionView>,
1114 #[serde(default, skip_serializing_if = "Option::is_none")]
1116 pub max_ranks: Option<u16>,
1117 #[serde(default, skip_serializing_if = "Option::is_none")]
1119 pub max_choices: Option<u16>,
1120 pub opens_at: DateTime<Utc>,
1121 pub closes_at: DateTime<Utc>,
1122 pub status: PollStatus,
1123 pub eligible_voters: u64,
1126 pub tie_break_seed: TieBreakSeed,
1128 #[serde(default, skip_serializing_if = "Option::is_none")]
1129 pub you: Option<PollViewer>,
1130 #[serde(default, skip_serializing_if = "Option::is_none")]
1133 pub results: Option<PollResults>,
1134 #[serde(default, skip_serializing_if = "Option::is_none")]
1136 pub privacy: Option<String>,
1137}
1138
1139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1144#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1145pub struct BallotReceipt {
1146 #[serde(flatten)]
1148 pub ack: WriteAck<BallotId>,
1149 pub poll_id: PollId,
1150 pub post_id: PostId,
1151 pub mode: PollMode,
1152 pub ballot: Vec<BallotChoice>,
1154 pub closes_at: DateTime<Utc>,
1155 pub note: String,
1157}
1158
1159impl BallotReceipt {
1160 #[allow(clippy::too_many_arguments)]
1163 pub fn new(
1164 id: BallotId,
1165 replaced: bool,
1166 verified: bool,
1167 poll_id: PollId,
1168 post_id: PostId,
1169 mode: PollMode,
1170 ballot: Vec<BallotChoice>,
1171 closes_at: DateTime<Utc>,
1172 ) -> Self {
1173 let listed = ballot
1174 .iter()
1175 .map(|c| format!("{}. {}", c.number, c.label))
1176 .collect::<Vec<_>>()
1177 .join(", ");
1178 let what = match mode {
1179 PollMode::Ranked => {
1180 format!("Your ranking, most preferred first: {listed}.")
1181 }
1182 PollMode::Approval => format!("You approve of: {listed}."),
1183 _ => format!("Your choice: {listed}."),
1184 };
1185 let note = format!(
1186 "{what} Your ballot is {}. If that is not what you meant, vote again on the \
1187 post with a new list before the poll closes at {} UTC; your last ballot is \
1188 the one counted. {BALLOT_PRIVACY}",
1189 if replaced { "replaced" } else { "cast" },
1190 closes_at.format("%Y-%m-%d %H:%M"),
1191 );
1192 Self {
1193 ack: WriteAck {
1194 id,
1195 status: if replaced { "replaced" } else { "cast" }.into(),
1196 verified,
1197 },
1198 poll_id,
1199 post_id,
1200 mode,
1201 ballot,
1202 closes_at,
1203 note,
1204 }
1205 }
1206}
1207
1208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1210#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1211pub struct OpenPollPointer {
1212 pub poll_id: PollId,
1213 pub post_id: PostId,
1215 pub title: String,
1216 pub community: crate::names::CommunityName,
1217 pub mode: PollMode,
1218 pub option_count: u32,
1220 pub closes_at: DateTime<Utc>,
1221 #[serde(default)]
1223 pub advisory: bool,
1224}
1225
1226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1228#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1229pub struct ExportedBallot {
1230 pub id: BallotId,
1231 pub poll_id: PollId,
1232 pub post_id: PostId,
1233 pub choices: Vec<String>,
1235 pub cast_at: DateTime<Utc>,
1236}
1237
1238#[cfg(test)]
1239mod tests;