Files
decision/crates/ld-engine/src/inertia.rs
T
YvvandClaude Opus 5.5 01b7841472 Lot S, phase 2 : le quorum dans le serveur (ADR 0013), vu rouge puis vert
- 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>
2026-11-03 05:00:41 +01:00

535 lines
23 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.
//! 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:?}");
}
}
}