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:
Yvv
2026-10-21 02:04:42 +02:00
co-authored by Claude Opus 5.5
parent 4b1732916d
commit 67ddee28b2
275 changed files with 8979 additions and 0 deletions
+285
View File
@@ -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(&params.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));
}
}