forked from yvv/decision
v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
//! 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 one deliberate guard — the participation ratio is
|
||||
//! capped at 1, so a stale W can never lower the bar below M.
|
||||
|
||||
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 {
|
||||
/// 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;
|
||||
|
||||
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)]
|
||||
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(¶ms.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 + 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))
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
#[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.
|
||||
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 = FormulaParams::for_inertia(InertiaPreset::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 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 = FormulaParams::for_inertia(InertiaPreset::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 = FormulaParams::for_inertia(InertiaPreset::Low, 0.0);
|
||||
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 = FormulaParams::for_inertia(InertiaPreset::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 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));
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user