Files
decision/crates/ld-engine/src/inertia.rs
T
YvvandClaude Opus 5.5 d762301706 v3 : inertie à cinq crans — moteur, données, serveur (L10)
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>
2026-10-30 15:14:40 +01:00

439 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Self> {
(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<Self> {
match raw.trim() {
"low" => Some(Self(2)),
"standard" => Some(Self(3)),
"high" => Some(Self(4)),
"max" => Some(Self(5)),
digits => digits.parse::<u8>().ok().and_then(Self::new),
}
}
}
impl Default for InertiaLevel {
fn default() -> Self {
Self::DEFAULT
}
}
impl Serialize for InertiaLevel {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
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: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[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<f64>,
/// 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<f64>, 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<f64> {
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<Evaluation, ThresholdError> {
evaluate(tally, self.electorate, &self.params(level))
}
/// ⌈R⌉ for a level at `cast` votes.
pub fn required(&self, cast: u32, level: InertiaLevel) -> Result<u32, ThresholdError> {
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<u32> {
InertiaLevel::ALL.iter().map(|k| scale.required(cast, *k).expect("valid")).collect()
}
#[test]
fn five_levels_three_in_the_middle() {
let m: Vec<f64> = InertiaLevel::ALL.iter().map(|k| k.majority_pct()).collect();
let s: Vec<f64> = 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::<InertiaLevel>(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<f64> =
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<f64> = 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:?}");
}
}
}