Files
decision/crates/ld-engine/src/compose.rs
T
YvvandClaude Opus 5.5 37147d1f59 Lot S, phase 2 : le quorum dans le moteur (ADR 0013), vu rouge puis vert
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>
2026-11-03 04:36:43 +01:00

429 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, &params_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, &params_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"));
}
}