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>
This commit is contained in:
Yvv
2026-10-30 15:14:40 +01:00
co-authored by Claude Opus 5.5
parent 8727315612
commit d762301706
16 changed files with 1817 additions and 224 deletions
+32 -74
View File
@@ -12,66 +12,13 @@
//! (Yvv, 2026-10-21: « Nombre minimal de votants »).
//!
//! Parity: identical to `maquette/frontend/app/engine/threshold.ts` and the
//! Python reference, with one deliberate guard — the participation ratio is
//! capped at 1, so a stale W can never lower the bar below M.
//! 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 four inertia levels of a modular text. Wiring of the v2 maquette:
/// low = M50 G0.1, standard = M50 G0.2, high = M60 G0.4, max = M66 G0.6 (B = 0.1).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InertiaPreset {
Low,
Standard,
High,
Max,
}
impl InertiaPreset {
pub const ALL: [Self; 4] = [Self::Low, Self::Standard, Self::High, Self::Max];
pub fn majority_pct(self) -> f64 {
match self {
Self::Low | Self::Standard => 50.0,
Self::High => 60.0,
Self::Max => 66.0,
}
}
pub fn gradient(self) -> f64 {
match self {
Self::Low => 0.1,
Self::Standard => 0.2,
Self::High => 0.4,
Self::Max => 0.6,
}
}
/// « Inertie + » : one level up, capped at max. A change of inertia is
/// itself voted at the raised level of the unit it touches.
pub fn raised(self) -> Self {
match self {
Self::Low => Self::Standard,
Self::Standard => Self::High,
Self::High | Self::Max => Self::Max,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Standard => "standard",
Self::High => "high",
Self::Max => "max",
}
}
pub fn parse(raw: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.as_str() == raw)
}
}
/// The letters of the formula, named.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct FormulaParams {
@@ -87,10 +34,6 @@ pub struct FormulaParams {
impl FormulaParams {
pub const DEFAULT_BASE: f64 = 0.1;
pub fn for_inertia(preset: InertiaPreset, constant: f64) -> Self {
Self { majority_pct: preset.majority_pct(), gradient: preset.gradient(), base: Self::DEFAULT_BASE, constant }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -119,12 +62,20 @@ pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Resu
let raw = if cast == 0 {
c + offset
} else {
c + offset + required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0)
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 {
@@ -193,9 +144,16 @@ mod tests {
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");
@@ -218,9 +176,9 @@ mod tests {
#[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 = FormulaParams::for_inertia(InertiaPreset::Standard, 0.0);
let bare = standard(0.0);
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached);
let guarded = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
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);
@@ -230,7 +188,7 @@ mod tests {
#[test]
fn floor_is_a_minimum_of_for_votes() {
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
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);
@@ -241,7 +199,7 @@ mod tests {
#[test]
fn no_vote_is_never_reached() {
let p = FormulaParams::for_inertia(InertiaPreset::Low, 0.0);
let p = g1vote(50.0, 0.1);
let e = evaluate(Tally::default(), 3, &p).expect("valid");
assert!(!e.reached);
assert!(!e.well_rated);
@@ -250,7 +208,7 @@ mod tests {
#[test]
fn small_group_needs_everyone() {
// Test group of two people, C = 1: both must vote for.
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 1.0);
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);
}
@@ -266,13 +224,13 @@ mod tests {
}
#[test]
fn inertia_levels_rise_and_cap() {
assert_eq!(InertiaPreset::Low.raised(), InertiaPreset::Standard);
assert_eq!(InertiaPreset::Standard.raised(), InertiaPreset::High);
assert_eq!(InertiaPreset::Max.raised(), InertiaPreset::Max);
for p in InertiaPreset::ALL {
assert_eq!(InertiaPreset::parse(p.as_str()), Some(p));
}
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]