circlejerk 0.1.1

A tiny in-memory opinion tracker for measuring group consensus
Documentation
//! Track opinions and measure how strongly a group agrees.
//!
//! [`CircleJerk`] stores submitted opinions and exposes the most common one,
//! the share of submissions supporting it, and whether every submission is
//! identical.
//!
//! # Example
//!
//! ```
//! use circlejerk::CircleJerk;
//!
//! let mut group = CircleJerk::new();
//! group.agree("Rust is great");
//! group.agree("Rust is great");
//! group.agree("Other languages are fine too");
//!
//! assert_eq!(group.participants(), 3);
//! assert_eq!(group.dominant_opinion(), Some("Rust is great"));
//! assert_eq!(group.consensus_ratio(), 2.0 / 3.0);
//! assert!(!group.consensus());
//! ```
#![warn(missing_docs)]

use std::collections::HashMap;

/// An in-memory collection of submitted opinions.
///
/// Each call to [`agree`](Self::agree) counts as one participant. Participant
/// identities are not stored, so the same caller may submit more than once.
#[derive(Debug, Default)]
pub struct CircleJerk {
    opinions: HashMap<String, usize>,
    total: usize,
}

impl CircleJerk {
    /// Creates an empty collection with no participants.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Records one participant's agreement with an opinion.
    ///
    /// Equal strings are grouped as the same opinion.
    pub fn agree(&mut self, opinion: impl Into<String>) {
        *self.opinions.entry(opinion.into()).or_insert(0) += 1;
        self.total += 1;
    }

    /// Returns `true` when every participant submitted the same opinion.
    ///
    /// An empty collection does not have consensus and returns `false`.
    #[must_use]
    pub fn consensus(&self) -> bool {
        if self.total == 0 {
            return false;
        }

        self.opinions
            .values()
            .copied()
            .max()
            .is_some_and(|count| count == self.total)
    }

    /// Returns the fraction of participants supporting the dominant opinion.
    ///
    /// The result is between `0.0` and `1.0`. An empty collection returns
    /// `0.0`.
    #[must_use]
    pub fn consensus_ratio(&self) -> f64 {
        if self.total == 0 {
            return 0.0;
        }

        let max = self.opinions.values().copied().max().unwrap_or(0);
        max as f64 / self.total as f64
    }

    /// Returns an opinion with the most support.
    ///
    /// Returns `None` when the collection is empty. If multiple opinions are
    /// tied for the most support, any one of them may be returned.
    #[must_use]
    pub fn dominant_opinion(&self) -> Option<&str> {
        self.opinions
            .iter()
            .max_by_key(|(_, count)| *count)
            .map(|(opinion, _)| opinion.as_str())
    }

    /// Returns the number of submitted opinions.
    #[must_use]
    pub fn participants(&self) -> usize {
        self.total
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn perfect_circlejerk() {
        let mut cj = CircleJerk::new();

        cj.agree("Rust is the best language");
        cj.agree("Rust is the best language");
        cj.agree("Rust is the best language");

        assert!(cj.consensus());
        assert_eq!(cj.consensus_ratio(), 1.0);
        assert_eq!(cj.dominant_opinion(), Some("Rust is the best language"));
    }

    #[test]
    fn dissent_detected() {
        let mut cj = CircleJerk::new();

        cj.agree("Rust is the best language");
        cj.agree("Rust is the best language");
        cj.agree("C++ is fine actually");

        assert!(!cj.consensus());
        assert_eq!(cj.consensus_ratio(), 2.0 / 3.0);
    }
}