Moteur (ld-engine/inertia.rs) : cinq crans, M_k = 50, 50, 55, 60, 66 % ; s_k = 60, 67, 75, 83, 90 % de « pour » au-delà de C à la participation habituelle p̃ ; G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃). p̃ = médiane des votants des propositions du groupe (au moins 5, votées dans l'année), sinon la référence 2C/W ; bornée à [1 %, 50 %]. Le changement d'inertie hérite du cran de l'article (remplace « inertie + »). Un seuil qui tombe sur un entier n'y gagne plus une voix par arrondi. Données : migration 0005 (les quatre mots deviennent 2 à 5, gardés tels quels dans *_legacy ; adoptions intactes ; journaux inertia_changes et inertia_amendments). Seed : 3 partout, 4 pour E1 et E2. Amendement d'inertie gardé (E1, E2 : 3 → 4, seulement si aucun vote n'a changé leur inertie ; une proposition d'inertie ouverte reste ouverte, votée au nouveau cran), journalisé, une seule fois, republié. Serveur : participation mesurée une fois par jour (settings), échelle dans les seuils, le balayage et l'adoption ; crans, M, G, s_k, p̃ et sa source servis au client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
9.2 KiB
Rust
244 lines
9.2 KiB
Rust
//! 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<u32, ThresholdError> {
|
||
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<Evaluation, ThresholdError> {
|
||
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));
|
||
}
|
||
}
|