From 37147d1f596b8e5c1f2afcd9718d6d54f33912bd Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 04:36:43 +0100 Subject: [PATCH] Lot S, phase 2 : le quorum dans le moteur (ADR 0013), vu rouge puis vert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le quorum Q (votants = pour + contre) remplace le C additif : T' = max(T, Q), la version passe si pour ≥ ⌈B^W + ratio(T')·T'⌉, ratio la courbe des cinq crans (M, G calé à la participation de référence, inchangé). La règle d'Yvv du 03/11 (« Ils comptent comme non ») est seule dans threshold::judged_voters. Pour l'écran : yes_needed (le total de pour qui passe avec les contre d'aujourd'hui) et no_needed (le total de contre qui tient les pour d'aujourd'hui, 0 si le quorum suffit) ; passe ⇔ pour ≥ yesNeeded ⇔ contre < noNeeded, éprouvé sur une grille. well_rated : la part tient sur ceux qui sont venus. Scale porte les quorums par cran, UnitState le quorum propre d'un article ; p0 reste 2·vote_floor/W. Vu rouge : sans la règle (T' = T), 9 tests échouent ; avec l'autre lecture d'un quorum (un minimum de votants, les absents non comptés), 8 échouent, dont « un contre n'aide jamais ». Chiffres de contrôle (W 5 707, p0 = 100/W) tenus : 9, 39, 46 au quorum ; 7, 8, 9, 9, 10 à dix votants. Le serveur suit au commit suivant. Co-Authored-By: Claude Opus 5.5 --- crates/ld-engine/src/compose.rs | 37 +++- crates/ld-engine/src/inertia.rs | 208 +++++++++++++------- crates/ld-engine/src/order.rs | 16 +- crates/ld-engine/src/threshold.rs | 316 +++++++++++++++++++++++------- 4 files changed, 419 insertions(+), 158 deletions(-) diff --git a/crates/ld-engine/src/compose.rs b/crates/ld-engine/src/compose.rs index 6f640e7..53cbb3e 100644 --- a/crates/ld-engine/src/compose.rs +++ b/crates/ld-engine/src/compose.rs @@ -19,7 +19,8 @@ //! - what the document would read if the well-rated, or all, proposals passed //! (`preview`); //! - which formula a proposal is judged by (`params_for`): the level of -//! inertia of its unit, on the scale of its group (`inertia::Scale`). +//! inertia of its unit and its quorum, on the scale of its document +//! (`inertia::Scale`). use crate::inertia::{InertiaLevel, Scale}; use crate::threshold::{FormulaParams, Tally, ThresholdError, evaluate}; @@ -40,6 +41,10 @@ pub struct UnitState { pub id: String, pub position: f64, pub inertia: InertiaLevel, + /// Its own quorum, set by the author of its first version before the first vote + /// (ADR 0013); `None`: the quorum of its level. + #[serde(default)] + pub quorum: Option, pub in_force: Option, } @@ -84,8 +89,11 @@ pub fn voting_level(unit: &UnitState, kind: ProposalKind) -> InertiaLevel { } } +/// The formula a proposal is judged by: the level it is voted at, and the quorum of its +/// unit — its own, or that level's (a change of inertia inherits both). pub fn params_for(unit: &UnitState, kind: ProposalKind, scale: &Scale) -> FormulaParams { - scale.params(voting_level(unit, kind)) + let level = voting_level(unit, kind); + scale.params(level, scale.quorum(level, unit.quorum)) } /// A proposal that changes nothing cannot enter into force. @@ -264,6 +272,7 @@ mod tests { id: id.into(), position, inertia: InertiaLevel::DEFAULT, + quorum: None, in_force: in_force.map(|b| VersionText { id: format!("{id}-v"), title: id.into(), @@ -286,8 +295,9 @@ mod tests { } } - /// Monnaie libre before any median: W 5745, C 50, the reference p0 = 2C/W. - const SCALE: Scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 }; + /// Monnaie libre before any median: W 5745, the reference p0 = 2 × 50 / W, the quorums + /// 12 · 12 · 50 · 50 · 50 (ADR 0013). + const SCALE: Scale = Scale { electorate: 5745, participation: 100.0 / 5745.0, quorums: [12, 12, 50, 50, 50] }; fn level(k: u8) -> InertiaLevel { InertiaLevel::new(k).expect("1 to 5") @@ -301,12 +311,18 @@ mod tests { } #[test] - fn the_floor_holds_then_releases() { + fn the_quorum_holds_then_releases() { + // Level 3, quorum 50: under it the voters missing count as against, 39 « pour » pass. let units = [unit("E3", 7.0, None)]; - let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)]; + let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 38, 0)]; assert!(adoptions(&units, &few, &SCALE).expect("valid").is_empty()); - let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)]; + let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 39, 0)]; assert_eq!(adoptions(&units, &enough, &SCALE).expect("valid"), vec!["p1".to_string()]); + // Its own quorum, lower, set by its author: the same curve on fewer voters. + let mut own = unit("E3", 7.0, None); + own.quorum = Some(12); + let lower = [prop("p1", "E3", ProposalKind::Text, "connaître", 11, 0)]; + assert_eq!(adoptions(&[own], &lower, &SCALE).expect("valid"), vec!["p1".to_string()]); } #[test] @@ -339,7 +355,10 @@ mod tests { assert_eq!(voting_level(&u, kind), level(4), "inherits the level of the article"); assert_eq!(params_for(&u, kind, &SCALE), params_for(&u, ProposalKind::Text, &SCALE)); } - assert_eq!(params_for(&u, ProposalKind::Text, &SCALE).majority_pct, 60.0); + let p = params_for(&u, ProposalKind::Text, &SCALE); + assert_eq!((p.majority_pct, p.quorum), (60.0, 50), "the level, and so its quorum"); + u.quorum = Some(20); + assert_eq!(params_for(&u, ProposalKind::Inertia(level(1)), &SCALE).quorum, 20, "and the article's own"); } #[test] @@ -358,7 +377,7 @@ mod tests { // The seed a minima has no vote: the first rival that reaches its threshold wins. let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)]; assert_eq!(adoptions(&units, &rival, &SCALE).expect("valid"), vec!["r".to_string()]); - // A version in force by vote, margin 60 − 58 = 2: a rival at 55 (margin 55 − 54 = 1) waits. + // A version in force by vote, margin 60 − 47 = 13: a rival at 55 (margin 55 − 43 = 12) waits. if let Some(v) = units[0].in_force.as_mut() { v.tally = Tally { for_votes: 60, against: 0 }; } diff --git a/crates/ld-engine/src/inertia.rs b/crates/ld-engine/src/inertia.rs index 0025cb9..231dbfa 100644 --- a/crates/ld-engine/src/inertia.rs +++ b/crates/ld-engine/src/inertia.rs @@ -5,20 +5,25 @@ //! médiane aux votes du groupe » — the scale chosen is « calée sur la //! participation ». //! -//! The formula stays the Ğ1 one (`threshold`); only M and G depend on the -//! level k: +//! The formula stays the Ğ1 one (`threshold`); M, G and the quorum Q depend on +//! the level k: //! //! - M_k, the majority at full participation: 50, 50, 55, 60, 66 %; -//! - s_k, the share of *for* votes asked **beyond C** at the group's usual +//! - s_k, the share of *for* votes asked of the voters at the group's usual //! participation p̃: 60, 67, 75, 83, 90 %; //! - G_k follows from `s_k = M_k + (1 − M_k)(1 − p̃^G_k)`: -//! `G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃)`. +//! `G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃)`; +//! - Q_k, the quorum of the level, set by the document (Monnaie libre: 12, 12, +//! 50, 50, 50; the test group: 1 everywhere — ADR 0013); an article may hold its +//! own, set by its author before the first vote (`Scale::quorum`). //! //! p̃ is the median number of voters on the group's proposals, over W //! (`Participation`). Until the group has voted on enough proposals, a -//! reference `p0 = 2C/W` stands in. Both are kept within [1 %, 50 %]: ln(p̃) -//! stays negative and finite, so G is finite and positive, and the threshold -//! rises with the level and relaxes toward M as participation grows. +//! reference `p0 = 2Q₀/W` stands in, Q₀ the document's reference quorum (its +//! `vote_floor`, never an article's quorum: an author who lowers his would move +//! the curve of everyone). Both are kept within [1 %, 50 %]: ln(p̃) stays +//! negative and finite, so G is finite and positive, and the threshold rises +//! with the level and relaxes toward M as participation grows. use crate::threshold::{Evaluation, FormulaParams, Tally, ThresholdError, evaluate}; use serde::de::Error as _; @@ -57,7 +62,7 @@ impl InertiaLevel { MAJORITY_PCT[self.index()] } - /// s_k, in percent: the share of *for* asked beyond C at the usual participation. + /// s_k, in percent: the share of *for* asked at the usual participation. pub fn share_pct(self) -> f64 { SHARE_PCT[self.index()] } @@ -115,7 +120,7 @@ impl<'de> Deserialize<'de> for InertiaLevel { pub enum ParticipationSource { /// The median of the group's voted proposals. Median, - /// Too few voted proposals yet: `p0 = 2C/W`. + /// Too few voted proposals yet: `p0 = 2Q₀/W`, Q₀ the document's reference quorum. Reference, } @@ -140,18 +145,19 @@ impl Participation { pub const LOWEST: f64 = 0.01; pub const HIGHEST: f64 = 0.5; - /// `2C/W`, before the bounds: the floor twice over, a participation the - /// group must reach anyway for C to leave room to the majority. - pub fn reference_ratio(electorate: u32, floor: f64) -> f64 { - 2.0 * floor.max(0.0) / f64::from(electorate.max(1)) + /// `2Q₀/W`, before the bounds: the reference quorum of the document twice + /// over (`documents.vote_floor`: 50 in Monnaie libre, 100 voters). It never + /// rests on an article's quorum, which its author may lower (ADR 0013). + pub fn reference_ratio(electorate: u32, reference_quorum: f64) -> f64 { + 2.0 * reference_quorum.max(0.0) / f64::from(electorate.max(1)) } /// p̃ from what was measured: the median when it rests on enough proposals, /// the reference otherwise; brought back within the bounds. - pub fn of(electorate: u32, floor: f64, median: Option, sample: u32) -> Self { + pub fn of(electorate: u32, reference_quorum: f64, median: Option, sample: u32) -> Self { let (raw, source) = match median { Some(m) if sample >= Self::MIN_SAMPLE => (m / f64::from(electorate.max(1)), ParticipationSource::Median), - _ => (Self::reference_ratio(electorate, floor), ParticipationSource::Reference), + _ => (Self::reference_ratio(electorate, reference_quorum), ParticipationSource::Reference), }; let ratio = bounded(raw); Self { ratio, source, sample, median, bounded: ratio != raw } @@ -190,35 +196,44 @@ pub fn gradient(level: InertiaLevel, participation: f64) -> f64 { (1.0 - (s - m) / (1.0 - m)).ln() / bounded(participation).ln() } -/// What the votes of a group are judged by: its electorate W, its floor C and -/// its usual participation p̃. +/// What the votes of a document are judged by: its electorate W, its usual +/// participation p̃ and the quorum of each level. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Scale { pub electorate: u32, - pub floor: f64, /// p̃. pub participation: f64, + /// Q_k, the quorum of each level, 1 to 5: the default of an article at that level. + pub quorums: [u32; 5], } impl Scale { - /// The letters of the formula for a level. - pub fn params(&self, level: InertiaLevel) -> FormulaParams { + /// The quorum an article is judged on: its own, set by its author before the first + /// vote, else the one of its level (Yvv, 2026-11-02: « C suit le cran, mais c'est une + /// proposition par défaut, variable modifiable par l'auteur initial du texte »). A + /// change of inertia inherits the level of its article, and so its quorum. + pub fn quorum(&self, level: InertiaLevel, own: Option) -> u32 { + own.unwrap_or(self.quorums[level.index()]) + } + + /// The letters of the formula for a level, on a quorum. + pub fn params(&self, level: InertiaLevel, quorum: u32) -> FormulaParams { FormulaParams { majority_pct: level.majority_pct(), gradient: gradient(level, self.participation), base: FormulaParams::DEFAULT_BASE, - constant: self.floor, + quorum, } } - pub fn evaluate(&self, tally: Tally, level: InertiaLevel) -> Result { - evaluate(tally, self.electorate, &self.params(level)) + pub fn evaluate(&self, tally: Tally, level: InertiaLevel, quorum: u32) -> Result { + evaluate(tally, self.electorate, &self.params(level, quorum)) } - /// ⌈R⌉ for a level at `cast` votes. - pub fn required(&self, cast: u32, level: InertiaLevel) -> Result { - crate::threshold::wot_threshold(self.electorate, cast, &self.params(level)) + /// ⌈R⌉ for a level and a quorum at `cast` votes. + pub fn required(&self, cast: u32, level: InertiaLevel, quorum: u32) -> Result { + crate::threshold::wot_threshold(self.electorate, cast, &self.params(level, quorum)) } } @@ -228,14 +243,22 @@ mod tests { use crate::threshold::required_ratio; const W: u32 = 7224; - const C: f64 = 50.0; + /// The reference quorum of Monnaie libre (`vote_floor`): p0 = 2 × 50 / W. + const Q0: f64 = 50.0; + /// The quorums of Monnaie libre by level (Yvv, 2026-11-02: « 12 · 12 · 50 · 50 · 50 »). + const QUORUMS: [u32; 5] = [12, 12, 50, 50, 50]; - fn at_median(voters: u32) -> Scale { - Scale { electorate: W, floor: C, participation: f64::from(voters) / f64::from(W) } + fn level(k: u8) -> InertiaLevel { + InertiaLevel::new(k).expect("1 to 5") } + fn at_median(voters: u32) -> Scale { + Scale { electorate: W, participation: f64::from(voters) / f64::from(W), quorums: QUORUMS } + } + + /// ⌈R⌉ at `cast` votes, each level on its own quorum. fn row(scale: &Scale, cast: u32) -> Vec { - InertiaLevel::ALL.iter().map(|k| scale.required(cast, *k).expect("valid")).collect() + InertiaLevel::ALL.iter().map(|k| scale.required(cast, *k, scale.quorum(*k, None)).expect("valid")).collect() } #[test] @@ -265,30 +288,48 @@ mod tests { assert_eq!(serde_json::to_string(&InertiaLevel::HIGHEST).expect("json"), "5"); } + #[test] + fn the_control_figures_of_the_quorum() { + // W 5 707, before any median: p0 = 2 × 50 / W = 100 / W (orch, 2026-11-03). + let w = 5707; + let scale = Scale { electorate: w, participation: Participation::of(w, Q0, None, 0).ratio, quorums: QUORUMS }; + let yes = |k: u8, q: u32, against: u32| { + crate::threshold::yes_needed(against, w, &scale.params(level(k), q)).expect("valid") + }; + // At the quorum: level 2, Q 12 → 9 « pour »; level 3, Q 50 → 39; level 5, Q 50 → 46. + assert_eq!((yes(2, 12, 0), yes(3, 50, 0), yes(5, 50, 0)), (9, 39, 46)); + // 9 « pour » pass at level 2 with 3 « contre » at most (Yvv, 2026-11-03); with a 4th, + // 14 voters, 11 « pour » are asked (73,8 % of 14 is 10,2). + assert_eq!((yes(2, 12, 3), yes(2, 12, 4)), (9, 11)); + // Ten voters, no quorum in the way: 7, 8, 9, 9 and 10 « pour », level 1 to 5. + let ten: Vec = InertiaLevel::ALL.iter().map(|k| scale.required(10, *k, 1).expect("valid")).collect(); + assert_eq!(ten, [7, 8, 9, 9, 10]); + } + #[test] fn the_values_shown_to_yvv() { - // W 7224, C 50, a median of 120 voters (the smith vote of 2024). + // W 7224, quorums 12 · 12 · 50 · 50 · 50, a median of 120 voters (the smith vote of 2024). let scale = at_median(120); - assert_eq!(row(&scale, 120), [92, 97, 103, 109, 113]); - assert_eq!(row(&scale, 500), [306, 329, 362, 397, 432]); - assert_eq!(row(&scale, 2000), [1091, 1145, 1271, 1404, 1549]); + assert_eq!(row(&scale, 120), [72, 81, 90, 100, 108]); + assert_eq!(row(&scale, 500), [284, 310, 347, 386, 424]); + assert_eq!(row(&scale, 2000), [1068, 1123, 1252, 1389, 1537]); } #[test] fn a_median_of_360_voters() { let scale = at_median(360); - assert_eq!(row(&scale, 120), [95, 101, 106, 112, 116]); - assert_eq!(row(&scale, 360), [236, 258, 283, 308, 329]); - assert_eq!(row(&scale, 500), [316, 345, 381, 416, 449]); - assert_eq!(row(&scale, 2000), [1114, 1184, 1318, 1460, 1608]); + assert_eq!(row(&scale, 120), [76, 86, 96, 106, 113]); + assert_eq!(row(&scale, 360), [216, 242, 270, 299, 324]); + assert_eq!(row(&scale, 500), [296, 328, 367, 407, 443]); + assert_eq!(row(&scale, 2000), [1092, 1164, 1301, 1446, 1598]); } #[test] - fn at_the_usual_participation_the_share_beyond_c_is_s_k() { - for (w, c, voters) in [(7224, 50.0, 120), (7224, 50.0, 360), (5745, 50.0, 300), (40, 1.0, 9), (2, 1.0, 1)] { - let scale = Scale { electorate: w, floor: c, participation: f64::from(voters) / f64::from(w) }; + fn at_the_usual_participation_the_share_is_s_k() { + for (w, voters) in [(7224, 120), (7224, 360), (5745, 300), (40, 9), (2, 1)] { + let scale = Scale { electorate: w, participation: f64::from(voters) / f64::from(w), quorums: QUORUMS }; for k in InertiaLevel::ALL { - let ratio = required_ratio(w, voters, &scale.params(k)); + let ratio = required_ratio(w, voters, &scale.params(k, 0)); assert!((ratio - k.share_pct() / 100.0).abs() < 1e-12, "W {w}, k {}: {ratio}", k.get()); } } @@ -296,26 +337,33 @@ mod tests { #[test] fn a_threshold_landing_on_a_whole_number_stays_on_it() { - // Level 5 at the median: 90 % of the 70 votes beyond C is exactly 63. - assert_eq!(at_median(120).required(120, InertiaLevel::HIGHEST), Ok(113)); - // Level 1: 60 % of 70 is exactly 42. - assert_eq!(at_median(120).required(120, InertiaLevel::LOWEST), Ok(92)); + // Level 5 at the median: 90 % of 120 voters is exactly 108. + assert_eq!(at_median(120).required(120, InertiaLevel::HIGHEST, 50), Ok(108)); + // Level 1: 60 % of 120 is exactly 72. + assert_eq!(at_median(120).required(120, InertiaLevel::LOWEST, 12), Ok(72)); } #[test] fn the_threshold_rises_with_the_level() { - for (w, c) in [(7224, 50.0), (5745, 50.0), (40, 1.0), (2, 1.0), (1, 1.0)] { + for (w, q0, quorums) in [ + (7224, Q0, QUORUMS), + (5745, Q0, QUORUMS), + (5745, Q0, [50; 5]), + (40, 1.0, [1; 5]), + (2, 1.0, [1; 5]), + (1, 1.0, [1; 5]), + ] { for voters in [1, 2, 9, 60, 120, 360, 3000] { let scale = Scale { electorate: w, - floor: c, - participation: Participation::of(w, c, Some(f64::from(voters)), 9).ratio, + participation: Participation::of(w, q0, Some(f64::from(voters)), 9).ratio, + quorums, }; for cast in (0..=w.min(4000)).step_by(7).chain([w]) { let r = row(&scale, cast); assert!(r.windows(2).all(|p| p[0] <= p[1]), "W {w}, p̃ {voters}, T {cast}: {r:?}"); let ratios: Vec = - InertiaLevel::ALL.iter().map(|k| required_ratio(w, cast, &scale.params(*k))).collect(); + InertiaLevel::ALL.iter().map(|k| required_ratio(w, cast, &scale.params(*k, 0))).collect(); if cast > 0 && cast < w { assert!( ratios.windows(2).all(|p| p[0] < p[1]), @@ -332,7 +380,7 @@ mod tests { for voters in [120, 360] { let scale = at_median(voters); for k in InertiaLevel::ALL { - let p = scale.params(k); + let p = scale.params(k, 0); let mut last = 1.0; for cast in (1..=W).step_by(13).chain([W]) { let ratio = required_ratio(W, cast, &p); @@ -346,26 +394,44 @@ mod tests { } #[test] - fn c_stays_a_floor_one_vote_never_passes_in_monnaie_libre() { + fn under_the_quorum_one_vote_never_passes_in_monnaie_libre() { for voters in [1, 72, 120, 360, 3612] { let scale = Scale { electorate: W, - floor: C, - participation: Participation::of(W, C, Some(f64::from(voters)), 9).ratio, + participation: Participation::of(W, Q0, Some(f64::from(voters)), 9).ratio, + quorums: QUORUMS, }; for k in InertiaLevel::ALL { - let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k).expect("valid"); + let q = scale.quorum(k, None); + let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k, q).expect("valid"); assert!(!lone.reached, "k {}", k.get()); - assert_eq!(lone.required, 50); - for cast in 1..=50 { - assert!(scale.required(cast, k).expect("valid") >= 50, "nothing passes under C for"); + // Under the quorum, the voters missing count as against: the bar of Q voters. + let at_q = scale.required(q, k, q).expect("valid"); + assert!(at_q >= 2, "one voice is never enough"); + for cast in 0..=q { + assert_eq!(scale.required(cast, k, q).expect("valid"), at_q, "under the quorum, k {}", k.get()); } - assert!(!scale.evaluate(Tally { for_votes: 49, against: 0 }, k).expect("valid").reached); - assert!(scale.evaluate(Tally { for_votes: 50, against: 0 }, k).expect("valid").reached); + let short = Tally { for_votes: at_q - 1, against: 0 }; + assert!(!scale.evaluate(short, k, q).expect("valid").reached); + let enough = scale.evaluate(Tally { for_votes: at_q, against: 0 }, k, q).expect("valid"); + assert!(enough.reached); + assert_eq!(enough.yes_needed, at_q); } } } + #[test] + fn an_article_s_own_quorum_stands_for_its_level() { + let scale = at_median(120); + assert_eq!(scale.quorum(InertiaLevel::DEFAULT, None), 50); + assert_eq!(scale.quorum(level(2), None), 12); + assert_eq!(scale.quorum(InertiaLevel::DEFAULT, Some(20)), 20); + // The curve stays the level's: only the quorum moves. + let own = scale.params(InertiaLevel::DEFAULT, 20); + let default = scale.params(InertiaLevel::DEFAULT, 50); + assert_eq!((own.majority_pct, own.gradient, own.quorum), (default.majority_pct, default.gradient, 20)); + } + #[test] fn the_median() { assert_eq!(median(&[]), None); @@ -378,14 +444,14 @@ mod tests { #[test] fn the_reference_stands_in_until_five_proposals_are_voted_on() { // Monnaie libre: 2 × 50 / 7224 = 1.38 %, a hundred voters. - assert!((Participation::reference_ratio(W, C) - 100.0 / 7224.0).abs() < 1e-15); - let early = Participation::of(W, C, Some(120.0), 4); + assert!((Participation::reference_ratio(W, Q0) - 100.0 / 7224.0).abs() < 1e-15); + let early = Participation::of(W, Q0, Some(120.0), 4); assert_eq!(early.source, ParticipationSource::Reference); assert!((early.voters(W) - 100.0).abs() < 1e-9); assert_eq!((early.sample, early.median, early.bounded), (4, Some(120.0), false)); - let none = Participation::of(W, C, None, 0); + let none = Participation::of(W, Q0, None, 0); assert_eq!(none.source, ParticipationSource::Reference); - let measured = Participation::of(W, C, Some(120.0), 5); + let measured = Participation::of(W, Q0, Some(120.0), 5); assert_eq!(measured.source, ParticipationSource::Median); assert!((measured.voters(W) - 120.0).abs() < 1e-9); assert!(!measured.bounded); @@ -394,7 +460,7 @@ mod tests { #[test] fn participation_is_kept_within_one_and_fifty_percent() { // Two voters on 7224: brought up to 1 %. - let low = Participation::of(W, C, Some(2.0), 9); + let low = Participation::of(W, Q0, Some(2.0), 9); assert_eq!((low.ratio, low.bounded), (0.01, true)); // A group where everyone votes: brought down to half. let all = Participation::of(12, 1.0, Some(12.0), 9); @@ -418,12 +484,14 @@ mod tests { } #[test] - fn the_test_group_needs_everyone() { - // W = 2, C = 1: both must vote for, at every level. - let scale = Scale { electorate: 2, floor: 1.0, participation: Participation::of(2, 1.0, None, 0).ratio }; + fn the_test_group_at_a_quorum_of_one() { + // W = 2, quorum 1 everywhere (Yvv, 2026-11-02): one « pour » alone passes, at every + // level; one « pour » and one « contre » never (half of all, under any M of the levels). + let scale = Scale { electorate: 2, participation: Participation::of(2, 1.0, None, 0).ratio, quorums: [1; 5] }; for k in InertiaLevel::ALL { - assert!(!scale.evaluate(Tally { for_votes: 1, against: 0 }, k).expect("valid").reached); - assert!(scale.evaluate(Tally { for_votes: 2, against: 0 }, k).expect("valid").reached); + assert!(scale.evaluate(Tally { for_votes: 1, against: 0 }, k, 1).expect("valid").reached); + assert!(!scale.evaluate(Tally { for_votes: 1, against: 1 }, k, 1).expect("valid").reached); + assert!(scale.evaluate(Tally { for_votes: 2, against: 0 }, k, 1).expect("valid").reached); } } diff --git a/crates/ld-engine/src/order.rs b/crates/ld-engine/src/order.rs index 5616bfc..7775743 100644 --- a/crates/ld-engine/src/order.rs +++ b/crates/ld-engine/src/order.rs @@ -274,13 +274,15 @@ mod tests { use crate::compose::{ProposalKind, ProposalState, UnitState, VersionText, adoptions}; use crate::inertia::{InertiaLevel, Scale}; use crate::threshold::Tally; - // Monnaie libre before any median: W 5745, C 50; the order of a section at level 2. - let scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 }; + // Monnaie libre before any median: W 5745, p0 = 100 / W; the order of a section at + // level 2, on the quorum of that level, 12 (ADR 0013). + let scale = Scale { electorate: 5745, participation: 100.0 / 5745.0, quorums: [12, 12, 50, 50, 50] }; let section = ids(&["T1", "E1", "E2"]); let module = |tally: Tally| UnitState { id: "ordre.fondamental".into(), position: 0.0, inertia: InertiaLevel::new(2).expect("level"), + quorum: None, in_force: Some(VersionText { id: "seed".into(), title: String::new(), body: canonical(§ion), tally }), }; let proposal = |id: &str, order: &[&str], for_votes: u32| ProposalState { @@ -294,12 +296,14 @@ mod tests { blocked: false, }; let seed = [module(Tally::default())]; - // Never under C voices for; the order in force at zero votes is a seed a minima: margin −C. - assert!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 49)], &scale).expect("valid").is_empty()); - assert_eq!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 50)], &scale).expect("valid"), ["p"]); + // Never under the bar of the quorum (9 « pour » of 12 voters); the order in force at zero + // votes is a seed a minima: the margin of zero votes, −9. + assert!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 8)], &scale).expect("valid").is_empty()); + assert_eq!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 9)], &scale).expect("valid"), ["p"]); // An order that is the one in force changes nothing, whatever its votes. assert!(adoptions(&seed, &[proposal("same", &["T1", "E1", "E2"], 80)], &scale).expect("valid").is_empty()); - // Adopted by a vote, the order in force holds its margin: a weaker order waits, a stronger one passes. + // Adopted by a vote, the order in force holds its margin (70 − 48): a weaker order waits + // (60 − 42), a stronger one passes (90 − 61). let voted = [module(Tally { for_votes: 70, against: 0 })]; assert!(adoptions(&voted, &[proposal("w", &["E2", "E1", "T1"], 60)], &scale).expect("valid").is_empty()); assert_eq!(adoptions(&voted, &[proposal("s", &["E2", "E1", "T1"], 90)], &scale).expect("valid"), ["s"]); diff --git a/crates/ld-engine/src/threshold.rs b/crates/ld-engine/src/threshold.rs index 52dfe97..0d87d5e 100644 --- a/crates/ld-engine/src/threshold.rs +++ b/crates/ld-engine/src/threshold.rs @@ -1,21 +1,24 @@ -//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula. +//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula, judged on a quorum. //! //! ```text -//! R = C + B^W + (M + (1 − M)(1 − (T/W)^G)) · max(0, T − C) +//! T' = max(T, Q) +//! R = B^W + (M + (1 − M)(1 − (T'/W)^G)) · T' //! ``` //! -//! R is the minimum number of *for* votes; a proposal is adopted when -//! `for ≥ ⌈R⌉`. W is the size of the electorate, T the votes cast (for + -//! against). At low participation the formula asks for near-unanimity, at full -//! participation the simple majority M suffices. C is the minimal number of -//! *for* votes below which nothing passes — the guard of a permanent vote -//! (Yvv, 2026-10-21: « Nombre minimal de votants »). +//! R is the minimum number of *for* votes; a version passes when `for ≥ ⌈R⌉`. +//! W is the size of the electorate, T the votes cast (for + against). At low +//! participation the formula asks for near-unanimity, at full participation the +//! simple majority M suffices. Q is the **quorum** (ADR 0013, Yvv, 2026-11-02: +//! « ce n'est pas un nombre de vote pour a priori, c'est plutôt un quorum, à partir +//! de ce nombre, alors il faut la bonne proportion attendue par l'indice ») — and +//! below it the voters missing count as against (`judged_voters`). It replaces the +//! floor C of *for* votes added in front of the formula (ADR 0002, 0007). //! -//! Parity: identical to `maquette/frontend/app/engine/threshold.ts` and the -//! Python reference, with two deliberate guards — the participation ratio is -//! capped at 1, so a stale W can never lower the bar below M; and a count that -//! lands on a whole number stays on it (`settle`), so a rounding error never -//! adds a vote. Which M and G a text is judged by: `inertia` (five levels). +//! Parity with `maquette/frontend/app/engine/threshold.ts` at Q = 0 (the C = 0 of +//! the maquette), with two deliberate guards — the participation ratio is capped +//! at 1, so a stale W can never lower the bar below M; and a count that lands on a +//! whole number stays on it (`settle`), so a rounding error never adds a vote. +//! Which M, G and Q a text is judged by: `inertia` (five levels, their quorums). use serde::{Deserialize, Serialize}; @@ -28,8 +31,9 @@ pub struct FormulaParams { pub gradient: f64, /// B. pub base: f64, - /// C — minimal number of *for* votes. - pub constant: f64, + /// Q — the quorum: the voters (for + against) from which the share of the inertia + /// applies; below it, the voters missing count as against (`judged_voters`). + pub quorum: u32, } impl FormulaParams { @@ -42,14 +46,26 @@ pub enum ThresholdError { MajorityOutOfRange, } -/// The required ratio of *for* votes among the votes cast beyond C. -pub fn required_ratio(electorate: u32, cast: u32, params: &FormulaParams) -> f64 { +/// T' — the voters a version is judged on. **The rule of the quorum, here alone.** +/// +/// Yvv, 2026-11-03, to « Sous le quorum, les votants qui manquent comptent-ils comme des +/// « non » ? » : « Ils comptent comme non ». Below the quorum Q, the voters who did not +/// come count as against: the share of the inertia is asked of max(T, Q) voters. At level +/// 2, quorum 12, 9 « pour » pass with 3 « contre » at most; Yvv's own example, 60 % and +/// Q = 10, takes 6 « pour ». So an « against » never helps a version pass — completing +/// the quorum with a « non » adds nothing the missing voters did not already count. +pub fn judged_voters(cast: u32, quorum: u32) -> u32 { + cast.max(quorum) +} + +/// The share of *for* votes asked of `voters` voters (T' when judging a version). +pub fn required_ratio(electorate: u32, voters: u32, params: &FormulaParams) -> f64 { let m = params.majority_pct / 100.0; - let participation = (f64::from(cast) / f64::from(electorate.max(1))).min(1.0); + let participation = (f64::from(voters) / f64::from(electorate.max(1))).min(1.0); m + (1.0 - m) * (1.0 - participation.powf(params.gradient)) } -/// ⌈R⌉ — the minimum number of *for* votes for adoption. +/// ⌈R⌉ — the minimum number of *for* votes for adoption, at `cast` votes cast. pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Result { if electorate == 0 { return Err(ThresholdError::EmptyElectorate); @@ -57,20 +73,27 @@ pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Resu if !(0.0..=100.0).contains(¶ms.majority_pct) { return Err(ThresholdError::MajorityOutOfRange); } - let c = params.constant; + let voters = judged_voters(cast, params.quorum); let offset = params.base.powf(f64::from(electorate)); - let raw = if cast == 0 { - c + offset + let raw = if voters == 0 { + offset } else { - c + offset + settle(required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0)) + offset + settle(required_ratio(electorate, voters, params) * f64::from(voters)) }; // `raw` is finite and non-negative for any valid input; saturate rather than wrap. Ok(clamp_ceil(raw)) } +/// Does a version with this tally pass? At least one vote cast, and its *for* reach +/// ⌈R⌉ on the voters it is judged on (`judged_voters`). +pub fn passes(tally: Tally, electorate: u32, params: &FormulaParams) -> Result { + let cast = tally.cast(); + Ok(cast > 0 && tally.for_votes >= wot_threshold(electorate, cast, params)?) +} + /// A count of votes that lands on a whole number stays on it: a rounding error /// of the logarithms (a billionth of a vote) must never raise a threshold by -/// one. At the usual participation, 90 % of the 70 votes beyond C is exactly 63. +/// one. At the usual participation, 90 % of 70 votes is exactly 63. fn settle(votes: f64) -> f64 { let whole = votes.round(); if (votes - whole).abs() < 1e-9 { whole } else { votes } @@ -107,32 +130,82 @@ impl Tally { #[serde(rename_all = "camelCase")] pub struct Evaluation { pub cast: u32, - /// ⌈R⌉ at the current participation. + /// Q, the quorum it is judged on. + pub quorum: u32, + /// ⌈R⌉ at the current participation (on T' = max(T, Q) voters). pub required: u32, - /// `for ≥ ⌈R⌉` with at least one vote cast. + /// `passes`: `for ≥ ⌈R⌉` with at least one vote cast. pub reached: bool, - /// *for* votes still missing (0 once reached). + /// *for* votes still missing with the *against* of now: `yes_needed − for` (0 once reached). pub missing: u32, - /// The majority the inertia asks for at this participation. + /// The share of *for* the inertia asks of T' voters. pub required_ratio: f64, - /// « Bien cotée » : it holds the majority its inertia asks for, it only - /// lacks voters (C ignored). Used by the "if the well-rated pass" preview. + /// « Bien cotée » : it holds the share its inertia asks of the T voters who came, + /// it only lacks voters (the quorum ignored). Used by the "if the well-rated pass" preview. pub well_rated: bool, + /// The total of *for* that passes with the *against* cast now. + pub yes_needed: u32, + /// The total of *against* that holds the *for* cast now in check: 0 when the quorum + /// already holds them (they do not pass even without any *against*). + pub no_needed: u32, +} + +/// The number of votes searched for never goes past this: the five levels always find +/// theirs well before (`yes_needed`, `no_needed`). +const SEARCH_LIMIT: u32 = u32::MAX / 4; + +/// The smallest `n` in `0..` for which `holds(n)`, `holds` being monotone (false, then +/// true); `u32::MAX` if it never holds up to `SEARCH_LIMIT`. +fn first(mut holds: impl FnMut(u32) -> Result) -> Result { + if holds(0)? { + return Ok(0); + } + let (mut lo, mut hi) = (0u32, 1u32); + while !holds(hi)? { + if hi >= SEARCH_LIMIT { + return Ok(u32::MAX); + } + lo = hi; + hi = hi.saturating_mul(2).min(SEARCH_LIMIT); + } + // holds(lo) is false, holds(hi) true. + while hi - lo > 1 { + let mid = lo + (hi - lo) / 2; + if holds(mid)? { hi = mid } else { lo = mid } + } + Ok(hi) +} + +/// The total of *for* that passes with `against` votes against. A *for* more never +/// hurts (`passes` is monotone): every total from this one on passes. +pub fn yes_needed(against: u32, electorate: u32, params: &FormulaParams) -> Result { + first(|y| passes(Tally { for_votes: y, against }, electorate, params)) +} + +/// The total of *against* that holds `for_votes` in check; 0 when they do not pass even +/// without any *against* (the quorum holds them). An *against* more never helps: every +/// total from this one on holds them. +pub fn no_needed(for_votes: u32, electorate: u32, params: &FormulaParams) -> Result { + first(|n| passes(Tally { for_votes, against: n }, electorate, params).map(|p| !p)) } pub fn evaluate(tally: Tally, electorate: u32, params: &FormulaParams) -> Result { let cast = tally.cast(); let required = wot_threshold(electorate, cast, params)?; - let without_floor = FormulaParams { constant: 0.0, ..*params }; - let required_without_floor = wot_threshold(electorate, cast, &without_floor)?; - let reached = cast > 0 && tally.for_votes >= required; + let reached = passes(tally, electorate, params)?; + let without_quorum = FormulaParams { quorum: 0, ..*params }; + let required_without_quorum = wot_threshold(electorate, cast, &without_quorum)?; + let yes = yes_needed(tally.against, electorate, params)?; Ok(Evaluation { cast, + quorum: params.quorum, required, reached, - missing: required.saturating_sub(tally.for_votes), - required_ratio: required_ratio(electorate, cast, params), - well_rated: cast > 0 && tally.for_votes >= required_without_floor, + missing: if reached { 0 } else { yes.saturating_sub(tally.for_votes) }, + required_ratio: required_ratio(electorate, judged_voters(cast, params.quorum), params), + well_rated: cast > 0 && tally.for_votes >= required_without_quorum, + yes_needed: yes, + no_needed: no_needed(tally.for_votes, electorate, params)?, }) } @@ -141,19 +214,19 @@ mod tests { use super::*; fn g1vote(m: f64, g: f64) -> FormulaParams { - FormulaParams { majority_pct: m, gradient: g, base: 0.1, constant: 0.0 } + FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: 0 } } /// The letters of the « standard » inertia before the five levels: M 50 %, G 0.2. - fn standard(constant: f64) -> FormulaParams { - FormulaParams { constant, ..g1vote(50.0, 0.2) } + fn standard(quorum: u32) -> FormulaParams { + FormulaParams { quorum, ..g1vote(50.0, 0.2) } } #[test] fn reference_case_of_the_smith_vote() { // Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94. - // The formula's own reference, letters given: the five levels (ADR 0007) - // choose M and G, the formula that turns them into a threshold is this one. + // The formula's own reference, letters given, without a quorum (Q = 0, the C = 0 + // of the maquette): the five levels choose M, G and Q (ADR 0007, 0013). let p = g1vote(50.0, 0.2); assert_eq!(wot_threshold(7224, 120, &p), Ok(94)); let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid"); @@ -163,7 +236,7 @@ mod tests { #[test] fn parity_with_the_typescript_reference() { - // Values computed by maquette/frontend/app/engine/threshold.ts. + // Values computed by maquette/frontend/app/engine/threshold.ts (C = 0). let p = g1vote(50.0, 0.2); assert_eq!(wot_threshold(7224, 0, &p), Ok(0)); assert_eq!(wot_threshold(7224, 1, &p), Ok(1)); @@ -174,42 +247,138 @@ mod tests { } #[test] - fn one_vote_passes_without_floor_and_never_with_it() { - // The trap of a permanent vote: a lone "for" meets the inertial bar. - let bare = standard(0.0); - assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached); - let guarded = standard(50.0); - let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &guarded).expect("valid"); - assert!(!e.reached); - assert_eq!(e.required, 50); - assert_eq!(e.missing, 49); - assert!(e.well_rated, "unanimous, it only lacks voters"); + fn yvv_s_example_sixty_percent_and_a_quorum_of_ten() { + // Yvv, 2026-11-02: « Si c'est 60% et c=10, alors il faut 6 voix pour et ça passe. » + // A share of 60 % whatever the participation (G = 0), a large web (B^W vanishes). + let p = FormulaParams { majority_pct: 60.0, gradient: 0.0, base: 0.1, quorum: 10 }; + // G = 0: (T/W)^0 = 1, the share is M, 60 %. + assert!((required_ratio(7224, 10, &p) - 0.6).abs() < 1e-12); + for against in 0..=4 { + let t = Tally { for_votes: 6, against }; + assert_eq!(passes(t, 7224, &p), Ok(true), "6 pour, {against} contre"); + } + assert_eq!(passes(Tally { for_votes: 5, against: 0 }, 7224, &p), Ok(false), "5 of the 10 counted"); + assert_eq!(wot_threshold(7224, 6, &p), Ok(6)); + // Beyond the quorum, the share of all who came: 6 pour and 5 contre are 11 voters. + assert_eq!(passes(Tally { for_votes: 6, against: 5 }, 7224, &p), Ok(false)); + assert_eq!(passes(Tally { for_votes: 7, against: 4 }, 7224, &p), Ok(true)); } #[test] - fn floor_is_a_minimum_of_for_votes() { - let p = standard(50.0); - // 50 unanimous votes reach it, 49 do not. - assert!(evaluate(Tally { for_votes: 50, against: 0 }, 5745, &p).expect("valid").reached); - assert!(!evaluate(Tally { for_votes: 49, against: 0 }, 5745, &p).expect("valid").reached); - // Beyond the floor the inertial majority applies to the rest. - let e = evaluate(Tally { for_votes: 55, against: 5 }, 5745, &p).expect("valid"); - assert!(!e.reached, "near-unanimity is asked of the votes beyond C"); + fn under_the_quorum_the_voters_missing_count_as_against() { + // Yvv, 2026-11-03: « Ils comptent comme non ». T' = max(T, Q). + assert_eq!( + (judged_voters(0, 12), judged_voters(9, 12), judged_voters(12, 12), judged_voters(30, 12)), + (12, 12, 12, 30) + ); + let p = standard(50); + // One « pour » alone is judged on 50 voters; so are 1 « pour » and 49 « contre ». + assert_eq!(wot_threshold(5745, 1, &p), wot_threshold(5745, 50, &p)); + assert!(!passes(Tally { for_votes: 1, against: 0 }, 5745, &p).expect("valid")); + } + + #[test] + fn an_against_never_helps_a_version_pass() { + // Without the rule, an « against » that completes the quorum could make a text pass. + for (m, g, q, w) in [(50.0, 0.1, 12, 5707), (55.0, 0.15, 50, 5707), (66.0, 0.3, 50, 5745), (50.0, 0.6, 1, 2)] { + let p = FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: q }; + for y in 0..80 { + let mut passed = passes(Tally { for_votes: y, against: 0 }, w, &p).expect("valid"); + for n in 1..80 { + let now = passes(Tally { for_votes: y, against: n }, w, &p).expect("valid"); + assert!(passed || !now, "M {m} Q {q} W {w}: {y} pour, {n} contre passes, {} contre did not", n - 1); + passed = now; + } + } + } + } + + #[test] + fn one_vote_never_passes_with_a_quorum_of_two_or_more() { + // The invariant of Monnaie libre (quorum 2 at least): one voice alone never passes. + for q in [2, 12, 50] { + for g in [0.05, 0.1, 0.3, 1.77] { + let p = FormulaParams { majority_pct: 50.0, gradient: g, base: 0.1, quorum: q }; + let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &p).expect("valid"); + assert!(!e.reached, "Q {q}, G {g}"); + assert!(e.well_rated, "unanimous, it only lacks voters"); + } + } + // Without a quorum, the trap of a permanent vote: a lone « pour » meets the inertial bar. + assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &standard(0)).expect("valid").reached); + } + + #[test] + fn yes_needed_and_no_needed_say_where_the_line_is() { + // At level 2 on the scale of Monnaie libre (G 0.10274, W 5707), quorum 12. + let p = FormulaParams { majority_pct: 50.0, gradient: 0.102_742, base: 0.1, quorum: 12 }; + let e = evaluate(Tally::default(), 5707, &p).expect("valid"); + assert_eq!((e.quorum, e.required, e.yes_needed, e.missing, e.no_needed), (12, 9, 9, 9, 0)); + // 9 « pour »: they pass with 3 « contre » at most; the 4th holds them. + let e = evaluate(Tally { for_votes: 9, against: 0 }, 5707, &p).expect("valid"); + assert!(e.reached); + assert_eq!((e.yes_needed, e.no_needed, e.missing), (9, 4, 0)); + // With a 4th « contre », 14 voters at least: 11 « pour » are asked (10,2 rounded up). + let e = evaluate(Tally { for_votes: 9, against: 4 }, 5707, &p).expect("valid"); + assert!(!e.reached); + assert_eq!(e.yes_needed, 11); + assert_eq!(e.missing, 2); + // 8 « pour »: the quorum already holds them, no « contre » is needed. + let e = evaluate(Tally { for_votes: 8, against: 0 }, 5707, &p).expect("valid"); + assert_eq!((e.reached, e.no_needed, e.missing), (false, 0, 1)); + } + + #[test] + fn passes_means_yes_needed_reached_means_no_needed_not_reached() { + // On a grid: levels (M, G as at the reference participation and beyond), quorums, + // electorates, « pour », « contre ». passes ⇔ for ≥ yesNeeded(against) ⇔ against < noNeeded(for). + let levels = [(50.0, 0.055), (50.0, 0.103), (55.0, 0.145), (60.0, 0.212), (66.0, 0.303), (66.0, 1.77)]; + for (m, g) in levels { + for q in [0, 1, 2, 12, 50] { + for w in [1, 2, 16, 40, 500, 5707] { + let p = FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: q }; + let yes: Vec = (0..70).map(|n| yes_needed(n, w, &p).expect("valid")).collect(); + for y in 0..70 { + let no = no_needed(y, w, &p).expect("valid"); + for n in 0..70 { + let t = Tally { for_votes: y, against: n }; + let ok = passes(t, w, &p).expect("valid"); + assert_eq!(ok, y >= yes[n as usize], "M {m} G {g} Q {q} W {w}: {y} pour {n} contre"); + assert_eq!(ok, n < no, "M {m} G {g} Q {q} W {w}: {y} pour {n} contre, noNeeded {no}"); + } + } + } + } + } + } + + #[test] + fn well_rated_holds_the_share_on_those_who_came() { + let p = standard(50); + // 30 « pour », 2 « contre »: the share holds on 32 voters, the quorum is not there. + let e = evaluate(Tally { for_votes: 30, against: 2 }, 5745, &p).expect("valid"); + assert!(!e.reached && e.well_rated); + // 10 « pour », 10 « contre »: the share does not hold. + assert!(!evaluate(Tally { for_votes: 10, against: 10 }, 5745, &p).expect("valid").well_rated); } #[test] fn no_vote_is_never_reached() { - let p = g1vote(50.0, 0.1); - let e = evaluate(Tally::default(), 3, &p).expect("valid"); - assert!(!e.reached); - assert!(!e.well_rated); + for q in [0, 1, 12] { + let p = FormulaParams { quorum: q, ..g1vote(50.0, 0.1) }; + let e = evaluate(Tally::default(), 3, &p).expect("valid"); + assert!(!e.reached); + assert!(!e.well_rated); + } } #[test] - fn small_group_needs_everyone() { - // Test group of two people, C = 1: both must vote for. - let p = standard(1.0); - assert!(!evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached); + fn the_test_group_passes_with_one_voice_at_a_quorum_of_one() { + // Two people, quorum 1 (Yvv, 2026-11-02: the test group stays at 1 everywhere): one + // « pour » of one voter holds any share; a « contre » beside it does not. + let p = standard(1); + assert!(evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached); + assert!(!evaluate(Tally { for_votes: 1, against: 1 }, 2, &p).expect("valid").reached); assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached); } @@ -225,12 +394,12 @@ mod tests { #[test] fn a_whole_number_of_votes_never_gains_one_by_rounding() { - // 101 members, everyone votes, M 55 %: 1 + 55 % of 100 = 56. In floating - // point 0.55 × 100 is 55.000000000000007, which would ask for 57. - let p = FormulaParams { majority_pct: 55.0, gradient: 0.3, base: 0.1, constant: 1.0 }; - assert_eq!(wot_threshold(101, 101, &p), Ok(56)); + // 100 members, everyone votes, M 55 %: 55 % of 100 = 55. In floating point 0.55 × 100 + // is 55.000000000000007, which would ask for 56 (B^W = 10⁻¹⁰⁰ adds nothing). + let p = FormulaParams { majority_pct: 55.0, gradient: 0.3, base: 0.1, quorum: 1 }; + assert_eq!(wot_threshold(100, 100, &p), Ok(55)); // A genuine fraction still rounds up. - assert_eq!(wot_threshold(101, 100, &FormulaParams { constant: 0.0, ..p }), Ok(56)); + assert_eq!(wot_threshold(101, 99, &FormulaParams { quorum: 0, ..p }), Ok(55)); } #[test] @@ -239,5 +408,6 @@ mod tests { assert_eq!(wot_threshold(0, 1, &p), Err(ThresholdError::EmptyElectorate)); let bad = g1vote(120.0, 0.2); assert_eq!(wot_threshold(10, 1, &bad), Err(ThresholdError::MajorityOutOfRange)); + assert_eq!(yes_needed(0, 0, &p), Err(ThresholdError::EmptyElectorate)); } }