Lot S, phase 2 : le quorum dans le moteur (ADR 0013), vu rouge puis vert

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 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 04:36:43 +01:00
co-authored by Claude Opus 5.5
parent e49f7fb98f
commit 37147d1f59
4 changed files with 419 additions and 158 deletions
+28 -9
View File
@@ -19,7 +19,8 @@
//! - what the document would read if the well-rated, or all, proposals passed //! - what the document would read if the well-rated, or all, proposals passed
//! (`preview`); //! (`preview`);
//! - which formula a proposal is judged by (`params_for`): the level of //! - 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::inertia::{InertiaLevel, Scale};
use crate::threshold::{FormulaParams, Tally, ThresholdError, evaluate}; use crate::threshold::{FormulaParams, Tally, ThresholdError, evaluate};
@@ -40,6 +41,10 @@ pub struct UnitState {
pub id: String, pub id: String,
pub position: f64, pub position: f64,
pub inertia: InertiaLevel, 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<u32>,
pub in_force: Option<VersionText>, pub in_force: Option<VersionText>,
} }
@@ -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 { 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. /// A proposal that changes nothing cannot enter into force.
@@ -264,6 +272,7 @@ mod tests {
id: id.into(), id: id.into(),
position, position,
inertia: InertiaLevel::DEFAULT, inertia: InertiaLevel::DEFAULT,
quorum: None,
in_force: in_force.map(|b| VersionText { in_force: in_force.map(|b| VersionText {
id: format!("{id}-v"), id: format!("{id}-v"),
title: id.into(), title: id.into(),
@@ -286,8 +295,9 @@ mod tests {
} }
} }
/// Monnaie libre before any median: W 5745, C 50, the reference p0 = 2C/W. /// Monnaie libre before any median: W 5745, the reference p0 = 2 × 50 / W, the quorums
const SCALE: Scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 }; /// 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 { fn level(k: u8) -> InertiaLevel {
InertiaLevel::new(k).expect("1 to 5") InertiaLevel::new(k).expect("1 to 5")
@@ -301,12 +311,18 @@ mod tests {
} }
#[test] #[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 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()); 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()]); 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] #[test]
@@ -339,7 +355,10 @@ mod tests {
assert_eq!(voting_level(&u, kind), level(4), "inherits the level of the article"); 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, 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] #[test]
@@ -358,7 +377,7 @@ mod tests {
// The seed a minima has no vote: the first rival that reaches its threshold wins. // 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)]; let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)];
assert_eq!(adoptions(&units, &rival, &SCALE).expect("valid"), vec!["r".to_string()]); 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() { if let Some(v) = units[0].in_force.as_mut() {
v.tally = Tally { for_votes: 60, against: 0 }; v.tally = Tally { for_votes: 60, against: 0 };
} }
+138 -70
View File
@@ -5,20 +5,25 @@
//! médiane aux votes du groupe » — the scale chosen is « calée sur la //! médiane aux votes du groupe » — the scale chosen is « calée sur la
//! participation ». //! participation ».
//! //!
//! The formula stays the Ğ1 one (`threshold`); only M and G depend on the //! The formula stays the Ğ1 one (`threshold`); M, G and the quorum Q depend on
//! level k: //! the level k:
//! //!
//! - M_k, the majority at full participation: 50, 50, 55, 60, 66 %; //! - 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 %; //! participation p̃: 60, 67, 75, 83, 90 %;
//! - G_k follows from `s_k = M_k + (1 − M_k)(1 − p̃^G_k)`: //! - 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 //! p̃ is the median number of voters on the group's proposals, over W
//! (`Participation`). Until the group has voted on enough proposals, a //! (`Participation`). Until the group has voted on enough proposals, a
//! reference `p0 = 2C/W` stands in. Both are kept within [1 %, 50 %]: ln(p̃) //! reference `p0 = 2Q₀/W` stands in, Q₀ the document's reference quorum (its
//! stays negative and finite, so G is finite and positive, and the threshold //! `vote_floor`, never an article's quorum: an author who lowers his would move
//! rises with the level and relaxes toward M as participation grows. //! 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 crate::threshold::{Evaluation, FormulaParams, Tally, ThresholdError, evaluate};
use serde::de::Error as _; use serde::de::Error as _;
@@ -57,7 +62,7 @@ impl InertiaLevel {
MAJORITY_PCT[self.index()] 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 { pub fn share_pct(self) -> f64 {
SHARE_PCT[self.index()] SHARE_PCT[self.index()]
} }
@@ -115,7 +120,7 @@ impl<'de> Deserialize<'de> for InertiaLevel {
pub enum ParticipationSource { pub enum ParticipationSource {
/// The median of the group's voted proposals. /// The median of the group's voted proposals.
Median, Median,
/// Too few voted proposals yet: `p0 = 2C/W`. /// Too few voted proposals yet: `p0 = 2Q₀/W`, Q₀ the document's reference quorum.
Reference, Reference,
} }
@@ -140,18 +145,19 @@ impl Participation {
pub const LOWEST: f64 = 0.01; pub const LOWEST: f64 = 0.01;
pub const HIGHEST: f64 = 0.5; pub const HIGHEST: f64 = 0.5;
/// `2C/W`, before the bounds: the floor twice over, a participation the /// `2Q₀/W`, before the bounds: the reference quorum of the document twice
/// group must reach anyway for C to leave room to the majority. /// over (`documents.vote_floor`: 50 in Monnaie libre, 100 voters). It never
pub fn reference_ratio(electorate: u32, floor: f64) -> f64 { /// rests on an article's quorum, which its author may lower (ADR 0013).
2.0 * floor.max(0.0) / f64::from(electorate.max(1)) 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, /// p̃ from what was measured: the median when it rests on enough proposals,
/// the reference otherwise; brought back within the bounds. /// the reference otherwise; brought back within the bounds.
pub fn of(electorate: u32, floor: f64, median: Option<f64>, sample: u32) -> Self { pub fn of(electorate: u32, reference_quorum: f64, median: Option<f64>, sample: u32) -> Self {
let (raw, source) = match median { let (raw, source) = match median {
Some(m) if sample >= Self::MIN_SAMPLE => (m / f64::from(electorate.max(1)), ParticipationSource::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); let ratio = bounded(raw);
Self { ratio, source, sample, median, bounded: ratio != 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() (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 /// What the votes of a document are judged by: its electorate W, its usual
/// its usual participation p̃. /// participation p̃ and the quorum of each level.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Scale { pub struct Scale {
pub electorate: u32, pub electorate: u32,
pub floor: f64,
/// p̃. /// p̃.
pub participation: f64, 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 { impl Scale {
/// The letters of the formula for a level. /// The quorum an article is judged on: its own, set by its author before the first
pub fn params(&self, level: InertiaLevel) -> FormulaParams { /// 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>) -> 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 { FormulaParams {
majority_pct: level.majority_pct(), majority_pct: level.majority_pct(),
gradient: gradient(level, self.participation), gradient: gradient(level, self.participation),
base: FormulaParams::DEFAULT_BASE, base: FormulaParams::DEFAULT_BASE,
constant: self.floor, quorum,
} }
} }
pub fn evaluate(&self, tally: Tally, level: InertiaLevel) -> Result<Evaluation, ThresholdError> { pub fn evaluate(&self, tally: Tally, level: InertiaLevel, quorum: u32) -> Result<Evaluation, ThresholdError> {
evaluate(tally, self.electorate, &self.params(level)) evaluate(tally, self.electorate, &self.params(level, quorum))
} }
/// ⌈R⌉ for a level at `cast` votes. /// ⌈R⌉ for a level and a quorum at `cast` votes.
pub fn required(&self, cast: u32, level: InertiaLevel) -> Result<u32, ThresholdError> { pub fn required(&self, cast: u32, level: InertiaLevel, quorum: u32) -> Result<u32, ThresholdError> {
crate::threshold::wot_threshold(self.electorate, cast, &self.params(level)) crate::threshold::wot_threshold(self.electorate, cast, &self.params(level, quorum))
} }
} }
@@ -228,14 +243,22 @@ mod tests {
use crate::threshold::required_ratio; use crate::threshold::required_ratio;
const W: u32 = 7224; 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 { fn level(k: u8) -> InertiaLevel {
Scale { electorate: W, floor: C, participation: f64::from(voters) / f64::from(W) } 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<u32> { fn row(scale: &Scale, cast: u32) -> Vec<u32> {
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] #[test]
@@ -265,30 +288,48 @@ mod tests {
assert_eq!(serde_json::to_string(&InertiaLevel::HIGHEST).expect("json"), "5"); 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<u32> = InertiaLevel::ALL.iter().map(|k| scale.required(10, *k, 1).expect("valid")).collect();
assert_eq!(ten, [7, 8, 9, 9, 10]);
}
#[test] #[test]
fn the_values_shown_to_yvv() { 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); let scale = at_median(120);
assert_eq!(row(&scale, 120), [92, 97, 103, 109, 113]); assert_eq!(row(&scale, 120), [72, 81, 90, 100, 108]);
assert_eq!(row(&scale, 500), [306, 329, 362, 397, 432]); assert_eq!(row(&scale, 500), [284, 310, 347, 386, 424]);
assert_eq!(row(&scale, 2000), [1091, 1145, 1271, 1404, 1549]); assert_eq!(row(&scale, 2000), [1068, 1123, 1252, 1389, 1537]);
} }
#[test] #[test]
fn a_median_of_360_voters() { fn a_median_of_360_voters() {
let scale = at_median(360); let scale = at_median(360);
assert_eq!(row(&scale, 120), [95, 101, 106, 112, 116]); assert_eq!(row(&scale, 120), [76, 86, 96, 106, 113]);
assert_eq!(row(&scale, 360), [236, 258, 283, 308, 329]); assert_eq!(row(&scale, 360), [216, 242, 270, 299, 324]);
assert_eq!(row(&scale, 500), [316, 345, 381, 416, 449]); assert_eq!(row(&scale, 500), [296, 328, 367, 407, 443]);
assert_eq!(row(&scale, 2000), [1114, 1184, 1318, 1460, 1608]); assert_eq!(row(&scale, 2000), [1092, 1164, 1301, 1446, 1598]);
} }
#[test] #[test]
fn at_the_usual_participation_the_share_beyond_c_is_s_k() { fn at_the_usual_participation_the_share_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)] { for (w, voters) in [(7224, 120), (7224, 360), (5745, 300), (40, 9), (2, 1)] {
let scale = Scale { electorate: w, floor: c, participation: f64::from(voters) / f64::from(w) }; let scale = Scale { electorate: w, participation: f64::from(voters) / f64::from(w), quorums: QUORUMS };
for k in InertiaLevel::ALL { 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()); assert!((ratio - k.share_pct() / 100.0).abs() < 1e-12, "W {w}, k {}: {ratio}", k.get());
} }
} }
@@ -296,26 +337,33 @@ mod tests {
#[test] #[test]
fn a_threshold_landing_on_a_whole_number_stays_on_it() { 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. // Level 5 at the median: 90 % of 120 voters is exactly 108.
assert_eq!(at_median(120).required(120, InertiaLevel::HIGHEST), Ok(113)); assert_eq!(at_median(120).required(120, InertiaLevel::HIGHEST, 50), Ok(108));
// Level 1: 60 % of 70 is exactly 42. // Level 1: 60 % of 120 is exactly 72.
assert_eq!(at_median(120).required(120, InertiaLevel::LOWEST), Ok(92)); assert_eq!(at_median(120).required(120, InertiaLevel::LOWEST, 12), Ok(72));
} }
#[test] #[test]
fn the_threshold_rises_with_the_level() { 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] { for voters in [1, 2, 9, 60, 120, 360, 3000] {
let scale = Scale { let scale = Scale {
electorate: w, electorate: w,
floor: c, participation: Participation::of(w, q0, Some(f64::from(voters)), 9).ratio,
participation: Participation::of(w, c, Some(f64::from(voters)), 9).ratio, quorums,
}; };
for cast in (0..=w.min(4000)).step_by(7).chain([w]) { for cast in (0..=w.min(4000)).step_by(7).chain([w]) {
let r = row(&scale, cast); let r = row(&scale, cast);
assert!(r.windows(2).all(|p| p[0] <= p[1]), "W {w}, p̃ {voters}, T {cast}: {r:?}"); assert!(r.windows(2).all(|p| p[0] <= p[1]), "W {w}, p̃ {voters}, T {cast}: {r:?}");
let ratios: Vec<f64> = let ratios: Vec<f64> =
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 { if cast > 0 && cast < w {
assert!( assert!(
ratios.windows(2).all(|p| p[0] < p[1]), ratios.windows(2).all(|p| p[0] < p[1]),
@@ -332,7 +380,7 @@ mod tests {
for voters in [120, 360] { for voters in [120, 360] {
let scale = at_median(voters); let scale = at_median(voters);
for k in InertiaLevel::ALL { for k in InertiaLevel::ALL {
let p = scale.params(k); let p = scale.params(k, 0);
let mut last = 1.0; let mut last = 1.0;
for cast in (1..=W).step_by(13).chain([W]) { for cast in (1..=W).step_by(13).chain([W]) {
let ratio = required_ratio(W, cast, &p); let ratio = required_ratio(W, cast, &p);
@@ -346,26 +394,44 @@ mod tests {
} }
#[test] #[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] { for voters in [1, 72, 120, 360, 3612] {
let scale = Scale { let scale = Scale {
electorate: W, electorate: W,
floor: C, participation: Participation::of(W, Q0, Some(f64::from(voters)), 9).ratio,
participation: Participation::of(W, C, Some(f64::from(voters)), 9).ratio, quorums: QUORUMS,
}; };
for k in InertiaLevel::ALL { 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!(!lone.reached, "k {}", k.get());
assert_eq!(lone.required, 50); // Under the quorum, the voters missing count as against: the bar of Q voters.
for cast in 1..=50 { let at_q = scale.required(q, k, q).expect("valid");
assert!(scale.required(cast, k).expect("valid") >= 50, "nothing passes under C for"); 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); let short = Tally { for_votes: at_q - 1, against: 0 };
assert!(scale.evaluate(Tally { for_votes: 50, against: 0 }, k).expect("valid").reached); 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] #[test]
fn the_median() { fn the_median() {
assert_eq!(median(&[]), None); assert_eq!(median(&[]), None);
@@ -378,14 +444,14 @@ mod tests {
#[test] #[test]
fn the_reference_stands_in_until_five_proposals_are_voted_on() { fn the_reference_stands_in_until_five_proposals_are_voted_on() {
// Monnaie libre: 2 × 50 / 7224 = 1.38 %, a hundred voters. // Monnaie libre: 2 × 50 / 7224 = 1.38 %, a hundred voters.
assert!((Participation::reference_ratio(W, C) - 100.0 / 7224.0).abs() < 1e-15); assert!((Participation::reference_ratio(W, Q0) - 100.0 / 7224.0).abs() < 1e-15);
let early = Participation::of(W, C, Some(120.0), 4); let early = Participation::of(W, Q0, Some(120.0), 4);
assert_eq!(early.source, ParticipationSource::Reference); assert_eq!(early.source, ParticipationSource::Reference);
assert!((early.voters(W) - 100.0).abs() < 1e-9); assert!((early.voters(W) - 100.0).abs() < 1e-9);
assert_eq!((early.sample, early.median, early.bounded), (4, Some(120.0), false)); 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); 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_eq!(measured.source, ParticipationSource::Median);
assert!((measured.voters(W) - 120.0).abs() < 1e-9); assert!((measured.voters(W) - 120.0).abs() < 1e-9);
assert!(!measured.bounded); assert!(!measured.bounded);
@@ -394,7 +460,7 @@ mod tests {
#[test] #[test]
fn participation_is_kept_within_one_and_fifty_percent() { fn participation_is_kept_within_one_and_fifty_percent() {
// Two voters on 7224: brought up to 1 %. // 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)); assert_eq!((low.ratio, low.bounded), (0.01, true));
// A group where everyone votes: brought down to half. // A group where everyone votes: brought down to half.
let all = Participation::of(12, 1.0, Some(12.0), 9); let all = Participation::of(12, 1.0, Some(12.0), 9);
@@ -418,12 +484,14 @@ mod tests {
} }
#[test] #[test]
fn the_test_group_needs_everyone() { fn the_test_group_at_a_quorum_of_one() {
// W = 2, C = 1: both must vote for, at every level. // W = 2, quorum 1 everywhere (Yvv, 2026-11-02): one « pour » alone passes, at every
let scale = Scale { electorate: 2, floor: 1.0, participation: Participation::of(2, 1.0, None, 0).ratio }; // 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 { for k in InertiaLevel::ALL {
assert!(!scale.evaluate(Tally { for_votes: 1, 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: 2, against: 0 }, k).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);
} }
} }
+10 -6
View File
@@ -274,13 +274,15 @@ mod tests {
use crate::compose::{ProposalKind, ProposalState, UnitState, VersionText, adoptions}; use crate::compose::{ProposalKind, ProposalState, UnitState, VersionText, adoptions};
use crate::inertia::{InertiaLevel, Scale}; use crate::inertia::{InertiaLevel, Scale};
use crate::threshold::Tally; use crate::threshold::Tally;
// Monnaie libre before any median: W 5745, C 50; the order of a section at level 2. // Monnaie libre before any median: W 5745, p0 = 100 / W; the order of a section at
let scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 }; // 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 section = ids(&["T1", "E1", "E2"]);
let module = |tally: Tally| UnitState { let module = |tally: Tally| UnitState {
id: "ordre.fondamental".into(), id: "ordre.fondamental".into(),
position: 0.0, position: 0.0,
inertia: InertiaLevel::new(2).expect("level"), inertia: InertiaLevel::new(2).expect("level"),
quorum: None,
in_force: Some(VersionText { id: "seed".into(), title: String::new(), body: canonical(&section), tally }), in_force: Some(VersionText { id: "seed".into(), title: String::new(), body: canonical(&section), tally }),
}; };
let proposal = |id: &str, order: &[&str], for_votes: u32| ProposalState { let proposal = |id: &str, order: &[&str], for_votes: u32| ProposalState {
@@ -294,12 +296,14 @@ mod tests {
blocked: false, blocked: false,
}; };
let seed = [module(Tally::default())]; let seed = [module(Tally::default())];
// Never under C voices for; the order in force at zero votes is a seed a minima: margin −C. // Never under the bar of the quorum (9 « pour » of 12 voters); the order in force at zero
assert!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 49)], &scale).expect("valid").is_empty()); // votes is a seed a minima: the margin of zero votes, −9.
assert_eq!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 50)], &scale).expect("valid"), ["p"]); 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. // 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()); 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 })]; let voted = [module(Tally { for_votes: 70, against: 0 })];
assert!(adoptions(&voted, &[proposal("w", &["E2", "E1", "T1"], 60)], &scale).expect("valid").is_empty()); 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"]); assert_eq!(adoptions(&voted, &[proposal("s", &["E2", "E1", "T1"], 90)], &scale).expect("valid"), ["s"]);
+243 -73
View File
@@ -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 //! ```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 //! R is the minimum number of *for* votes; a version passes when `for ≥ ⌈R⌉`.
//! `for ≥ ⌈R⌉`. W is the size of the electorate, T the votes cast (for + //! W is the size of the electorate, T the votes cast (for + against). At low
//! against). At low participation the formula asks for near-unanimity, at full //! participation the formula asks for near-unanimity, at full participation the
//! participation the simple majority M suffices. C is the minimal number of //! simple majority M suffices. Q is the **quorum** (ADR 0013, Yvv, 2026-11-02:
//! *for* votes below which nothing passes — the guard of a permanent vote //! « ce n'est pas un nombre de vote pour a priori, c'est plutôt un quorum, à partir
//! (Yvv, 2026-10-21: « Nombre minimal de votants »). //! 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 //! Parity with `maquette/frontend/app/engine/threshold.ts` at Q = 0 (the C = 0 of
//! Python reference, with two deliberate guards — the participation ratio is //! the maquette), with two deliberate guards — the participation ratio is capped
//! capped at 1, so a stale W can never lower the bar below M; and a count that //! at 1, so a stale W can never lower the bar below M; and a count that lands on a
//! lands on a whole number stays on it (`settle`), so a rounding error never //! whole number stays on it (`settle`), so a rounding error never adds a vote.
//! adds a vote. Which M and G a text is judged by: `inertia` (five levels). //! Which M, G and Q a text is judged by: `inertia` (five levels, their quorums).
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -28,8 +31,9 @@ pub struct FormulaParams {
pub gradient: f64, pub gradient: f64,
/// B. /// B.
pub base: f64, pub base: f64,
/// C — minimal number of *for* votes. /// Q — the quorum: the voters (for + against) from which the share of the inertia
pub constant: f64, /// applies; below it, the voters missing count as against (`judged_voters`).
pub quorum: u32,
} }
impl FormulaParams { impl FormulaParams {
@@ -42,14 +46,26 @@ pub enum ThresholdError {
MajorityOutOfRange, MajorityOutOfRange,
} }
/// The required ratio of *for* votes among the votes cast beyond C. /// T' — the voters a version is judged on. **The rule of the quorum, here alone.**
pub fn required_ratio(electorate: u32, cast: u32, params: &FormulaParams) -> f64 { ///
/// 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 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)) 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<u32, ThresholdError> { pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Result<u32, ThresholdError> {
if electorate == 0 { if electorate == 0 {
return Err(ThresholdError::EmptyElectorate); 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(&params.majority_pct) { if !(0.0..=100.0).contains(&params.majority_pct) {
return Err(ThresholdError::MajorityOutOfRange); return Err(ThresholdError::MajorityOutOfRange);
} }
let c = params.constant; let voters = judged_voters(cast, params.quorum);
let offset = params.base.powf(f64::from(electorate)); let offset = params.base.powf(f64::from(electorate));
let raw = if cast == 0 { let raw = if voters == 0 {
c + offset offset
} else { } 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. // `raw` is finite and non-negative for any valid input; saturate rather than wrap.
Ok(clamp_ceil(raw)) 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<bool, ThresholdError> {
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 /// 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 /// 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 { fn settle(votes: f64) -> f64 {
let whole = votes.round(); let whole = votes.round();
if (votes - whole).abs() < 1e-9 { whole } else { votes } if (votes - whole).abs() < 1e-9 { whole } else { votes }
@@ -107,32 +130,82 @@ impl Tally {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Evaluation { pub struct Evaluation {
pub cast: u32, 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, pub required: u32,
/// `for ≥ ⌈R⌉` with at least one vote cast. /// `passes`: `for ≥ ⌈R⌉` with at least one vote cast.
pub reached: bool, 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, 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, pub required_ratio: f64,
/// « Bien cotée » : it holds the majority its inertia asks for, it only /// « Bien cotée » : it holds the share its inertia asks of the T voters who came,
/// lacks voters (C ignored). Used by the "if the well-rated pass" preview. /// it only lacks voters (the quorum ignored). Used by the "if the well-rated pass" preview.
pub well_rated: bool, 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<bool, ThresholdError>) -> Result<u32, ThresholdError> {
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<u32, ThresholdError> {
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<u32, ThresholdError> {
first(|n| passes(Tally { for_votes, against: n }, electorate, params).map(|p| !p))
} }
pub fn evaluate(tally: Tally, electorate: u32, params: &FormulaParams) -> Result<Evaluation, ThresholdError> { pub fn evaluate(tally: Tally, electorate: u32, params: &FormulaParams) -> Result<Evaluation, ThresholdError> {
let cast = tally.cast(); let cast = tally.cast();
let required = wot_threshold(electorate, cast, params)?; let required = wot_threshold(electorate, cast, params)?;
let without_floor = FormulaParams { constant: 0.0, ..*params }; let reached = passes(tally, electorate, params)?;
let required_without_floor = wot_threshold(electorate, cast, &without_floor)?; let without_quorum = FormulaParams { quorum: 0, ..*params };
let reached = cast > 0 && tally.for_votes >= required; let required_without_quorum = wot_threshold(electorate, cast, &without_quorum)?;
let yes = yes_needed(tally.against, electorate, params)?;
Ok(Evaluation { Ok(Evaluation {
cast, cast,
quorum: params.quorum,
required, required,
reached, reached,
missing: required.saturating_sub(tally.for_votes), missing: if reached { 0 } else { yes.saturating_sub(tally.for_votes) },
required_ratio: required_ratio(electorate, cast, params), required_ratio: required_ratio(electorate, judged_voters(cast, params.quorum), params),
well_rated: cast > 0 && tally.for_votes >= required_without_floor, 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::*; use super::*;
fn g1vote(m: f64, g: f64) -> FormulaParams { 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. /// The letters of the « standard » inertia before the five levels: M 50 %, G 0.2.
fn standard(constant: f64) -> FormulaParams { fn standard(quorum: u32) -> FormulaParams {
FormulaParams { constant, ..g1vote(50.0, 0.2) } FormulaParams { quorum, ..g1vote(50.0, 0.2) }
} }
#[test] #[test]
fn reference_case_of_the_smith_vote() { fn reference_case_of_the_smith_vote() {
// Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94. // 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) // The formula's own reference, letters given, without a quorum (Q = 0, the C = 0
// choose M and G, the formula that turns them into a threshold is this one. // of the maquette): the five levels choose M, G and Q (ADR 0007, 0013).
let p = g1vote(50.0, 0.2); let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(7224, 120, &p), Ok(94)); assert_eq!(wot_threshold(7224, 120, &p), Ok(94));
let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid"); let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid");
@@ -163,7 +236,7 @@ mod tests {
#[test] #[test]
fn parity_with_the_typescript_reference() { 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); let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(7224, 0, &p), Ok(0)); assert_eq!(wot_threshold(7224, 0, &p), Ok(0));
assert_eq!(wot_threshold(7224, 1, &p), Ok(1)); assert_eq!(wot_threshold(7224, 1, &p), Ok(1));
@@ -174,42 +247,138 @@ mod tests {
} }
#[test] #[test]
fn one_vote_passes_without_floor_and_never_with_it() { fn yvv_s_example_sixty_percent_and_a_quorum_of_ten() {
// The trap of a permanent vote: a lone "for" meets the inertial bar. // Yvv, 2026-11-02: « Si c'est 60% et c=10, alors il faut 6 voix pour et ça passe. »
let bare = standard(0.0); // A share of 60 % whatever the participation (G = 0), a large web (B^W vanishes).
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached); let p = FormulaParams { majority_pct: 60.0, gradient: 0.0, base: 0.1, quorum: 10 };
let guarded = standard(50.0); // G = 0: (T/W)^0 = 1, the share is M, 60 %.
let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &guarded).expect("valid"); assert!((required_ratio(7224, 10, &p) - 0.6).abs() < 1e-12);
assert!(!e.reached); for against in 0..=4 {
assert_eq!(e.required, 50); let t = Tally { for_votes: 6, against };
assert_eq!(e.missing, 49); assert_eq!(passes(t, 7224, &p), Ok(true), "6 pour, {against} contre");
assert!(e.well_rated, "unanimous, it only lacks voters"); }
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] #[test]
fn floor_is_a_minimum_of_for_votes() { fn under_the_quorum_the_voters_missing_count_as_against() {
let p = standard(50.0); // Yvv, 2026-11-03: « Ils comptent comme non ». T' = max(T, Q).
// 50 unanimous votes reach it, 49 do not. assert_eq!(
assert!(evaluate(Tally { for_votes: 50, against: 0 }, 5745, &p).expect("valid").reached); (judged_voters(0, 12), judged_voters(9, 12), judged_voters(12, 12), judged_voters(30, 12)),
assert!(!evaluate(Tally { for_votes: 49, against: 0 }, 5745, &p).expect("valid").reached); (12, 12, 12, 30)
// Beyond the floor the inertial majority applies to the rest. );
let e = evaluate(Tally { for_votes: 55, against: 5 }, 5745, &p).expect("valid"); let p = standard(50);
assert!(!e.reached, "near-unanimity is asked of the votes beyond C"); // 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<u32> = (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] #[test]
fn no_vote_is_never_reached() { fn no_vote_is_never_reached() {
let p = g1vote(50.0, 0.1); for q in [0, 1, 12] {
let e = evaluate(Tally::default(), 3, &p).expect("valid"); let p = FormulaParams { quorum: q, ..g1vote(50.0, 0.1) };
assert!(!e.reached); let e = evaluate(Tally::default(), 3, &p).expect("valid");
assert!(!e.well_rated); assert!(!e.reached);
assert!(!e.well_rated);
}
} }
#[test] #[test]
fn small_group_needs_everyone() { fn the_test_group_passes_with_one_voice_at_a_quorum_of_one() {
// Test group of two people, C = 1: both must vote for. // Two people, quorum 1 (Yvv, 2026-11-02: the test group stays at 1 everywhere): one
let p = standard(1.0); // « pour » of one voter holds any share; a « contre » beside it does not.
assert!(!evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached); 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); assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached);
} }
@@ -225,12 +394,12 @@ mod tests {
#[test] #[test]
fn a_whole_number_of_votes_never_gains_one_by_rounding() { fn a_whole_number_of_votes_never_gains_one_by_rounding() {
// 101 members, everyone votes, M 55 %: 1 + 55 % of 100 = 56. In floating // 100 members, everyone votes, M 55 %: 55 % of 100 = 55. In floating point 0.55 × 100
// point 0.55 × 100 is 55.000000000000007, which would ask for 57. // 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, constant: 1.0 }; let p = FormulaParams { majority_pct: 55.0, gradient: 0.3, base: 0.1, quorum: 1 };
assert_eq!(wot_threshold(101, 101, &p), Ok(56)); assert_eq!(wot_threshold(100, 100, &p), Ok(55));
// A genuine fraction still rounds up. // 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] #[test]
@@ -239,5 +408,6 @@ mod tests {
assert_eq!(wot_threshold(0, 1, &p), Err(ThresholdError::EmptyElectorate)); assert_eq!(wot_threshold(0, 1, &p), Err(ThresholdError::EmptyElectorate));
let bad = g1vote(120.0, 0.2); let bad = g1vote(120.0, 0.2);
assert_eq!(wot_threshold(10, 1, &bad), Err(ThresholdError::MajorityOutOfRange)); assert_eq!(wot_threshold(10, 1, &bad), Err(ThresholdError::MajorityOutOfRange));
assert_eq!(yes_needed(0, 0, &p), Err(ThresholdError::EmptyElectorate));
} }
} }