//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula. //! //! ```text //! R = C + B^W + (M + (1 − M)(1 − (T/W)^G)) · max(0, T − C) //! ``` //! //! 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 »). //! //! 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). use serde::{Deserialize, Serialize}; /// The letters of the formula, named. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct FormulaParams { /// M, in percent (0–100). pub majority_pct: f64, /// G. pub gradient: f64, /// B. pub base: f64, /// C — minimal number of *for* votes. pub constant: f64, } impl FormulaParams { pub const DEFAULT_BASE: f64 = 0.1; } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThresholdError { EmptyElectorate, MajorityOutOfRange, } /// The required ratio of *for* votes among the votes cast beyond C. pub fn required_ratio(electorate: u32, cast: u32, params: &FormulaParams) -> f64 { let m = params.majority_pct / 100.0; let participation = (f64::from(cast) / 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. pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Result { if electorate == 0 { return Err(ThresholdError::EmptyElectorate); } if !(0.0..=100.0).contains(¶ms.majority_pct) { return Err(ThresholdError::MajorityOutOfRange); } let c = params.constant; let offset = params.base.powf(f64::from(electorate)); let raw = if cast == 0 { c + offset } else { c + offset + settle(required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0)) }; // `raw` is finite and non-negative for any valid input; saturate rather than wrap. Ok(clamp_ceil(raw)) } /// 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. fn settle(votes: f64) -> f64 { let whole = votes.round(); if (votes - whole).abs() < 1e-9 { whole } else { votes } } fn clamp_ceil(raw: f64) -> u32 { let ceiled = raw.ceil(); if ceiled <= 0.0 { 0 } else if ceiled >= f64::from(u32::MAX) { u32::MAX } else { // Exact: `ceiled` is an integer in (0, u32::MAX). ceiled as u32 } } /// Votes counted on one proposal. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Tally { #[serde(rename = "for")] pub for_votes: u32, pub against: u32, } impl Tally { pub fn cast(&self) -> u32 { self.for_votes.saturating_add(self.against) } } /// Where a proposal stands, in words a screen can say. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Evaluation { pub cast: u32, /// ⌈R⌉ at the current participation. pub required: u32, /// `for ≥ ⌈R⌉` with at least one vote cast. pub reached: bool, /// *for* votes still missing (0 once reached). pub missing: u32, /// The majority the inertia asks for at this participation. 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. pub well_rated: bool, } 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; Ok(Evaluation { cast, 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, }) } #[cfg(test)] mod tests { use super::*; fn g1vote(m: f64, g: f64) -> FormulaParams { FormulaParams { majority_pct: m, gradient: g, base: 0.1, constant: 0.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) } } #[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. 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"); assert!(e.reached); assert_eq!(e.missing, 0); } #[test] fn parity_with_the_typescript_reference() { // Values computed by maquette/frontend/app/engine/threshold.ts. 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)); assert_eq!(wot_threshold(100, 100, &p), Ok(50)); assert_eq!(wot_threshold(5, 5, &p), Ok(3)); let high = g1vote(60.0, 0.4); assert_eq!(wot_threshold(7224, 120, &high), Ok(111)); } #[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"); } #[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"); } #[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); } #[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); assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached); } #[test] fn stale_electorate_cannot_lower_the_bar_below_majority() { let p = g1vote(50.0, 0.2); // 30 votes cast for an electorate believed to be 10: the reference // would ask 12 (ratio below M), the cap keeps the simple majority: // ⌈B^W + 0.5 × 30⌉ = 16, the tiny B^W offset rounding up. let r = wot_threshold(10, 30, &p).expect("valid"); assert_eq!(r, 16); } #[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)); // A genuine fraction still rounds up. assert_eq!(wot_threshold(101, 100, &FormulaParams { constant: 0.0, ..p }), Ok(56)); } #[test] fn errors() { let p = g1vote(50.0, 0.2); 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)); } }