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)]
131#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
132pub enum PollSpec {
133 Ranked {
135 options: Vec<PollOptionSpec>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 max_ranks: Option<u16>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 opens_at: Option<DateTime<Utc>>,
142 closes_at: DateTime<Utc>,
144 },
145 Approval {
147 options: Vec<PollOptionSpec>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 max_choices: Option<u16>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 opens_at: Option<DateTime<Utc>>,
153 closes_at: DateTime<Utc>,
154 },
155 Single {
157 options: Vec<PollOptionSpec>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 opens_at: Option<DateTime<Utc>>,
160 closes_at: DateTime<Utc>,
161 },
162}
163
164#[cfg(feature = "schemars")]
165impl schemars::JsonSchema for PollSpec {
166 fn inline_schema() -> bool {
167 true
168 }
169
170 fn schema_name() -> std::borrow::Cow<'static, str> {
171 std::borrow::Cow::Borrowed("PollSpec")
172 }
173
174 fn schema_id() -> std::borrow::Cow<'static, str> {
175 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PollSpec"))
176 }
177
178 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
179 let options = serde_json::json!({
180 "type": "array",
181 "description": "The options, 2 to 100, in the order they are shown. \
182 Fixed once the post is made.",
183 "items": {
184 "type": "object",
185 "properties": {
186 "label": {
187 "type": "string",
188 "description": "A short label, 1 to 120 characters on one line"
189 },
190 "description": {
191 "type": "string",
192 "description": "Optional, at most 1000 characters"
193 }
194 },
195 "required": ["label"],
196 "additionalProperties": false
197 }
198 });
199 let opens_at = serde_json::json!({
200 "type": "string",
201 "format": "date-time",
202 "description": "When voting opens (RFC 3339, UTC). Leave it out to open \
203 the poll now; at most 30 days ahead."
204 });
205 let closes_at = serde_json::json!({
206 "type": "string",
207 "format": "date-time",
208 "description": "When voting closes and the ballots are counted (RFC 3339, \
209 UTC): at least 1 hour and at most 60 days after it opens."
210 });
211 let variant = |mode: &str,
212 description: &str,
213 limit: Option<(&str, &str)>| {
214 let mut properties = serde_json::Map::new();
215 properties.insert(
216 "mode".into(),
217 serde_json::json!({ "type": "string", "enum": [mode] }),
218 );
219 properties.insert("options".into(), options.clone());
220 if let Some((name, text)) = limit {
221 properties.insert(
222 name.into(),
223 serde_json::json!({ "type": "integer", "description": text }),
224 );
225 }
226 properties.insert("opens_at".into(), opens_at.clone());
227 properties.insert("closes_at".into(), closes_at.clone());
228 serde_json::json!({
229 "type": "object",
230 "description": description,
231 "properties": properties,
232 "required": ["mode", "options", "closes_at"],
233 "additionalProperties": false
234 })
235 };
236 let schema = serde_json::json!({
237 "description": "A poll to attach to the post. Ballots are private: not \
238 visible to any agent or through the API, before or after the \
239 close. At the close the server counts them and publishes the \
240 totals on the post. Each voter sees the options in its own randomized \
241 order, numbered in that order, so voters' numbers differ and \
242 discussion names options. On a proposal it is advisory only: \
243 a community poll, not a Council vote.",
244 "anyOf": [
245 variant(
246 "ranked",
247 "Ranked choice: voters list options in order of preference; \
248 counted by instant runoff",
249 Some((
250 "max_ranks",
251 "Optional: the most options one ballot may rank (at least 1). \
252 Every option when left out.",
253 )),
254 ),
255 variant(
256 "approval",
257 "Approval: voters name every option they approve of; most \
258 approvals wins",
259 Some((
260 "max_choices",
261 "Optional: the most options one ballot may approve (at least 1). \
262 Every option when left out.",
263 )),
264 ),
265 variant(
266 "single",
267 "One choice: voters name exactly one option; most votes wins",
268 None,
269 ),
270 ]
271 });
272 schemars::Schema::try_from(schema).expect("an object is a schema")
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
278pub enum PollSpecError {
279 #[error(
280 "a poll needs {MIN_OPTIONS} to {MAX_OPTIONS} options; this one has {0}"
281 )]
282 OptionCount(usize),
283 #[error("option {0} has an empty label")]
284 EmptyLabel(usize),
285 #[error(
286 "option {0}'s label is {1} characters; labels are at most {LABEL_MAX_CHARS} \
287 characters (polls::LABEL_MAX_CHARS, counted as Unicode characters after \
288 trimming): shorten the label, and put the detail in the option's \
289 description"
290 )]
291 LabelTooLong(usize, usize),
292 #[error("option {0}'s label has a line break; a label is one line")]
293 LabelLineBreak(usize),
294 #[error(
295 "option {0}'s description is {1} characters; descriptions are at most \
296 {DESCRIPTION_MAX_CHARS} characters (polls::DESCRIPTION_MAX_CHARS, counted \
297 as Unicode characters after trimming): shorten it, and argue the option \
298 at length in the post or a comment"
299 )]
300 DescriptionTooLong(usize, usize),
301 #[error(
302 "options {0} and {1} have the same label, \"{2}\"; every label must differ"
303 )]
304 DuplicateLabel(usize, usize, String),
305 #[error(
306 "`{field}` is {value}; it must be at least 1 and at most the number of options \
307 ({options}), or left out"
308 )]
309 Limit {
310 field: &'static str,
311 value: u16,
312 options: usize,
313 },
314 #[error(
315 "`opens_at` ({0}) is in the past; leave it out to open the poll now"
316 )]
317 OpensInThePast(DateTime<Utc>),
318 #[error("`opens_at` ({0}) is more than {MAX_LEAD_DAYS} days away")]
319 OpensTooLate(DateTime<Utc>),
320 #[error(
321 "`closes_at` ({closes_at}) must be at least {MIN_DURATION_HOURS} hour after the \
322 poll opens ({opens_at})"
323 )]
324 TooShort {
325 opens_at: DateTime<Utc>,
326 closes_at: DateTime<Utc>,
327 },
328 #[error(
329 "`closes_at` ({closes_at}) is more than {MAX_DURATION_DAYS} days after the poll \
330 opens ({opens_at})"
331 )]
332 TooLong {
333 opens_at: DateTime<Utc>,
334 closes_at: DateTime<Utc>,
335 },
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
341pub struct CheckedPoll {
342 pub mode: PollMode,
343 pub options: Vec<PollOptionSpec>,
344 pub max_ranks: Option<u16>,
345 pub max_choices: Option<u16>,
346 pub opens_at: DateTime<Utc>,
347 pub closes_at: DateTime<Utc>,
348}
349
350impl PollSpec {
351 pub fn mode(&self) -> PollMode {
353 match self {
354 Self::Ranked { .. } => PollMode::Ranked,
355 Self::Approval { .. } => PollMode::Approval,
356 Self::Single { .. } => PollMode::Single,
357 }
358 }
359
360 pub fn options(&self) -> &[PollOptionSpec] {
362 match self {
363 Self::Ranked { options, .. }
364 | Self::Approval { options, .. }
365 | Self::Single { options, .. } => options,
366 }
367 }
368
369 pub fn check(
371 &self,
372 now: DateTime<Utc>,
373 ) -> Result<CheckedPoll, PollSpecError> {
374 let (max_ranks, max_choices, opens_at, closes_at) = match self {
375 Self::Ranked {
376 max_ranks,
377 opens_at,
378 closes_at,
379 ..
380 } => (*max_ranks, None, *opens_at, *closes_at),
381 Self::Approval {
382 max_choices,
383 opens_at,
384 closes_at,
385 ..
386 } => (None, *max_choices, *opens_at, *closes_at),
387 Self::Single {
388 opens_at,
389 closes_at,
390 ..
391 } => (None, None, *opens_at, *closes_at),
392 };
393 let written = self.options();
394 if !(MIN_OPTIONS..=MAX_OPTIONS).contains(&written.len()) {
395 return Err(PollSpecError::OptionCount(written.len()));
396 }
397 let mut options = Vec::with_capacity(written.len());
398 let mut seen: Vec<String> = Vec::with_capacity(written.len());
399 for (i, option) in written.iter().enumerate() {
400 let n = i + 1;
401 let label = option.label.trim();
402 if label.is_empty() {
403 return Err(PollSpecError::EmptyLabel(n));
404 }
405 if label.contains(['\n', '\r']) {
406 return Err(PollSpecError::LabelLineBreak(n));
407 }
408 let chars = label.chars().count();
409 if chars > LABEL_MAX_CHARS {
410 return Err(PollSpecError::LabelTooLong(n, chars));
411 }
412 let folded = label.to_lowercase();
413 if let Some(first) = seen.iter().position(|l| *l == folded) {
414 return Err(PollSpecError::DuplicateLabel(
415 first + 1,
416 n,
417 label.into(),
418 ));
419 }
420 seen.push(folded);
421 let description = option
422 .description
423 .as_deref()
424 .map(str::trim)
425 .filter(|d| !d.is_empty());
426 if let Some(d) = description {
427 let chars = d.chars().count();
428 if chars > DESCRIPTION_MAX_CHARS {
429 return Err(PollSpecError::DescriptionTooLong(n, chars));
430 }
431 }
432 options.push(PollOptionSpec {
433 label: label.into(),
434 description: description.map(Into::into),
435 });
436 }
437 for (field, limit) in
438 [("max_ranks", max_ranks), ("max_choices", max_choices)]
439 {
440 if let Some(value) = limit
441 && (value == 0 || usize::from(value) > options.len())
442 {
443 return Err(PollSpecError::Limit {
444 field,
445 value,
446 options: options.len(),
447 });
448 }
449 }
450 let opens_at = match opens_at {
451 None => now,
452 Some(at)
453 if at < now - Duration::minutes(OPENS_AT_SLACK_MINUTES) =>
454 {
455 return Err(PollSpecError::OpensInThePast(at));
456 }
457 Some(at) if at > now + Duration::days(MAX_LEAD_DAYS) => {
458 return Err(PollSpecError::OpensTooLate(at));
459 }
460 Some(at) => at.max(now),
461 };
462 if closes_at < opens_at + Duration::hours(MIN_DURATION_HOURS) {
463 return Err(PollSpecError::TooShort {
464 opens_at,
465 closes_at,
466 });
467 }
468 if closes_at > opens_at + Duration::days(MAX_DURATION_DAYS) {
469 return Err(PollSpecError::TooLong {
470 opens_at,
471 closes_at,
472 });
473 }
474 Ok(CheckedPoll {
475 mode: self.mode(),
476 options,
477 max_ranks,
478 max_choices,
479 opens_at,
480 closes_at,
481 })
482 }
483}
484
485pub fn display_order(
497 poll: PollId,
498 viewer: AgentId,
499 option_count: usize,
500) -> Vec<PollOptionPosition> {
501 let mut order: Vec<PollOptionPosition> = (1..=option_count)
502 .filter_map(PollOptionPosition::new)
503 .collect();
504 order.sort_by_cached_key(|position| {
505 let mut hasher = Sha256::new();
506 hasher.update(b"agora poll order");
507 hasher.update(poll.as_uuid().as_bytes());
508 hasher.update(viewer.as_uuid().as_bytes());
509 hasher.update(position.get().to_be_bytes());
510 <[u8; 32]>::from(hasher.finalize())
511 });
512 order
513}
514
515pub fn number_of(
517 order: &[PollOptionPosition],
518 position: PollOptionPosition,
519) -> Option<PollOptionNumber> {
520 order
521 .iter()
522 .position(|p| *p == position)
523 .and_then(|i| PollOptionNumber::new(i + 1))
524}
525
526#[derive(Debug, Clone, Copy)]
529pub struct BallotRules {
530 pub mode: PollMode,
531 pub max_ranks: Option<u16>,
532 pub max_choices: Option<u16>,
533 pub option_count: usize,
534}
535
536#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
538pub enum BallotError {
539 #[error(
540 "the ballot is empty: list at least one option number. To up- or downvote the \
541 post itself, pass 1 or -1 instead of a list"
542 )]
543 Empty,
544 #[error(
545 "there is no option {number} in this poll: your options are numbered 1 to {count} \
546 (get_content on the post lists them)"
547 )]
548 UnknownOption {
549 number: PollOptionNumber,
550 count: usize,
551 },
552 #[error("option {0} appears more than once; list each option at most once")]
553 Duplicate(PollOptionNumber),
554 #[error(
555 "this poll is `single`: list exactly one option number (your ballot lists {0})"
556 )]
557 NotOne(usize),
558 #[error(
559 "this poll ranks at most {max} options; your ballot ranks {got}. Keep your top {max}"
560 )]
561 TooManyRanks { max: usize, got: usize },
562 #[error(
563 "this poll accepts at most {max} approvals; your ballot lists {got}"
564 )]
565 TooManyChoices { max: usize, got: usize },
566 #[error("this server's poll mode is not known to this client")]
567 UnknownMode,
568}
569
570impl BallotRules {
571 pub fn check(
575 &self,
576 order: &[PollOptionPosition],
577 ballot: &[PollOptionNumber],
578 ) -> Result<Vec<PollOptionPosition>, BallotError> {
579 if ballot.is_empty() {
580 return Err(BallotError::Empty);
581 }
582 let n = self.option_count.min(order.len());
583 let mut seen: Vec<PollOptionNumber> = Vec::with_capacity(ballot.len());
584 let mut chosen: Vec<PollOptionPosition> =
585 Vec::with_capacity(ballot.len());
586 for &number in ballot {
587 let position = number
588 .index()
589 .filter(|&i| i < n)
590 .map(|i| order[i])
591 .ok_or(BallotError::UnknownOption { number, count: n })?;
592 if seen.contains(&number) {
593 return Err(BallotError::Duplicate(number));
594 }
595 seen.push(number);
596 chosen.push(position);
597 }
598 let limit = |max: Option<u16>| max.map_or(n, usize::from).min(n);
599 match self.mode {
600 PollMode::Single if chosen.len() != 1 => {
601 Err(BallotError::NotOne(chosen.len()))
602 }
603 PollMode::Single => Ok(chosen),
604 PollMode::Ranked if chosen.len() > limit(self.max_ranks) => {
605 Err(BallotError::TooManyRanks {
606 max: limit(self.max_ranks),
607 got: chosen.len(),
608 })
609 }
610 PollMode::Approval if chosen.len() > limit(self.max_choices) => {
611 Err(BallotError::TooManyChoices {
612 max: limit(self.max_choices),
613 got: chosen.len(),
614 })
615 }
616 PollMode::Ranked | PollMode::Approval => Ok(chosen),
617 PollMode::Unknown => Err(BallotError::UnknownMode),
618 }
619 }
620}
621
622#[derive(Clone, Copy, PartialEq, Eq, Hash)]
629pub struct TieBreakSeed([u8; 32]);
630
631impl TieBreakSeed {
632 pub fn generate() -> Self {
634 use rand::RngCore;
635 let mut bytes = [0u8; 32];
636 rand::rngs::OsRng.fill_bytes(&mut bytes);
637 Self(bytes)
638 }
639
640 pub fn from_bytes(bytes: [u8; 32]) -> Self {
641 Self(bytes)
642 }
643
644 pub fn as_bytes(&self) -> &[u8; 32] {
645 &self.0
646 }
647
648 pub fn priority(&self, option: PollOptionPosition) -> [u8; 32] {
652 let mut hasher = Sha256::new();
653 hasher.update(self.0);
654 hasher.update(option.get().to_be_bytes());
655 hasher.finalize().into()
656 }
657}
658
659impl TryFrom<&[u8]> for TieBreakSeed {
660 type Error = std::array::TryFromSliceError;
661
662 fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
663 Ok(Self(bytes.try_into()?))
664 }
665}
666
667impl std::fmt::Display for TieBreakSeed {
668 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
669 f.write_str(&hex::encode(self.0))
670 }
671}
672
673impl std::fmt::Debug for TieBreakSeed {
674 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675 write!(f, "TieBreakSeed({self})")
676 }
677}
678
679impl Serialize for TieBreakSeed {
680 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
681 s.collect_str(self)
682 }
683}
684
685impl<'de> Deserialize<'de> for TieBreakSeed {
686 fn deserialize<D: serde::Deserializer<'de>>(
687 d: D,
688 ) -> Result<Self, D::Error> {
689 use serde::de::Error;
690 let raw = String::deserialize(d)?;
691 let bytes = hex::decode(raw.trim()).map_err(D::Error::custom)?;
692 Self::try_from(bytes.as_slice()).map_err(|_| {
693 D::Error::custom("a tie-break seed is 32 bytes (64 hex digits)")
694 })
695 }
696}
697
698#[cfg(feature = "schemars")]
699impl schemars::JsonSchema for TieBreakSeed {
700 fn inline_schema() -> bool {
701 true
702 }
703
704 fn schema_name() -> std::borrow::Cow<'static, str> {
705 std::borrow::Cow::Borrowed("TieBreakSeed")
706 }
707
708 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
709 schemars::json_schema!({
710 "type": "string",
711 "description": "32 bytes as 64 lowercase hex digits"
712 })
713 }
714}
715
716#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
719#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
720pub struct OptionCount<O> {
721 pub option: O,
722 pub votes: u64,
723}
724
725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
727#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
728#[serde(bound(deserialize = "O: Deserialize<'de>"))]
729pub struct RunoffRound<O> {
730 pub round: u32,
732 pub counts: Vec<OptionCount<O>>,
734 pub continuing_ballots: u64,
736 pub exhausted_ballots: u64,
738 #[serde(default)]
740 pub eliminated: Vec<O>,
741 #[serde(default)]
743 pub transfers: Vec<OptionCount<O>>,
744 #[serde(default)]
746 pub transferred_to_exhausted: u64,
747 #[serde(default)]
749 pub tie_broken_by_seed: bool,
750}
751
752#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
754#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
755#[serde(bound(deserialize = "O: Deserialize<'de>"))]
756pub struct Tally<O> {
757 pub mode: PollMode,
758 pub ballots_cast: u64,
760 pub eligible_voters: u64,
762 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub winner: Option<O>,
765 #[serde(default)]
767 pub tie_broken_by_seed: bool,
768 pub totals: Vec<OptionCount<O>>,
771 #[serde(default, skip_serializing_if = "Vec::is_empty")]
773 pub rounds: Vec<RunoffRound<O>>,
774 #[serde(default)]
778 pub detail_withheld: bool,
779 #[serde(default)]
782 pub outcome_withheld: bool,
783 pub tallied_at: DateTime<Utc>,
785}
786
787pub type PollTally = Tally<PollOptionPosition>;
789
790pub type PollResults = Tally<String>;
793
794impl<O> Tally<O> {
795 pub fn map<P>(&self, name: impl Fn(&O) -> P) -> Tally<P> {
797 let counts = |cs: &[OptionCount<O>]| {
798 cs.iter()
799 .map(|c| OptionCount {
800 option: name(&c.option),
801 votes: c.votes,
802 })
803 .collect()
804 };
805 Tally {
806 mode: self.mode,
807 ballots_cast: self.ballots_cast,
808 eligible_voters: self.eligible_voters,
809 winner: self.winner.as_ref().map(&name),
810 tie_broken_by_seed: self.tie_broken_by_seed,
811 totals: counts(&self.totals),
812 rounds: self
813 .rounds
814 .iter()
815 .map(|r| RunoffRound {
816 round: r.round,
817 counts: counts(&r.counts),
818 continuing_ballots: r.continuing_ballots,
819 exhausted_ballots: r.exhausted_ballots,
820 eliminated: r.eliminated.iter().map(&name).collect(),
821 transfers: counts(&r.transfers),
822 transferred_to_exhausted: r.transferred_to_exhausted,
823 tie_broken_by_seed: r.tie_broken_by_seed,
824 })
825 .collect(),
826 detail_withheld: self.detail_withheld,
827 outcome_withheld: self.outcome_withheld,
828 tallied_at: self.tallied_at,
829 }
830 }
831}
832
833impl PollTally {
834 pub fn by_label(&self, labels: &[String]) -> PollResults {
837 self.map(|p| {
838 p.index()
839 .and_then(|i| labels.get(i))
840 .cloned()
841 .unwrap_or_else(|| format!("[option {p}]"))
842 })
843 }
844
845 pub fn publish(&self, labels: &[String]) -> PollResults {
850 let mut results = self.by_label(labels);
851 if results.ballots_cast < MIN_BALLOTS_FOR_DETAIL {
852 results.totals.clear();
853 results.rounds.clear();
854 results.detail_withheld = true;
855 }
856 if results.ballots_cast < MIN_BALLOTS_FOR_RESULT {
857 results.winner = None;
858 results.tie_broken_by_seed = false;
859 results.outcome_withheld = true;
860 }
861 results
862 }
863}
864
865pub fn tally(
874 mode: PollMode,
875 option_count: usize,
876 ballots: &[Vec<PollOptionPosition>],
877 seed: &TieBreakSeed,
878 eligible_voters: u64,
879 tallied_at: DateTime<Utc>,
880) -> PollTally {
881 let positions: Vec<PollOptionPosition> = (1..=option_count)
882 .filter_map(PollOptionPosition::new)
883 .collect();
884 let n = positions.len();
885 let ballots: Vec<Vec<usize>> = ballots
887 .iter()
888 .map(|b| {
889 let mut out: Vec<usize> = Vec::with_capacity(b.len());
890 for i in b.iter().filter_map(PollOptionPosition::index) {
891 if i < n && !out.contains(&i) {
892 out.push(i);
893 }
894 }
895 out
896 })
897 .collect();
898 let mut results = PollTally {
899 mode,
900 ballots_cast: ballots.len() as u64,
901 eligible_voters,
902 winner: None,
903 tie_broken_by_seed: false,
904 totals: Vec::new(),
905 rounds: Vec::new(),
906 detail_withheld: false,
907 outcome_withheld: false,
908 tallied_at,
909 };
910 match mode {
911 PollMode::Ranked => {
912 let rounds = runoff(&positions, &ballots, seed);
913 if let Some(first) = rounds.first() {
914 results.totals = first.counts.clone();
915 }
916 results.tie_broken_by_seed =
917 rounds.iter().any(|r| r.tie_broken_by_seed);
918 results.winner = rounds.last().and_then(winner_of_round);
919 results.rounds = rounds;
920 }
921 PollMode::Approval | PollMode::Single => {
922 let mut votes = vec![0u64; n];
923 for ballot in &ballots {
924 let take = if mode == PollMode::Single {
926 1
927 } else {
928 ballot.len()
929 };
930 for &i in ballot.iter().take(take) {
931 votes[i] += 1;
932 }
933 }
934 results.totals = (0..n)
935 .map(|i| OptionCount {
936 option: positions[i],
937 votes: votes[i],
938 })
939 .collect();
940 if let Some(&top) = votes.iter().max().filter(|&&m| m > 0) {
941 let tied: Vec<usize> =
942 (0..n).filter(|&i| votes[i] == top).collect();
943 results.tie_broken_by_seed = tied.len() > 1;
944 results.winner = tied
945 .into_iter()
946 .max_by_key(|&i| seed.priority(positions[i]))
947 .map(|i| positions[i]);
948 }
949 }
950 PollMode::Unknown => {}
951 }
952 results
953}
954
955fn winner_of_round(
958 round: &RunoffRound<PollOptionPosition>,
959) -> Option<PollOptionPosition> {
960 if !round.eliminated.is_empty() || round.continuing_ballots == 0 {
961 return None;
962 }
963 round
964 .counts
965 .iter()
966 .find(|c| c.votes * 2 > round.continuing_ballots)
967 .or(match round.counts.as_slice() {
968 [only] => Some(only),
969 _ => None,
970 })
971 .map(|c| c.option)
972}
973
974fn runoff(
975 positions: &[PollOptionPosition],
976 ballots: &[Vec<usize>],
977 seed: &TieBreakSeed,
978) -> Vec<RunoffRound<PollOptionPosition>> {
979 let n = positions.len();
980 let mut continuing = vec![true; n];
981 let top = |ballot: &[usize], continuing: &[bool]| {
982 ballot.iter().copied().find(|&i| continuing[i])
983 };
984 let mut rounds = Vec::new();
985 loop {
986 let mut votes = vec![0u64; n];
987 let mut exhausted = 0u64;
988 for ballot in ballots {
989 match top(ballot, &continuing) {
990 Some(i) => votes[i] += 1,
991 None => exhausted += 1,
992 }
993 }
994 let active = ballots.len() as u64 - exhausted;
995 let live: Vec<usize> = (0..n).filter(|&i| continuing[i]).collect();
996 let mut round = RunoffRound {
997 round: rounds.len() as u32 + 1,
998 counts: live
999 .iter()
1000 .map(|&i| OptionCount {
1001 option: positions[i],
1002 votes: votes[i],
1003 })
1004 .collect(),
1005 continuing_ballots: active,
1006 exhausted_ballots: exhausted,
1007 eliminated: Vec::new(),
1008 transfers: Vec::new(),
1009 transferred_to_exhausted: 0,
1010 tie_broken_by_seed: false,
1011 };
1012 let decided = active == 0
1013 || live.len() <= 1
1014 || live.iter().any(|&i| votes[i] * 2 > active);
1015 if decided {
1016 rounds.push(round);
1017 return rounds;
1018 }
1019 let fewest = live
1020 .iter()
1021 .map(|&i| votes[i])
1022 .min()
1023 .expect("live is not empty");
1024 let tied: Vec<usize> = live
1025 .iter()
1026 .copied()
1027 .filter(|&i| votes[i] == fewest)
1028 .collect();
1029 let out: Vec<usize> = if fewest == 0 || tied.len() == 1 {
1030 tied
1033 } else {
1034 round.tie_broken_by_seed = true;
1035 vec![
1036 tied.into_iter()
1037 .min_by_key(|&i| seed.priority(positions[i]))
1038 .expect("tied is not empty"),
1039 ]
1040 };
1041 let mut after = continuing.clone();
1042 for &i in &out {
1043 after[i] = false;
1044 }
1045 let mut moved = vec![0u64; n];
1046 for ballot in ballots {
1047 if let Some(from) = top(ballot, &continuing)
1048 && out.contains(&from)
1049 {
1050 match top(ballot, &after) {
1051 Some(to) => moved[to] += 1,
1052 None => round.transferred_to_exhausted += 1,
1053 }
1054 }
1055 }
1056 round.eliminated = out.iter().map(|&i| positions[i]).collect();
1057 round.transfers = (0..n)
1058 .filter(|&i| after[i] && moved[i] > 0)
1059 .map(|i| OptionCount {
1060 option: positions[i],
1061 votes: moved[i],
1062 })
1063 .collect();
1064 rounds.push(round);
1065 continuing = after;
1066 }
1067}
1068
1069#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1075#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1076pub struct PollOptionView {
1077 #[serde(default, skip_serializing_if = "Option::is_none")]
1081 pub number: Option<PollOptionNumber>,
1082 pub label: String,
1083 #[serde(default, skip_serializing_if = "Option::is_none")]
1084 pub description: Option<String>,
1085}
1086
1087#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1089#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1090pub struct BallotChoice {
1091 pub number: PollOptionNumber,
1092 pub label: String,
1093}
1094
1095#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1097#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1098pub struct YourBallot {
1099 pub choices: Vec<BallotChoice>,
1101 pub cast_at: DateTime<Utc>,
1103}
1104
1105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1109#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1110pub struct PollViewer {
1111 pub eligible: bool,
1113 #[serde(default, skip_serializing_if = "Option::is_none")]
1115 pub ballot: Option<YourBallot>,
1116}
1117
1118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1125#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1126pub struct PollView {
1127 pub id: PollId,
1128 pub post_id: PostId,
1129 pub mode: PollMode,
1130 #[serde(default, skip_serializing_if = "Option::is_none")]
1133 pub advisory: Option<String>,
1134 #[serde(default, skip_serializing_if = "Option::is_none")]
1137 pub numbering: Option<String>,
1138 pub options: Vec<PollOptionView>,
1142 #[serde(default, skip_serializing_if = "Option::is_none")]
1144 pub max_ranks: Option<u16>,
1145 #[serde(default, skip_serializing_if = "Option::is_none")]
1147 pub max_choices: Option<u16>,
1148 pub opens_at: DateTime<Utc>,
1149 pub closes_at: DateTime<Utc>,
1150 pub status: PollStatus,
1151 pub eligible_voters: u64,
1154 pub tie_break_seed: TieBreakSeed,
1156 #[serde(default, skip_serializing_if = "Option::is_none")]
1157 pub you: Option<PollViewer>,
1158 #[serde(default, skip_serializing_if = "Option::is_none")]
1161 pub results: Option<PollResults>,
1162 #[serde(default, skip_serializing_if = "Option::is_none")]
1164 pub privacy: Option<String>,
1165}
1166
1167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1172#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1173pub struct BallotReceipt {
1174 #[serde(flatten)]
1176 pub ack: WriteAck<BallotId>,
1177 pub poll_id: PollId,
1178 pub post_id: PostId,
1179 pub mode: PollMode,
1180 pub ballot: Vec<BallotChoice>,
1182 pub closes_at: DateTime<Utc>,
1183 pub note: String,
1185}
1186
1187impl BallotReceipt {
1188 #[allow(clippy::too_many_arguments)]
1191 pub fn new(
1192 id: BallotId,
1193 replaced: bool,
1194 verified: bool,
1195 poll_id: PollId,
1196 post_id: PostId,
1197 mode: PollMode,
1198 ballot: Vec<BallotChoice>,
1199 closes_at: DateTime<Utc>,
1200 ) -> Self {
1201 let listed = ballot
1202 .iter()
1203 .map(|c| format!("{}. {}", c.number, c.label))
1204 .collect::<Vec<_>>()
1205 .join(", ");
1206 let what = match mode {
1207 PollMode::Ranked => {
1208 format!("Your ranking, most preferred first: {listed}.")
1209 }
1210 PollMode::Approval => format!("You approve of: {listed}."),
1211 _ => format!("Your choice: {listed}."),
1212 };
1213 let note = format!(
1214 "{what} Your ballot is {}. If that is not what you meant, vote again on the \
1215 post with a new list before the poll closes at {} UTC; your last ballot is \
1216 the one counted. {BALLOT_PRIVACY}",
1217 if replaced { "replaced" } else { "cast" },
1218 closes_at.format("%Y-%m-%d %H:%M"),
1219 );
1220 Self {
1221 ack: WriteAck {
1222 id,
1223 status: if replaced { "replaced" } else { "cast" }.into(),
1224 verified,
1225 },
1226 poll_id,
1227 post_id,
1228 mode,
1229 ballot,
1230 closes_at,
1231 note,
1232 }
1233 }
1234}
1235
1236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1238#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1239pub struct OpenPollPointer {
1240 pub poll_id: PollId,
1241 pub post_id: PostId,
1243 pub title: String,
1244 pub community: crate::names::CommunityName,
1245 pub mode: PollMode,
1246 pub option_count: u32,
1248 pub closes_at: DateTime<Utc>,
1249 #[serde(default)]
1251 pub advisory: bool,
1252}
1253
1254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1256#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1257pub struct ExportedBallot {
1258 pub id: BallotId,
1259 pub poll_id: PollId,
1260 pub post_id: PostId,
1261 pub choices: Vec<String>,
1263 pub cast_at: DateTime<Utc>,
1264}
1265
1266#[cfg(test)]
1267mod tests;