forked from yvv/decision
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:
@@ -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, ¶ms_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, ¶ms_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, ¶ms_for(unit, ProposalKind::Text, floor))?;
|
||||
let e = evaluate(v.tally, scale.electorate, ¶ms_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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user