Files
decision/crates/ld-engine/src/threshold.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

414 lines
18 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.
//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula, judged on a quorum.
//!
//! ```text
//! T' = max(T, Q)
//! R = B^W + (M + (1 − M)(1 − (T'/W)^G)) · T'
//! ```
//!
//! R is the minimum number of *for* votes; a version passes when `for ≥ ⌈R⌉`.
//! W is the size of the electorate, T the votes cast (for + against). At low
//! participation the formula asks for near-unanimity, at full participation the
//! simple majority M suffices. Q is the **quorum** (ADR 0013, Yvv, 2026-11-02:
//! « ce n'est pas un nombre de vote pour a priori, c'est plutôt un quorum, à partir
//! de ce nombre, alors il faut la bonne proportion attendue par l'indice ») — and
//! below it the voters missing count as against (`judged_voters`). It replaces the
//! floor C of *for* votes added in front of the formula (ADR 0002, 0007).
//!
//! Parity with `maquette/frontend/app/engine/threshold.ts` at Q = 0 (the C = 0 of
//! the maquette), with two deliberate guards — the participation ratio is capped
//! at 1, so a stale W can never lower the bar below M; and a count that lands on a
//! whole number stays on it (`settle`), so a rounding error never adds a vote.
//! Which M, G and Q a text is judged by: `inertia` (five levels, their quorums).
use serde::{Deserialize, Serialize};
/// The letters of the formula, named.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct FormulaParams {
/// M, in percent (0–100).
pub majority_pct: f64,
/// G.
pub gradient: f64,
/// B.
pub base: f64,
/// Q — the quorum: the voters (for + against) from which the share of the inertia
/// applies; below it, the voters missing count as against (`judged_voters`).
pub quorum: u32,
}
impl FormulaParams {
pub const DEFAULT_BASE: f64 = 0.1;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThresholdError {
EmptyElectorate,
MajorityOutOfRange,
}
/// T' — the voters a version is judged on. **The rule of the quorum, here alone.**
///
/// Yvv, 2026-11-03, to « Sous le quorum, les votants qui manquent comptent-ils comme des
/// « non » ? » : « Ils comptent comme non ». Below the quorum Q, the voters who did not
/// come count as against: the share of the inertia is asked of max(T, Q) voters. At level
/// 2, quorum 12, 9 « pour » pass with 3 « contre » at most; Yvv's own example, 60 % and
/// Q = 10, takes 6 « pour ». So an « against » never helps a version pass — completing
/// the quorum with a « non » adds nothing the missing voters did not already count.
pub fn judged_voters(cast: u32, quorum: u32) -> u32 {
cast.max(quorum)
}
/// The share of *for* votes asked of `voters` voters (T' when judging a version).
pub fn required_ratio(electorate: u32, voters: u32, params: &FormulaParams) -> f64 {
let m = params.majority_pct / 100.0;
let participation = (f64::from(voters) / f64::from(electorate.max(1))).min(1.0);
m + (1.0 - m) * (1.0 - participation.powf(params.gradient))
}
/// ⌈R⌉ — the minimum number of *for* votes for adoption, at `cast` votes cast.
pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Result<u32, ThresholdError> {
if electorate == 0 {
return Err(ThresholdError::EmptyElectorate);
}
if !(0.0..=100.0).contains(&params.majority_pct) {
return Err(ThresholdError::MajorityOutOfRange);
}
let voters = judged_voters(cast, params.quorum);
let offset = params.base.powf(f64::from(electorate));
let raw = if voters == 0 {
offset
} else {
offset + settle(required_ratio(electorate, voters, params) * f64::from(voters))
};
// `raw` is finite and non-negative for any valid input; saturate rather than wrap.
Ok(clamp_ceil(raw))
}
/// Does a version with this tally pass? At least one vote cast, and its *for* reach
/// ⌈R⌉ on the voters it is judged on (`judged_voters`).
pub fn passes(tally: Tally, electorate: u32, params: &FormulaParams) -> Result<bool, ThresholdError> {
let cast = tally.cast();
Ok(cast > 0 && tally.for_votes >= wot_threshold(electorate, cast, params)?)
}
/// A count of votes that lands on a whole number stays on it: a rounding error
/// of the logarithms (a billionth of a vote) must never raise a threshold by
/// one. At the usual participation, 90 % of 70 votes is exactly 63.
fn settle(votes: f64) -> f64 {
let whole = votes.round();
if (votes - whole).abs() < 1e-9 { whole } else { votes }
}
fn clamp_ceil(raw: f64) -> u32 {
let ceiled = raw.ceil();
if ceiled <= 0.0 {
0
} else if ceiled >= f64::from(u32::MAX) {
u32::MAX
} else {
// Exact: `ceiled` is an integer in (0, u32::MAX).
ceiled as u32
}
}
/// Votes counted on one proposal.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tally {
#[serde(rename = "for")]
pub for_votes: u32,
pub against: u32,
}
impl Tally {
pub fn cast(&self) -> u32 {
self.for_votes.saturating_add(self.against)
}
}
/// Where a proposal stands, in words a screen can say.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Evaluation {
pub cast: u32,
/// Q, the quorum it is judged on.
pub quorum: u32,
/// ⌈R⌉ at the current participation (on T' = max(T, Q) voters).
pub required: u32,
/// `passes`: `for ≥ ⌈R⌉` with at least one vote cast.
pub reached: bool,
/// *for* votes still missing with the *against* of now: `yes_needed − for` (0 once reached).
pub missing: u32,
/// The share of *for* the inertia asks of T' voters.
pub required_ratio: f64,
/// « Bien cotée » : it holds the share its inertia asks of the T voters who came,
/// it only lacks voters (the quorum ignored). Used by the "if the well-rated pass" preview.
pub well_rated: bool,
/// The total of *for* that passes with the *against* cast now.
pub yes_needed: u32,
/// The total of *against* that holds the *for* cast now in check: 0 when the quorum
/// already holds them (they do not pass even without any *against*).
pub no_needed: u32,
}
/// The number of votes searched for never goes past this: the five levels always find
/// theirs well before (`yes_needed`, `no_needed`).
const SEARCH_LIMIT: u32 = u32::MAX / 4;
/// The smallest `n` in `0..` for which `holds(n)`, `holds` being monotone (false, then
/// true); `u32::MAX` if it never holds up to `SEARCH_LIMIT`.
fn first(mut holds: impl FnMut(u32) -> Result<bool, ThresholdError>) -> Result<u32, ThresholdError> {
if holds(0)? {
return Ok(0);
}
let (mut lo, mut hi) = (0u32, 1u32);
while !holds(hi)? {
if hi >= SEARCH_LIMIT {
return Ok(u32::MAX);
}
lo = hi;
hi = hi.saturating_mul(2).min(SEARCH_LIMIT);
}
// holds(lo) is false, holds(hi) true.
while hi - lo > 1 {
let mid = lo + (hi - lo) / 2;
if holds(mid)? { hi = mid } else { lo = mid }
}
Ok(hi)
}
/// The total of *for* that passes with `against` votes against. A *for* more never
/// hurts (`passes` is monotone): every total from this one on passes.
pub fn yes_needed(against: u32, electorate: u32, params: &FormulaParams) -> Result<u32, ThresholdError> {
first(|y| passes(Tally { for_votes: y, against }, electorate, params))
}
/// The total of *against* that holds `for_votes` in check; 0 when they do not pass even
/// without any *against* (the quorum holds them). An *against* more never helps: every
/// total from this one on holds them.
pub fn no_needed(for_votes: u32, electorate: u32, params: &FormulaParams) -> Result<u32, ThresholdError> {
first(|n| passes(Tally { for_votes, against: n }, electorate, params).map(|p| !p))
}
pub fn evaluate(tally: Tally, electorate: u32, params: &FormulaParams) -> Result<Evaluation, ThresholdError> {
let cast = tally.cast();
let required = wot_threshold(electorate, cast, params)?;
let reached = passes(tally, electorate, params)?;
let without_quorum = FormulaParams { quorum: 0, ..*params };
let required_without_quorum = wot_threshold(electorate, cast, &without_quorum)?;
let yes = yes_needed(tally.against, electorate, params)?;
Ok(Evaluation {
cast,
quorum: params.quorum,
required,
reached,
missing: if reached { 0 } else { yes.saturating_sub(tally.for_votes) },
required_ratio: required_ratio(electorate, judged_voters(cast, params.quorum), params),
well_rated: cast > 0 && tally.for_votes >= required_without_quorum,
yes_needed: yes,
no_needed: no_needed(tally.for_votes, electorate, params)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn g1vote(m: f64, g: f64) -> FormulaParams {
FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: 0 }
}
/// The letters of the « standard » inertia before the five levels: M 50 %, G 0.2.
fn standard(quorum: u32) -> FormulaParams {
FormulaParams { quorum, ..g1vote(50.0, 0.2) }
}
#[test]
fn reference_case_of_the_smith_vote() {
// Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94.
// The formula's own reference, letters given, without a quorum (Q = 0, the C = 0
// of the maquette): the five levels choose M, G and Q (ADR 0007, 0013).
let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(7224, 120, &p), Ok(94));
let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid");
assert!(e.reached);
assert_eq!(e.missing, 0);
}
#[test]
fn parity_with_the_typescript_reference() {
// Values computed by maquette/frontend/app/engine/threshold.ts (C = 0).
let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(7224, 0, &p), Ok(0));
assert_eq!(wot_threshold(7224, 1, &p), Ok(1));
assert_eq!(wot_threshold(100, 100, &p), Ok(50));
assert_eq!(wot_threshold(5, 5, &p), Ok(3));
let high = g1vote(60.0, 0.4);
assert_eq!(wot_threshold(7224, 120, &high), Ok(111));
}
#[test]
fn yvv_s_example_sixty_percent_and_a_quorum_of_ten() {
// Yvv, 2026-11-02: « Si c'est 60% et c=10, alors il faut 6 voix pour et ça passe. »
// A share of 60 % whatever the participation (G = 0), a large web (B^W vanishes).
let p = FormulaParams { majority_pct: 60.0, gradient: 0.0, base: 0.1, quorum: 10 };
// G = 0: (T/W)^0 = 1, the share is M, 60 %.
assert!((required_ratio(7224, 10, &p) - 0.6).abs() < 1e-12);
for against in 0..=4 {
let t = Tally { for_votes: 6, against };
assert_eq!(passes(t, 7224, &p), Ok(true), "6 pour, {against} contre");
}
assert_eq!(passes(Tally { for_votes: 5, against: 0 }, 7224, &p), Ok(false), "5 of the 10 counted");
assert_eq!(wot_threshold(7224, 6, &p), Ok(6));
// Beyond the quorum, the share of all who came: 6 pour and 5 contre are 11 voters.
assert_eq!(passes(Tally { for_votes: 6, against: 5 }, 7224, &p), Ok(false));
assert_eq!(passes(Tally { for_votes: 7, against: 4 }, 7224, &p), Ok(true));
}
#[test]
fn under_the_quorum_the_voters_missing_count_as_against() {
// Yvv, 2026-11-03: « Ils comptent comme non ». T' = max(T, Q).
assert_eq!(
(judged_voters(0, 12), judged_voters(9, 12), judged_voters(12, 12), judged_voters(30, 12)),
(12, 12, 12, 30)
);
let p = standard(50);
// One « pour » alone is judged on 50 voters; so are 1 « pour » and 49 « contre ».
assert_eq!(wot_threshold(5745, 1, &p), wot_threshold(5745, 50, &p));
assert!(!passes(Tally { for_votes: 1, against: 0 }, 5745, &p).expect("valid"));
}
#[test]
fn an_against_never_helps_a_version_pass() {
// Without the rule, an « against » that completes the quorum could make a text pass.
for (m, g, q, w) in [(50.0, 0.1, 12, 5707), (55.0, 0.15, 50, 5707), (66.0, 0.3, 50, 5745), (50.0, 0.6, 1, 2)] {
let p = FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: q };
for y in 0..80 {
let mut passed = passes(Tally { for_votes: y, against: 0 }, w, &p).expect("valid");
for n in 1..80 {
let now = passes(Tally { for_votes: y, against: n }, w, &p).expect("valid");
assert!(passed || !now, "M {m} Q {q} W {w}: {y} pour, {n} contre passes, {} contre did not", n - 1);
passed = now;
}
}
}
}
#[test]
fn one_vote_never_passes_with_a_quorum_of_two_or_more() {
// The invariant of Monnaie libre (quorum 2 at least): one voice alone never passes.
for q in [2, 12, 50] {
for g in [0.05, 0.1, 0.3, 1.77] {
let p = FormulaParams { majority_pct: 50.0, gradient: g, base: 0.1, quorum: q };
let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &p).expect("valid");
assert!(!e.reached, "Q {q}, G {g}");
assert!(e.well_rated, "unanimous, it only lacks voters");
}
}
// Without a quorum, the trap of a permanent vote: a lone « pour » meets the inertial bar.
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &standard(0)).expect("valid").reached);
}
#[test]
fn yes_needed_and_no_needed_say_where_the_line_is() {
// At level 2 on the scale of Monnaie libre (G 0.10274, W 5707), quorum 12.
let p = FormulaParams { majority_pct: 50.0, gradient: 0.102_742, base: 0.1, quorum: 12 };
let e = evaluate(Tally::default(), 5707, &p).expect("valid");
assert_eq!((e.quorum, e.required, e.yes_needed, e.missing, e.no_needed), (12, 9, 9, 9, 0));
// 9 « pour »: they pass with 3 « contre » at most; the 4th holds them.
let e = evaluate(Tally { for_votes: 9, against: 0 }, 5707, &p).expect("valid");
assert!(e.reached);
assert_eq!((e.yes_needed, e.no_needed, e.missing), (9, 4, 0));
// With a 4th « contre », 14 voters at least: 11 « pour » are asked (10,2 rounded up).
let e = evaluate(Tally { for_votes: 9, against: 4 }, 5707, &p).expect("valid");
assert!(!e.reached);
assert_eq!(e.yes_needed, 11);
assert_eq!(e.missing, 2);
// 8 « pour »: the quorum already holds them, no « contre » is needed.
let e = evaluate(Tally { for_votes: 8, against: 0 }, 5707, &p).expect("valid");
assert_eq!((e.reached, e.no_needed, e.missing), (false, 0, 1));
}
#[test]
fn passes_means_yes_needed_reached_means_no_needed_not_reached() {
// On a grid: levels (M, G as at the reference participation and beyond), quorums,
// electorates, « pour », « contre ». passes ⇔ for ≥ yesNeeded(against) ⇔ against < noNeeded(for).
let levels = [(50.0, 0.055), (50.0, 0.103), (55.0, 0.145), (60.0, 0.212), (66.0, 0.303), (66.0, 1.77)];
for (m, g) in levels {
for q in [0, 1, 2, 12, 50] {
for w in [1, 2, 16, 40, 500, 5707] {
let p = FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: q };
let yes: Vec<u32> = (0..70).map(|n| yes_needed(n, w, &p).expect("valid")).collect();
for y in 0..70 {
let no = no_needed(y, w, &p).expect("valid");
for n in 0..70 {
let t = Tally { for_votes: y, against: n };
let ok = passes(t, w, &p).expect("valid");
assert_eq!(ok, y >= yes[n as usize], "M {m} G {g} Q {q} W {w}: {y} pour {n} contre");
assert_eq!(ok, n < no, "M {m} G {g} Q {q} W {w}: {y} pour {n} contre, noNeeded {no}");
}
}
}
}
}
}
#[test]
fn well_rated_holds_the_share_on_those_who_came() {
let p = standard(50);
// 30 « pour », 2 « contre »: the share holds on 32 voters, the quorum is not there.
let e = evaluate(Tally { for_votes: 30, against: 2 }, 5745, &p).expect("valid");
assert!(!e.reached && e.well_rated);
// 10 « pour », 10 « contre »: the share does not hold.
assert!(!evaluate(Tally { for_votes: 10, against: 10 }, 5745, &p).expect("valid").well_rated);
}
#[test]
fn no_vote_is_never_reached() {
for q in [0, 1, 12] {
let p = FormulaParams { quorum: q, ..g1vote(50.0, 0.1) };
let e = evaluate(Tally::default(), 3, &p).expect("valid");
assert!(!e.reached);
assert!(!e.well_rated);
}
}
#[test]
fn the_test_group_passes_with_one_voice_at_a_quorum_of_one() {
// Two people, quorum 1 (Yvv, 2026-11-02: the test group stays at 1 everywhere): one
// « pour » of one voter holds any share; a « contre » beside it does not.
let p = standard(1);
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached);
assert!(!evaluate(Tally { for_votes: 1, against: 1 }, 2, &p).expect("valid").reached);
assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached);
}
#[test]
fn stale_electorate_cannot_lower_the_bar_below_majority() {
let p = g1vote(50.0, 0.2);
// 30 votes cast for an electorate believed to be 10: the reference
// would ask 12 (ratio below M), the cap keeps the simple majority:
// ⌈B^W + 0.5 × 30⌉ = 16, the tiny B^W offset rounding up.
let r = wot_threshold(10, 30, &p).expect("valid");
assert_eq!(r, 16);
}
#[test]
fn a_whole_number_of_votes_never_gains_one_by_rounding() {
// 100 members, everyone votes, M 55 %: 55 % of 100 = 55. In floating point 0.55 × 100
// is 55.000000000000007, which would ask for 56 (B^W = 10⁻¹⁰⁰ adds nothing).
let p = FormulaParams { majority_pct: 55.0, gradient: 0.3, base: 0.1, quorum: 1 };
assert_eq!(wot_threshold(100, 100, &p), Ok(55));
// A genuine fraction still rounds up.
assert_eq!(wot_threshold(101, 99, &FormulaParams { quorum: 0, ..p }), Ok(55));
}
#[test]
fn errors() {
let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(0, 1, &p), Err(ThresholdError::EmptyElectorate));
let bad = g1vote(120.0, 0.2);
assert_eq!(wot_threshold(10, 1, &bad), Err(ThresholdError::MajorityOutOfRange));
assert_eq!(yes_needed(0, 0, &p), Err(ThresholdError::EmptyElectorate));
}
}