//! 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, pub in_force: Option, } #[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, 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>) -> 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, 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, 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, } /// 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, 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::>(), ["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::>(), [("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::>(), [("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")); } }