circlejerk/lib.rs
1//! Track opinions and measure how strongly a group agrees.
2//!
3//! [`CircleJerk`] stores submitted opinions and exposes the most common one,
4//! the share of submissions supporting it, and whether every submission is
5//! identical.
6//!
7//! # Example
8//!
9//! ```
10//! use circlejerk::CircleJerk;
11//!
12//! let mut group = CircleJerk::new();
13//! group.agree("Rust is great");
14//! group.agree("Rust is great");
15//! group.agree("Other languages are fine too");
16//!
17//! assert_eq!(group.participants(), 3);
18//! assert_eq!(group.dominant_opinion(), Some("Rust is great"));
19//! assert_eq!(group.consensus_ratio(), 2.0 / 3.0);
20//! assert!(!group.consensus());
21//! ```
22#![warn(missing_docs)]
23
24use std::collections::HashMap;
25
26/// An in-memory collection of submitted opinions.
27///
28/// Each call to [`agree`](Self::agree) counts as one participant. Participant
29/// identities are not stored, so the same caller may submit more than once.
30#[derive(Debug, Default)]
31pub struct CircleJerk {
32 opinions: HashMap<String, usize>,
33 total: usize,
34}
35
36impl CircleJerk {
37 /// Creates an empty collection with no participants.
38 #[must_use]
39 pub fn new() -> Self {
40 Self::default()
41 }
42
43 /// Records one participant's agreement with an opinion.
44 ///
45 /// Equal strings are grouped as the same opinion.
46 pub fn agree(&mut self, opinion: impl Into<String>) {
47 *self.opinions.entry(opinion.into()).or_insert(0) += 1;
48 self.total += 1;
49 }
50
51 /// Returns `true` when every participant submitted the same opinion.
52 ///
53 /// An empty collection does not have consensus and returns `false`.
54 #[must_use]
55 pub fn consensus(&self) -> bool {
56 if self.total == 0 {
57 return false;
58 }
59
60 self.opinions
61 .values()
62 .copied()
63 .max()
64 .is_some_and(|count| count == self.total)
65 }
66
67 /// Returns the fraction of participants supporting the dominant opinion.
68 ///
69 /// The result is between `0.0` and `1.0`. An empty collection returns
70 /// `0.0`.
71 #[must_use]
72 pub fn consensus_ratio(&self) -> f64 {
73 if self.total == 0 {
74 return 0.0;
75 }
76
77 let max = self.opinions.values().copied().max().unwrap_or(0);
78 max as f64 / self.total as f64
79 }
80
81 /// Returns an opinion with the most support.
82 ///
83 /// Returns `None` when the collection is empty. If multiple opinions are
84 /// tied for the most support, any one of them may be returned.
85 #[must_use]
86 pub fn dominant_opinion(&self) -> Option<&str> {
87 self.opinions
88 .iter()
89 .max_by_key(|(_, count)| *count)
90 .map(|(opinion, _)| opinion.as_str())
91 }
92
93 /// Returns the number of submitted opinions.
94 #[must_use]
95 pub fn participants(&self) -> usize {
96 self.total
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn perfect_circlejerk() {
106 let mut cj = CircleJerk::new();
107
108 cj.agree("Rust is the best language");
109 cj.agree("Rust is the best language");
110 cj.agree("Rust is the best language");
111
112 assert!(cj.consensus());
113 assert_eq!(cj.consensus_ratio(), 1.0);
114 assert_eq!(cj.dominant_opinion(), Some("Rust is the best language"));
115 }
116
117 #[test]
118 fn dissent_detected() {
119 let mut cj = CircleJerk::new();
120
121 cj.agree("Rust is the best language");
122 cj.agree("Rust is the best language");
123 cj.agree("C++ is fine actually");
124
125 assert!(!cj.consensus());
126 assert_eq!(cj.consensus_ratio(), 2.0 / 3.0);
127 }
128}