1use std::collections::BTreeSet;
34
35use serde::{Deserialize, Serialize};
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(try_from = "DeclaredQuorum")]
54pub struct Quorum {
55 need: u32,
57 lenses: Vec<String>,
59}
60
61#[derive(Deserialize)]
67struct DeclaredQuorum {
68 need: u32,
69 lenses: Vec<String>,
70}
71
72impl TryFrom<DeclaredQuorum> for Quorum {
73 type Error = QuorumError;
74
75 fn try_from(d: DeclaredQuorum) -> Result<Self, Self::Error> {
76 Self::new(d.need, d.lenses)
77 }
78}
79
80impl Quorum {
81 pub fn new<I, S>(need: u32, lenses: I) -> Result<Self, QuorumError>
89 where
90 I: IntoIterator<Item = S>,
91 S: Into<String>,
92 {
93 let lenses: Vec<String> = lenses.into_iter().map(Into::into).collect();
94 let of = u32::try_from(lenses.len()).unwrap_or(u32::MAX);
95
96 if need == 0 {
97 return Err(QuorumError::NeedsNobody);
98 }
99 if need > of {
100 return Err(QuorumError::Unreachable { need, of });
101 }
102 if of > 1 && need * 2 <= of {
108 return Err(QuorumError::NotAMajority { need, of });
109 }
110 let unique: BTreeSet<&String> = lenses.iter().collect();
111 if unique.len() != lenses.len() {
112 return Err(QuorumError::RepeatedLens);
113 }
114 if lenses.iter().any(|l| l.trim().is_empty()) {
115 return Err(QuorumError::UnnamedLens);
116 }
117 Ok(Self { need, lenses })
118 }
119
120 #[must_use]
121 pub const fn need(&self) -> u32 {
122 self.need
123 }
124
125 #[must_use]
127 pub fn of(&self) -> u32 {
128 u32::try_from(self.lenses.len()).unwrap_or(u32::MAX)
129 }
130
131 pub fn lenses(&self) -> impl Iterator<Item = &str> {
132 self.lenses.iter().map(String::as_str)
133 }
134
135 pub fn tally(&self, verdicts: &[(&str, Verdict)]) -> Result<PanelOutcome, QuorumError> {
148 let mut seen = BTreeSet::new();
149 for (lens, _) in verdicts {
150 if !self.lenses.iter().any(|l| l == lens) {
151 return Err(QuorumError::NotALens((*lens).to_owned()));
152 }
153 if !seen.insert(*lens) {
154 return Err(QuorumError::JudgedTwice((*lens).to_owned()));
155 }
156 }
157 let passed = verdicts.iter().filter(|(_, v)| *v == Verdict::Pass).count();
158 let failed = verdicts.iter().filter(|(_, v)| *v == Verdict::Fail).count();
159 let tally = Tally {
160 passed: u32::try_from(passed).unwrap_or(u32::MAX),
161 failed: u32::try_from(failed).unwrap_or(u32::MAX),
162 asked: self.of(),
163 };
164 if tally.passed >= self.need {
165 return Ok(PanelOutcome::Reached(Verdict::Pass, tally));
166 }
167 if tally.failed >= self.need {
168 return Ok(PanelOutcome::Reached(Verdict::Fail, tally));
169 }
170 Ok(PanelOutcome::NoQuorum(tally))
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176pub enum Verdict {
177 Pass,
178 Fail,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub struct Tally {
184 pub passed: u32,
185 pub failed: u32,
186 pub asked: u32,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum PanelOutcome {
199 Reached(Verdict, Tally),
200 NoQuorum(Tally),
201}
202
203impl PanelOutcome {
204 #[must_use]
206 pub const fn decided(&self) -> Option<Verdict> {
207 match self {
208 Self::Reached(v, _) => Some(*v),
209 Self::NoQuorum(_) => None,
210 }
211 }
212
213 #[must_use]
214 pub const fn tally(&self) -> Tally {
215 match self {
216 Self::Reached(_, t) | Self::NoQuorum(t) => *t,
217 }
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
223pub enum QuorumError {
224 #[error("a quorum needing zero agreeing judgements decides nothing")]
225 NeedsNobody,
226
227 #[error("a quorum of {need} cannot be reached from {of} judgement(s)")]
228 Unreachable { need: u32, of: u32 },
229
230 #[error(
231 "{need} of {of} is not a majority: the panel could reach a quorum for \
232 'pass' and for 'fail' at once, and which one is reported would depend \
233 on tally order rather than on the judgements"
234 )]
235 NotAMajority { need: u32, of: u32 },
236
237 #[error(
238 "a lens is repeated; identical judges share their blind spots, so a \
239 repeated lens is repetition rather than the diversity a quorum is for"
240 )]
241 RepeatedLens,
242
243 #[error("a lens with no name cannot be a distinct angle")]
244 UnnamedLens,
245
246 #[error("a judgement from '{0}', which is not a lens of this panel")]
247 NotALens(String),
248
249 #[error("lens '{0}' was judged twice; one judge counted twice is not two judges")]
250 JudgedTwice(String),
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 fn q(need: u32, lenses: &[&str]) -> Quorum {
258 Quorum::new(need, lenses.iter().copied()).expect("valid")
259 }
260
261 #[test]
262 fn a_declared_panel_reports_its_shape() {
263 let quorum = q(2, &["correctness", "policy", "arithmetic"]);
264 assert_eq!(quorum.need(), 2);
265 assert_eq!(quorum.of(), 3);
266 assert_eq!(
267 quorum.lenses().collect::<Vec<_>>(),
268 vec!["correctness", "policy", "arithmetic"]
269 );
270 }
271
272 #[test]
274 fn repeating_a_lens_is_refused() {
275 let err = Quorum::new(2, ["correctness", "correctness", "policy"])
276 .expect_err("identical judges are not a panel");
277 assert_eq!(err, QuorumError::RepeatedLens);
278 }
279
280 #[test]
281 fn a_quorum_larger_than_the_panel_is_refused() {
282 assert_eq!(
283 Quorum::new(4, ["a", "b", "c"]).expect_err("unreachable"),
284 QuorumError::Unreachable { need: 4, of: 3 }
285 );
286 }
287
288 #[test]
289 fn a_quorum_of_zero_is_refused() {
290 assert_eq!(
291 Quorum::new(0, ["a"]).expect_err("decides nothing"),
292 QuorumError::NeedsNobody
293 );
294 }
295
296 #[test]
298 fn a_threshold_that_both_sides_could_meet_is_refused() {
299 assert_eq!(
300 Quorum::new(2, ["a", "b", "c", "d"]).expect_err("2 of 4 is not a majority"),
301 QuorumError::NotAMajority { need: 2, of: 4 }
302 );
303 assert_eq!(
304 Quorum::new(1, ["a", "b"]).expect_err("1 of 2 is any judge deciding alone"),
305 QuorumError::NotAMajority { need: 1, of: 2 }
306 );
307 }
308
309 #[test]
311 fn unanimity_is_allowed() {
312 assert!(Quorum::new(2, ["a", "b"]).is_ok());
313 assert!(Quorum::new(3, ["a", "b", "c"]).is_ok());
314 assert!(Quorum::new(1, ["a"]).is_ok(), "a panel of one is a panel");
315 }
316
317 #[test]
318 fn an_agreeing_panel_decides() {
319 let quorum = q(2, &["a", "b", "c"]);
320 let out = quorum
321 .tally(&[
322 ("a", Verdict::Pass),
323 ("b", Verdict::Pass),
324 ("c", Verdict::Fail),
325 ])
326 .unwrap();
327 assert_eq!(out.decided(), Some(Verdict::Pass));
328 assert_eq!(out.tally().passed, 2);
329 assert_eq!(out.tally().failed, 1);
330 }
331
332 #[test]
333 fn a_panel_agreeing_to_refuse_also_decides() {
334 let quorum = q(2, &["a", "b", "c"]);
335 let out = quorum
336 .tally(&[
337 ("a", Verdict::Fail),
338 ("b", Verdict::Fail),
339 ("c", Verdict::Pass),
340 ])
341 .unwrap();
342 assert_eq!(out.decided(), Some(Verdict::Fail));
343 }
344
345 #[test]
347 fn a_split_panel_decides_nothing() {
348 let quorum = q(3, &["a", "b", "c"]);
349 let out = quorum
350 .tally(&[
351 ("a", Verdict::Pass),
352 ("b", Verdict::Pass),
353 ("c", Verdict::Fail),
354 ])
355 .unwrap();
356 assert_eq!(
357 out.decided(),
358 None,
359 "2 of 3 where 3 was required is a disagreement, and reporting the \
360 majority converts 'we do not know' into 'approved'"
361 );
362 assert!(matches!(out, PanelOutcome::NoQuorum(_)));
363 assert_eq!(out.tally().passed, 2, "the tally is still reported");
364 }
365
366 #[test]
368 fn a_short_panel_does_not_reach_quorum() {
369 let quorum = q(2, &["a", "b", "c"]);
370 let out = quorum.tally(&[("a", Verdict::Pass)]).unwrap();
371 assert_eq!(out.decided(), None);
372 assert_eq!(out.tally().asked, 3, "the shortfall is visible");
373 }
374
375 #[test]
376 fn an_empty_panel_decides_nothing() {
377 let quorum = q(2, &["a", "b", "c"]);
378 assert_eq!(quorum.tally(&[]).unwrap().decided(), None);
379 }
380
381 #[test]
383 fn a_lens_judged_twice_is_refused() {
384 let quorum = q(2, &["a", "b", "c"]);
385 assert_eq!(
386 quorum.tally(&[("a", Verdict::Pass), ("a", Verdict::Pass)]),
387 Err(QuorumError::JudgedTwice("a".to_owned())),
388 "four passes over three lenses must not reach a quorum"
389 );
390 assert_eq!(
391 quorum.tally(&[("a", Verdict::Pass), ("z", Verdict::Pass)]),
392 Err(QuorumError::NotALens("z".to_owned()))
393 );
394 }
395
396 #[test]
397 fn an_unnamed_lens_is_refused() {
398 assert_eq!(
399 Quorum::new(1, [" "]).expect_err("unnamed"),
400 QuorumError::UnnamedLens
401 );
402 }
403}