Skip to main content

agentplane/core/
quorum.rs

1//! Judging a high-stakes step more than once, and disagreeing usefully.
2//!
3//! The measurement is the whole argument: an agent at 61 % pass^1 is around
4//! **25 % at pass^8**. A single execution of a judgement that moves money or
5//! closes a regulatory case is therefore not adequate evidence, however
6//! confident it sounds.
7//!
8//! # Diversity, not repetition
9//!
10//! The obvious implementation runs the same judgement three times and takes the
11//! majority. That buys almost nothing: identical prompts against the same model
12//! share the same blind spots, so three runs agree confidently and wrongly about
13//! precisely the cases a second opinion existed to catch. Redundancy catches
14//! *variance*; only diversity catches *bias*.
15//!
16//! So a quorum declares **lenses** — distinct angles the same work is judged
17//! from — and duplicate lenses are rejected at construction rather than warned
18//! about. A quorum of three identical lenses is repetition wearing diversity's
19//! clothing, and the type refuses to express it.
20//!
21//! # No quorum is an answer, and it is not the majority
22//!
23//! The one thing this must never do is fall back to "pick whichever side had
24//! more votes". A panel that split 2–2, or that reached 2 of 3 where 3 was
25//! required, is the strongest available signal that a human should look — it is
26//! the case where the judgement is genuinely hard. Silently resolving it is how
27//! a system converts *we do not know* into *approved*.
28//!
29//! [`PanelOutcome::NoQuorum`] therefore carries the tally and offers no accessor that
30//! resolves it. There is no `majority()`, deliberately: the caller escalates,
31//! because there is nothing else the type lets them do.
32
33use std::collections::BTreeSet;
34
35use serde::{Deserialize, Serialize};
36
37/// How many judgements, from how many angles, and how many must agree.
38///
39/// # Deserialization takes the same door as `new`
40///
41/// A derived `Deserialize` would reach the private fields directly, which is
42/// the `Quorum::new(need, lenses)` this type deliberately does not offer. It is
43/// the door that matters most here: a panel's quorum is configuration the
44/// aggregating skill reads — from a file, a store, a step's arguments — and a
45/// panel is exactly the control a hijacked input wants weakened. `need: 0` then
46/// reports [`Verdict::Pass`] having judged nothing, and a non-majority
47/// threshold reports whichever side `tally` happens to count first. A plan
48/// node carries no quorum ([`PlanNode`] says why): the panel is *k* verifier
49/// nodes and a terminal aggregator that decides with this type.
50///
51/// [`PlanNode`]: crate::core::PlanNode
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(try_from = "DeclaredQuorum")]
54pub struct Quorum {
55    /// How many must agree for the panel to have decided.
56    need: u32,
57    /// The angles to judge from. One judgement per lens.
58    lenses: Vec<String>,
59}
60
61/// The wire form of a [`Quorum`], before it has been checked.
62///
63/// Exists only so `serde` has a shape to build that is *not* a `Quorum`. It
64/// carries no invariant, which is the point: the only way across is
65/// [`Quorum::new`].
66#[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    /// Declare a quorum of `need` agreeing judgements across `lenses`.
82    ///
83    /// # Errors
84    ///
85    /// [`QuorumError`] — every variant is a way of declaring a panel that
86    /// cannot do the job it is being asked to do, so all are refused at
87    /// construction rather than discovered at run time.
88    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        // Two lenses agreeing out of two is unanimity, which is a legitimate
103        // and strict choice. One of two is not a quorum at all — it is "any
104        // judge may decide alone", with the second judge's disagreement
105        // discarded. That is worse than a single judgement, because it looks
106        // like a panel.
107        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    /// How many judgements this panel takes.
126    #[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    /// Tally judgements, each named by the lens that gave it, into a decision
136    /// or an escalation.
137    ///
138    /// A panel that returned fewer than it was asked for has not reached
139    /// quorum — a missing judgement is not an abstention the others can
140    /// outvote, it is evidence the panel did not run.
141    ///
142    /// # Errors
143    ///
144    /// [`QuorumError::NotALens`] for a judgement from a lens this panel does
145    /// not declare, and [`QuorumError::JudgedTwice`] for a lens counted twice:
146    /// either would let one judge's answer stand in for another's.
147    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/// One judge's answer.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176pub enum Verdict {
177    Pass,
178    Fail,
179}
180
181/// What the panel produced.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub struct Tally {
184    pub passed: u32,
185    pub failed: u32,
186    /// How many judgements were asked for — so a short panel is visible.
187    pub asked: u32,
188}
189
190/// The panel's decision, or its failure to reach one.
191///
192/// Note what is missing: there is no way to extract a decision from
193/// [`NoQuorum`](PanelOutcome::NoQuorum). That is the point. A panel that could not
194/// agree is the signal a person should look, and an accessor returning "whoever
195/// had more votes" would turn the one useful thing this mechanism produces back
196/// into a confident answer.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum PanelOutcome {
199    Reached(Verdict, Tally),
200    NoQuorum(Tally),
201}
202
203impl PanelOutcome {
204    /// The decision, if there was one.
205    #[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/// Why a declared panel was refused.
222#[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    /// The rule the whole mechanism rests on.
273    #[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    /// A non-majority threshold can be met by both sides at once.
297    #[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    /// Unanimity is strict, not invalid.
310    #[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    /// **The property the design turns on.**
346    #[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    /// A panel that did not fully run has not agreed.
367    #[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    /// One judge counted twice is not two judges.
382    #[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}