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