//! Inertia in five levels, on a scale that follows the group (ADR 0007). //! //! Yvv, 2026-10-30: « L'indice d'inertie doit avoir 5 niveaux » ; « une échelle //! cohérente qui s'adapte aux nombres de certifiés et à la participation //! 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: //! //! - 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 //! 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̃)`. //! //! 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. use crate::threshold::{Evaluation, FormulaParams, Tally, ThresholdError, evaluate}; use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// One of the five levels of inertia, 1 (lightest) to 5 (heaviest). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct InertiaLevel(u8); /// M_k, in percent. const MAJORITY_PCT: [f64; 5] = [50.0, 50.0, 55.0, 60.0, 66.0]; /// s_k, in percent. const SHARE_PCT: [f64; 5] = [60.0, 67.0, 75.0, 83.0, 90.0]; impl InertiaLevel { pub const LOWEST: Self = Self(1); pub const HIGHEST: Self = Self(5); /// The middle level: every proposal starts there (Yvv, 2026-10-30). pub const DEFAULT: Self = Self(3); pub const ALL: [Self; 5] = [Self(1), Self(2), Self(3), Self(4), Self(5)]; pub fn new(k: u8) -> Option { (1..=5).contains(&k).then_some(Self(k)) } pub fn get(self) -> u8 { self.0 } fn index(self) -> usize { usize::from(self.0 - 1) } /// M_k, in percent. pub fn majority_pct(self) -> f64 { MAJORITY_PCT[self.index()] } /// s_k, in percent: the share of *for* asked beyond C at the usual participation. pub fn share_pct(self) -> f64 { SHARE_PCT[self.index()] } /// How a level is kept in the database: its digit. pub fn as_db(self) -> String { self.0.to_string() } /// A digit from 1 to 5, or a word of the four levels before 2026-10-30, read /// as the level it became: low → 2, standard → 3, high → 4, max → 5. pub fn parse(raw: &str) -> Option { match raw.trim() { "low" => Some(Self(2)), "standard" => Some(Self(3)), "high" => Some(Self(4)), "max" => Some(Self(5)), digits => digits.parse::().ok().and_then(Self::new), } } } impl Default for InertiaLevel { fn default() -> Self { Self::DEFAULT } } impl Serialize for InertiaLevel { fn serialize(&self, s: S) -> Result { s.serialize_u8(self.0) } } impl<'de> Deserialize<'de> for InertiaLevel { /// A number from 1 to 5, or a string `parse` reads (a seed or a client of before). fn deserialize>(d: D) -> Result { #[derive(Deserialize)] #[serde(untagged)] enum Raw { Number(u64), Text(String), } let level = match Raw::deserialize(d)? { Raw::Number(n) => u8::try_from(n).ok().and_then(Self::new), Raw::Text(s) => Self::parse(&s), }; level.ok_or_else(|| D::Error::custom("an inertia level is 1 to 5")) } } /// Where p̃ comes from. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ParticipationSource { /// The median of the group's voted proposals. Median, /// Too few voted proposals yet: `p0 = 2C/W`. Reference, } /// p̃ — the usual participation of a group, as a share of its electorate. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Participation { /// p̃, within [`Participation::LOWEST`, `Participation::HIGHEST`]. pub ratio: f64, pub source: ParticipationSource, /// Voted proposals the median rests on (counted even when too few). pub sample: u32, /// The median number of voters measured, when at least one proposal was voted on. pub median: Option, /// The measured (or reference) share fell outside the bounds and was brought back. pub bounded: bool, } impl Participation { /// Fewer voted proposals than this, and the reference stands in. pub const MIN_SAMPLE: u32 = 5; 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)) } /// 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 { 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), }; let ratio = bounded(raw); Self { ratio, source, sample, median, bounded: ratio != raw } } /// p̃ · W — the number of voters p̃ stands for. pub fn voters(&self, electorate: u32) -> f64 { self.ratio * f64::from(electorate.max(1)) } } /// The median of the numbers of voters, `None` for none. pub fn median(values: &[u32]) -> Option { let mut sorted = values.to_vec(); sorted.sort_unstable(); let n = sorted.len(); let mid = n / 2; match n { 0 => None, _ if n % 2 == 1 => Some(f64::from(sorted[mid])), _ => Some((f64::from(sorted[mid - 1]) + f64::from(sorted[mid])) / 2.0), } } /// Within [1 %, 50 %]; anything that is not a number goes to the lowest bound. fn bounded(ratio: f64) -> f64 { if ratio.is_nan() { Participation::LOWEST } else { ratio.clamp(Participation::LOWEST, Participation::HIGHEST) } } /// G_k at the usual participation p̃ (brought back within the bounds). pub fn gradient(level: InertiaLevel, participation: f64) -> f64 { let m = level.majority_pct() / 100.0; let s = level.share_pct() / 100.0; // Both logarithms are negative: 0 < 1 − (s − M)/(1 − M) < 1 since M < s < 1, // and 0 < p̃ < 1 within the bounds. (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̃. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Scale { pub electorate: u32, pub floor: f64, /// p̃. pub participation: f64, } impl Scale { /// The letters of the formula for a level. pub fn params(&self, level: InertiaLevel) -> FormulaParams { FormulaParams { majority_pct: level.majority_pct(), gradient: gradient(level, self.participation), base: FormulaParams::DEFAULT_BASE, constant: self.floor, } } pub fn evaluate(&self, tally: Tally, level: InertiaLevel) -> Result { evaluate(tally, self.electorate, &self.params(level)) } /// ⌈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)) } } #[cfg(test)] mod tests { use super::*; use crate::threshold::required_ratio; const W: u32 = 7224; const C: f64 = 50.0; fn at_median(voters: u32) -> Scale { Scale { electorate: W, floor: C, participation: f64::from(voters) / f64::from(W) } } fn row(scale: &Scale, cast: u32) -> Vec { InertiaLevel::ALL.iter().map(|k| scale.required(cast, *k).expect("valid")).collect() } #[test] fn five_levels_three_in_the_middle() { let m: Vec = InertiaLevel::ALL.iter().map(|k| k.majority_pct()).collect(); let s: Vec = InertiaLevel::ALL.iter().map(|k| k.share_pct()).collect(); assert_eq!(m, [50.0, 50.0, 55.0, 60.0, 66.0]); assert_eq!(s, [60.0, 67.0, 75.0, 83.0, 90.0]); for k in InertiaLevel::ALL { assert!(k.share_pct() > k.majority_pct(), "s_k above M_k keeps G positive"); assert_eq!(InertiaLevel::parse(&k.as_db()), Some(k)); } assert_eq!(InertiaLevel::default().get(), 3); assert_eq!(InertiaLevel::new(0), None); assert_eq!(InertiaLevel::new(6), None); } #[test] fn the_four_words_of_before_read_as_levels() { let read = |w: &str| InertiaLevel::parse(w).map(InertiaLevel::get); assert_eq!([read("low"), read("standard"), read("high"), read("max")], [Some(2), Some(3), Some(4), Some(5)]); assert_eq!(read("maximale"), None); let from_json = |v: &str| serde_json::from_str::(v).ok().map(InertiaLevel::get); assert_eq!([from_json("4"), from_json("\"standard\""), from_json("\"5\"")], [Some(4), Some(3), Some(5)]); assert_eq!(from_json("0"), None); assert_eq!(from_json("\"6\""), None); assert_eq!(serde_json::to_string(&InertiaLevel::HIGHEST).expect("json"), "5"); } #[test] fn the_values_shown_to_yvv() { // W 7224, C 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]); } #[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]); } #[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) }; for k in InertiaLevel::ALL { let ratio = required_ratio(w, voters, &scale.params(k)); assert!((ratio - k.share_pct() / 100.0).abs() < 1e-12, "W {w}, k {}: {ratio}", k.get()); } } } #[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)); } #[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 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, }; 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(); if cast > 0 && cast < w { assert!( ratios.windows(2).all(|p| p[0] < p[1]), "strictly below full participation: {ratios:?}" ); } } } } } #[test] fn the_share_relaxes_toward_m_as_participation_rises() { for voters in [120, 360] { let scale = at_median(voters); for k in InertiaLevel::ALL { let p = scale.params(k); let mut last = 1.0; for cast in (1..=W).step_by(13).chain([W]) { let ratio = required_ratio(W, cast, &p); assert!(ratio <= last, "k {}: the share never rises with participation", k.get()); assert!(ratio >= k.majority_pct() / 100.0 - 1e-12); last = ratio; } assert!((required_ratio(W, W, &p) - k.majority_pct() / 100.0).abs() < 1e-12, "M at full participation"); } } } #[test] fn c_stays_a_floor_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, }; for k in InertiaLevel::ALL { let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k).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"); } 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); } } } #[test] fn the_median() { assert_eq!(median(&[]), None); assert_eq!(median(&[7]), Some(7.0)); assert_eq!(median(&[3, 1, 2]), Some(2.0)); assert_eq!(median(&[4, 1, 3, 2]), Some(2.5)); assert_eq!(median(&[1, 1, 1, 200, 300]), Some(1.0), "one big vote does not move it"); } #[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_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); assert_eq!(none.source, ParticipationSource::Reference); let measured = Participation::of(W, C, Some(120.0), 5); assert_eq!(measured.source, ParticipationSource::Median); assert!((measured.voters(W) - 120.0).abs() < 1e-9); assert!(!measured.bounded); } #[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); 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); assert_eq!((all.ratio, all.bounded), (0.5, true)); // The test group, W = 2, C = 1: the reference is 100 %, kept at half. let essai = Participation::of(2, 1.0, None, 0); assert_eq!((essai.ratio, essai.source, essai.bounded), (0.5, ParticipationSource::Reference, true)); // A tiny electorate never breaks G. for w in [1, 2, 3] { let p = Participation::of(w, 1.0, Some(1.0), 9); for k in InertiaLevel::ALL { let g = gradient(k, p.ratio); assert!(g.is_finite() && g > 0.0, "W {w}, k {}: {g}", k.get()); } } // Whatever reaches it, G stays finite and positive. for bad in [0.0, 1.0, -3.0, 7.0, f64::NAN, f64::INFINITY] { let g = gradient(InertiaLevel::DEFAULT, bad); assert!(g.is_finite() && g > 0.0, "{bad}: {g}"); } } #[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 }; 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); } } #[test] fn gradients_at_a_median_of_120() { let g: Vec = InertiaLevel::ALL.iter().map(|k| gradient(*k, 120.0 / 7224.0)).collect(); let expected = [0.054_455, 0.101_404, 0.143_442, 0.208_818, 0.298_651]; for (got, want) in g.iter().zip(expected) { assert!((got - want).abs() < 1e-5, "{g:?}"); } } }