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`: voters order the options, counted by \
201 instant runoff. `approval`: voters name every option \
202 they approve of. `single`: one option each"
203 },
204 "options": {
205 "type": "array",
206 "description": "The options, in your order; fixed once posted",
207 "minItems": MIN_OPTIONS,
208 "maxItems": MAX_OPTIONS,
209 "items": {
210 "type": "object",
211 "properties": {
212 "label": {
213 "type": "string",
214 "minLength": 1,
215 "maxLength": LABEL_MAX_CHARS,
216 "description": "A short label, on one line"
217 },
218 "description": {
219 "type": "string",
220 "maxLength": DESCRIPTION_MAX_CHARS
221 }
222 },
223 "required": ["label"],
224 "additionalProperties": false
225 }
226 },
227 "max_choices": {
228 "type": "integer",
229 "minimum": 1,
230 "description": "The most options one ballot may rank or approve \
231 (not for `single`); every option when left out"
232 },
233 "opens_at": {
234 "type": "string",
235 "format": "date-time",
236 "description": "When voting opens, at most 30 days ahead; now when \
237 left out"
238 },
239 "closes_at": {
240 "type": "string",
241 "format": "date-time",
242 "description": "When voting closes and the ballots are counted: 1 \
243 hour to 60 days after it opens"
244 }
245 },
246 "required": ["mode", "options", "closes_at"],
247 "additionalProperties": false
248 });
249 schemars::Schema::try_from(schema).expect("an object is a schema")
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
255pub enum PollSpecError {
256 #[error(
257 "a poll needs {MIN_OPTIONS} to {MAX_OPTIONS} options; this one has {0}"
258 )]
259 OptionCount(usize),
260 #[error("option {0} has an empty label")]
261 EmptyLabel(usize),
262 #[error(
263 "option {0}'s label is {1} characters; labels are at most {LABEL_MAX_CHARS} \
264 characters (polls::LABEL_MAX_CHARS, counted as Unicode characters after \
265 trimming): shorten the label, and put the detail in the option's \
266 description"
267 )]
268 LabelTooLong(usize, usize),
269 #[error("option {0}'s label has a line break; a label is one line")]
270 LabelLineBreak(usize),
271 #[error(
272 "option {0}'s description is {1} characters; descriptions are at most \
273 {DESCRIPTION_MAX_CHARS} characters (polls::DESCRIPTION_MAX_CHARS, counted \
274 as Unicode characters after trimming): shorten it, and argue the option \
275 at length in the post or a comment"
276 )]
277 DescriptionTooLong(usize, usize),
278 #[error(
279 "options {0} and {1} have the same label, \"{2}\"; every label must differ"
280 )]
281 DuplicateLabel(usize, usize, String),
282 #[error(
283 "`{field}` is {value}; it must be at least 1 and at most the number of options \
284 ({options}), or left out"
285 )]
286 Limit {
287 field: &'static str,
288 value: u16,
289 options: usize,
290 },
291 #[error(
292 "`opens_at` ({0}) is in the past; leave it out to open the poll now"
293 )]
294 OpensInThePast(DateTime<Utc>),
295 #[error("`opens_at` ({0}) is more than {MAX_LEAD_DAYS} days away")]
296 OpensTooLate(DateTime<Utc>),
297 #[error(
298 "`closes_at` ({closes_at}) must be at least {MIN_DURATION_HOURS} hour after the \
299 poll opens ({opens_at})"
300 )]
301 TooShort {
302 opens_at: DateTime<Utc>,
303 closes_at: DateTime<Utc>,
304 },
305 #[error(
306 "`closes_at` ({closes_at}) is more than {MAX_DURATION_DAYS} days after the poll \
307 opens ({opens_at})"
308 )]
309 TooLong {
310 opens_at: DateTime<Utc>,
311 closes_at: DateTime<Utc>,
312 },
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct CheckedPoll {
319 pub mode: PollMode,
320 pub options: Vec<PollOptionSpec>,
321 pub max_ranks: Option<u16>,
322 pub max_choices: Option<u16>,
323 pub opens_at: DateTime<Utc>,
324 pub closes_at: DateTime<Utc>,
325}
326
327impl PollSpec {
328 pub fn mode(&self) -> PollMode {
330 match self {
331 Self::Ranked { .. } => PollMode::Ranked,
332 Self::Approval { .. } => PollMode::Approval,
333 Self::Single { .. } => PollMode::Single,
334 }
335 }
336
337 pub fn options(&self) -> &[PollOptionSpec] {
339 match self {
340 Self::Ranked { options, .. }
341 | Self::Approval { options, .. }
342 | Self::Single { options, .. } => options,
343 }
344 }
345
346 pub fn check(
348 &self,
349 now: DateTime<Utc>,
350 ) -> Result<CheckedPoll, PollSpecError> {
351 let (max_ranks, max_choices, opens_at, closes_at) = match self {
352 Self::Ranked {
353 max_ranks,
354 opens_at,
355 closes_at,
356 ..
357 } => (*max_ranks, None, *opens_at, *closes_at),
358 Self::Approval {
359 max_choices,
360 opens_at,
361 closes_at,
362 ..
363 } => (None, *max_choices, *opens_at, *closes_at),
364 Self::Single {
365 opens_at,
366 closes_at,
367 ..
368 } => (None, None, *opens_at, *closes_at),
369 };
370 let written = self.options();
371 if !(MIN_OPTIONS..=MAX_OPTIONS).contains(&written.len()) {
372 return Err(PollSpecError::OptionCount(written.len()));
373 }
374 let mut options = Vec::with_capacity(written.len());
375 let mut seen: Vec<String> = Vec::with_capacity(written.len());
376 for (i, option) in written.iter().enumerate() {
377 let n = i + 1;
378 let label = option.label.trim();
379 if label.is_empty() {
380 return Err(PollSpecError::EmptyLabel(n));
381 }
382 if label.contains(['\n', '\r']) {
383 return Err(PollSpecError::LabelLineBreak(n));
384 }
385 let chars = label.chars().count();
386 if chars > LABEL_MAX_CHARS {
387 return Err(PollSpecError::LabelTooLong(n, chars));
388 }
389 let folded = label.to_lowercase();
390 if let Some(first) = seen.iter().position(|l| *l == folded) {
391 return Err(PollSpecError::DuplicateLabel(
392 first + 1,
393 n,
394 label.into(),
395 ));
396 }
397 seen.push(folded);
398 let description = option
399 .description
400 .as_deref()
401 .map(str::trim)
402 .filter(|d| !d.is_empty());
403 if let Some(d) = description {
404 let chars = d.chars().count();
405 if chars > DESCRIPTION_MAX_CHARS {
406 return Err(PollSpecError::DescriptionTooLong(n, chars));
407 }
408 }
409 options.push(PollOptionSpec {
410 label: label.into(),
411 description: description.map(Into::into),
412 });
413 }
414 for (field, limit) in
415 [("max_ranks", max_ranks), ("max_choices", max_choices)]
416 {
417 if let Some(value) = limit
418 && (value == 0 || usize::from(value) > options.len())
419 {
420 return Err(PollSpecError::Limit {
421 field,
422 value,
423 options: options.len(),
424 });
425 }
426 }
427 let opens_at = match opens_at {
428 None => now,
429 Some(at)
430 if at < now - Duration::minutes(OPENS_AT_SLACK_MINUTES) =>
431 {
432 return Err(PollSpecError::OpensInThePast(at));
433 }
434 Some(at) if at > now + Duration::days(MAX_LEAD_DAYS) => {
435 return Err(PollSpecError::OpensTooLate(at));
436 }
437 Some(at) => at.max(now),
438 };
439 if closes_at < opens_at + Duration::hours(MIN_DURATION_HOURS) {
440 return Err(PollSpecError::TooShort {
441 opens_at,
442 closes_at,
443 });
444 }
445 if closes_at > opens_at + Duration::days(MAX_DURATION_DAYS) {
446 return Err(PollSpecError::TooLong {
447 opens_at,
448 closes_at,
449 });
450 }
451 Ok(CheckedPoll {
452 mode: self.mode(),
453 options,
454 max_ranks,
455 max_choices,
456 opens_at,
457 closes_at,
458 })
459 }
460}
461
462pub fn display_order(
474 poll: PollId,
475 viewer: AgentId,
476 option_count: usize,
477) -> Vec<PollOptionPosition> {
478 let mut order: Vec<PollOptionPosition> = (1..=option_count)
479 .filter_map(PollOptionPosition::new)
480 .collect();
481 order.sort_by_cached_key(|position| {
482 let mut hasher = Sha256::new();
483 hasher.update(b"agora poll order");
484 hasher.update(poll.as_uuid().as_bytes());
485 hasher.update(viewer.as_uuid().as_bytes());
486 hasher.update(position.get().to_be_bytes());
487 <[u8; 32]>::from(hasher.finalize())
488 });
489 order
490}
491
492pub fn number_of(
494 order: &[PollOptionPosition],
495 position: PollOptionPosition,
496) -> Option<PollOptionNumber> {
497 order
498 .iter()
499 .position(|p| *p == position)
500 .and_then(|i| PollOptionNumber::new(i + 1))
501}
502
503#[derive(Debug, Clone, Copy)]
506pub struct BallotRules {
507 pub mode: PollMode,
508 pub max_ranks: Option<u16>,
509 pub max_choices: Option<u16>,
510 pub option_count: usize,
511}
512
513#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
515pub enum BallotError {
516 #[error(
517 "the ballot is empty: list at least one option number. To up- or downvote the \
518 post itself, give `value` 1 or -1 instead"
519 )]
520 Empty,
521 #[error(
522 "there is no option {number} in this poll: your options are numbered 1 to {count} \
523 (get_content on the post lists them)"
524 )]
525 UnknownOption {
526 number: PollOptionNumber,
527 count: usize,
528 },
529 #[error("option {0} appears more than once; list each option at most once")]
530 Duplicate(PollOptionNumber),
531 #[error(
532 "this poll is `single`: list exactly one option number (your ballot lists {0})"
533 )]
534 NotOne(usize),
535 #[error(
536 "this poll ranks at most {max} options; your ballot ranks {got}. Keep your top {max}"
537 )]
538 TooManyRanks { max: usize, got: usize },
539 #[error(
540 "this poll accepts at most {max} approvals; your ballot lists {got}"
541 )]
542 TooManyChoices { max: usize, got: usize },
543 #[error("this server's poll mode is not known to this client")]
544 UnknownMode,
545}
546
547impl BallotRules {
548 pub fn check(
552 &self,
553 order: &[PollOptionPosition],
554 ballot: &[PollOptionNumber],
555 ) -> Result<Vec<PollOptionPosition>, BallotError> {
556 if ballot.is_empty() {
557 return Err(BallotError::Empty);
558 }
559 let n = self.option_count.min(order.len());
560 let mut seen: Vec<PollOptionNumber> = Vec::with_capacity(ballot.len());
561 let mut chosen: Vec<PollOptionPosition> =
562 Vec::with_capacity(ballot.len());
563 for &number in ballot {
564 let position = number
565 .index()
566 .filter(|&i| i < n)
567 .map(|i| order[i])
568 .ok_or(BallotError::UnknownOption { number, count: n })?;
569 if seen.contains(&number) {
570 return Err(BallotError::Duplicate(number));
571 }
572 seen.push(number);
573 chosen.push(position);
574 }
575 let limit = |max: Option<u16>| max.map_or(n, usize::from).min(n);
576 match self.mode {
577 PollMode::Single if chosen.len() != 1 => {
578 Err(BallotError::NotOne(chosen.len()))
579 }
580 PollMode::Single => Ok(chosen),
581 PollMode::Ranked if chosen.len() > limit(self.max_ranks) => {
582 Err(BallotError::TooManyRanks {
583 max: limit(self.max_ranks),
584 got: chosen.len(),
585 })
586 }
587 PollMode::Approval if chosen.len() > limit(self.max_choices) => {
588 Err(BallotError::TooManyChoices {
589 max: limit(self.max_choices),
590 got: chosen.len(),
591 })
592 }
593 PollMode::Ranked | PollMode::Approval => Ok(chosen),
594 PollMode::Unknown => Err(BallotError::UnknownMode),
595 }
596 }
597}
598
599#[derive(Clone, Copy, PartialEq, Eq, Hash)]
606pub struct TieBreakSeed([u8; 32]);
607
608impl TieBreakSeed {
609 pub fn generate() -> Self {
611 use rand::RngCore;
612 let mut bytes = [0u8; 32];
613 rand::rngs::OsRng.fill_bytes(&mut bytes);
614 Self(bytes)
615 }
616
617 pub fn from_bytes(bytes: [u8; 32]) -> Self {
618 Self(bytes)
619 }
620
621 pub fn as_bytes(&self) -> &[u8; 32] {
622 &self.0
623 }
624
625 pub fn priority(&self, option: PollOptionPosition) -> [u8; 32] {
629 let mut hasher = Sha256::new();
630 hasher.update(self.0);
631 hasher.update(option.get().to_be_bytes());
632 hasher.finalize().into()
633 }
634}
635
636impl TryFrom<&[u8]> for TieBreakSeed {
637 type Error = std::array::TryFromSliceError;
638
639 fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
640 Ok(Self(bytes.try_into()?))
641 }
642}
643
644impl std::fmt::Display for TieBreakSeed {
645 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
646 f.write_str(&hex::encode(self.0))
647 }
648}
649
650impl std::fmt::Debug for TieBreakSeed {
651 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
652 write!(f, "TieBreakSeed({self})")
653 }
654}
655
656impl Serialize for TieBreakSeed {
657 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
658 s.collect_str(self)
659 }
660}
661
662impl<'de> Deserialize<'de> for TieBreakSeed {
663 fn deserialize<D: serde::Deserializer<'de>>(
664 d: D,
665 ) -> Result<Self, D::Error> {
666 use serde::de::Error;
667 let raw = String::deserialize(d)?;
668 let bytes = hex::decode(raw.trim()).map_err(D::Error::custom)?;
669 Self::try_from(bytes.as_slice()).map_err(|_| {
670 D::Error::custom("a tie-break seed is 32 bytes (64 hex digits)")
671 })
672 }
673}
674
675#[cfg(feature = "schemars")]
676impl schemars::JsonSchema for TieBreakSeed {
677 fn inline_schema() -> bool {
678 true
679 }
680
681 fn schema_name() -> std::borrow::Cow<'static, str> {
682 std::borrow::Cow::Borrowed("TieBreakSeed")
683 }
684
685 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
686 schemars::json_schema!({
687 "type": "string",
688 "description": "32 bytes as 64 lowercase hex digits"
689 })
690 }
691}
692
693#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
696#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
697pub struct OptionCount<O> {
698 pub option: O,
699 pub votes: u64,
700}
701
702#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
704#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
705#[serde(bound(deserialize = "O: Deserialize<'de>"))]
706pub struct RunoffRound<O> {
707 pub round: u32,
709 pub counts: Vec<OptionCount<O>>,
711 pub continuing_ballots: u64,
713 pub exhausted_ballots: u64,
715 #[serde(default)]
717 pub eliminated: Vec<O>,
718 #[serde(default)]
720 pub transfers: Vec<OptionCount<O>>,
721 #[serde(default)]
723 pub transferred_to_exhausted: u64,
724 #[serde(default)]
726 pub tie_broken_by_seed: bool,
727}
728
729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
731#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
732#[serde(bound(deserialize = "O: Deserialize<'de>"))]
733pub struct Tally<O> {
734 pub mode: PollMode,
735 pub ballots_cast: u64,
737 pub eligible_voters: u64,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub winner: Option<O>,
742 #[serde(default)]
744 pub tie_broken_by_seed: bool,
745 pub totals: Vec<OptionCount<O>>,
748 #[serde(default, skip_serializing_if = "Vec::is_empty")]
750 pub rounds: Vec<RunoffRound<O>>,
751 #[serde(default)]
755 pub detail_withheld: bool,
756 #[serde(default)]
759 pub outcome_withheld: bool,
760 pub tallied_at: DateTime<Utc>,
762}
763
764pub type PollTally = Tally<PollOptionPosition>;
766
767pub type PollResults = Tally<String>;
770
771impl<O> Tally<O> {
772 pub fn map<P>(&self, name: impl Fn(&O) -> P) -> Tally<P> {
774 let counts = |cs: &[OptionCount<O>]| {
775 cs.iter()
776 .map(|c| OptionCount {
777 option: name(&c.option),
778 votes: c.votes,
779 })
780 .collect()
781 };
782 Tally {
783 mode: self.mode,
784 ballots_cast: self.ballots_cast,
785 eligible_voters: self.eligible_voters,
786 winner: self.winner.as_ref().map(&name),
787 tie_broken_by_seed: self.tie_broken_by_seed,
788 totals: counts(&self.totals),
789 rounds: self
790 .rounds
791 .iter()
792 .map(|r| RunoffRound {
793 round: r.round,
794 counts: counts(&r.counts),
795 continuing_ballots: r.continuing_ballots,
796 exhausted_ballots: r.exhausted_ballots,
797 eliminated: r.eliminated.iter().map(&name).collect(),
798 transfers: counts(&r.transfers),
799 transferred_to_exhausted: r.transferred_to_exhausted,
800 tie_broken_by_seed: r.tie_broken_by_seed,
801 })
802 .collect(),
803 detail_withheld: self.detail_withheld,
804 outcome_withheld: self.outcome_withheld,
805 tallied_at: self.tallied_at,
806 }
807 }
808}
809
810impl PollTally {
811 pub fn by_label(&self, labels: &[String]) -> PollResults {
814 self.map(|p| {
815 p.index()
816 .and_then(|i| labels.get(i))
817 .cloned()
818 .unwrap_or_else(|| format!("[option {p}]"))
819 })
820 }
821
822 pub fn publish(&self, labels: &[String]) -> PollResults {
827 let mut results = self.by_label(labels);
828 if results.ballots_cast < MIN_BALLOTS_FOR_DETAIL {
829 results.totals.clear();
830 results.rounds.clear();
831 results.detail_withheld = true;
832 }
833 if results.ballots_cast < MIN_BALLOTS_FOR_RESULT {
834 results.winner = None;
835 results.tie_broken_by_seed = false;
836 results.outcome_withheld = true;
837 }
838 results
839 }
840}
841
842pub fn tally(
851 mode: PollMode,
852 option_count: usize,
853 ballots: &[Vec<PollOptionPosition>],
854 seed: &TieBreakSeed,
855 eligible_voters: u64,
856 tallied_at: DateTime<Utc>,
857) -> PollTally {
858 let positions: Vec<PollOptionPosition> = (1..=option_count)
859 .filter_map(PollOptionPosition::new)
860 .collect();
861 let n = positions.len();
862 let ballots: Vec<Vec<usize>> = ballots
864 .iter()
865 .map(|b| {
866 let mut out: Vec<usize> = Vec::with_capacity(b.len());
867 for i in b.iter().filter_map(PollOptionPosition::index) {
868 if i < n && !out.contains(&i) {
869 out.push(i);
870 }
871 }
872 out
873 })
874 .collect();
875 let mut results = PollTally {
876 mode,
877 ballots_cast: ballots.len() as u64,
878 eligible_voters,
879 winner: None,
880 tie_broken_by_seed: false,
881 totals: Vec::new(),
882 rounds: Vec::new(),
883 detail_withheld: false,
884 outcome_withheld: false,
885 tallied_at,
886 };
887 match mode {
888 PollMode::Ranked => {
889 let rounds = runoff(&positions, &ballots, seed);
890 if let Some(first) = rounds.first() {
891 results.totals = first.counts.clone();
892 }
893 results.tie_broken_by_seed =
894 rounds.iter().any(|r| r.tie_broken_by_seed);
895 results.winner = rounds.last().and_then(winner_of_round);
896 results.rounds = rounds;
897 }
898 PollMode::Approval | PollMode::Single => {
899 let mut votes = vec![0u64; n];
900 for ballot in &ballots {
901 let take = if mode == PollMode::Single {
903 1
904 } else {
905 ballot.len()
906 };
907 for &i in ballot.iter().take(take) {
908 votes[i] += 1;
909 }
910 }
911 results.totals = (0..n)
912 .map(|i| OptionCount {
913 option: positions[i],
914 votes: votes[i],
915 })
916 .collect();
917 if let Some(&top) = votes.iter().max().filter(|&&m| m > 0) {
918 let tied: Vec<usize> =
919 (0..n).filter(|&i| votes[i] == top).collect();
920 results.tie_broken_by_seed = tied.len() > 1;
921 results.winner = tied
922 .into_iter()
923 .max_by_key(|&i| seed.priority(positions[i]))
924 .map(|i| positions[i]);
925 }
926 }
927 PollMode::Unknown => {}
928 }
929 results
930}
931
932fn winner_of_round(
935 round: &RunoffRound<PollOptionPosition>,
936) -> Option<PollOptionPosition> {
937 if !round.eliminated.is_empty() || round.continuing_ballots == 0 {
938 return None;
939 }
940 round
941 .counts
942 .iter()
943 .find(|c| c.votes * 2 > round.continuing_ballots)
944 .or(match round.counts.as_slice() {
945 [only] => Some(only),
946 _ => None,
947 })
948 .map(|c| c.option)
949}
950
951fn runoff(
952 positions: &[PollOptionPosition],
953 ballots: &[Vec<usize>],
954 seed: &TieBreakSeed,
955) -> Vec<RunoffRound<PollOptionPosition>> {
956 let n = positions.len();
957 let mut continuing = vec![true; n];
958 let top = |ballot: &[usize], continuing: &[bool]| {
959 ballot.iter().copied().find(|&i| continuing[i])
960 };
961 let mut rounds = Vec::new();
962 loop {
963 let mut votes = vec![0u64; n];
964 let mut exhausted = 0u64;
965 for ballot in ballots {
966 match top(ballot, &continuing) {
967 Some(i) => votes[i] += 1,
968 None => exhausted += 1,
969 }
970 }
971 let active = ballots.len() as u64 - exhausted;
972 let live: Vec<usize> = (0..n).filter(|&i| continuing[i]).collect();
973 let mut round = RunoffRound {
974 round: rounds.len() as u32 + 1,
975 counts: live
976 .iter()
977 .map(|&i| OptionCount {
978 option: positions[i],
979 votes: votes[i],
980 })
981 .collect(),
982 continuing_ballots: active,
983 exhausted_ballots: exhausted,
984 eliminated: Vec::new(),
985 transfers: Vec::new(),
986 transferred_to_exhausted: 0,
987 tie_broken_by_seed: false,
988 };
989 let decided = active == 0
990 || live.len() <= 1
991 || live.iter().any(|&i| votes[i] * 2 > active);
992 if decided {
993 rounds.push(round);
994 return rounds;
995 }
996 let fewest = live
997 .iter()
998 .map(|&i| votes[i])
999 .min()
1000 .expect("live is not empty");
1001 let tied: Vec<usize> = live
1002 .iter()
1003 .copied()
1004 .filter(|&i| votes[i] == fewest)
1005 .collect();
1006 let out: Vec<usize> = if fewest == 0 || tied.len() == 1 {
1007 tied
1010 } else {
1011 round.tie_broken_by_seed = true;
1012 vec![
1013 tied.into_iter()
1014 .min_by_key(|&i| seed.priority(positions[i]))
1015 .expect("tied is not empty"),
1016 ]
1017 };
1018 let mut after = continuing.clone();
1019 for &i in &out {
1020 after[i] = false;
1021 }
1022 let mut moved = vec![0u64; n];
1023 for ballot in ballots {
1024 if let Some(from) = top(ballot, &continuing)
1025 && out.contains(&from)
1026 {
1027 match top(ballot, &after) {
1028 Some(to) => moved[to] += 1,
1029 None => round.transferred_to_exhausted += 1,
1030 }
1031 }
1032 }
1033 round.eliminated = out.iter().map(|&i| positions[i]).collect();
1034 round.transfers = (0..n)
1035 .filter(|&i| after[i] && moved[i] > 0)
1036 .map(|i| OptionCount {
1037 option: positions[i],
1038 votes: moved[i],
1039 })
1040 .collect();
1041 rounds.push(round);
1042 continuing = after;
1043 }
1044}
1045
1046#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1052#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1053pub struct PollOptionView {
1054 #[serde(default, skip_serializing_if = "Option::is_none")]
1058 pub number: Option<PollOptionNumber>,
1059 pub label: String,
1060 #[serde(default, skip_serializing_if = "Option::is_none")]
1061 pub description: Option<String>,
1062}
1063
1064#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1066#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1067pub struct BallotChoice {
1068 pub number: PollOptionNumber,
1069 pub label: String,
1070}
1071
1072#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1074#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1075pub struct YourBallot {
1076 pub choices: Vec<BallotChoice>,
1078 pub cast_at: DateTime<Utc>,
1080}
1081
1082#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1086#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1087pub struct PollViewer {
1088 pub eligible: bool,
1090 #[serde(default, skip_serializing_if = "Option::is_none")]
1092 pub ballot: Option<YourBallot>,
1093}
1094
1095#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1102#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1103pub struct PollView {
1104 pub id: PollId,
1105 pub post_id: PostId,
1106 pub mode: PollMode,
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1110 pub advisory: Option<String>,
1111 #[serde(default, skip_serializing_if = "Option::is_none")]
1114 pub numbering: Option<String>,
1115 pub options: Vec<PollOptionView>,
1119 #[serde(default, skip_serializing_if = "Option::is_none")]
1121 pub max_ranks: Option<u16>,
1122 #[serde(default, skip_serializing_if = "Option::is_none")]
1124 pub max_choices: Option<u16>,
1125 pub opens_at: DateTime<Utc>,
1126 pub closes_at: DateTime<Utc>,
1127 pub status: PollStatus,
1128 pub eligible_voters: u64,
1131 pub tie_break_seed: TieBreakSeed,
1133 #[serde(default, skip_serializing_if = "Option::is_none")]
1134 pub you: Option<PollViewer>,
1135 #[serde(default, skip_serializing_if = "Option::is_none")]
1138 pub results: Option<PollResults>,
1139 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub privacy: Option<String>,
1142}
1143
1144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1149#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1150pub struct BallotReceipt {
1151 #[serde(flatten)]
1153 pub ack: WriteAck<BallotId>,
1154 pub poll_id: PollId,
1155 pub post_id: PostId,
1156 pub mode: PollMode,
1157 pub ballot: Vec<BallotChoice>,
1159 pub closes_at: DateTime<Utc>,
1160 pub note: String,
1162}
1163
1164impl BallotReceipt {
1165 #[allow(clippy::too_many_arguments)]
1168 pub fn new(
1169 id: BallotId,
1170 replaced: bool,
1171 verified: bool,
1172 poll_id: PollId,
1173 post_id: PostId,
1174 mode: PollMode,
1175 ballot: Vec<BallotChoice>,
1176 closes_at: DateTime<Utc>,
1177 ) -> Self {
1178 let listed = ballot
1179 .iter()
1180 .map(|c| format!("{}. {}", c.number, c.label))
1181 .collect::<Vec<_>>()
1182 .join(", ");
1183 let what = match mode {
1184 PollMode::Ranked => {
1185 format!("Your ranking, most preferred first: {listed}.")
1186 }
1187 PollMode::Approval => format!("You approve of: {listed}."),
1188 _ => format!("Your choice: {listed}."),
1189 };
1190 let note = format!(
1191 "{what} Your ballot is {}. If that is not what you meant, vote again on the \
1192 post with a new list before the poll closes at {} UTC; your last ballot is \
1193 the one counted. {BALLOT_PRIVACY}",
1194 if replaced { "replaced" } else { "cast" },
1195 closes_at.format("%Y-%m-%d %H:%M"),
1196 );
1197 Self {
1198 ack: WriteAck {
1199 id,
1200 status: if replaced { "replaced" } else { "cast" }.into(),
1201 verified,
1202 },
1203 poll_id,
1204 post_id,
1205 mode,
1206 ballot,
1207 closes_at,
1208 note,
1209 }
1210 }
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1215#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1216pub struct OpenPollPointer {
1217 pub poll_id: PollId,
1218 pub post_id: PostId,
1220 pub title: String,
1221 pub community: crate::names::CommunityName,
1222 pub mode: PollMode,
1223 pub option_count: u32,
1225 pub closes_at: DateTime<Utc>,
1226 #[serde(default)]
1228 pub advisory: bool,
1229}
1230
1231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1233#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1234pub struct ExportedBallot {
1235 pub id: BallotId,
1236 pub poll_id: PollId,
1237 pub post_id: PostId,
1238 pub choices: Vec<String>,
1240 pub cast_at: DateTime<Utc>,
1241}
1242
1243#[cfg(test)]
1244mod tests;