forked from yvv/decision
- migration 0011 (additive ; 0010 reste au lot V) : documents.quorums (par cran,
NULL = vote_floor à chaque cran, le C d'avant), units.quorum (NULL = celui du
cran), journaux quorum_changes et quorum_amendments ;
- quorum effectif = units.quorum ?? quorums[cran] ; les propositions d'inertie et
les modules en héritent ; p0 = 2 × vote_floor / W, jamais le quorum d'un article ;
- route PUT /units/{id}/quorum { "quorum": n | null } : trustin_access::sets_quorum
(l'auteur de la première version, un administrateur pour le seed), refusée dès
qu'un vote existe (quorum::voted_units : vote du serveur, bulletin de la chaîne
en attente ou confirmé, adoption) ; bornes 2..W, 1..W dans le groupe d'essai ;
- amendement gardé 2026-11-03-quorums-par-cran : écrit les quorums du seed là où
il n'y en a pas, une fois par document, consigné, file de publication là où il
publie ;
- JSON : evaluation.quorum / yesNeeded / noNeeded ; l'unité : quorum,
quorumDefault, canSetQuorum ; doc.quorums ; levels[].quorum ; document.json
publié : quorums ; adoptions.params : quorum (plus de constant) ;
- seed : quorums 12 · 12 · 50 · 50 · 50 (Monnaie libre), 1 partout (essai).
- tests : tests/quorum.rs (5) et les règles pures ; les scénarios du groupe
d'essai qui demandaient deux voix posent le quorum 2 de l'article, comme son
auteur le ferait ; les chiffres de Monnaie libre suivent (9 au cran 2, 39 au
cran 3, 43 au cran 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
535 lines
23 KiB
Rust
535 lines
23 KiB
Rust
//! Inertia in five levels, on a scale that follows the group (ADR 0007).
|
||
//!
|
||
//! Yvv, 2026-10-30: « L'indice d'inertie doit avoir 5 niveaux » ; « une échelle
|
||
//! cohérente qui s'adapte aux nombres de certifiés et à la participation
|
||
//! médiane aux votes du groupe » — the scale chosen is « calée sur la
|
||
//! participation ».
|
||
//!
|
||
//! The formula stays the Ğ1 one (`threshold`); M, G and the quorum Q depend on
|
||
//! the level k:
|
||
//!
|
||
//! - M_k, the majority at full participation: 50, 50, 55, 60, 66 %;
|
||
//! - s_k, the share of *for* votes asked of the voters at the group's usual
|
||
//! participation p̃: 60, 67, 75, 83, 90 %;
|
||
//! - G_k follows from `s_k = M_k + (1 − M_k)(1 − p̃^G_k)`:
|
||
//! `G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃)`;
|
||
//! - Q_k, the quorum of the level, set by the document (Monnaie libre: 12, 12,
|
||
//! 50, 50, 50; the test group: 1 everywhere — ADR 0013); an article may hold its
|
||
//! own, set by its author before the first vote (`Scale::quorum`).
|
||
//!
|
||
//! p̃ is the median number of voters on the group's proposals, over W
|
||
//! (`Participation`). Until the group has voted on enough proposals, a
|
||
//! reference `p0 = 2Q₀/W` stands in, Q₀ the document's reference quorum (its
|
||
//! `vote_floor`, never an article's quorum: an author who lowers his would move
|
||
//! the curve of everyone). Both are kept within [1 %, 50 %]: ln(p̃) stays
|
||
//! negative and finite, so G is finite and positive, and the threshold rises
|
||
//! with the level and relaxes toward M as participation grows.
|
||
|
||
use crate::threshold::{Evaluation, FormulaParams, Tally, ThresholdError, evaluate};
|
||
use serde::de::Error as _;
|
||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||
|
||
/// One of the five levels of inertia, 1 (lightest) to 5 (heaviest).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub struct InertiaLevel(u8);
|
||
|
||
/// M_k, in percent.
|
||
const MAJORITY_PCT: [f64; 5] = [50.0, 50.0, 55.0, 60.0, 66.0];
|
||
/// s_k, in percent.
|
||
const SHARE_PCT: [f64; 5] = [60.0, 67.0, 75.0, 83.0, 90.0];
|
||
|
||
impl InertiaLevel {
|
||
pub const LOWEST: Self = Self(1);
|
||
pub const HIGHEST: Self = Self(5);
|
||
/// The middle level: every proposal starts there (Yvv, 2026-10-30).
|
||
pub const DEFAULT: Self = Self(3);
|
||
pub const ALL: [Self; 5] = [Self(1), Self(2), Self(3), Self(4), Self(5)];
|
||
|
||
pub fn new(k: u8) -> Option<Self> {
|
||
(1..=5).contains(&k).then_some(Self(k))
|
||
}
|
||
|
||
pub fn get(self) -> u8 {
|
||
self.0
|
||
}
|
||
|
||
fn index(self) -> usize {
|
||
usize::from(self.0 - 1)
|
||
}
|
||
|
||
/// M_k, in percent.
|
||
pub fn majority_pct(self) -> f64 {
|
||
MAJORITY_PCT[self.index()]
|
||
}
|
||
|
||
/// s_k, in percent: the share of *for* asked at the usual participation.
|
||
pub fn share_pct(self) -> f64 {
|
||
SHARE_PCT[self.index()]
|
||
}
|
||
|
||
/// How a level is kept in the database: its digit.
|
||
pub fn as_db(self) -> String {
|
||
self.0.to_string()
|
||
}
|
||
|
||
/// A digit from 1 to 5, or a word of the four levels before 2026-10-30, read
|
||
/// as the level it became: low → 2, standard → 3, high → 4, max → 5.
|
||
pub fn parse(raw: &str) -> Option<Self> {
|
||
match raw.trim() {
|
||
"low" => Some(Self(2)),
|
||
"standard" => Some(Self(3)),
|
||
"high" => Some(Self(4)),
|
||
"max" => Some(Self(5)),
|
||
digits => digits.parse::<u8>().ok().and_then(Self::new),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for InertiaLevel {
|
||
fn default() -> Self {
|
||
Self::DEFAULT
|
||
}
|
||
}
|
||
|
||
impl Serialize for InertiaLevel {
|
||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||
s.serialize_u8(self.0)
|
||
}
|
||
}
|
||
|
||
impl<'de> Deserialize<'de> for InertiaLevel {
|
||
/// A number from 1 to 5, or a string `parse` reads (a seed or a client of before).
|
||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||
#[derive(Deserialize)]
|
||
#[serde(untagged)]
|
||
enum Raw {
|
||
Number(u64),
|
||
Text(String),
|
||
}
|
||
let level = match Raw::deserialize(d)? {
|
||
Raw::Number(n) => u8::try_from(n).ok().and_then(Self::new),
|
||
Raw::Text(s) => Self::parse(&s),
|
||
};
|
||
level.ok_or_else(|| D::Error::custom("an inertia level is 1 to 5"))
|
||
}
|
||
}
|
||
|
||
/// Where p̃ comes from.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum ParticipationSource {
|
||
/// The median of the group's voted proposals.
|
||
Median,
|
||
/// Too few voted proposals yet: `p0 = 2Q₀/W`, Q₀ the document's reference quorum.
|
||
Reference,
|
||
}
|
||
|
||
/// p̃ — the usual participation of a group, as a share of its electorate.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct Participation {
|
||
/// p̃, within [`Participation::LOWEST`, `Participation::HIGHEST`].
|
||
pub ratio: f64,
|
||
pub source: ParticipationSource,
|
||
/// Voted proposals the median rests on (counted even when too few).
|
||
pub sample: u32,
|
||
/// The median number of voters measured, when at least one proposal was voted on.
|
||
pub median: Option<f64>,
|
||
/// The measured (or reference) share fell outside the bounds and was brought back.
|
||
pub bounded: bool,
|
||
}
|
||
|
||
impl Participation {
|
||
/// Fewer voted proposals than this, and the reference stands in.
|
||
pub const MIN_SAMPLE: u32 = 5;
|
||
pub const LOWEST: f64 = 0.01;
|
||
pub const HIGHEST: f64 = 0.5;
|
||
|
||
/// `2Q₀/W`, before the bounds: the reference quorum of the document twice
|
||
/// over (`documents.vote_floor`: 50 in Monnaie libre, 100 voters). It never
|
||
/// rests on an article's quorum, which its author may lower (ADR 0013).
|
||
pub fn reference_ratio(electorate: u32, reference_quorum: f64) -> f64 {
|
||
2.0 * reference_quorum.max(0.0) / f64::from(electorate.max(1))
|
||
}
|
||
|
||
/// p̃ from what was measured: the median when it rests on enough proposals,
|
||
/// the reference otherwise; brought back within the bounds.
|
||
pub fn of(electorate: u32, reference_quorum: f64, median: Option<f64>, sample: u32) -> Self {
|
||
let (raw, source) = match median {
|
||
Some(m) if sample >= Self::MIN_SAMPLE => (m / f64::from(electorate.max(1)), ParticipationSource::Median),
|
||
_ => (Self::reference_ratio(electorate, reference_quorum), ParticipationSource::Reference),
|
||
};
|
||
let ratio = bounded(raw);
|
||
Self { ratio, source, sample, median, bounded: ratio != raw }
|
||
}
|
||
|
||
/// p̃ · W — the number of voters p̃ stands for.
|
||
pub fn voters(&self, electorate: u32) -> f64 {
|
||
self.ratio * f64::from(electorate.max(1))
|
||
}
|
||
}
|
||
|
||
/// The median of the numbers of voters, `None` for none.
|
||
pub fn median(values: &[u32]) -> Option<f64> {
|
||
let mut sorted = values.to_vec();
|
||
sorted.sort_unstable();
|
||
let n = sorted.len();
|
||
let mid = n / 2;
|
||
match n {
|
||
0 => None,
|
||
_ if n % 2 == 1 => Some(f64::from(sorted[mid])),
|
||
_ => Some((f64::from(sorted[mid - 1]) + f64::from(sorted[mid])) / 2.0),
|
||
}
|
||
}
|
||
|
||
/// Within [1 %, 50 %]; anything that is not a number goes to the lowest bound.
|
||
fn bounded(ratio: f64) -> f64 {
|
||
if ratio.is_nan() { Participation::LOWEST } else { ratio.clamp(Participation::LOWEST, Participation::HIGHEST) }
|
||
}
|
||
|
||
/// G_k at the usual participation p̃ (brought back within the bounds).
|
||
pub fn gradient(level: InertiaLevel, participation: f64) -> f64 {
|
||
let m = level.majority_pct() / 100.0;
|
||
let s = level.share_pct() / 100.0;
|
||
// Both logarithms are negative: 0 < 1 − (s − M)/(1 − M) < 1 since M < s < 1,
|
||
// and 0 < p̃ < 1 within the bounds.
|
||
(1.0 - (s - m) / (1.0 - m)).ln() / bounded(participation).ln()
|
||
}
|
||
|
||
/// What the votes of a document are judged by: its electorate W, its usual
|
||
/// participation p̃ and the quorum of each level.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct Scale {
|
||
pub electorate: u32,
|
||
/// p̃.
|
||
pub participation: f64,
|
||
/// Q_k, the quorum of each level, 1 to 5: the default of an article at that level.
|
||
pub quorums: [u32; 5],
|
||
}
|
||
|
||
impl Scale {
|
||
/// The quorum an article is judged on: its own, set by its author before the first
|
||
/// vote, else the one of its level (Yvv, 2026-11-02: « C suit le cran, mais c'est une
|
||
/// proposition par défaut, variable modifiable par l'auteur initial du texte »). A
|
||
/// change of inertia inherits the level of its article, and so its quorum.
|
||
pub fn quorum(&self, level: InertiaLevel, own: Option<u32>) -> u32 {
|
||
own.unwrap_or(self.quorums[level.index()])
|
||
}
|
||
|
||
/// The letters of the formula for a level, on a quorum.
|
||
pub fn params(&self, level: InertiaLevel, quorum: u32) -> FormulaParams {
|
||
FormulaParams {
|
||
majority_pct: level.majority_pct(),
|
||
gradient: gradient(level, self.participation),
|
||
base: FormulaParams::DEFAULT_BASE,
|
||
quorum,
|
||
}
|
||
}
|
||
|
||
pub fn evaluate(&self, tally: Tally, level: InertiaLevel, quorum: u32) -> Result<Evaluation, ThresholdError> {
|
||
evaluate(tally, self.electorate, &self.params(level, quorum))
|
||
}
|
||
|
||
/// ⌈R⌉ for a level and a quorum at `cast` votes.
|
||
pub fn required(&self, cast: u32, level: InertiaLevel, quorum: u32) -> Result<u32, ThresholdError> {
|
||
crate::threshold::wot_threshold(self.electorate, cast, &self.params(level, quorum))
|
||
}
|
||
}
|
||
|
||
/// The quorums an author may set (ADR 0013): from 2 — one voice never passes a text
|
||
/// alone — to W; from 1 in the test group, where one voice passes to try things out. A
|
||
/// tiny electorate never brings the lowest under 2.
|
||
pub fn quorum_bounds(electorate: u32, test_group: bool) -> (u32, u32) {
|
||
let lowest = if test_group { 1 } else { 2 };
|
||
(lowest, electorate.max(lowest))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::threshold::required_ratio;
|
||
|
||
const W: u32 = 7224;
|
||
/// The reference quorum of Monnaie libre (`vote_floor`): p0 = 2 × 50 / W.
|
||
const Q0: f64 = 50.0;
|
||
/// The quorums of Monnaie libre by level (Yvv, 2026-11-02: « 12 · 12 · 50 · 50 · 50 »).
|
||
const QUORUMS: [u32; 5] = [12, 12, 50, 50, 50];
|
||
|
||
fn level(k: u8) -> InertiaLevel {
|
||
InertiaLevel::new(k).expect("1 to 5")
|
||
}
|
||
|
||
fn at_median(voters: u32) -> Scale {
|
||
Scale { electorate: W, participation: f64::from(voters) / f64::from(W), quorums: QUORUMS }
|
||
}
|
||
|
||
/// ⌈R⌉ at `cast` votes, each level on its own quorum.
|
||
fn row(scale: &Scale, cast: u32) -> Vec<u32> {
|
||
InertiaLevel::ALL.iter().map(|k| scale.required(cast, *k, scale.quorum(*k, None)).expect("valid")).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn five_levels_three_in_the_middle() {
|
||
let m: Vec<f64> = InertiaLevel::ALL.iter().map(|k| k.majority_pct()).collect();
|
||
let s: Vec<f64> = InertiaLevel::ALL.iter().map(|k| k.share_pct()).collect();
|
||
assert_eq!(m, [50.0, 50.0, 55.0, 60.0, 66.0]);
|
||
assert_eq!(s, [60.0, 67.0, 75.0, 83.0, 90.0]);
|
||
for k in InertiaLevel::ALL {
|
||
assert!(k.share_pct() > k.majority_pct(), "s_k above M_k keeps G positive");
|
||
assert_eq!(InertiaLevel::parse(&k.as_db()), Some(k));
|
||
}
|
||
assert_eq!(InertiaLevel::default().get(), 3);
|
||
assert_eq!(InertiaLevel::new(0), None);
|
||
assert_eq!(InertiaLevel::new(6), None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_four_words_of_before_read_as_levels() {
|
||
let read = |w: &str| InertiaLevel::parse(w).map(InertiaLevel::get);
|
||
assert_eq!([read("low"), read("standard"), read("high"), read("max")], [Some(2), Some(3), Some(4), Some(5)]);
|
||
assert_eq!(read("maximale"), None);
|
||
let from_json = |v: &str| serde_json::from_str::<InertiaLevel>(v).ok().map(InertiaLevel::get);
|
||
assert_eq!([from_json("4"), from_json("\"standard\""), from_json("\"5\"")], [Some(4), Some(3), Some(5)]);
|
||
assert_eq!(from_json("0"), None);
|
||
assert_eq!(from_json("\"6\""), None);
|
||
assert_eq!(serde_json::to_string(&InertiaLevel::HIGHEST).expect("json"), "5");
|
||
}
|
||
|
||
#[test]
|
||
fn the_control_figures_of_the_quorum() {
|
||
// W 5 707, before any median: p0 = 2 × 50 / W = 100 / W (orch, 2026-11-03).
|
||
let w = 5707;
|
||
let scale = Scale { electorate: w, participation: Participation::of(w, Q0, None, 0).ratio, quorums: QUORUMS };
|
||
let yes = |k: u8, q: u32, against: u32| {
|
||
crate::threshold::yes_needed(against, w, &scale.params(level(k), q)).expect("valid")
|
||
};
|
||
// At the quorum: level 2, Q 12 → 9 « pour »; level 3, Q 50 → 39; level 5, Q 50 → 46.
|
||
assert_eq!((yes(2, 12, 0), yes(3, 50, 0), yes(5, 50, 0)), (9, 39, 46));
|
||
// 9 « pour » pass at level 2 with 3 « contre » at most (Yvv, 2026-11-03); with a 4th,
|
||
// 14 voters, 11 « pour » are asked (73,8 % of 14 is 10,2).
|
||
assert_eq!((yes(2, 12, 3), yes(2, 12, 4)), (9, 11));
|
||
// Ten voters, no quorum in the way: 7, 8, 9, 9 and 10 « pour », level 1 to 5.
|
||
let ten: Vec<u32> = InertiaLevel::ALL.iter().map(|k| scale.required(10, *k, 1).expect("valid")).collect();
|
||
assert_eq!(ten, [7, 8, 9, 9, 10]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_values_shown_to_yvv() {
|
||
// W 7224, quorums 12 · 12 · 50 · 50 · 50, a median of 120 voters (the smith vote of 2024).
|
||
let scale = at_median(120);
|
||
assert_eq!(row(&scale, 120), [72, 81, 90, 100, 108]);
|
||
assert_eq!(row(&scale, 500), [284, 310, 347, 386, 424]);
|
||
assert_eq!(row(&scale, 2000), [1068, 1123, 1252, 1389, 1537]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_median_of_360_voters() {
|
||
let scale = at_median(360);
|
||
assert_eq!(row(&scale, 120), [76, 86, 96, 106, 113]);
|
||
assert_eq!(row(&scale, 360), [216, 242, 270, 299, 324]);
|
||
assert_eq!(row(&scale, 500), [296, 328, 367, 407, 443]);
|
||
assert_eq!(row(&scale, 2000), [1092, 1164, 1301, 1446, 1598]);
|
||
}
|
||
|
||
#[test]
|
||
fn at_the_usual_participation_the_share_is_s_k() {
|
||
for (w, voters) in [(7224, 120), (7224, 360), (5745, 300), (40, 9), (2, 1)] {
|
||
let scale = Scale { electorate: w, participation: f64::from(voters) / f64::from(w), quorums: QUORUMS };
|
||
for k in InertiaLevel::ALL {
|
||
let ratio = required_ratio(w, voters, &scale.params(k, 0));
|
||
assert!((ratio - k.share_pct() / 100.0).abs() < 1e-12, "W {w}, k {}: {ratio}", k.get());
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_threshold_landing_on_a_whole_number_stays_on_it() {
|
||
// Level 5 at the median: 90 % of 120 voters is exactly 108.
|
||
assert_eq!(at_median(120).required(120, InertiaLevel::HIGHEST, 50), Ok(108));
|
||
// Level 1: 60 % of 120 is exactly 72.
|
||
assert_eq!(at_median(120).required(120, InertiaLevel::LOWEST, 12), Ok(72));
|
||
}
|
||
|
||
#[test]
|
||
fn the_threshold_rises_with_the_level() {
|
||
for (w, q0, quorums) in [
|
||
(7224, Q0, QUORUMS),
|
||
(5745, Q0, QUORUMS),
|
||
(5745, Q0, [50; 5]),
|
||
(40, 1.0, [1; 5]),
|
||
(2, 1.0, [1; 5]),
|
||
(1, 1.0, [1; 5]),
|
||
] {
|
||
for voters in [1, 2, 9, 60, 120, 360, 3000] {
|
||
let scale = Scale {
|
||
electorate: w,
|
||
participation: Participation::of(w, q0, Some(f64::from(voters)), 9).ratio,
|
||
quorums,
|
||
};
|
||
for cast in (0..=w.min(4000)).step_by(7).chain([w]) {
|
||
let r = row(&scale, cast);
|
||
assert!(r.windows(2).all(|p| p[0] <= p[1]), "W {w}, p̃ {voters}, T {cast}: {r:?}");
|
||
let ratios: Vec<f64> =
|
||
InertiaLevel::ALL.iter().map(|k| required_ratio(w, cast, &scale.params(*k, 0))).collect();
|
||
if cast > 0 && cast < w {
|
||
assert!(
|
||
ratios.windows(2).all(|p| p[0] < p[1]),
|
||
"strictly below full participation: {ratios:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_share_relaxes_toward_m_as_participation_rises() {
|
||
for voters in [120, 360] {
|
||
let scale = at_median(voters);
|
||
for k in InertiaLevel::ALL {
|
||
let p = scale.params(k, 0);
|
||
let mut last = 1.0;
|
||
for cast in (1..=W).step_by(13).chain([W]) {
|
||
let ratio = required_ratio(W, cast, &p);
|
||
assert!(ratio <= last, "k {}: the share never rises with participation", k.get());
|
||
assert!(ratio >= k.majority_pct() / 100.0 - 1e-12);
|
||
last = ratio;
|
||
}
|
||
assert!((required_ratio(W, W, &p) - k.majority_pct() / 100.0).abs() < 1e-12, "M at full participation");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_author_sets_a_quorum_from_two_to_w_from_one_in_the_test_group() {
|
||
assert_eq!(quorum_bounds(5745, false), (2, 5745));
|
||
assert_eq!(quorum_bounds(2, true), (1, 2));
|
||
assert_eq!(quorum_bounds(1, true), (1, 1));
|
||
assert_eq!(quorum_bounds(1, false), (2, 2), "never under two, even on a tiny electorate");
|
||
// At the lowest bound outside the test group, one voice never passes, whatever the
|
||
// level, the electorate and the usual participation.
|
||
for w in [1, 2, 3, 10, 120, 5745, 7224] {
|
||
let (lowest, _) = quorum_bounds(w, false);
|
||
for p in [Participation::LOWEST, 0.1, Participation::HIGHEST] {
|
||
let scale = Scale { electorate: w, participation: p, quorums: [lowest; 5] };
|
||
for k in InertiaLevel::ALL {
|
||
let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k, lowest).expect("valid");
|
||
assert!(!lone.reached, "W {w}, p̃ {p}, k {}", k.get());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn under_the_quorum_one_vote_never_passes_in_monnaie_libre() {
|
||
for voters in [1, 72, 120, 360, 3612] {
|
||
let scale = Scale {
|
||
electorate: W,
|
||
participation: Participation::of(W, Q0, Some(f64::from(voters)), 9).ratio,
|
||
quorums: QUORUMS,
|
||
};
|
||
for k in InertiaLevel::ALL {
|
||
let q = scale.quorum(k, None);
|
||
let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k, q).expect("valid");
|
||
assert!(!lone.reached, "k {}", k.get());
|
||
// Under the quorum, the voters missing count as against: the bar of Q voters.
|
||
let at_q = scale.required(q, k, q).expect("valid");
|
||
assert!(at_q >= 2, "one voice is never enough");
|
||
for cast in 0..=q {
|
||
assert_eq!(scale.required(cast, k, q).expect("valid"), at_q, "under the quorum, k {}", k.get());
|
||
}
|
||
let short = Tally { for_votes: at_q - 1, against: 0 };
|
||
assert!(!scale.evaluate(short, k, q).expect("valid").reached);
|
||
let enough = scale.evaluate(Tally { for_votes: at_q, against: 0 }, k, q).expect("valid");
|
||
assert!(enough.reached);
|
||
assert_eq!(enough.yes_needed, at_q);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_article_s_own_quorum_stands_for_its_level() {
|
||
let scale = at_median(120);
|
||
assert_eq!(scale.quorum(InertiaLevel::DEFAULT, None), 50);
|
||
assert_eq!(scale.quorum(level(2), None), 12);
|
||
assert_eq!(scale.quorum(InertiaLevel::DEFAULT, Some(20)), 20);
|
||
// The curve stays the level's: only the quorum moves.
|
||
let own = scale.params(InertiaLevel::DEFAULT, 20);
|
||
let default = scale.params(InertiaLevel::DEFAULT, 50);
|
||
assert_eq!((own.majority_pct, own.gradient, own.quorum), (default.majority_pct, default.gradient, 20));
|
||
}
|
||
|
||
#[test]
|
||
fn the_median() {
|
||
assert_eq!(median(&[]), None);
|
||
assert_eq!(median(&[7]), Some(7.0));
|
||
assert_eq!(median(&[3, 1, 2]), Some(2.0));
|
||
assert_eq!(median(&[4, 1, 3, 2]), Some(2.5));
|
||
assert_eq!(median(&[1, 1, 1, 200, 300]), Some(1.0), "one big vote does not move it");
|
||
}
|
||
|
||
#[test]
|
||
fn the_reference_stands_in_until_five_proposals_are_voted_on() {
|
||
// Monnaie libre: 2 × 50 / 7224 = 1.38 %, a hundred voters.
|
||
assert!((Participation::reference_ratio(W, Q0) - 100.0 / 7224.0).abs() < 1e-15);
|
||
let early = Participation::of(W, Q0, Some(120.0), 4);
|
||
assert_eq!(early.source, ParticipationSource::Reference);
|
||
assert!((early.voters(W) - 100.0).abs() < 1e-9);
|
||
assert_eq!((early.sample, early.median, early.bounded), (4, Some(120.0), false));
|
||
let none = Participation::of(W, Q0, None, 0);
|
||
assert_eq!(none.source, ParticipationSource::Reference);
|
||
let measured = Participation::of(W, Q0, Some(120.0), 5);
|
||
assert_eq!(measured.source, ParticipationSource::Median);
|
||
assert!((measured.voters(W) - 120.0).abs() < 1e-9);
|
||
assert!(!measured.bounded);
|
||
}
|
||
|
||
#[test]
|
||
fn participation_is_kept_within_one_and_fifty_percent() {
|
||
// Two voters on 7224: brought up to 1 %.
|
||
let low = Participation::of(W, Q0, Some(2.0), 9);
|
||
assert_eq!((low.ratio, low.bounded), (0.01, true));
|
||
// A group where everyone votes: brought down to half.
|
||
let all = Participation::of(12, 1.0, Some(12.0), 9);
|
||
assert_eq!((all.ratio, all.bounded), (0.5, true));
|
||
// The test group, W = 2, C = 1: the reference is 100 %, kept at half.
|
||
let essai = Participation::of(2, 1.0, None, 0);
|
||
assert_eq!((essai.ratio, essai.source, essai.bounded), (0.5, ParticipationSource::Reference, true));
|
||
// A tiny electorate never breaks G.
|
||
for w in [1, 2, 3] {
|
||
let p = Participation::of(w, 1.0, Some(1.0), 9);
|
||
for k in InertiaLevel::ALL {
|
||
let g = gradient(k, p.ratio);
|
||
assert!(g.is_finite() && g > 0.0, "W {w}, k {}: {g}", k.get());
|
||
}
|
||
}
|
||
// Whatever reaches it, G stays finite and positive.
|
||
for bad in [0.0, 1.0, -3.0, 7.0, f64::NAN, f64::INFINITY] {
|
||
let g = gradient(InertiaLevel::DEFAULT, bad);
|
||
assert!(g.is_finite() && g > 0.0, "{bad}: {g}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_test_group_at_a_quorum_of_one() {
|
||
// W = 2, quorum 1 everywhere (Yvv, 2026-11-02): one « pour » alone passes, at every
|
||
// level; one « pour » and one « contre » never (half of all, under any M of the levels).
|
||
let scale = Scale { electorate: 2, participation: Participation::of(2, 1.0, None, 0).ratio, quorums: [1; 5] };
|
||
for k in InertiaLevel::ALL {
|
||
assert!(scale.evaluate(Tally { for_votes: 1, against: 0 }, k, 1).expect("valid").reached);
|
||
assert!(!scale.evaluate(Tally { for_votes: 1, against: 1 }, k, 1).expect("valid").reached);
|
||
assert!(scale.evaluate(Tally { for_votes: 2, against: 0 }, k, 1).expect("valid").reached);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn gradients_at_a_median_of_120() {
|
||
let g: Vec<f64> = InertiaLevel::ALL.iter().map(|k| gradient(*k, 120.0 / 7224.0)).collect();
|
||
let expected = [0.054_455, 0.101_404, 0.143_442, 0.208_818, 0.298_651];
|
||
for (got, want) in g.iter().zip(expected) {
|
||
assert!((got - want).abs() < 1e-5, "{g:?}");
|
||
}
|
||
}
|
||
}
|