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
+62 -55
View File
@@ -18,9 +18,11 @@
//! - which proposals enter into force now (`adoptions`);
//! - what the document would read if the well-rated, or all, proposals passed
//! (`preview`);
//! - which formula a proposal is judged by (`params_for`).
//! - which formula a proposal is judged by (`params_for`): the level of
//! inertia of its unit, on the scale of its group (`inertia::Scale`).
use crate::threshold::{FormulaParams, InertiaPreset, Tally, ThresholdError, evaluate};
use crate::inertia::{InertiaLevel, Scale};
use crate::threshold::{FormulaParams, Tally, ThresholdError, evaluate};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -37,7 +39,7 @@ pub struct VersionText {
pub struct UnitState {
pub id: String,
pub position: f64,
pub inertia: InertiaPreset,
pub inertia: InertiaLevel,
pub in_force: Option<VersionText>,
}
@@ -49,7 +51,7 @@ pub enum ProposalKind {
/// Remove the unit from the text in force.
Remove,
/// Change the inertia of the unit.
Inertia(InertiaPreset),
Inertia(InertiaLevel),
}
impl ProposalKind {
@@ -72,13 +74,18 @@ pub struct ProposalState {
pub blocked: bool,
}
/// A change of inertia is voted one level above the unit (« inertie + »).
pub fn params_for(unit: &UnitState, kind: ProposalKind, floor: f64) -> FormulaParams {
let preset = match kind {
ProposalKind::Inertia(_) => unit.inertia.raised(),
ProposalKind::Text | ProposalKind::Remove => unit.inertia,
};
FormulaParams::for_inertia(preset, floor)
/// The level a proposal is voted at. A change of inertia inherits the level of
/// its unit, as its text does (Yvv, 2026-10-30: « par défaut l'inertie de
/// l'indice d'inertie lui-même hérite de l'inertie de l'article ») — this
/// replaces « inertie + », one level above (2026-10-21).
pub fn voting_level(unit: &UnitState, kind: ProposalKind) -> InertiaLevel {
match kind {
ProposalKind::Text | ProposalKind::Remove | ProposalKind::Inertia(_) => unit.inertia,
}
}
pub fn params_for(unit: &UnitState, kind: ProposalKind, scale: &Scale) -> FormulaParams {
scale.params(voting_level(unit, kind))
}
/// A proposal that changes nothing cannot enter into force.
@@ -98,13 +105,8 @@ struct Scored<'a> {
well_rated: bool,
}
fn score<'a>(
unit: &UnitState,
p: &'a ProposalState,
electorate: u32,
floor: f64,
) -> Result<Scored<'a>, ThresholdError> {
let e = evaluate(p.tally, electorate, &params_for(unit, p.kind, floor))?;
fn score<'a>(unit: &UnitState, p: &'a ProposalState, scale: &Scale) -> Result<Scored<'a>, ThresholdError> {
let e = evaluate(p.tally, scale.electorate, &params_for(unit, p.kind, scale))?;
Ok(Scored {
proposal: p,
margin: i64::from(p.tally.for_votes) - i64::from(e.required),
@@ -126,9 +128,9 @@ fn best<'a>(mut rivals: Vec<Scored<'a>>) -> Option<&'a ProposalState> {
}
/// Margin of the version in force, judged like a text proposal on its unit.
fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result<Option<i64>, ThresholdError> {
fn in_force_margin(unit: &UnitState, scale: &Scale) -> Result<Option<i64>, ThresholdError> {
let Some(v) = &unit.in_force else { return Ok(None) };
let e = evaluate(v.tally, electorate, &params_for(unit, ProposalKind::Text, floor))?;
let e = evaluate(v.tally, scale.electorate, &params_for(unit, ProposalKind::Text, scale))?;
Ok(Some(i64::from(v.tally.for_votes) - i64::from(e.required)))
}
@@ -138,19 +140,18 @@ fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result<Opti
pub fn adoptions(
units: &[UnitState],
proposals: &[ProposalState],
electorate: u32,
floor: f64,
scale: &Scale,
) -> Result<Vec<String>, ThresholdError> {
let mut out = Vec::new();
for unit in units {
let incumbent = in_force_margin(unit, electorate, floor)?;
let incumbent = in_force_margin(unit, scale)?;
for content in [true, false] {
let mut rivals = Vec::new();
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content() == content) {
if p.blocked || !is_effective(unit, p) {
continue;
}
let s = score(unit, p, electorate, floor)?;
let s = score(unit, p, scale)?;
let beats_incumbent = !content || incumbent.is_none_or(|m| s.margin > m);
if s.reached && beats_incumbent {
rivals.push(s);
@@ -200,8 +201,7 @@ pub struct PreviewUnit {
pub fn preview(
units: &[UnitState],
proposals: &[ProposalState],
electorate: u32,
floor: f64,
scale: &Scale,
mode: PreviewMode,
) -> Result<Vec<PreviewUnit>, ThresholdError> {
let mut ordered: Vec<&UnitState> = units.iter().collect();
@@ -216,7 +216,7 @@ pub fn preview(
if !is_effective(unit, p) {
continue;
}
let s = score(unit, p, electorate, floor)?;
let s = score(unit, p, scale)?;
if mode == PreviewMode::All || s.well_rated {
rivals.push(s);
}
@@ -263,7 +263,7 @@ mod tests {
UnitState {
id: id.into(),
position,
inertia: InertiaPreset::Standard,
inertia: InertiaLevel::DEFAULT,
in_force: in_force.map(|b| VersionText {
id: format!("{id}-v"),
title: id.into(),
@@ -286,23 +286,27 @@ mod tests {
}
}
const W: u32 = 5745;
const C: f64 = 50.0;
/// Monnaie libre before any median: W 5745, C 50, the reference p0 = 2C/W.
const SCALE: Scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 };
fn level(k: u8) -> InertiaLevel {
InertiaLevel::new(k).expect("1 to 5")
}
#[test]
fn nothing_passes_at_zero_votes() {
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None)];
let props = [prop("p1", "E3", ProposalKind::Text, "connaître", 0, 0)];
assert!(adoptions(&units, &props, W, C).expect("valid").is_empty());
assert!(adoptions(&units, &props, &SCALE).expect("valid").is_empty());
}
#[test]
fn the_floor_holds_then_releases() {
let units = [unit("E3", 7.0, None)];
let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)];
assert!(adoptions(&units, &few, W, C).expect("valid").is_empty());
assert!(adoptions(&units, &few, &SCALE).expect("valid").is_empty());
let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)];
assert_eq!(adoptions(&units, &enough, W, C).expect("valid"), vec!["p1".to_string()]);
assert_eq!(adoptions(&units, &enough, &SCALE).expect("valid"), vec!["p1".to_string()]);
}
#[test]
@@ -312,7 +316,7 @@ mod tests {
prop("a", "E3", ProposalKind::Text, "variante a", 60, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 70, 0),
];
assert_eq!(adoptions(&units, &props, W, C).expect("valid"), vec!["bb".to_string()]);
assert_eq!(adoptions(&units, &props, &SCALE).expect("valid"), vec!["bb".to_string()]);
}
#[test]
@@ -320,19 +324,22 @@ mod tests {
let units = [unit("E1", 4.0, Some("unicité"))];
let props = [
prop("t", "E1", ProposalKind::Text, "unicité reformulée", 60, 0),
prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 60, 0),
prop("i", "E1", ProposalKind::Inertia(level(4)), "", 60, 0),
];
let won = adoptions(&units, &props, W, C).expect("valid");
let won = adoptions(&units, &props, &SCALE).expect("valid");
assert_eq!(won, vec!["t".to_string(), "i".to_string()]);
}
#[test]
fn inertia_change_is_voted_one_level_up() {
let u = unit("E1", 4.0, Some("x"));
let p = params_for(&u, ProposalKind::Inertia(InertiaPreset::Low), C);
assert_eq!(p.majority_pct, InertiaPreset::High.majority_pct());
assert_eq!(p.gradient, InertiaPreset::High.gradient());
assert_eq!(params_for(&u, ProposalKind::Text, C).gradient, InertiaPreset::Standard.gradient());
fn inertia_change_is_voted_at_the_level_of_the_unit() {
let mut u = unit("E1", 4.0, Some("x"));
u.inertia = level(4);
for target in [1, 3, 5] {
let kind = ProposalKind::Inertia(level(target));
assert_eq!(voting_level(&u, kind), level(4), "inherits the level of the article");
assert_eq!(params_for(&u, kind, &SCALE), params_for(&u, ProposalKind::Text, &SCALE));
}
assert_eq!(params_for(&u, ProposalKind::Text, &SCALE).majority_pct, 60.0);
}
#[test]
@@ -341,8 +348,8 @@ mod tests {
let mut blocked = prop("t", "E1", ProposalKind::Text, "autre", 80, 0);
blocked.blocked = true;
let same = prop("s", "E1", ProposalKind::Text, "unicité", 80, 0);
let same_inertia = prop("i", "E1", ProposalKind::Inertia(InertiaPreset::Standard), "", 80, 0);
assert!(adoptions(&units, &[blocked, same, same_inertia], W, C).expect("valid").is_empty());
let same_inertia = prop("i", "E1", ProposalKind::Inertia(level(3)), "", 80, 0);
assert!(adoptions(&units, &[blocked, same, same_inertia], &SCALE).expect("valid").is_empty());
}
#[test]
@@ -350,20 +357,20 @@ mod tests {
let mut units = [unit("E1", 4.0, Some("unicité"))];
// The seed a minima has no vote: the first rival that reaches its threshold wins.
let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)];
assert_eq!(adoptions(&units, &rival, W, C).expect("valid"), vec!["r".to_string()]);
// A version in force by vote, margin 60 − 50 = 10: a rival at margin 5 waits.
assert_eq!(adoptions(&units, &rival, &SCALE).expect("valid"), vec!["r".to_string()]);
// A version in force by vote, margin 60 − 58 = 2: a rival at 55 (margin 55 − 54 = 1) waits.
if let Some(v) = units[0].in_force.as_mut() {
v.tally = Tally { for_votes: 60, against: 0 };
}
let weaker = [prop("w", "E1", ProposalKind::Text, "autre", 55, 0)];
assert!(adoptions(&units, &weaker, W, C).expect("valid").is_empty());
assert!(adoptions(&units, &weaker, &SCALE).expect("valid").is_empty());
let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)];
assert_eq!(adoptions(&units, &stronger, W, C).expect("valid"), vec!["s".to_string()]);
assert_eq!(adoptions(&units, &stronger, &SCALE).expect("valid"), vec!["s".to_string()]);
// The same rule holds for a removal; inertia does not compete with the text.
let removal = [prop("x", "E1", ProposalKind::Remove, "", 55, 0)];
assert!(adoptions(&units, &removal, W, C).expect("valid").is_empty());
let inertia = [prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 55, 0)];
assert_eq!(adoptions(&units, &inertia, W, C).expect("valid"), vec!["i".to_string()]);
assert!(adoptions(&units, &removal, &SCALE).expect("valid").is_empty());
let inertia = [prop("i", "E1", ProposalKind::Inertia(level(4)), "", 55, 0)];
assert_eq!(adoptions(&units, &inertia, &SCALE).expect("valid"), vec!["i".to_string()]);
}
#[test]
@@ -373,16 +380,16 @@ mod tests {
prop("well", "E3", ProposalKind::Text, "connaître", 3, 0),
prop("weak", "E1", ProposalKind::Remove, "", 1, 5),
];
let in_force = preview(&units, &props, W, C, PreviewMode::InForce).expect("valid");
let in_force = preview(&units, &props, &SCALE, PreviewMode::InForce).expect("valid");
assert_eq!(in_force.iter().map(|u| u.unit_id.as_str()).collect::<Vec<_>>(), ["I1", "E1"]);
let likely = preview(&units, &props, W, C, PreviewMode::WellRated).expect("valid");
let likely = preview(&units, &props, &SCALE, PreviewMode::WellRated).expect("valid");
assert_eq!(
likely.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Unchanged), ("E3", PreviewMark::Added),]
);
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
let all = preview(&units, &props, &SCALE, PreviewMode::All).expect("valid");
assert_eq!(
all.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Removed), ("E3", PreviewMark::Added),]
@@ -396,7 +403,7 @@ mod tests {
prop("a", "E3", ProposalKind::Text, "variante a", 0, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 1, 3),
];
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
let all = preview(&units, &props, &SCALE, PreviewMode::All).expect("valid");
assert_eq!(all[0].proposal_id.as_deref(), Some("bb"));
}
}
+438
View File
@@ -0,0 +1,438 @@
//! 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:?}");
}
}
}
+3 -1
View File
@@ -9,8 +9,10 @@
pub mod assist;
pub mod compose;
pub mod edit;
pub mod inertia;
pub mod names;
pub mod pending;
pub mod threshold;
pub use threshold::{FormulaParams, InertiaPreset, Tally};
pub use inertia::{InertiaLevel, Participation, Scale};
pub use threshold::{FormulaParams, Tally};
+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]