forked from yvv/decision
Le quorum Q (votants = pour + contre) remplace le C additif : T' = max(T, Q), la version passe si pour ≥ ⌈B^W + ratio(T')·T'⌉, ratio la courbe des cinq crans (M, G calé à la participation de référence, inchangé). La règle d'Yvv du 03/11 (« Ils comptent comme non ») est seule dans threshold::judged_voters. Pour l'écran : yes_needed (le total de pour qui passe avec les contre d'aujourd'hui) et no_needed (le total de contre qui tient les pour d'aujourd'hui, 0 si le quorum suffit) ; passe ⇔ pour ≥ yesNeeded ⇔ contre < noNeeded, éprouvé sur une grille. well_rated : la part tient sur ceux qui sont venus. Scale porte les quorums par cran, UnitState le quorum propre d'un article ; p0 reste 2·vote_floor/W. Vu rouge : sans la règle (T' = T), 9 tests échouent ; avec l'autre lecture d'un quorum (un minimum de votants, les absents non comptés), 8 échouent, dont « un contre n'aide jamais ». Chiffres de contrôle (W 5 707, p0 = 100/W) tenus : 9, 39, 46 au quorum ; 7, 8, 9, 9, 10 à dix votants. Le serveur suit au commit suivant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
429 lines
17 KiB
Rust
429 lines
17 KiB
Rust
//! 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`): the level of
|
||
//! inertia of its unit and its quorum, on the scale of its document
|
||
//! (`inertia::Scale`).
|
||
|
||
use crate::inertia::{InertiaLevel, Scale};
|
||
use crate::threshold::{FormulaParams, 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: InertiaLevel,
|
||
/// Its own quorum, set by the author of its first version before the first vote
|
||
/// (ADR 0013); `None`: the quorum of its level.
|
||
#[serde(default)]
|
||
pub quorum: Option<u32>,
|
||
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(InertiaLevel),
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
/// The level a proposal is voted at. A change of inertia inherits the level of
|
||
/// its unit, as its text does (Yvv, 2026-10-30: « par défaut l'inertie de
|
||
/// l'indice d'inertie lui-même hérite de l'inertie de l'article ») — this
|
||
/// replaces « inertie + », one level above (2026-10-21).
|
||
pub fn voting_level(unit: &UnitState, kind: ProposalKind) -> InertiaLevel {
|
||
match kind {
|
||
ProposalKind::Text | ProposalKind::Remove | ProposalKind::Inertia(_) => unit.inertia,
|
||
}
|
||
}
|
||
|
||
/// The formula a proposal is judged by: the level it is voted at, and the quorum of its
|
||
/// unit — its own, or that level's (a change of inertia inherits both).
|
||
pub fn params_for(unit: &UnitState, kind: ProposalKind, scale: &Scale) -> FormulaParams {
|
||
let level = voting_level(unit, kind);
|
||
scale.params(level, scale.quorum(level, unit.quorum))
|
||
}
|
||
|
||
/// 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, scale: &Scale) -> Result<Scored<'a>, ThresholdError> {
|
||
let e = evaluate(p.tally, scale.electorate, ¶ms_for(unit, p.kind, scale))?;
|
||
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, scale: &Scale) -> Result<Option<i64>, ThresholdError> {
|
||
let Some(v) = &unit.in_force else { return Ok(None) };
|
||
let e = evaluate(v.tally, scale.electorate, ¶ms_for(unit, ProposalKind::Text, scale))?;
|
||
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],
|
||
scale: &Scale,
|
||
) -> Result<Vec<String>, ThresholdError> {
|
||
let mut out = Vec::new();
|
||
for unit in units {
|
||
let incumbent = in_force_margin(unit, scale)?;
|
||
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, scale)?;
|
||
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],
|
||
scale: &Scale,
|
||
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, scale)?;
|
||
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: InertiaLevel::DEFAULT,
|
||
quorum: None,
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// Monnaie libre before any median: W 5745, the reference p0 = 2 × 50 / W, the quorums
|
||
/// 12 · 12 · 50 · 50 · 50 (ADR 0013).
|
||
const SCALE: Scale = Scale { electorate: 5745, participation: 100.0 / 5745.0, quorums: [12, 12, 50, 50, 50] };
|
||
|
||
fn level(k: u8) -> InertiaLevel {
|
||
InertiaLevel::new(k).expect("1 to 5")
|
||
}
|
||
|
||
#[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, &SCALE).expect("valid").is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn the_quorum_holds_then_releases() {
|
||
// Level 3, quorum 50: under it the voters missing count as against, 39 « pour » pass.
|
||
let units = [unit("E3", 7.0, None)];
|
||
let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 38, 0)];
|
||
assert!(adoptions(&units, &few, &SCALE).expect("valid").is_empty());
|
||
let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 39, 0)];
|
||
assert_eq!(adoptions(&units, &enough, &SCALE).expect("valid"), vec!["p1".to_string()]);
|
||
// Its own quorum, lower, set by its author: the same curve on fewer voters.
|
||
let mut own = unit("E3", 7.0, None);
|
||
own.quorum = Some(12);
|
||
let lower = [prop("p1", "E3", ProposalKind::Text, "connaître", 11, 0)];
|
||
assert_eq!(adoptions(&[own], &lower, &SCALE).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, &SCALE).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(level(4)), "", 60, 0),
|
||
];
|
||
let won = adoptions(&units, &props, &SCALE).expect("valid");
|
||
assert_eq!(won, vec!["t".to_string(), "i".to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn inertia_change_is_voted_at_the_level_of_the_unit() {
|
||
let mut u = unit("E1", 4.0, Some("x"));
|
||
u.inertia = level(4);
|
||
for target in [1, 3, 5] {
|
||
let kind = ProposalKind::Inertia(level(target));
|
||
assert_eq!(voting_level(&u, kind), level(4), "inherits the level of the article");
|
||
assert_eq!(params_for(&u, kind, &SCALE), params_for(&u, ProposalKind::Text, &SCALE));
|
||
}
|
||
let p = params_for(&u, ProposalKind::Text, &SCALE);
|
||
assert_eq!((p.majority_pct, p.quorum), (60.0, 50), "the level, and so its quorum");
|
||
u.quorum = Some(20);
|
||
assert_eq!(params_for(&u, ProposalKind::Inertia(level(1)), &SCALE).quorum, 20, "and the article's own");
|
||
}
|
||
|
||
#[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(level(3)), "", 80, 0);
|
||
assert!(adoptions(&units, &[blocked, same, same_inertia], &SCALE).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, &SCALE).expect("valid"), vec!["r".to_string()]);
|
||
// A version in force by vote, margin 60 − 47 = 13: a rival at 55 (margin 55 − 43 = 12) 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, &SCALE).expect("valid").is_empty());
|
||
let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)];
|
||
assert_eq!(adoptions(&units, &stronger, &SCALE).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, &SCALE).expect("valid").is_empty());
|
||
let inertia = [prop("i", "E1", ProposalKind::Inertia(level(4)), "", 55, 0)];
|
||
assert_eq!(adoptions(&units, &inertia, &SCALE).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, &SCALE, 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, &SCALE, 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, &SCALE, 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, &SCALE, PreviewMode::All).expect("valid");
|
||
assert_eq!(all[0].proposal_id.as_deref(), Some("bb"));
|
||
}
|
||
}
|