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
+117 -20
View File
@@ -6,11 +6,13 @@
use crate::AppState;
use crate::auth::{IdentityRow, User};
use crate::error::{AppError, AppResult};
use crate::participation;
use crate::util::{self, LangMap};
use axum::Json;
use axum::extract::{Path, Query, State};
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
use ld_engine::threshold::{Evaluation, InertiaPreset, Tally, evaluate};
use ld_engine::inertia::{InertiaLevel, Participation, Scale};
use ld_engine::threshold::{Evaluation, Tally};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -231,16 +233,17 @@ impl ProposalRow {
pub fn kind(&self) -> ProposalKind {
match self.kind.as_str() {
"remove" => ProposalKind::Remove,
"inertia" => ProposalKind::Inertia(
self.inertia_target.as_deref().and_then(InertiaPreset::parse).unwrap_or(InertiaPreset::Standard),
),
"inertia" => {
ProposalKind::Inertia(self.inertia_target.as_deref().and_then(InertiaLevel::parse).unwrap_or_default())
}
_ => ProposalKind::Text,
}
}
}
pub fn inertia_of(unit: &UnitRow) -> InertiaPreset {
InertiaPreset::parse(&unit.inertia).unwrap_or(InertiaPreset::Standard)
/// The level of a unit: a digit, or one of the four words of before.
pub fn inertia_of(unit: &UnitRow) -> InertiaLevel {
InertiaLevel::parse(&unit.inertia).unwrap_or_default()
}
pub struct DocState {
@@ -253,6 +256,9 @@ pub struct DocState {
pub signal_counts: HashMap<String, i64>,
pub electorate: u32,
pub electorate_source: &'static str,
/// p̃, from the group's last measure (or the reference).
pub participation: Participation,
pub measured_at: Option<String>,
}
pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> {
@@ -351,10 +357,40 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
}
}
let (electorate, electorate_source) = electorate(state, &group).await?;
Ok(DocState { doc, group, units, proposals, tallies, blocked, signal_counts, electorate, electorate_source })
let measure = participation::stored(state, &group.slug).await?;
let participation = Participation::of(
electorate,
doc.vote_floor,
measure.as_ref().and_then(|m| m.median),
measure.as_ref().map_or(0, |m| m.sample),
);
let measured_at = measure.map(|m| m.measured_at);
Ok(DocState {
doc,
group,
units,
proposals,
tallies,
blocked,
signal_counts,
electorate,
electorate_source,
participation,
measured_at,
})
}
impl DocState {
/// What this document's votes are judged by: W, C and p̃.
pub fn scale(&self) -> Scale {
Scale { electorate: self.electorate, floor: self.doc.vote_floor, participation: self.participation.ratio }
}
/// p̃ · W, as a whole number of voters.
pub fn usual_voters(&self) -> u32 {
whole(self.participation.voters(self.electorate))
}
pub fn tally(&self, id: &str) -> Tally {
self.tallies.get(id).copied().unwrap_or_default()
}
@@ -396,9 +432,45 @@ impl DocState {
}
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
evaluate(self.tally(&p.id), self.electorate, &compose::params_for(&us, p.kind(), self.doc.vote_floor)).ok()
self.scale().evaluate(self.tally(&p.id), self.voting_level(unit, p)).ok()
}
/// The level a proposal is voted at — a change of inertia inherits its article's.
pub fn voting_level(&self, unit: &UnitRow, p: &ProposalRow) -> InertiaLevel {
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
compose::voting_level(&us, p.kind())
}
/// The usual participation, as the screen tells it.
pub fn participation_json(&self) -> serde_json::Value {
let p = &self.participation;
json!({
"ratio": p.ratio, "source": p.source, "sample": p.sample, "median": p.median, "bounded": p.bounded,
"voters": self.usual_voters(), "minSample": Participation::MIN_SAMPLE,
"windowDays": participation::WINDOW_DAYS, "everyHours": participation::EVERY_HOURS,
"bounds": [Participation::LOWEST, Participation::HIGHEST], "measuredAt": self.measured_at,
})
}
/// The five levels on this scale: M, G, s_k, and the *for* votes asked at `cast` votes.
pub fn levels_json(&self, cast: u32) -> Vec<serde_json::Value> {
let scale = self.scale();
InertiaLevel::ALL
.iter()
.map(|k| {
let params = scale.params(*k);
json!({
"level": k, "majorityPct": params.majority_pct, "gradient": params.gradient,
"sharePct": k.share_pct(), "cast": cast, "required": scale.required(cast, *k).ok(),
})
})
.collect()
}
}
/// A non-negative count, rounded to the nearest whole.
fn whole(x: f64) -> u32 {
if x.is_finite() && x > 0.0 { x.round().min(f64::from(u32::MAX)) as u32 } else { 0 }
}
// ---------------------------------------------------------------------------
@@ -462,12 +534,9 @@ fn proposal_json(
let (kind, target) = match p.kind() {
ProposalKind::Text => ("text", None),
ProposalKind::Remove => ("remove", None),
ProposalKind::Inertia(t) => ("inertia", Some(t.as_str())),
};
let voted_at = match p.kind() {
ProposalKind::Inertia(_) => inertia_of(unit).raised(),
_ => inertia_of(unit),
ProposalKind::Inertia(t) => ("inertia", Some(t)),
};
let voted_at = st.voting_level(unit, p);
json!({
"id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target,
"title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label,
@@ -480,7 +549,7 @@ fn proposal_json(
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id),
"evaluation": eval,
"votedAtInertia": voted_at.as_str(),
"votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
"myVote": mine.map(|v| v.choice.as_str()),
@@ -514,6 +583,7 @@ pub async fn document(
.map(|v| (v.proposal_id.clone(), v))
.collect();
let changes = inertia_changes(&state, &id).await?;
let units: Vec<_> = st
.units
.iter()
@@ -529,7 +599,8 @@ pub async fn document(
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
json!({
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": u.inertia,
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u),
"inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(),
"inForce": in_force, "proposals": proposals,
})
})
@@ -545,15 +616,41 @@ pub async fn document(
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor,
"electorate": st.electorate, "electorateSource": st.electorate_source,
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
"participation": st.participation_json(),
"publication": publication,
},
"units": units,
"inertia": InertiaPreset::ALL.iter().map(|p| json!({
"key": p.as_str(), "majorityPct": p.majority_pct(), "gradient": p.gradient(),
})).collect::<Vec<_>>(),
// The five levels, with the *for* votes asked at the usual participation.
"levels": st.levels_json(st.usual_voters()),
})))
}
/// The past of each article's inertia (unit id → changes, oldest first).
async fn inertia_changes(state: &AppState, doc_id: &str) -> AppResult<HashMap<String, Vec<serde_json::Value>>> {
#[derive(sqlx::FromRow)]
struct Row {
unit_id: String,
from_level: String,
to_level: String,
cause: String,
at: String,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT unit_id, from_level, to_level, cause, at FROM inertia_changes WHERE doc_id = ? ORDER BY id",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let mut out: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
for r in rows {
out.entry(r.unit_id).or_default().push(json!({
"from": InertiaLevel::parse(&r.from_level), "to": InertiaLevel::parse(&r.to_level),
"cause": r.cause, "at": r.at,
}));
}
Ok(out)
}
pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<serde_json::Value> {
#[derive(sqlx::FromRow)]
struct Row {
@@ -598,7 +695,7 @@ pub async fn preview(
Some("wellrated") => PreviewMode::WellRated,
_ => PreviewMode::InForce,
};
let units = compose::preview(&st.unit_states(), &st.proposal_states(), st.electorate, st.doc.vote_floor, mode)
let units = compose::preview(&st.unit_states(), &st.proposal_states(), &st.scale(), mode)
.map_err(|_| AppError::Invalid("electorate"))?;
// Translations of the versions shown.
let shown: HashSet<String> = units