Files
decision/crates/ld-engine/src/threshold.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

244 lines
9.2 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.
//! 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(&params.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));
}
}