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,402 @@
|
||||
//! Composition of a modular document under permanent vote.
|
||||
//!
|
||||
//! A document is an ordered list of **units** (preamble, articles, headings).
|
||||
//! A unit is a slot: it may hold one version in force, or none yet. Every
|
||||
//! **proposal** targets exactly one unit — one proposal, one decision — and is
|
||||
//! one of: a text for the unit (new or replacing), its removal, or a change of
|
||||
//! its inertia. Proposals on the same unit and the same dimension (content or
|
||||
//! inertia) are rivals: at most one of them enters into force at a time.
|
||||
//!
|
||||
//! A rival replaces the version in force only if it reached its threshold
|
||||
//! **and** holds a wider margin than the version in force (for − required).
|
||||
//! The displaced version goes back among the proposals with its votes; it can
|
||||
//! only return by widening its margin again — no oscillation. A seed a minima
|
||||
//! (in force without any vote) has the margin of zero votes, so the first
|
||||
//! rival that reaches its threshold replaces it.
|
||||
//!
|
||||
//! This module answers three questions, purely:
|
||||
//! - which proposals enter into force now (`adoptions`);
|
||||
//! - what the document would read if the well-rated, or all, proposals passed
|
||||
//! (`preview`);
|
||||
//! - which formula a proposal is judged by (`params_for`).
|
||||
|
||||
use crate::threshold::{FormulaParams, InertiaPreset, Tally, ThresholdError, evaluate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VersionText {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// Votes still cast on the version in force (permanent vote).
|
||||
#[serde(default)]
|
||||
pub tally: Tally,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitState {
|
||||
pub id: String,
|
||||
pub position: f64,
|
||||
pub inertia: InertiaPreset,
|
||||
pub in_force: Option<VersionText>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "inertia", rename_all = "lowercase")]
|
||||
pub enum ProposalKind {
|
||||
/// A text for the unit: an addition if the unit is empty, a replacement otherwise.
|
||||
Text,
|
||||
/// Remove the unit from the text in force.
|
||||
Remove,
|
||||
/// Change the inertia of the unit.
|
||||
Inertia(InertiaPreset),
|
||||
}
|
||||
|
||||
impl ProposalKind {
|
||||
fn is_content(self) -> bool {
|
||||
!matches!(self, Self::Inertia(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProposalState {
|
||||
pub id: String,
|
||||
pub unit_id: String,
|
||||
pub kind: ProposalKind,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub tally: Tally,
|
||||
/// Sortable submission stamp (RFC 3339): the earlier wins a perfect tie.
|
||||
pub submitted_at: String,
|
||||
/// An active contradiction signal forbids entering into force.
|
||||
pub blocked: bool,
|
||||
}
|
||||
|
||||
/// A change of inertia is voted one level above the unit (« inertie + »).
|
||||
pub fn params_for(unit: &UnitState, kind: ProposalKind, floor: f64) -> FormulaParams {
|
||||
let preset = match kind {
|
||||
ProposalKind::Inertia(_) => unit.inertia.raised(),
|
||||
ProposalKind::Text | ProposalKind::Remove => unit.inertia,
|
||||
};
|
||||
FormulaParams::for_inertia(preset, floor)
|
||||
}
|
||||
|
||||
/// A proposal that changes nothing cannot enter into force.
|
||||
fn is_effective(unit: &UnitState, p: &ProposalState) -> bool {
|
||||
match p.kind {
|
||||
ProposalKind::Text => unit.in_force.as_ref().is_none_or(|v| v.title != p.title || v.body != p.body),
|
||||
ProposalKind::Remove => unit.in_force.is_some(),
|
||||
ProposalKind::Inertia(target) => target != unit.inertia,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Scored<'a> {
|
||||
proposal: &'a ProposalState,
|
||||
margin: i64,
|
||||
reached: bool,
|
||||
well_rated: bool,
|
||||
}
|
||||
|
||||
fn score<'a>(
|
||||
unit: &UnitState,
|
||||
p: &'a ProposalState,
|
||||
electorate: u32,
|
||||
floor: f64,
|
||||
) -> Result<Scored<'a>, ThresholdError> {
|
||||
let e = evaluate(p.tally, electorate, ¶ms_for(unit, p.kind, floor))?;
|
||||
Ok(Scored {
|
||||
proposal: p,
|
||||
margin: i64::from(p.tally.for_votes) - i64::from(e.required),
|
||||
reached: e.reached,
|
||||
well_rated: e.well_rated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Best of rivals: widest margin, then most *for* votes, then earliest.
|
||||
fn best<'a>(mut rivals: Vec<Scored<'a>>) -> Option<&'a ProposalState> {
|
||||
rivals.sort_by(|a, b| {
|
||||
b.margin
|
||||
.cmp(&a.margin)
|
||||
.then(b.proposal.tally.for_votes.cmp(&a.proposal.tally.for_votes))
|
||||
.then(a.proposal.submitted_at.cmp(&b.proposal.submitted_at))
|
||||
.then(a.proposal.id.cmp(&b.proposal.id))
|
||||
});
|
||||
rivals.first().map(|s| s.proposal)
|
||||
}
|
||||
|
||||
/// Margin of the version in force, judged like a text proposal on its unit.
|
||||
fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result<Option<i64>, ThresholdError> {
|
||||
let Some(v) = &unit.in_force else { return Ok(None) };
|
||||
let e = evaluate(v.tally, electorate, ¶ms_for(unit, ProposalKind::Text, floor))?;
|
||||
Ok(Some(i64::from(v.tally.for_votes) - i64::from(e.required)))
|
||||
}
|
||||
|
||||
/// The proposals that enter into force now: per unit and dimension, the best
|
||||
/// of those that reached their threshold, are effective and not blocked — and,
|
||||
/// for content, hold a wider margin than the version in force.
|
||||
pub fn adoptions(
|
||||
units: &[UnitState],
|
||||
proposals: &[ProposalState],
|
||||
electorate: u32,
|
||||
floor: f64,
|
||||
) -> Result<Vec<String>, ThresholdError> {
|
||||
let mut out = Vec::new();
|
||||
for unit in units {
|
||||
let incumbent = in_force_margin(unit, electorate, floor)?;
|
||||
for content in [true, false] {
|
||||
let mut rivals = Vec::new();
|
||||
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content() == content) {
|
||||
if p.blocked || !is_effective(unit, p) {
|
||||
continue;
|
||||
}
|
||||
let s = score(unit, p, electorate, floor)?;
|
||||
let beats_incumbent = !content || incumbent.is_none_or(|m| s.margin > m);
|
||||
if s.reached && beats_incumbent {
|
||||
rivals.push(s);
|
||||
}
|
||||
}
|
||||
if let Some(winner) = best(rivals) {
|
||||
out.push(winner.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PreviewMode {
|
||||
/// Only the in-force text.
|
||||
InForce,
|
||||
/// As if every well-rated proposal passed (« si les bien cotées passent »).
|
||||
WellRated,
|
||||
/// As if every proposal passed, whatever its rating (« si tout passe »).
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PreviewMark {
|
||||
Unchanged,
|
||||
Added,
|
||||
Replaced,
|
||||
Removed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreviewUnit {
|
||||
pub unit_id: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub mark: PreviewMark,
|
||||
/// The proposal that would bring the change.
|
||||
pub proposal_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The document as it would read. Removed units are kept with their mark so
|
||||
/// the screen can show them struck through.
|
||||
pub fn preview(
|
||||
units: &[UnitState],
|
||||
proposals: &[ProposalState],
|
||||
electorate: u32,
|
||||
floor: f64,
|
||||
mode: PreviewMode,
|
||||
) -> Result<Vec<PreviewUnit>, ThresholdError> {
|
||||
let mut ordered: Vec<&UnitState> = units.iter().collect();
|
||||
ordered.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(&b.id)));
|
||||
let mut out = Vec::new();
|
||||
for unit in ordered {
|
||||
let chosen = if mode == PreviewMode::InForce {
|
||||
None
|
||||
} else {
|
||||
let mut rivals = Vec::new();
|
||||
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content()) {
|
||||
if !is_effective(unit, p) {
|
||||
continue;
|
||||
}
|
||||
let s = score(unit, p, electorate, floor)?;
|
||||
if mode == PreviewMode::All || s.well_rated {
|
||||
rivals.push(s);
|
||||
}
|
||||
}
|
||||
if mode == PreviewMode::All {
|
||||
// Whatever its rating: the most supported, then the earliest.
|
||||
for r in &mut rivals {
|
||||
r.margin = 0;
|
||||
}
|
||||
}
|
||||
best(rivals)
|
||||
};
|
||||
match (chosen, &unit.in_force) {
|
||||
(Some(p), current) => {
|
||||
let mark = match (p.kind, current) {
|
||||
(ProposalKind::Remove, _) => PreviewMark::Removed,
|
||||
(_, Some(_)) => PreviewMark::Replaced,
|
||||
(_, None) => PreviewMark::Added,
|
||||
};
|
||||
let (title, body) = match (p.kind, current) {
|
||||
(ProposalKind::Remove, Some(v)) => (v.title.clone(), v.body.clone()),
|
||||
_ => (p.title.clone(), p.body.clone()),
|
||||
};
|
||||
out.push(PreviewUnit { unit_id: unit.id.clone(), title, body, mark, proposal_id: Some(p.id.clone()) });
|
||||
}
|
||||
(None, Some(v)) => out.push(PreviewUnit {
|
||||
unit_id: unit.id.clone(),
|
||||
title: v.title.clone(),
|
||||
body: v.body.clone(),
|
||||
mark: PreviewMark::Unchanged,
|
||||
proposal_id: None,
|
||||
}),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unit(id: &str, position: f64, in_force: Option<&str>) -> UnitState {
|
||||
UnitState {
|
||||
id: id.into(),
|
||||
position,
|
||||
inertia: InertiaPreset::Standard,
|
||||
in_force: in_force.map(|b| VersionText {
|
||||
id: format!("{id}-v"),
|
||||
title: id.into(),
|
||||
body: b.into(),
|
||||
tally: Tally::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn prop(id: &str, unit_id: &str, kind: ProposalKind, body: &str, for_votes: u32, against: u32) -> ProposalState {
|
||||
ProposalState {
|
||||
id: id.into(),
|
||||
unit_id: unit_id.into(),
|
||||
kind,
|
||||
title: unit_id.into(),
|
||||
body: body.into(),
|
||||
tally: Tally { for_votes, against },
|
||||
submitted_at: format!("2026-10-21T00:00:0{}Z", id.len() % 10),
|
||||
blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
const W: u32 = 5745;
|
||||
const C: f64 = 50.0;
|
||||
|
||||
#[test]
|
||||
fn nothing_passes_at_zero_votes() {
|
||||
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None)];
|
||||
let props = [prop("p1", "E3", ProposalKind::Text, "connaître", 0, 0)];
|
||||
assert!(adoptions(&units, &props, W, C).expect("valid").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_floor_holds_then_releases() {
|
||||
let units = [unit("E3", 7.0, None)];
|
||||
let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)];
|
||||
assert!(adoptions(&units, &few, W, C).expect("valid").is_empty());
|
||||
let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)];
|
||||
assert_eq!(adoptions(&units, &enough, W, C).expect("valid"), vec!["p1".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rivals_on_one_unit_yield_one_winner() {
|
||||
let units = [unit("E3", 7.0, None)];
|
||||
let props = [
|
||||
prop("a", "E3", ProposalKind::Text, "variante a", 60, 0),
|
||||
prop("bb", "E3", ProposalKind::Text, "variante b", 70, 0),
|
||||
];
|
||||
assert_eq!(adoptions(&units, &props, W, C).expect("valid"), vec!["bb".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inertia_and_content_are_independent_dimensions() {
|
||||
let units = [unit("E1", 4.0, Some("unicité"))];
|
||||
let props = [
|
||||
prop("t", "E1", ProposalKind::Text, "unicité reformulée", 60, 0),
|
||||
prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 60, 0),
|
||||
];
|
||||
let won = adoptions(&units, &props, W, C).expect("valid");
|
||||
assert_eq!(won, vec!["t".to_string(), "i".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inertia_change_is_voted_one_level_up() {
|
||||
let u = unit("E1", 4.0, Some("x"));
|
||||
let p = params_for(&u, ProposalKind::Inertia(InertiaPreset::Low), C);
|
||||
assert_eq!(p.majority_pct, InertiaPreset::High.majority_pct());
|
||||
assert_eq!(p.gradient, InertiaPreset::High.gradient());
|
||||
assert_eq!(params_for(&u, ProposalKind::Text, C).gradient, InertiaPreset::Standard.gradient());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_or_ineffective_never_pass() {
|
||||
let units = [unit("E1", 4.0, Some("unicité"))];
|
||||
let mut blocked = prop("t", "E1", ProposalKind::Text, "autre", 80, 0);
|
||||
blocked.blocked = true;
|
||||
let same = prop("s", "E1", ProposalKind::Text, "unicité", 80, 0);
|
||||
let same_inertia = prop("i", "E1", ProposalKind::Inertia(InertiaPreset::Standard), "", 80, 0);
|
||||
assert!(adoptions(&units, &[blocked, same, same_inertia], W, C).expect("valid").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rival_must_beat_the_version_in_force() {
|
||||
let mut units = [unit("E1", 4.0, Some("unicité"))];
|
||||
// The seed a minima has no vote: the first rival that reaches its threshold wins.
|
||||
let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)];
|
||||
assert_eq!(adoptions(&units, &rival, W, C).expect("valid"), vec!["r".to_string()]);
|
||||
// A version in force by vote, margin 60 − 50 = 10: a rival at margin 5 waits.
|
||||
if let Some(v) = units[0].in_force.as_mut() {
|
||||
v.tally = Tally { for_votes: 60, against: 0 };
|
||||
}
|
||||
let weaker = [prop("w", "E1", ProposalKind::Text, "autre", 55, 0)];
|
||||
assert!(adoptions(&units, &weaker, W, C).expect("valid").is_empty());
|
||||
let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)];
|
||||
assert_eq!(adoptions(&units, &stronger, W, C).expect("valid"), vec!["s".to_string()]);
|
||||
// The same rule holds for a removal; inertia does not compete with the text.
|
||||
let removal = [prop("x", "E1", ProposalKind::Remove, "", 55, 0)];
|
||||
assert!(adoptions(&units, &removal, W, C).expect("valid").is_empty());
|
||||
let inertia = [prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 55, 0)];
|
||||
assert_eq!(adoptions(&units, &inertia, W, C).expect("valid"), vec!["i".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews() {
|
||||
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None), unit("E1", 4.0, Some("unicité"))];
|
||||
let props = [
|
||||
prop("well", "E3", ProposalKind::Text, "connaître", 3, 0),
|
||||
prop("weak", "E1", ProposalKind::Remove, "", 1, 5),
|
||||
];
|
||||
let in_force = preview(&units, &props, W, C, PreviewMode::InForce).expect("valid");
|
||||
assert_eq!(in_force.iter().map(|u| u.unit_id.as_str()).collect::<Vec<_>>(), ["I1", "E1"]);
|
||||
|
||||
let likely = preview(&units, &props, W, C, PreviewMode::WellRated).expect("valid");
|
||||
assert_eq!(
|
||||
likely.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
|
||||
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Unchanged), ("E3", PreviewMark::Added),]
|
||||
);
|
||||
|
||||
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
|
||||
assert_eq!(
|
||||
all.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
|
||||
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Removed), ("E3", PreviewMark::Added),]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_mode_prefers_the_most_supported_rival() {
|
||||
let units = [unit("E3", 7.0, None)];
|
||||
let props = [
|
||||
prop("a", "E3", ProposalKind::Text, "variante a", 0, 0),
|
||||
prop("bb", "E3", ProposalKind::Text, "variante b", 1, 3),
|
||||
];
|
||||
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
|
||||
assert_eq!(all[0].proposal_id.as_deref(), Some("bb"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user