use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "DeclaredQuorum")]
pub struct Quorum {
need: u32,
lenses: Vec<String>,
}
#[derive(Deserialize)]
struct DeclaredQuorum {
need: u32,
lenses: Vec<String>,
}
impl TryFrom<DeclaredQuorum> for Quorum {
type Error = QuorumError;
fn try_from(d: DeclaredQuorum) -> Result<Self, Self::Error> {
Self::new(d.need, d.lenses)
}
}
impl Quorum {
pub fn new<I, S>(need: u32, lenses: I) -> Result<Self, QuorumError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let lenses: Vec<String> = lenses.into_iter().map(Into::into).collect();
let of = u32::try_from(lenses.len()).unwrap_or(u32::MAX);
if need == 0 {
return Err(QuorumError::NeedsNobody);
}
if need > of {
return Err(QuorumError::Unreachable { need, of });
}
if of > 1 && need * 2 <= of {
return Err(QuorumError::NotAMajority { need, of });
}
let unique: BTreeSet<&String> = lenses.iter().collect();
if unique.len() != lenses.len() {
return Err(QuorumError::RepeatedLens);
}
if lenses.iter().any(|l| l.trim().is_empty()) {
return Err(QuorumError::UnnamedLens);
}
Ok(Self { need, lenses })
}
#[must_use]
pub const fn need(&self) -> u32 {
self.need
}
#[must_use]
pub fn of(&self) -> u32 {
u32::try_from(self.lenses.len()).unwrap_or(u32::MAX)
}
pub fn lenses(&self) -> impl Iterator<Item = &str> {
self.lenses.iter().map(String::as_str)
}
#[must_use]
pub fn tally(&self, verdicts: &[Verdict]) -> Outcome {
let passed = verdicts.iter().filter(|&&v| v == Verdict::Pass).count();
let failed = verdicts.iter().filter(|&&v| v == Verdict::Fail).count();
let tally = Tally {
passed: u32::try_from(passed).unwrap_or(u32::MAX),
failed: u32::try_from(failed).unwrap_or(u32::MAX),
asked: self.of(),
};
if tally.passed >= self.need {
return Outcome::Reached(Verdict::Pass, tally);
}
if tally.failed >= self.need {
return Outcome::Reached(Verdict::Fail, tally);
}
Outcome::NoQuorum(tally)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Verdict {
Pass,
Fail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Tally {
pub passed: u32,
pub failed: u32,
pub asked: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Reached(Verdict, Tally),
NoQuorum(Tally),
}
impl Outcome {
#[must_use]
pub const fn decided(&self) -> Option<Verdict> {
match self {
Self::Reached(v, _) => Some(*v),
Self::NoQuorum(_) => None,
}
}
#[must_use]
pub const fn tally(&self) -> Tally {
match self {
Self::Reached(_, t) | Self::NoQuorum(t) => *t,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum QuorumError {
#[error("a quorum needing zero agreeing judgements decides nothing")]
NeedsNobody,
#[error("a quorum of {need} cannot be reached from {of} judgement(s)")]
Unreachable { need: u32, of: u32 },
#[error(
"{need} of {of} is not a majority: the panel could reach a quorum for \
'pass' and for 'fail' at once, and which one is reported would depend \
on tally order rather than on the judgements"
)]
NotAMajority { need: u32, of: u32 },
#[error(
"a lens is repeated; identical judges share their blind spots, so a \
repeated lens is repetition rather than the diversity a quorum is for"
)]
RepeatedLens,
#[error("a lens with no name cannot be a distinct angle")]
UnnamedLens,
}
#[cfg(test)]
mod tests {
use super::*;
fn q(need: u32, lenses: &[&str]) -> Quorum {
Quorum::new(need, lenses.iter().copied()).expect("valid")
}
#[test]
fn a_declared_panel_reports_its_shape() {
let quorum = q(2, &["correctness", "policy", "arithmetic"]);
assert_eq!(quorum.need(), 2);
assert_eq!(quorum.of(), 3);
assert_eq!(
quorum.lenses().collect::<Vec<_>>(),
vec!["correctness", "policy", "arithmetic"]
);
}
#[test]
fn repeating_a_lens_is_refused() {
let err = Quorum::new(2, ["correctness", "correctness", "policy"])
.expect_err("identical judges are not a panel");
assert_eq!(err, QuorumError::RepeatedLens);
}
#[test]
fn a_quorum_larger_than_the_panel_is_refused() {
assert_eq!(
Quorum::new(4, ["a", "b", "c"]).expect_err("unreachable"),
QuorumError::Unreachable { need: 4, of: 3 }
);
}
#[test]
fn a_quorum_of_zero_is_refused() {
assert_eq!(
Quorum::new(0, ["a"]).expect_err("decides nothing"),
QuorumError::NeedsNobody
);
}
#[test]
fn a_threshold_that_both_sides_could_meet_is_refused() {
assert_eq!(
Quorum::new(2, ["a", "b", "c", "d"]).expect_err("2 of 4 is not a majority"),
QuorumError::NotAMajority { need: 2, of: 4 }
);
assert_eq!(
Quorum::new(1, ["a", "b"]).expect_err("1 of 2 is any judge deciding alone"),
QuorumError::NotAMajority { need: 1, of: 2 }
);
}
#[test]
fn unanimity_is_allowed() {
assert!(Quorum::new(2, ["a", "b"]).is_ok());
assert!(Quorum::new(3, ["a", "b", "c"]).is_ok());
assert!(Quorum::new(1, ["a"]).is_ok(), "a panel of one is a panel");
}
#[test]
fn an_agreeing_panel_decides() {
let quorum = q(2, &["a", "b", "c"]);
let out = quorum.tally(&[Verdict::Pass, Verdict::Pass, Verdict::Fail]);
assert_eq!(out.decided(), Some(Verdict::Pass));
assert_eq!(out.tally().passed, 2);
assert_eq!(out.tally().failed, 1);
}
#[test]
fn a_panel_agreeing_to_refuse_also_decides() {
let quorum = q(2, &["a", "b", "c"]);
let out = quorum.tally(&[Verdict::Fail, Verdict::Fail, Verdict::Pass]);
assert_eq!(out.decided(), Some(Verdict::Fail));
}
#[test]
fn a_split_panel_decides_nothing() {
let quorum = q(3, &["a", "b", "c"]);
let out = quorum.tally(&[Verdict::Pass, Verdict::Pass, Verdict::Fail]);
assert_eq!(
out.decided(),
None,
"2 of 3 where 3 was required is a disagreement, and reporting the \
majority converts 'we do not know' into 'approved'"
);
assert!(matches!(out, Outcome::NoQuorum(_)));
assert_eq!(out.tally().passed, 2, "the tally is still reported");
}
#[test]
fn a_short_panel_does_not_reach_quorum() {
let quorum = q(2, &["a", "b", "c"]);
let out = quorum.tally(&[Verdict::Pass]);
assert_eq!(out.decided(), None);
assert_eq!(out.tally().asked, 3, "the shortfall is visible");
}
#[test]
fn an_empty_panel_decides_nothing() {
let quorum = q(2, &["a", "b", "c"]);
assert_eq!(quorum.tally(&[]).decided(), None);
}
#[test]
fn an_unnamed_lens_is_refused() {
assert_eq!(
Quorum::new(1, [" "]).expect_err("unnamed"),
QuorumError::UnnamedLens
);
}
}