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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user