Skip to main content

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}