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"));
}
}