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