v3 : inertie à cinq crans — moteur, données, serveur (L10)

Moteur (ld-engine/inertia.rs) : cinq crans, M_k = 50, 50, 55, 60, 66 % ;
s_k = 60, 67, 75, 83, 90 % de « pour » au-delà de C à la participation
habituelle p̃ ; G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃). p̃ = médiane
des votants des propositions du groupe (au moins 5, votées dans l'année),
sinon la référence 2C/W ; bornée à [1 %, 50 %]. Le changement d'inertie
hérite du cran de l'article (remplace « inertie + »). Un seuil qui tombe
sur un entier n'y gagne plus une voix par arrondi.

Données : migration 0005 (les quatre mots deviennent 2 à 5, gardés tels
quels dans *_legacy ; adoptions intactes ; journaux inertia_changes et
inertia_amendments). Seed : 3 partout, 4 pour E1 et E2. Amendement
d'inertie gardé (E1, E2 : 3 → 4, seulement si aucun vote n'a changé leur
inertie ; une proposition d'inertie ouverte reste ouverte, votée au
nouveau cran), journalisé, une seule fois, republié.

Serveur : participation mesurée une fois par jour (settings), échelle
dans les seuils, le balayage et l'adoption ; crans, M, G, s_k, p̃ et sa
source servis au client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 15:14:40 +01:00
co-authored by Claude Opus 5.5
parent 8727315612
commit d762301706
16 changed files with 1817 additions and 224 deletions
+62 -55
View File
@@ -18,9 +18,11 @@
//! - 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`).
//! - which formula a proposal is judged by (`params_for`): the level of
//! inertia of its unit, on the scale of its group (`inertia::Scale`).
use crate::threshold::{FormulaParams, InertiaPreset, Tally, ThresholdError, evaluate};
use crate::inertia::{InertiaLevel, Scale};
use crate::threshold::{FormulaParams, Tally, ThresholdError, evaluate};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -37,7 +39,7 @@ pub struct VersionText {
pub struct UnitState {
pub id: String,
pub position: f64,
pub inertia: InertiaPreset,
pub inertia: InertiaLevel,
pub in_force: Option<VersionText>,
}
@@ -49,7 +51,7 @@ pub enum ProposalKind {
/// Remove the unit from the text in force.
Remove,
/// Change the inertia of the unit.
Inertia(InertiaPreset),
Inertia(InertiaLevel),
}
impl ProposalKind {
@@ -72,13 +74,18 @@ pub struct ProposalState {
pub blocked: bool,
}
/// A change of inertia is voted one level above the unit (« inertie + »).
pub fn params_for(unit: &UnitState, kind: ProposalKind, floor: f64) -> FormulaParams {
let preset = match kind {
ProposalKind::Inertia(_) => unit.inertia.raised(),
ProposalKind::Text | ProposalKind::Remove => unit.inertia,
};
FormulaParams::for_inertia(preset, floor)
/// 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,
}
}
pub fn params_for(unit: &UnitState, kind: ProposalKind, scale: &Scale) -> FormulaParams {
scale.params(voting_level(unit, kind))
}
/// A proposal that changes nothing cannot enter into force.
@@ -98,13 +105,8 @@ struct Scored<'a> {
well_rated: bool,
}
fn score<'a>(
unit: &UnitState,
p: &'a ProposalState,
electorate: u32,
floor: f64,
) -> Result<Scored<'a>, ThresholdError> {
let e = evaluate(p.tally, electorate, &params_for(unit, p.kind, floor))?;
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),
@@ -126,9 +128,9 @@ fn best<'a>(mut rivals: Vec<Scored<'a>>) -> Option<&'a ProposalState> {
}
/// Margin of the version in force, judged like a text proposal on its unit.
fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result<Option<i64>, ThresholdError> {
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, electorate, &params_for(unit, ProposalKind::Text, floor))?;
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)))
}
@@ -138,19 +140,18 @@ fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result<Opti
pub fn adoptions(
units: &[UnitState],
proposals: &[ProposalState],
electorate: u32,
floor: f64,
scale: &Scale,
) -> Result<Vec<String>, ThresholdError> {
let mut out = Vec::new();
for unit in units {
let incumbent = in_force_margin(unit, electorate, floor)?;
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, electorate, floor)?;
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);
@@ -200,8 +201,7 @@ pub struct PreviewUnit {
pub fn preview(
units: &[UnitState],
proposals: &[ProposalState],
electorate: u32,
floor: f64,
scale: &Scale,
mode: PreviewMode,
) -> Result<Vec<PreviewUnit>, ThresholdError> {
let mut ordered: Vec<&UnitState> = units.iter().collect();
@@ -216,7 +216,7 @@ pub fn preview(
if !is_effective(unit, p) {
continue;
}
let s = score(unit, p, electorate, floor)?;
let s = score(unit, p, scale)?;
if mode == PreviewMode::All || s.well_rated {
rivals.push(s);
}
@@ -263,7 +263,7 @@ mod tests {
UnitState {
id: id.into(),
position,
inertia: InertiaPreset::Standard,
inertia: InertiaLevel::DEFAULT,
in_force: in_force.map(|b| VersionText {
id: format!("{id}-v"),
title: id.into(),
@@ -286,23 +286,27 @@ mod tests {
}
}
const W: u32 = 5745;
const C: f64 = 50.0;
/// Monnaie libre before any median: W 5745, C 50, the reference p0 = 2C/W.
const SCALE: Scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 };
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, W, C).expect("valid").is_empty());
assert!(adoptions(&units, &props, &SCALE).expect("valid").is_empty());
}
#[test]
fn the_floor_holds_then_releases() {
let units = [unit("E3", 7.0, None)];
let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)];
assert!(adoptions(&units, &few, W, C).expect("valid").is_empty());
assert!(adoptions(&units, &few, &SCALE).expect("valid").is_empty());
let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)];
assert_eq!(adoptions(&units, &enough, W, C).expect("valid"), vec!["p1".to_string()]);
assert_eq!(adoptions(&units, &enough, &SCALE).expect("valid"), vec!["p1".to_string()]);
}
#[test]
@@ -312,7 +316,7 @@ mod tests {
prop("a", "E3", ProposalKind::Text, "variante a", 60, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 70, 0),
];
assert_eq!(adoptions(&units, &props, W, C).expect("valid"), vec!["bb".to_string()]);
assert_eq!(adoptions(&units, &props, &SCALE).expect("valid"), vec!["bb".to_string()]);
}
#[test]
@@ -320,19 +324,22 @@ mod tests {
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(InertiaPreset::High), "", 60, 0),
prop("i", "E1", ProposalKind::Inertia(level(4)), "", 60, 0),
];
let won = adoptions(&units, &props, W, C).expect("valid");
let won = adoptions(&units, &props, &SCALE).expect("valid");
assert_eq!(won, vec!["t".to_string(), "i".to_string()]);
}
#[test]
fn inertia_change_is_voted_one_level_up() {
let u = unit("E1", 4.0, Some("x"));
let p = params_for(&u, ProposalKind::Inertia(InertiaPreset::Low), C);
assert_eq!(p.majority_pct, InertiaPreset::High.majority_pct());
assert_eq!(p.gradient, InertiaPreset::High.gradient());
assert_eq!(params_for(&u, ProposalKind::Text, C).gradient, InertiaPreset::Standard.gradient());
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));
}
assert_eq!(params_for(&u, ProposalKind::Text, &SCALE).majority_pct, 60.0);
}
#[test]
@@ -341,8 +348,8 @@ mod tests {
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(InertiaPreset::Standard), "", 80, 0);
assert!(adoptions(&units, &[blocked, same, same_inertia], W, C).expect("valid").is_empty());
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]
@@ -350,20 +357,20 @@ mod tests {
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, W, C).expect("valid"), vec!["r".to_string()]);
// A version in force by vote, margin 60 − 50 = 10: a rival at margin 5 waits.
assert_eq!(adoptions(&units, &rival, &SCALE).expect("valid"), vec!["r".to_string()]);
// A version in force by vote, margin 60 − 58 = 2: a rival at 55 (margin 55 − 54 = 1) 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, W, C).expect("valid").is_empty());
assert!(adoptions(&units, &weaker, &SCALE).expect("valid").is_empty());
let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)];
assert_eq!(adoptions(&units, &stronger, W, C).expect("valid"), vec!["s".to_string()]);
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, W, C).expect("valid").is_empty());
let inertia = [prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 55, 0)];
assert_eq!(adoptions(&units, &inertia, W, C).expect("valid"), vec!["i".to_string()]);
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]
@@ -373,16 +380,16 @@ mod tests {
prop("well", "E3", ProposalKind::Text, "connaître", 3, 0),
prop("weak", "E1", ProposalKind::Remove, "", 1, 5),
];
let in_force = preview(&units, &props, W, C, PreviewMode::InForce).expect("valid");
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, W, C, PreviewMode::WellRated).expect("valid");
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, W, C, PreviewMode::All).expect("valid");
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),]
@@ -396,7 +403,7 @@ mod tests {
prop("a", "E3", ProposalKind::Text, "variante a", 0, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 1, 3),
];
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
let all = preview(&units, &props, &SCALE, PreviewMode::All).expect("valid");
assert_eq!(all[0].proposal_id.as_deref(), Some("bb"));
}
}
+438
View File
@@ -0,0 +1,438 @@
//! 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`); only M and G 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 **beyond C** 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̃)`.
//!
//! 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 = 2C/W` stands in. 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 beyond C 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 = 2C/W`.
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;
/// `2C/W`, before the bounds: the floor twice over, a participation the
/// group must reach anyway for C to leave room to the majority.
pub fn reference_ratio(electorate: u32, floor: f64) -> f64 {
2.0 * floor.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, floor: 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, floor), 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 group are judged by: its electorate W, its floor C and
/// its usual participation p̃.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Scale {
pub electorate: u32,
pub floor: f64,
/// p̃.
pub participation: f64,
}
impl Scale {
/// The letters of the formula for a level.
pub fn params(&self, level: InertiaLevel) -> FormulaParams {
FormulaParams {
majority_pct: level.majority_pct(),
gradient: gradient(level, self.participation),
base: FormulaParams::DEFAULT_BASE,
constant: self.floor,
}
}
pub fn evaluate(&self, tally: Tally, level: InertiaLevel) -> Result<Evaluation, ThresholdError> {
evaluate(tally, self.electorate, &self.params(level))
}
/// ⌈R⌉ for a level at `cast` votes.
pub fn required(&self, cast: u32, level: InertiaLevel) -> Result<u32, ThresholdError> {
crate::threshold::wot_threshold(self.electorate, cast, &self.params(level))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::threshold::required_ratio;
const W: u32 = 7224;
const C: f64 = 50.0;
fn at_median(voters: u32) -> Scale {
Scale { electorate: W, floor: C, participation: f64::from(voters) / f64::from(W) }
}
fn row(scale: &Scale, cast: u32) -> Vec<u32> {
InertiaLevel::ALL.iter().map(|k| scale.required(cast, *k).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_values_shown_to_yvv() {
// W 7224, C 50, a median of 120 voters (the smith vote of 2024).
let scale = at_median(120);
assert_eq!(row(&scale, 120), [92, 97, 103, 109, 113]);
assert_eq!(row(&scale, 500), [306, 329, 362, 397, 432]);
assert_eq!(row(&scale, 2000), [1091, 1145, 1271, 1404, 1549]);
}
#[test]
fn a_median_of_360_voters() {
let scale = at_median(360);
assert_eq!(row(&scale, 120), [95, 101, 106, 112, 116]);
assert_eq!(row(&scale, 360), [236, 258, 283, 308, 329]);
assert_eq!(row(&scale, 500), [316, 345, 381, 416, 449]);
assert_eq!(row(&scale, 2000), [1114, 1184, 1318, 1460, 1608]);
}
#[test]
fn at_the_usual_participation_the_share_beyond_c_is_s_k() {
for (w, c, voters) in [(7224, 50.0, 120), (7224, 50.0, 360), (5745, 50.0, 300), (40, 1.0, 9), (2, 1.0, 1)] {
let scale = Scale { electorate: w, floor: c, participation: f64::from(voters) / f64::from(w) };
for k in InertiaLevel::ALL {
let ratio = required_ratio(w, voters, &scale.params(k));
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 the 70 votes beyond C is exactly 63.
assert_eq!(at_median(120).required(120, InertiaLevel::HIGHEST), Ok(113));
// Level 1: 60 % of 70 is exactly 42.
assert_eq!(at_median(120).required(120, InertiaLevel::LOWEST), Ok(92));
}
#[test]
fn the_threshold_rises_with_the_level() {
for (w, c) in [(7224, 50.0), (5745, 50.0), (40, 1.0), (2, 1.0), (1, 1.0)] {
for voters in [1, 2, 9, 60, 120, 360, 3000] {
let scale = Scale {
electorate: w,
floor: c,
participation: Participation::of(w, c, Some(f64::from(voters)), 9).ratio,
};
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))).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);
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 c_stays_a_floor_one_vote_never_passes_in_monnaie_libre() {
for voters in [1, 72, 120, 360, 3612] {
let scale = Scale {
electorate: W,
floor: C,
participation: Participation::of(W, C, Some(f64::from(voters)), 9).ratio,
};
for k in InertiaLevel::ALL {
let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k).expect("valid");
assert!(!lone.reached, "k {}", k.get());
assert_eq!(lone.required, 50);
for cast in 1..=50 {
assert!(scale.required(cast, k).expect("valid") >= 50, "nothing passes under C for");
}
assert!(!scale.evaluate(Tally { for_votes: 49, against: 0 }, k).expect("valid").reached);
assert!(scale.evaluate(Tally { for_votes: 50, against: 0 }, k).expect("valid").reached);
}
}
}
#[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, C) - 100.0 / 7224.0).abs() < 1e-15);
let early = Participation::of(W, C, 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, C, None, 0);
assert_eq!(none.source, ParticipationSource::Reference);
let measured = Participation::of(W, C, 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, C, 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_needs_everyone() {
// W = 2, C = 1: both must vote for, at every level.
let scale = Scale { electorate: 2, floor: 1.0, participation: Participation::of(2, 1.0, None, 0).ratio };
for k in InertiaLevel::ALL {
assert!(!scale.evaluate(Tally { for_votes: 1, against: 0 }, k).expect("valid").reached);
assert!(scale.evaluate(Tally { for_votes: 2, against: 0 }, k).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:?}");
}
}
}
+3 -1
View File
@@ -9,8 +9,10 @@
pub mod assist;
pub mod compose;
pub mod edit;
pub mod inertia;
pub mod names;
pub mod pending;
pub mod threshold;
pub use threshold::{FormulaParams, InertiaPreset, Tally};
pub use inertia::{InertiaLevel, Participation, Scale};
pub use threshold::{FormulaParams, Tally};
+32 -74
View File
@@ -12,66 +12,13 @@
//! (Yvv, 2026-10-21: « Nombre minimal de votants »).
//!
//! Parity: identical to `maquette/frontend/app/engine/threshold.ts` and the
//! Python reference, with one deliberate guard — the participation ratio is
//! capped at 1, so a stale W can never lower the bar below M.
//! Python reference, 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 and G a text is judged by: `inertia` (five levels).
use serde::{Deserialize, Serialize};
/// The four inertia levels of a modular text. Wiring of the v2 maquette:
/// low = M50 G0.1, standard = M50 G0.2, high = M60 G0.4, max = M66 G0.6 (B = 0.1).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InertiaPreset {
Low,
Standard,
High,
Max,
}
impl InertiaPreset {
pub const ALL: [Self; 4] = [Self::Low, Self::Standard, Self::High, Self::Max];
pub fn majority_pct(self) -> f64 {
match self {
Self::Low | Self::Standard => 50.0,
Self::High => 60.0,
Self::Max => 66.0,
}
}
pub fn gradient(self) -> f64 {
match self {
Self::Low => 0.1,
Self::Standard => 0.2,
Self::High => 0.4,
Self::Max => 0.6,
}
}
/// « Inertie + » : one level up, capped at max. A change of inertia is
/// itself voted at the raised level of the unit it touches.
pub fn raised(self) -> Self {
match self {
Self::Low => Self::Standard,
Self::Standard => Self::High,
Self::High | Self::Max => Self::Max,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Standard => "standard",
Self::High => "high",
Self::Max => "max",
}
}
pub fn parse(raw: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.as_str() == raw)
}
}
/// The letters of the formula, named.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct FormulaParams {
@@ -87,10 +34,6 @@ pub struct FormulaParams {
impl FormulaParams {
pub const DEFAULT_BASE: f64 = 0.1;
pub fn for_inertia(preset: InertiaPreset, constant: f64) -> Self {
Self { majority_pct: preset.majority_pct(), gradient: preset.gradient(), base: Self::DEFAULT_BASE, constant }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -119,12 +62,20 @@ pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Resu
let raw = if cast == 0 {
c + offset
} else {
c + offset + required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0)
c + offset + settle(required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0))
};
// `raw` is finite and non-negative for any valid input; saturate rather than wrap.
Ok(clamp_ceil(raw))
}
/// 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 the 70 votes beyond C 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 {
@@ -193,9 +144,16 @@ mod tests {
FormulaParams { majority_pct: m, gradient: g, base: 0.1, constant: 0.0 }
}
/// The letters of the « standard » inertia before the five levels: M 50 %, G 0.2.
fn standard(constant: f64) -> FormulaParams {
FormulaParams { constant, ..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: the five levels (ADR 0007)
// choose M and G, the formula that turns them into a threshold is this one.
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");
@@ -218,9 +176,9 @@ mod tests {
#[test]
fn one_vote_passes_without_floor_and_never_with_it() {
// The trap of a permanent vote: a lone "for" meets the inertial bar.
let bare = FormulaParams::for_inertia(InertiaPreset::Standard, 0.0);
let bare = standard(0.0);
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached);
let guarded = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
let guarded = standard(50.0);
let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &guarded).expect("valid");
assert!(!e.reached);
assert_eq!(e.required, 50);
@@ -230,7 +188,7 @@ mod tests {
#[test]
fn floor_is_a_minimum_of_for_votes() {
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
let p = standard(50.0);
// 50 unanimous votes reach it, 49 do not.
assert!(evaluate(Tally { for_votes: 50, against: 0 }, 5745, &p).expect("valid").reached);
assert!(!evaluate(Tally { for_votes: 49, against: 0 }, 5745, &p).expect("valid").reached);
@@ -241,7 +199,7 @@ mod tests {
#[test]
fn no_vote_is_never_reached() {
let p = FormulaParams::for_inertia(InertiaPreset::Low, 0.0);
let p = g1vote(50.0, 0.1);
let e = evaluate(Tally::default(), 3, &p).expect("valid");
assert!(!e.reached);
assert!(!e.well_rated);
@@ -250,7 +208,7 @@ mod tests {
#[test]
fn small_group_needs_everyone() {
// Test group of two people, C = 1: both must vote for.
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 1.0);
let p = standard(1.0);
assert!(!evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached);
assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached);
}
@@ -266,13 +224,13 @@ mod tests {
}
#[test]
fn inertia_levels_rise_and_cap() {
assert_eq!(InertiaPreset::Low.raised(), InertiaPreset::Standard);
assert_eq!(InertiaPreset::Standard.raised(), InertiaPreset::High);
assert_eq!(InertiaPreset::Max.raised(), InertiaPreset::Max);
for p in InertiaPreset::ALL {
assert_eq!(InertiaPreset::parse(p.as_str()), Some(p));
}
fn a_whole_number_of_votes_never_gains_one_by_rounding() {
// 101 members, everyone votes, M 55 %: 1 + 55 % of 100 = 56. In floating
// point 0.55 × 100 is 55.000000000000007, which would ask for 57.
let p = FormulaParams { majority_pct: 55.0, gradient: 0.3, base: 0.1, constant: 1.0 };
assert_eq!(wot_threshold(101, 101, &p), Ok(56));
// A genuine fraction still rounds up.
assert_eq!(wot_threshold(101, 100, &FormulaParams { constant: 0.0, ..p }), Ok(56));
}
#[test]
@@ -0,0 +1,50 @@
-- Inertia in five levels (2026-10-30, ADR 0007). Additive: nothing is lost.
--
-- The four words of before become levels: low → 2, standard → 3, high → 4,
-- max → 5 (level 1 is new, below them). Each word is first kept as it was, in a
-- column of its own, then its level is written in its place. The adoptions are
-- left as they are: their `params` hold the M and G each was judged by. The
-- server still reads the four words (a row written by an older image).
ALTER TABLE units ADD COLUMN inertia_legacy TEXT; -- the word before 2026-10-30, as it was
UPDATE units SET inertia_legacy = inertia WHERE inertia IN ('low', 'standard', 'high', 'max');
UPDATE units SET inertia = CASE inertia
WHEN 'low' THEN '2' WHEN 'standard' THEN '3' WHEN 'high' THEN '4' WHEN 'max' THEN '5' END
WHERE inertia IN ('low', 'standard', 'high', 'max');
ALTER TABLE proposals ADD COLUMN inertia_target_legacy TEXT; -- the word before 2026-10-30, as it was
UPDATE proposals SET inertia_target_legacy = inertia_target WHERE inertia_target IN ('low', 'standard', 'high', 'max');
UPDATE proposals SET inertia_target = CASE inertia_target
WHEN 'low' THEN '2' WHEN 'standard' THEN '3' WHEN 'high' THEN '4' WHEN 'max' THEN '5' END
WHERE inertia_target IN ('low', 'standard', 'high', 'max');
-- Every change of the inertia of an article from now on, by a vote or by an
-- amendment of the seed: what the article says of its own inertia.
CREATE TABLE inertia_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unit_id TEXT NOT NULL REFERENCES units(id),
doc_id TEXT NOT NULL REFERENCES documents(id),
from_level TEXT NOT NULL,
to_level TEXT NOT NULL,
cause TEXT NOT NULL, -- vote | amendment
proposal_id TEXT, -- the change of inertia voted
amendment_id TEXT, -- the amendment applied
at TEXT NOT NULL
);
CREATE INDEX inertia_changes_unit ON inertia_changes(unit_id);
-- Inertia amendments (src/inertia_amend.rs): a starting level of the seed raised
-- in an existing base, decided once per document and only on an article whose
-- inertia no vote ever changed. Each decision is kept here: applied, refused
-- (and why), or already present.
CREATE TABLE inertia_amendments (
amendment_id TEXT NOT NULL,
doc_id TEXT NOT NULL REFERENCES documents(id),
unit_code TEXT NOT NULL,
from_level TEXT, -- the level found
to_level TEXT NOT NULL,
outcome TEXT NOT NULL, -- applied | refused | present
reason TEXT, -- why it was refused
at TEXT NOT NULL,
PRIMARY KEY (amendment_id, doc_id)
);
+39 -8
View File
@@ -1,8 +1,9 @@
//! Entering into force — automatic, as soon as a proposal reaches its
//! threshold (Yvv, 2026-10-21: « Dès qu'une proposition atteint le score, elle
//! remonte en vigueur »). The human gesture is the vote itself; the engine
//! only counts. Every entry is recorded with its numbers and queued for
//! publication to the texts repository.
//! only counts. Every entry is recorded with its numbers — the level of
//! inertia, M, G, s_k and the usual participation it was judged by — and
//! queued for publication to the texts repository.
use crate::AppState;
use crate::docs::{self, DocRow, DocState};
@@ -66,16 +67,16 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
let st = docs::load_state(state, doc_id).await?;
confirm_seeds(state, &st).await?;
let units = st.unit_states();
let winners = compose::adoptions(&units, &st.proposal_states(), st.electorate, st.doc.vote_floor)
let scale = st.scale();
let winners = compose::adoptions(&units, &st.proposal_states(), &scale)
.map_err(|_| crate::error::AppError::Invalid("electorate"))?;
let mut entered = Vec::new();
for id in winners {
let Some(p) = st.proposals.iter().find(|p| p.id == id) else { continue };
let Some(unit) = st.units.iter().find(|u| u.id == p.unit_id) else { continue };
let us = units.iter().find(|u| u.id == unit.id);
let tally = st.tally(&p.id);
let eval = st.evaluation(unit, p);
let params = us.map(|u| compose::params_for(u, p.kind(), st.doc.vote_floor));
let params = kept_params(&st, unit, p);
let now = util::now();
let mut tx = state.db.begin().await?;
// The text that enters is the text that was counted: a revision that landed
@@ -140,10 +141,22 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
.bind(target.as_str())
.bind(target.as_db())
.bind(&unit.id)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, proposal_id, at)
VALUES (?, ?, ?, ?, 'vote', ?, ?)",
)
.bind(&unit.id)
.bind(&st.doc.id)
.bind(docs::inertia_of(unit).as_db())
.bind(target.as_db())
.bind(&p.id)
.bind(&now)
.execute(&mut *tx)
.await?;
"inertia"
}
};
@@ -159,7 +172,7 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
.bind(i64::from(tally.against))
.bind(i64::from(st.electorate))
.bind(eval.map_or(0, |e| i64::from(e.required)))
.bind(serde_json::to_string(&params).unwrap_or_default())
.bind(params.to_string())
.bind(&now)
.execute(&mut *tx)
.await?;
@@ -174,12 +187,30 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
Ok(entered)
}
/// What an adoption keeps of the formula it was judged by. The four letters
/// stay where the rows of before have them (`majority_pct`, `gradient`, `base`,
/// `constant`: read as they were written); the level and the scale join them.
fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value {
let level = st.voting_level(unit, p);
let f = st.scale().params(level);
serde_json::json!({
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "constant": f.constant,
"level": level, "share_pct": level.share_pct(),
"participation": st.participation.ratio, "participation_source": st.participation.source,
"participation_sample": st.participation.sample, "participation_median": st.participation.median,
})
}
/// The electorate changes (the web of trust grows or shrinks): every few
/// minutes, every document is passed through the engine again.
/// minutes, every document is passed through the engine again — after the
/// usual participation of each group is measured again, once a day.
pub async fn sweeper(state: AppState) {
let mut tick = tokio::time::interval(Duration::from_secs(300));
loop {
tick.tick().await;
if let Err(e) = crate::participation::refresh(&state, false).await {
tracing::warn!(error = %e, "usual participation");
}
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
Ok(ids) => ids,
Err(e) => {
+117 -20
View File
@@ -6,11 +6,13 @@
use crate::AppState;
use crate::auth::{IdentityRow, User};
use crate::error::{AppError, AppResult};
use crate::participation;
use crate::util::{self, LangMap};
use axum::Json;
use axum::extract::{Path, Query, State};
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
use ld_engine::threshold::{Evaluation, InertiaPreset, Tally, evaluate};
use ld_engine::inertia::{InertiaLevel, Participation, Scale};
use ld_engine::threshold::{Evaluation, Tally};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -231,16 +233,17 @@ impl ProposalRow {
pub fn kind(&self) -> ProposalKind {
match self.kind.as_str() {
"remove" => ProposalKind::Remove,
"inertia" => ProposalKind::Inertia(
self.inertia_target.as_deref().and_then(InertiaPreset::parse).unwrap_or(InertiaPreset::Standard),
),
"inertia" => {
ProposalKind::Inertia(self.inertia_target.as_deref().and_then(InertiaLevel::parse).unwrap_or_default())
}
_ => ProposalKind::Text,
}
}
}
pub fn inertia_of(unit: &UnitRow) -> InertiaPreset {
InertiaPreset::parse(&unit.inertia).unwrap_or(InertiaPreset::Standard)
/// The level of a unit: a digit, or one of the four words of before.
pub fn inertia_of(unit: &UnitRow) -> InertiaLevel {
InertiaLevel::parse(&unit.inertia).unwrap_or_default()
}
pub struct DocState {
@@ -253,6 +256,9 @@ pub struct DocState {
pub signal_counts: HashMap<String, i64>,
pub electorate: u32,
pub electorate_source: &'static str,
/// p̃, from the group's last measure (or the reference).
pub participation: Participation,
pub measured_at: Option<String>,
}
pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> {
@@ -351,10 +357,40 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
}
}
let (electorate, electorate_source) = electorate(state, &group).await?;
Ok(DocState { doc, group, units, proposals, tallies, blocked, signal_counts, electorate, electorate_source })
let measure = participation::stored(state, &group.slug).await?;
let participation = Participation::of(
electorate,
doc.vote_floor,
measure.as_ref().and_then(|m| m.median),
measure.as_ref().map_or(0, |m| m.sample),
);
let measured_at = measure.map(|m| m.measured_at);
Ok(DocState {
doc,
group,
units,
proposals,
tallies,
blocked,
signal_counts,
electorate,
electorate_source,
participation,
measured_at,
})
}
impl DocState {
/// What this document's votes are judged by: W, C and p̃.
pub fn scale(&self) -> Scale {
Scale { electorate: self.electorate, floor: self.doc.vote_floor, participation: self.participation.ratio }
}
/// p̃ · W, as a whole number of voters.
pub fn usual_voters(&self) -> u32 {
whole(self.participation.voters(self.electorate))
}
pub fn tally(&self, id: &str) -> Tally {
self.tallies.get(id).copied().unwrap_or_default()
}
@@ -396,9 +432,45 @@ impl DocState {
}
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
evaluate(self.tally(&p.id), self.electorate, &compose::params_for(&us, p.kind(), self.doc.vote_floor)).ok()
self.scale().evaluate(self.tally(&p.id), self.voting_level(unit, p)).ok()
}
/// The level a proposal is voted at — a change of inertia inherits its article's.
pub fn voting_level(&self, unit: &UnitRow, p: &ProposalRow) -> InertiaLevel {
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
compose::voting_level(&us, p.kind())
}
/// The usual participation, as the screen tells it.
pub fn participation_json(&self) -> serde_json::Value {
let p = &self.participation;
json!({
"ratio": p.ratio, "source": p.source, "sample": p.sample, "median": p.median, "bounded": p.bounded,
"voters": self.usual_voters(), "minSample": Participation::MIN_SAMPLE,
"windowDays": participation::WINDOW_DAYS, "everyHours": participation::EVERY_HOURS,
"bounds": [Participation::LOWEST, Participation::HIGHEST], "measuredAt": self.measured_at,
})
}
/// The five levels on this scale: M, G, s_k, and the *for* votes asked at `cast` votes.
pub fn levels_json(&self, cast: u32) -> Vec<serde_json::Value> {
let scale = self.scale();
InertiaLevel::ALL
.iter()
.map(|k| {
let params = scale.params(*k);
json!({
"level": k, "majorityPct": params.majority_pct, "gradient": params.gradient,
"sharePct": k.share_pct(), "cast": cast, "required": scale.required(cast, *k).ok(),
})
})
.collect()
}
}
/// A non-negative count, rounded to the nearest whole.
fn whole(x: f64) -> u32 {
if x.is_finite() && x > 0.0 { x.round().min(f64::from(u32::MAX)) as u32 } else { 0 }
}
// ---------------------------------------------------------------------------
@@ -462,12 +534,9 @@ fn proposal_json(
let (kind, target) = match p.kind() {
ProposalKind::Text => ("text", None),
ProposalKind::Remove => ("remove", None),
ProposalKind::Inertia(t) => ("inertia", Some(t.as_str())),
};
let voted_at = match p.kind() {
ProposalKind::Inertia(_) => inertia_of(unit).raised(),
_ => inertia_of(unit),
ProposalKind::Inertia(t) => ("inertia", Some(t)),
};
let voted_at = st.voting_level(unit, p);
json!({
"id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target,
"title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label,
@@ -480,7 +549,7 @@ fn proposal_json(
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id),
"evaluation": eval,
"votedAtInertia": voted_at.as_str(),
"votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
"myVote": mine.map(|v| v.choice.as_str()),
@@ -514,6 +583,7 @@ pub async fn document(
.map(|v| (v.proposal_id.clone(), v))
.collect();
let changes = inertia_changes(&state, &id).await?;
let units: Vec<_> = st
.units
.iter()
@@ -529,7 +599,8 @@ pub async fn document(
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
json!({
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": u.inertia,
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u),
"inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(),
"inForce": in_force, "proposals": proposals,
})
})
@@ -545,15 +616,41 @@ pub async fn document(
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor,
"electorate": st.electorate, "electorateSource": st.electorate_source,
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
"participation": st.participation_json(),
"publication": publication,
},
"units": units,
"inertia": InertiaPreset::ALL.iter().map(|p| json!({
"key": p.as_str(), "majorityPct": p.majority_pct(), "gradient": p.gradient(),
})).collect::<Vec<_>>(),
// The five levels, with the *for* votes asked at the usual participation.
"levels": st.levels_json(st.usual_voters()),
})))
}
/// The past of each article's inertia (unit id → changes, oldest first).
async fn inertia_changes(state: &AppState, doc_id: &str) -> AppResult<HashMap<String, Vec<serde_json::Value>>> {
#[derive(sqlx::FromRow)]
struct Row {
unit_id: String,
from_level: String,
to_level: String,
cause: String,
at: String,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT unit_id, from_level, to_level, cause, at FROM inertia_changes WHERE doc_id = ? ORDER BY id",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let mut out: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
for r in rows {
out.entry(r.unit_id).or_default().push(json!({
"from": InertiaLevel::parse(&r.from_level), "to": InertiaLevel::parse(&r.to_level),
"cause": r.cause, "at": r.at,
}));
}
Ok(out)
}
pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<serde_json::Value> {
#[derive(sqlx::FromRow)]
struct Row {
@@ -598,7 +695,7 @@ pub async fn preview(
Some("wellrated") => PreviewMode::WellRated,
_ => PreviewMode::InForce,
};
let units = compose::preview(&st.unit_states(), &st.proposal_states(), st.electorate, st.doc.vote_floor, mode)
let units = compose::preview(&st.unit_states(), &st.proposal_states(), &st.scale(), mode)
.map_err(|_| AppError::Invalid("electorate"))?;
// Translations of the versions shown.
let shown: HashSet<String> = units
+309
View File
@@ -0,0 +1,309 @@
//! Inertia amendments — a starting level of the seed raised in an existing base.
//!
//! Yvv, 2026-10-30: « Tu mets par défaut au milieu (3) pour toutes les
//! propositions, (4) pour les 2 engagements structurels » — Unicité du compte
//! (E1) and Réciprocité (E2). New bases get level 4 from the seed. An existing
//! base, where both are in force at the starting level (`standard`, now 3),
//! gets it through an amendment listed here, and **only if no vote ever
//! changed the article's inertia**:
//!
//! - no adoption of a change of inertia on the article (`adoptions`, and the
//! journal `inertia_changes`);
//! - its level is still the one the amendment raises (the seed's 3).
//!
//! An open proposal to change the inertia does not stop it (orch, 2026-10-30:
//! two such proposals exist in production, I1 → 5 and E1 → 5, at zero votes):
//! it stays open, with its target, and is voted at the article's new level,
//! since a change of inertia inherits it (ADR 0007).
//!
//! Like the seed amendments (`seed_amend`), each document is decided once, at
//! the first start that knows the amendment; the decision is kept in
//! `inertia_amendments` (applied, refused and why, or present) and in the
//! logs. Applied, the level is written in one transaction with a line in
//! `inertia_changes` (the article says so) and, where the document publishes,
//! a row in the publication queue (`document.json` carries the levels).
use crate::AppState;
use crate::seed::SeedDoc;
use crate::util;
use ld_engine::inertia::InertiaLevel;
use sqlx::SqliteConnection;
/// One starting level to raise, in every document instantiated from a seed.
pub struct InertiaAmendment {
/// Stable identifier: each document's decision is kept under it.
pub id: &'static str,
/// Slug of the seed document.
pub doc: &'static str,
/// Code of the article.
pub unit: &'static str,
/// The level it must still hold.
pub from: u8,
/// The level it gets: the seed's today, which must say so.
pub to: u8,
/// Reason given to the publication queue, and so to the commit of the texts.
pub reason: &'static str,
}
pub const AMENDMENTS: &[InertiaAmendment] = &[
InertiaAmendment {
id: "2026-10-30-e1-inertie-4",
doc: "acte-engagement-certification",
unit: "E1",
from: 3,
to: 4,
reason: "inertie de départ relevée : E1, 3 → 4",
},
InertiaAmendment {
id: "2026-10-30-e2-inertie-4",
doc: "acte-engagement-certification",
unit: "E2",
from: 3,
to: 4,
reason: "inertie de départ relevée : E2, 3 → 4",
},
];
/// What the database says of the article.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Facts {
/// `units.inertia` as written.
pub raw: String,
/// Changes of its inertia that a vote brought into force.
pub voted_changes: i64,
/// Open proposals to change its inertia (they stay open; told in the logs).
pub open_proposals: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Apply,
/// The level is already there: nothing to do.
Present,
Refuse(&'static str),
}
/// The rule, apart from the database: a vote on the inertia keeps it.
pub fn verdict(facts: Option<&Facts>, a: &InertiaAmendment) -> Verdict {
let Some(f) = facts else { return Verdict::Refuse("no_article") };
let Some(level) = InertiaLevel::parse(&f.raw) else { return Verdict::Refuse("level_unreadable") };
if level.get() == a.to {
return Verdict::Present;
}
if f.voted_changes > 0 {
Verdict::Refuse("changed_by_vote")
} else if level.get() != a.from {
Verdict::Refuse("level_differs")
} else {
Verdict::Apply
}
}
/// A decision taken at this start.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub amendment: &'static str,
pub group: String,
pub verdict: Verdict,
}
/// Decides every amendment not yet decided, document by document.
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[InertiaAmendment]) -> anyhow::Result<Vec<Outcome>> {
let mut out = Vec::new();
for a in list {
if !seed_says(seeds, a) {
continue;
}
let docs: Vec<(String, String, Option<String>)> =
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
.bind(a.doc)
.fetch_all(&state.db)
.await?;
for (doc_id, group, publish_path) in docs {
let Some((v, open)) = decide(state, a, &doc_id, publish_path.is_some()).await? else { continue };
match v {
Verdict::Apply => tracing::info!(
amendment = a.id, %group, unit = a.unit, from = a.from, to = a.to, open_inertia_proposals = open,
"inertia amendment applied"
),
Verdict::Present => {
tracing::info!(amendment = a.id, %group, unit = a.unit, "inertia amendment: level already there")
}
Verdict::Refuse(why) => tracing::warn!(
amendment = a.id, %group, unit = a.unit, reason = why,
"inertia amendment refused: the article keeps its inertia"
),
}
if v == Verdict::Apply && publish_path.is_some() {
state.publish_wake.notify_one();
}
out.push(Outcome { amendment: a.id, group, verdict: v });
}
}
Ok(out)
}
/// The seed must hold the level the amendment gives: a seed edited later never
/// reaches a base by this way.
fn seed_says(seeds: &[SeedDoc], a: &InertiaAmendment) -> bool {
let level = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.units.iter().find(|u| u.code == a.unit));
match level {
Some(u) if u.inertia.get() == a.to => true,
_ => {
tracing::error!(
amendment = a.id,
doc = a.doc,
unit = a.unit,
"inertia amendment: the seed does not give this level, nothing decided"
);
false
}
}
}
async fn facts(conn: &mut SqliteConnection, unit_id: &str, raw: String) -> anyhow::Result<Facts> {
let voted_changes: i64 = sqlx::query_scalar(
"SELECT (SELECT COUNT(*) FROM adoptions WHERE unit_id = ?1 AND kind = 'inertia')
+ (SELECT COUNT(*) FROM inertia_changes WHERE unit_id = ?1 AND cause = 'vote')",
)
.bind(unit_id)
.fetch_one(&mut *conn)
.await?;
let open_proposals: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM proposals WHERE unit_id = ? AND kind = 'inertia' AND status = 'open'")
.bind(unit_id)
.fetch_one(&mut *conn)
.await?;
Ok(Facts { raw, voted_changes, open_proposals })
}
/// One document, in one transaction: `None` when it was decided at an earlier start.
async fn decide(
state: &AppState,
a: &InertiaAmendment,
doc_id: &str,
publishes: bool,
) -> anyhow::Result<Option<(Verdict, i64)>> {
let mut tx = state.db.begin().await?;
let decided: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_amendments WHERE amendment_id = ? AND doc_id = ?")
.bind(a.id)
.bind(doc_id)
.fetch_one(&mut *tx)
.await?;
if decided > 0 {
return Ok(None);
}
let unit: Option<(String, String)> = sqlx::query_as("SELECT id, inertia FROM units WHERE doc_id = ? AND code = ?")
.bind(doc_id)
.bind(a.unit)
.fetch_optional(&mut *tx)
.await?;
let found = match &unit {
Some((id, raw)) => Some(facts(&mut tx, id, raw.clone()).await?),
None => None,
};
let mut v = verdict(found.as_ref(), a);
let now = util::now();
let to = InertiaLevel::new(a.to).map(InertiaLevel::as_db).unwrap_or_default();
if let (Verdict::Apply, Some((unit_id, raw))) = (v, &unit) {
// Written only over the level that was judged.
let done = sqlx::query("UPDATE units SET inertia = ? WHERE id = ? AND inertia = ?")
.bind(&to)
.bind(unit_id)
.bind(raw)
.execute(&mut *tx)
.await?;
if done.rows_affected() == 1 {
sqlx::query(
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, amendment_id, at)
VALUES (?, ?, ?, ?, 'amendment', ?, ?)",
)
.bind(unit_id)
.bind(doc_id)
.bind(a.from.to_string())
.bind(&to)
.bind(a.id)
.bind(&now)
.execute(&mut *tx)
.await?;
if publishes {
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
.bind(doc_id)
.bind(a.reason)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await?;
}
} else {
v = Verdict::Refuse("changed");
}
}
let (outcome, reason) = match v {
Verdict::Apply => ("applied", None),
Verdict::Present => ("present", None),
Verdict::Refuse(why) => ("refused", Some(why)),
};
sqlx::query(
"INSERT INTO inertia_amendments (amendment_id, doc_id, unit_code, from_level, to_level, outcome, reason, at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(a.id)
.bind(doc_id)
.bind(a.unit)
.bind(unit.as_ref().map(|(_, raw)| raw.clone()))
.bind(&to)
.bind(outcome)
.bind(reason)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some((v, found.map_or(0, |f| f.open_proposals))))
}
#[cfg(test)]
mod tests {
use super::*;
const A: InertiaAmendment =
InertiaAmendment { id: "test", doc: "doc", unit: "E1", from: 3, to: 4, reason: "inertie de départ relevée" };
fn at(raw: &str) -> Facts {
Facts { raw: raw.into(), ..Facts::default() }
}
#[test]
fn the_starting_level_is_raised() {
assert_eq!(verdict(Some(&at("3")), &A), Verdict::Apply);
// The word of before, if an older image wrote it.
assert_eq!(verdict(Some(&at("standard")), &A), Verdict::Apply);
}
#[test]
fn an_open_change_of_inertia_does_not_stop_it() {
let f = Facts { open_proposals: 1, ..at("3") };
assert_eq!(verdict(Some(&f), &A), Verdict::Apply);
}
#[test]
fn the_level_already_there_is_left_as_it_is() {
assert_eq!(verdict(Some(&at("4")), &A), Verdict::Present);
assert_eq!(verdict(Some(&at("high")), &A), Verdict::Present);
}
#[test]
fn a_vote_on_the_inertia_keeps_it() {
let cases = [
(None, "no_article"),
(Some(Facts { voted_changes: 1, ..at("3") }), "changed_by_vote"),
(Some(at("5")), "level_differs"),
(Some(at("2")), "level_differs"),
(Some(at("haute")), "level_unreadable"),
];
for (facts, reason) in cases {
assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}");
}
}
}
+3
View File
@@ -11,7 +11,9 @@ pub mod auth;
pub mod config;
pub mod docs;
pub mod error;
pub mod inertia_amend;
pub mod overview;
pub mod participation;
pub mod proposals;
pub mod publish;
pub mod seed;
@@ -197,6 +199,7 @@ pub async fn run() -> anyhow::Result<()> {
let state = AppState::new(cfg, db).await?;
seed::ensure(&state).await?;
participation::refresh(&state, false).await?;
auth::bootstrap(&state).await?;
if let Err(e) = publish::ensure_key(&state).await {
tracing::warn!(error = %e, "publication key");
+132
View File
@@ -0,0 +1,132 @@
//! p̃ — the usual participation of each group, the anchor of the five levels
//! of inertia (ADR 0007, `ld_engine::inertia`).
//!
//! What is measured: the proposals of the group (every document, every kind,
//! drafts aside) that hold at least one vote on their current revision, the
//! last one cast within the past year. For each, T is the number of those
//! votes **at the time of measuring** — the same T the formula sees, since the
//! vote is permanent. The median of these T, and how many proposals it rests
//! on, are kept in `settings` under `participation:<group>`.
//!
//! When: once a day (at start, then by the sweeper), never at each vote — a
//! threshold that moved with every vote cast elsewhere could not be read. W
//! moves on its own (the web of trust, every six hours): p̃ = T̃ / W follows it
//! at once, so the scale still asks s_k of the same usual number of voters.
use crate::AppState;
use crate::util;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
/// Only proposals voted on within this many days count.
pub const WINDOW_DAYS: i64 = 365;
/// A measure older than this is taken again.
pub const EVERY_HOURS: i64 = 24;
const KEY: &str = "participation:";
/// What was measured for a group.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Measure {
/// The median number of voters, when at least one proposal was voted on.
pub median: Option<f64>,
/// Proposals it rests on.
pub sample: u32,
pub measured_at: String,
}
/// The numbers of voters of the group's proposals voted on since `since`.
pub async fn sample(state: &AppState, group: &str, since: &str) -> sqlx::Result<Vec<u32>> {
let rows: Vec<i64> = sqlx::query_scalar(
"SELECT COUNT(*) FROM votes v
JOIN proposals p ON p.id = v.proposal_id
JOIN units u ON u.id = p.unit_id
JOIN documents d ON d.id = u.doc_id
WHERE d.group_slug = ? AND p.status <> 'draft' AND v.revision = p.revision
GROUP BY v.proposal_id
HAVING MAX(v.cast_at) >= ?",
)
.bind(group)
.bind(since)
.fetch_all(&state.db)
.await?;
Ok(rows.into_iter().map(|n| u32::try_from(n).unwrap_or(u32::MAX)).collect())
}
/// Measures the group now.
pub async fn measure(state: &AppState, group: &str) -> sqlx::Result<Measure> {
let now = OffsetDateTime::now_utc();
let since = util::stamp(now - time::Duration::days(WINDOW_DAYS));
let votes = sample(state, group, &since).await?;
Ok(Measure {
median: ld_engine::inertia::median(&votes),
sample: u32::try_from(votes.len()).unwrap_or(u32::MAX),
measured_at: util::stamp(now),
})
}
/// The last measure kept for the group, if any.
pub async fn stored(state: &AppState, group: &str) -> sqlx::Result<Option<Measure>> {
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(format!("{KEY}{group}"))
.fetch_optional(&state.db)
.await?;
Ok(raw.and_then(|v| serde_json::from_str(&v).ok()))
}
async fn keep(state: &AppState, group: &str, m: &Measure) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
)
.bind(format!("{KEY}{group}"))
.bind(serde_json::to_string(m)?)
.bind(&m.measured_at)
.execute(&state.db)
.await?;
Ok(())
}
fn is_stale(m: &Measure, now: OffsetDateTime) -> bool {
OffsetDateTime::parse(&m.measured_at, &Rfc3339)
.map_or(true, |at| now - at >= time::Duration::hours(EVERY_HOURS) || at > now)
}
/// Measures again every group whose measure is missing or a day old (all of
/// them with `force`). Returns the groups measured.
pub async fn refresh(state: &AppState, force: bool) -> anyhow::Result<Vec<String>> {
let groups: Vec<String> =
sqlx::query_scalar("SELECT slug FROM groups ORDER BY position").fetch_all(&state.db).await?;
let now = OffsetDateTime::now_utc();
let mut done = Vec::new();
for group in groups {
let due = force || stored(state, &group).await?.is_none_or(|m| is_stale(&m, now));
if !due {
continue;
}
let m = measure(state, &group).await?;
keep(state, &group, &m).await?;
tracing::info!(%group, median = ?m.median, sample = m.sample, "usual participation measured");
done.push(group);
}
Ok(done)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_measure_is_taken_again_after_a_day() {
let now = OffsetDateTime::now_utc();
let at =
|hours: i64| Measure { measured_at: util::stamp(now - time::Duration::hours(hours)), ..Measure::default() };
assert!(!is_stale(&at(1), now));
assert!(!is_stale(&at(23), now));
assert!(is_stale(&at(24), now));
assert!(is_stale(&at(-2), now), "a measure from the future is taken again");
assert!(is_stale(&Measure::default(), now), "unreadable");
}
}
+23 -21
View File
@@ -21,7 +21,7 @@ use axum::Json;
use axum::extract::{Path, State};
use ld_engine::assist::{self, Hint};
use ld_engine::edit::{self, EditKind};
use ld_engine::threshold::InertiaPreset;
use ld_engine::inertia::InertiaLevel;
use serde::Deserialize;
use serde_json::json;
@@ -191,13 +191,14 @@ async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppRe
let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone());
sqlx::query(
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
VALUES (?, ?, ?, ?, ?, 'standard', NULL, ?)",
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
)
.bind(&id)
.bind(&st.doc.id)
.bind(format!("N{}", n + 1))
.bind(section)
.bind(position_after(&st.units, after))
.bind(InertiaLevel::DEFAULT.as_db())
.bind(util::now())
.execute(&state.db)
.await?;
@@ -626,28 +627,33 @@ pub async fn detail(
_ => None,
};
let eval = st.evaluation(unit, &p);
let voted_at = if p.kind == "inertia" { docs::inertia_of(unit).raised() } else { docs::inertia_of(unit) };
let voted_at = st.voting_level(unit, &p);
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
let tr = docs::translations_for(&state, &[&p]).await?;
Ok(Json(json!({
"proposal": {
"id": p.id, "kind": p.kind, "inertiaTarget": p.inertia_target, "title": p.title, "body": p.body,
"id": p.id, "kind": p.kind, "inertiaTarget": target, "title": p.title, "body": p.body,
"status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision,
"author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false),
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at.as_str(),
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"myVote": my_vote.as_ref().map(|v| &v.0), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.1 != p.revision),
"translations": tr.get(&p.id).cloned().unwrap_or_default(),
},
"unit": { "id": unit.id, "code": unit.code, "inertia": unit.inertia },
"unit": { "id": unit.id, "code": unit.code, "inertia": docs::inertia_of(unit) },
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
"diff": diff,
"history": history,
"signals": signals,
"electorate": st.electorate,
"electorateSource": st.electorate_source,
"voteFloor": st.doc.vote_floor,
"participation": st.participation_json(),
// The five levels, with the *for* votes each would ask at this version's participation.
"levels": st.levels_json(st.tally(&p.id).cast()),
})))
}
@@ -783,18 +789,20 @@ pub async fn lift_signal(
#[derive(Deserialize)]
pub struct InertiaBody {
target: String,
/// A level, 1 to 5 (a word of before is read as its level).
target: serde_json::Value,
}
/// Propose a change of inertia; it is voted one level above the unit. An open
/// proposal for the same target is reused rather than duplicated.
/// Propose a change of inertia; it is voted at the level of the unit (it
/// inherits it, ADR 0007). An open proposal for the same target is reused
/// rather than duplicated.
pub async fn propose_inertia(
State(state): State<AppState>,
User(user): User,
Path(unit_id): Path<String>,
Json(b): Json<InertiaBody>,
) -> AppResult<Json<serde_json::Value>> {
let target = InertiaPreset::parse(&b.target).ok_or(AppError::Invalid("inertia"))?;
let target = serde_json::from_value::<InertiaLevel>(b.target).map_err(|_| AppError::Invalid("inertia"))?;
let doc_id = doc_of_unit(&state, &unit_id).await?;
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
@@ -806,17 +814,18 @@ pub async fn propose_inertia(
p.unit_id == unit_id
&& p.kind == "inertia"
&& p.status == "open"
&& p.inertia_target.as_deref() == Some(target.as_str())
&& p.inertia_target.as_deref().and_then(InertiaLevel::parse) == Some(target)
}) {
return Ok(Json(json!({ "id": existing.id, "existing": true })));
}
let title = format!("inertia:{}", target.as_str());
let title = format!("inertia:{}", target.get());
let level = target.as_db();
let id = insert_proposal(
&state,
NewProposal {
unit_id: &unit_id,
kind: "inertia",
inertia_target: Some(target.as_str()),
inertia_target: Some(&level),
title: &title,
body: "",
author: &user.id,
@@ -869,14 +878,7 @@ mod tests {
use super::*;
fn unit(id: &str, position: f64) -> UnitRow {
UnitRow {
id: id.into(),
code: id.into(),
section: None,
position,
inertia: "standard".into(),
in_force_id: None,
}
UnitRow { id: id.into(), code: id.into(), section: None, position, inertia: "3".into(), in_force_id: None }
}
#[test]
+1 -1
View File
@@ -347,7 +347,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
}
let tally = st.tally(&p.id);
json!({
"code": u.code, "title": title, "body": body, "inertia": u.inertia,
"code": u.code, "title": title, "body": body, "inertia": crate::docs::inertia_of(u),
"status": if p.entered_at.is_some() { "adopted" } else if p.confirmed_at.is_some() { "confirmed" } else { "seed-minimal" },
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
})
+11 -5
View File
@@ -5,11 +5,15 @@
//! yet**. An existing document is never instantiated again: from its first
//! vote on, it is runtime data. The one way a newer seed text reaches an
//! existing document is a seed amendment (`seed_amend`): listed, decided once,
//! and applied only to an article nobody has touched.
//! and applied only to an article nobody has touched; a newer starting level of
//! inertia, an inertia amendment (`inertia_amend`), on an article whose inertia
//! no vote changed.
use crate::AppState;
use crate::inertia_amend;
use crate::seed_amend;
use crate::util::{self, LangMap};
use ld_engine::inertia::InertiaLevel;
use serde::Deserialize;
use std::path::Path;
@@ -37,7 +41,8 @@ pub struct SeedUnit {
pub code: String,
pub section: Option<String>,
pub position: f64,
pub inertia: String,
/// A level, 1 to 5 (a word of before is read as its level).
pub inertia: InertiaLevel,
pub versions: Vec<SeedVersion>,
}
@@ -67,11 +72,12 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
Ok(())
}
/// On start: the groups, the documents missing from them, then the seed
/// amendments (each decided once per document).
/// On start: the groups, the documents missing from them, then the seed and
/// inertia amendments (each decided once per document).
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
let seeds = documents(state).await?;
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
inertia_amend::apply(state, &seeds, inertia_amend::AMENDMENTS).await?;
Ok(())
}
@@ -159,7 +165,7 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
.bind(&unit.code)
.bind(&unit.section)
.bind(unit.position)
.bind(&unit.inertia)
.bind(unit.inertia.as_db())
.bind(&now)
.execute(&mut *tx)
.await?;
+15 -6
View File
@@ -112,13 +112,15 @@ async fn the_seed_a_minima_and_the_open_proposals() {
assert_eq!(e1["tally"]["for"], 0);
assert_eq!(doc["doc"]["voteFloor"], 50.0);
assert_eq!(doc["doc"]["electorate"], 5745);
// Every other article is an open proposal at zero votes, standard inertia.
// Every other article is an open proposal at zero votes, at the middle level of inertia.
let e3 = unit(&doc, "E3");
assert!(e3["inForce"].is_null());
// In the order of the seed, whatever their random identifiers.
let labels: Vec<&str> = e3["proposals"].as_array().expect("p").iter().filter_map(|p| p["label"].as_str()).collect();
assert_eq!(labels, ["1.0.0", "0.4.0-draft"], "E3 and its 0.4.0 variant");
assert_eq!(e3["inertia"], "standard");
assert_eq!(e3["inertia"], 3);
// The two structural commitments start one level up (Yvv, 2026-10-30).
assert_eq!((unit(&doc, "E1")["inertia"].as_u64(), unit(&doc, "E2")["inertia"].as_u64()), (Some(4), Some(4)));
// Translations seeded.
assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit"));
}
@@ -375,20 +377,23 @@ async fn a_contradiction_signal_blocks_entering_into_force() {
assert_eq!(v["entered"][0]["code"], "K1");
}
/// Replaces « inertia change is voted one level up » (2026-10-21): since
/// 2026-10-30 a change of inertia inherits the level of its article (ADR 0007).
#[tokio::test]
async fn inertia_change_is_voted_one_level_up() {
async fn inertia_change_is_voted_at_the_level_of_the_article() {
let (mut a, _) = Client::new().await;
a.login("Yvv", false).await;
let id = doc_id(&mut a, "monnaie-libre").await;
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let uid = unit(&doc, "E1")["id"].as_str().expect("u").to_owned();
let (s, v) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await;
let (s, v) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": 5 }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let p = v["id"].as_str().expect("id").to_owned();
let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await;
// The word of before reads as its level: the same proposal, not a second one.
let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "max" }))).await;
assert_eq!(again["id"], p.as_str());
let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await;
assert_eq!(d["proposal"]["votedAtInertia"], "high");
assert_eq!((d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), (Some(5), Some(4)));
}
#[tokio::test]
@@ -769,6 +774,10 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
serde_json::from_str(&show("monnaie-libre/acte-engagement-certification/document.json")).expect("json");
assert_eq!(json["units"].as_array().expect("units").len(), 3);
assert_eq!(json["units"][0]["status"], "seed-minimal");
// Levels of inertia are published as numbers, 1 to 5.
let levels: Vec<Option<u64>> =
json["units"].as_array().expect("units").iter().map(|u| u["inertia"].as_u64()).collect();
assert_eq!(levels, [Some(3), Some(4), Some(4)]);
assert!(show("README.md").contains("libreDecision"));
let done: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
.fetch_one(&state.db)
+549
View File
@@ -0,0 +1,549 @@
//! Inertia in five levels (ADR 0007): the levels and the scale served, the
//! usual participation of a group, the change of inertia voted at the level
//! of its article, the migration of the four words of before, and the
//! amendment that raises E1 and E2 to level 4 in an existing base.
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use ld_engine::inertia::{InertiaLevel, Scale, gradient};
use ld_engine::threshold::required_ratio;
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
use serde_json::{Value, json};
use sqlx::SqlitePool;
use std::borrow::Cow;
use tower::ServiceExt;
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
fn of(state: &AppState) -> Self {
Self { router: app(state.clone()), cookie: None }
}
async fn new() -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self::of(&state), state)
}
fn other(&self) -> Self {
Self { router: self.router.clone(), cookie: None }
}
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
if let Some(c) = &self.cookie {
req = req.header(header::COOKIE, c);
}
let req = match body {
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
None => req.body(Body::empty()),
}
.expect("request");
let res = self.router.clone().oneshot(req).await.expect("response");
let status = res.status();
if let Some(set) = res.headers().get(header::SET_COOKIE) {
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
if raw.starts_with("ld_session=") {
self.cookie = Some(raw);
}
}
let bytes = res.into_body().collect().await.expect("body").to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn get(&mut self, uri: &str) -> Value {
let (s, v) = self.call("GET", uri, None).await;
assert_eq!(s, StatusCode::OK, "{uri}: {v}");
v
}
async fn login(&mut self, name: &str) {
let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
async fn vote(&mut self, proposal: &str, choice: &str) -> Value {
let (s, v) =
self.call("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
v
}
async fn doc(&mut self, group: &str) -> Value {
let g = self.get(&format!("/api/groups/{group}")).await;
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
self.get(&format!("/api/docs/{id}")).await
}
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
fn level(k: u8) -> InertiaLevel {
InertiaLevel::new(k).expect("1 to 5")
}
fn numbers(levels: &Value, key: &str) -> Vec<f64> {
levels.as_array().expect("levels").iter().map(|l| l[key].as_f64().expect(key)).collect()
}
#[tokio::test]
async fn the_five_levels_and_the_scale_are_served() {
let (mut c, _) = Client::new().await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
// 3 everywhere, 4 for the two structural commitments.
let raised: Vec<&str> = doc["units"]
.as_array()
.expect("units")
.iter()
.filter(|u| u["inertia"] != 3)
.map(|u| u["code"].as_str().expect("code"))
.collect();
assert_eq!(raised, ["E1", "E2"]);
assert!(unit(&doc, "E1")["inertia"] == 4 && unit(&doc, "E2")["inertia"] == 4);
// No vote yet: the reference, 2C/W = 100 voters out of 5745.
let p = &doc["doc"]["participation"];
assert_eq!(
(p["source"].as_str(), p["sample"].as_u64(), p["voters"].as_u64()),
(Some("reference"), Some(0), Some(100))
);
assert_eq!(
(p["minSample"].as_u64(), p["windowDays"].as_u64(), p["everyHours"].as_u64()),
(Some(5), Some(365), Some(24))
);
let ratio = p["ratio"].as_f64().expect("ratio");
assert!((ratio - 100.0 / 5745.0).abs() < 1e-12);
let levels = &doc["levels"];
assert_eq!(numbers(levels, "level"), [1.0, 2.0, 3.0, 4.0, 5.0]);
assert_eq!(numbers(levels, "majorityPct"), [50.0, 50.0, 55.0, 60.0, 66.0]);
assert_eq!(numbers(levels, "sharePct"), [60.0, 67.0, 75.0, 83.0, 90.0]);
let scale = Scale { electorate: 5745, floor: 50.0, participation: ratio };
for (l, k) in levels.as_array().expect("levels").iter().zip(InertiaLevel::ALL) {
assert!((l["gradient"].as_f64().expect("G") - gradient(k, ratio)).abs() < 1e-12);
// At the usual participation, C plus s_k of the votes beyond C.
assert_eq!(l["cast"], 100);
assert_eq!(l["required"].as_u64(), Some(u64::from(scale.required(100, k).expect("valid"))));
let at_usual = 50.0 + k.share_pct() / 100.0 * 50.0;
assert_eq!(l["required"].as_f64(), Some(at_usual.ceil()));
}
// A version: the five levels at its own participation, and the level it is voted at.
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let d = c.get(&format!("/api/proposals/{e1}")).await;
assert_eq!(d["proposal"]["votedAtInertia"], 4);
assert_eq!(numbers(&d["levels"], "cast"), [0.0; 5]);
assert_eq!(numbers(&d["levels"], "required"), [50.0; 5], "nothing passes under C");
assert_eq!(d["participation"]["source"], "reference");
assert_eq!(d["electorateSource"], "wot");
}
#[tokio::test]
async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled() {
let (mut a, state) = Client::new().await;
a.login("Yvv").await;
let mut b = a.other();
b.login("Chiara").await;
let doc = a.doc("essai").await;
let e2 = unit(&doc, "E2")["id"].as_str().expect("e2").to_owned();
let to = |t: Value| json!({ "target": t });
for bad in [json!(0), json!(6), json!("haute"), json!(null)] {
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(bad.clone()))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("inertia")), "{bad}");
}
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(4)))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("same_inertia")));
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(1)))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let p = v["id"].as_str().expect("id").to_owned();
let d = a.get(&format!("/api/proposals/{p}")).await;
assert_eq!((d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), (Some(1), Some(4)));
// W(essai) = 2: both vote for, the article goes to level 1.
a.vote(&p, "for").await;
let v = b.vote(&p, "for").await;
assert_eq!(v["entered"][0]["kind"], "inertia");
let doc = a.doc("essai").await;
let e2u = unit(&doc, "E2");
assert_eq!(e2u["inertia"], 1);
let changes = e2u["inertiaChanges"].as_array().expect("changes");
assert_eq!(changes.len(), 1);
assert_eq!(
(changes[0]["from"].as_u64(), changes[0]["to"].as_u64(), changes[0]["cause"].as_str()),
(Some(4), Some(1), Some("vote"))
);
// The adoption keeps the level it was judged at, and the scale.
let params: String = sqlx::query_scalar("SELECT params FROM adoptions WHERE proposal_id = ?")
.bind(&p)
.fetch_one(&state.db)
.await
.expect("adoption");
let params: Value = serde_json::from_str(&params).expect("json");
assert_eq!(
(params["level"].as_u64(), params["majority_pct"].as_f64(), params["share_pct"].as_f64()),
(Some(4), Some(60.0), Some(83.0))
);
assert_eq!(
(params["participation"].as_f64(), params["participation_source"].as_str()),
(Some(0.5), Some("reference"))
);
assert!(params["gradient"].as_f64().is_some_and(|g| (g - gradient(level(4), 0.5)).abs() < 1e-12));
}
#[tokio::test]
async fn the_usual_participation_is_the_median_of_five_voted_proposals() {
let (mut first, state) = Client::new().await;
// Ten people, all members of the test group: W = 10, C = 1.
let mut people = Vec::new();
for name in ["P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9", "P10"] {
let mut c = first.other();
c.login(name).await;
people.push(c);
}
first.login("P1").await;
let doc = first.doc("essai").await;
let open: Vec<String> = ["I2", "T1", "E4", "E5", "E6"]
.iter()
.map(|code| unit(&doc, code)["proposals"][0]["id"].as_str().expect("open").to_owned())
.collect();
// 1, 2, 3, 3 and 4 voters (against: nothing enters into force, the count is what matters).
for (p, n) in open.iter().zip([1, 2, 3, 3, 4]) {
for c in people.iter_mut().take(n) {
c.vote(p, "against").await;
}
}
// Measured once a day, not at each vote: until then, the reference.
let doc = first.doc("essai").await;
assert_eq!(doc["doc"]["participation"]["source"], "reference");
assert_eq!(doc["doc"]["electorate"], 10);
let measured = ld_server::participation::refresh(&state, false).await.expect("refresh");
assert!(measured.contains(&"essai".to_owned()));
let doc = first.doc("essai").await;
let p = &doc["doc"]["participation"];
assert_eq!(
(p["source"].as_str(), p["sample"].as_u64(), p["median"].as_f64()),
(Some("median"), Some(5), Some(3.0))
);
assert_eq!((p["ratio"].as_f64(), p["voters"].as_u64(), p["bounded"].as_bool()), (Some(0.3), Some(3), Some(false)));
assert!(p["measuredAt"].is_string());
for (l, k) in doc["levels"].as_array().expect("levels").iter().zip(InertiaLevel::ALL) {
assert!((l["gradient"].as_f64().expect("G") - gradient(k, 0.3)).abs() < 1e-12);
}
// Thresholds follow: the proposal with 4 voters is judged on this scale.
let d = first.get(&format!("/api/proposals/{}", open[4])).await;
let scale = Scale { electorate: 10, floor: 1.0, participation: 0.3 };
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
assert!((ratio - required_ratio(10, 4, &scale.params(level(3)))).abs() < 1e-12);
// A new vote does not move it within the day…
people[4].vote(&open[0], "against").await;
assert!(ld_server::participation::refresh(&state, false).await.expect("refresh").is_empty());
assert_eq!(first.doc("essai").await["doc"]["participation"]["median"], 3.0);
// …the next measure sees it: 2, 2, 3, 3, 4.
ld_server::participation::refresh(&state, true).await.expect("refresh");
assert_eq!(first.doc("essai").await["doc"]["participation"]["median"], 3.0);
// A proposal last voted on more than a year ago leaves the sample: four are too few.
sqlx::query("UPDATE votes SET cast_at = '2025-01-01T00:00:00Z' WHERE proposal_id = ?")
.bind(&open[2])
.execute(&state.db)
.await
.expect("age");
ld_server::participation::refresh(&state, true).await.expect("refresh");
let p = first.doc("essai").await["doc"]["participation"].clone();
assert_eq!(
(p["source"].as_str(), p["sample"].as_u64(), p["median"].as_f64()),
(Some("reference"), Some(4), Some(2.5))
);
// The reference of the test group: 2C/W = 20 %.
assert_eq!(p["ratio"].as_f64(), Some(0.2));
// Monnaie libre is measured apart, from its own votes (none).
let ml = first.doc("monnaie-libre").await;
assert_eq!(
(ml["doc"]["participation"]["sample"].as_u64(), ml["doc"]["participation"]["source"].as_str()),
(Some(0), Some("reference"))
);
}
// ---------------------------------------------------------------------------
// The base as production holds it on 2026-10-30, then the new version
// ---------------------------------------------------------------------------
/// Migrations up to 0004: the schema production runs before this version.
async fn base_before_levels(tag: &str) -> (SqlitePool, AppState) {
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true);
let pool = SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("db");
let mut before = sqlx::migrate!("./migrations");
before.migrations = Cow::Owned(before.migrations.iter().filter(|m| m.version <= 4).cloned().collect());
before.run(&pool).await.expect("migrations 1 to 4");
let mut cfg = Config::for_tests();
cfg.data_dir = std::env::temp_dir().join(format!("ld-levels-{tag}-{}", std::process::id()));
let state = AppState::new(cfg, pool.clone()).await.expect("state");
seed::documents(&state).await.expect("documents");
// The words of the time: every article `standard`.
sqlx::query("UPDATE units SET inertia = 'standard'").execute(&pool).await.expect("words");
(pool, state)
}
/// The new version starts: migration 0005, then the seed and its amendments.
async fn start_new_version(pool: &SqlitePool) -> AppState {
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(pool).await.expect("migrations");
restart(pool).await
}
async fn restart(pool: &SqlitePool) -> AppState {
let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
state
}
async fn unit_id(pool: &SqlitePool, group: &str, code: &str) -> String {
sqlx::query_scalar(
"SELECT u.id FROM units u JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ?",
)
.bind(group)
.bind(code)
.fetch_one(pool)
.await
.expect("unit")
}
/// An open change of inertia written as production holds it (a word, no vote).
async fn legacy_inertia_proposal(pool: &SqlitePool, unit: &str, word: &str) -> String {
let id = format!("legacy-{unit}-{word}");
sqlx::query(
"INSERT INTO proposals (id, unit_id, kind, inertia_target, title, body, status, origin, author_id, revision,
created_at, updated_at, submitted_at)
VALUES (?, ?, 'inertia', ?, ?, '', 'open', 'member', NULL, 1, '2026-10-29T21:54:00Z', '2026-10-29T21:54:00Z',
'2026-10-29T21:54:00Z')",
)
.bind(&id)
.bind(unit)
.bind(word)
.bind(format!("inertia:{word}"))
.execute(pool)
.await
.expect("proposal");
id
}
type Decision = (String, String, String, Option<String>);
async fn inertia_decisions(pool: &SqlitePool) -> Vec<Decision> {
sqlx::query_as(
"SELECT d.group_slug, a.unit_code, a.outcome, a.reason FROM inertia_amendments a
JOIN documents d ON d.id = a.doc_id ORDER BY d.group_slug, a.unit_code",
)
.fetch_all(pool)
.await
.expect("decisions")
}
fn decided(rows: &[Decision]) -> Vec<(&str, &str, &str, Option<&str>)> {
rows.iter().map(|(g, u, o, r)| (g.as_str(), u.as_str(), o.as_str(), r.as_deref())).collect()
}
#[tokio::test]
async fn a_new_base_starts_with_e1_and_e2_at_level_4() {
let (_, state) = Client::new().await;
assert_eq!(
decided(&inertia_decisions(&state.db).await),
[
("essai", "E1", "present", None),
("essai", "E2", "present", None),
("monnaie-libre", "E1", "present", None),
("monnaie-libre", "E2", "present", None),
]
);
let changes: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("n");
assert_eq!(changes, 0, "born at 4: nothing changed");
}
#[tokio::test]
async fn the_migration_turns_the_four_words_into_levels_and_keeps_them() {
let (pool, _) = base_before_levels("migration").await;
let i1 = unit_id(&pool, "monnaie-libre", "I1").await;
let e3 = unit_id(&pool, "monnaie-libre", "E3").await;
for (unit, word) in [(&i1, "low"), (&e3, "high")] {
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
.bind(word)
.bind(unit)
.execute(&pool)
.await
.expect("word");
}
let p = legacy_inertia_proposal(&pool, &e3, "max").await;
// An adoption of before: its params are the letters it was judged by.
let old_params = r#"{"majority_pct":50.0,"gradient":0.2,"base":0.1,"constant":50.0}"#;
sqlx::query(
"INSERT INTO adoptions (proposal_id, unit_id, doc_id, kind, votes_for, votes_against, electorate, required, params, at)
SELECT 'past', ?, doc_id, 'text', 60, 0, 7224, 58, ?, '2026-10-25T00:00:00Z' FROM units WHERE id = ?",
)
.bind(&e3)
.bind(old_params)
.bind(&e3)
.execute(&pool)
.await
.expect("adoption");
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(&pool).await.expect("migration 0005");
let words: Vec<(String, String, Option<String>)> =
sqlx::query_as("SELECT code, inertia, inertia_legacy FROM units WHERE doc_id = (SELECT doc_id FROM units WHERE id = ?) AND code IN ('I1', 'E1', 'E3') ORDER BY code")
.bind(&i1)
.fetch_all(&pool)
.await
.expect("units");
assert_eq!(
words,
[
("E1".into(), "3".into(), Some("standard".into())),
("E3".into(), "4".into(), Some("high".into())),
("I1".into(), "2".into(), Some("low".into())),
]
);
let target: (Option<String>, Option<String>) =
sqlx::query_as("SELECT inertia_target, inertia_target_legacy FROM proposals WHERE id = ?")
.bind(&p)
.fetch_one(&pool)
.await
.expect("proposal");
assert_eq!(target, (Some("5".into()), Some("max".into())));
let kept: String = sqlx::query_scalar("SELECT params FROM adoptions WHERE proposal_id = 'past'")
.fetch_one(&pool)
.await
.expect("params");
assert_eq!(kept, old_params, "the history is never rewritten");
}
/// Production on 2026-10-30 (orch): every article `standard`, no vote, and two open
/// changes of inertia at zero votes, I1 → max and E1 → max.
#[tokio::test]
async fn e1_and_e2_go_to_level_4_once_and_an_open_change_stays_open_voted_at_4() {
let (pool, _) = base_before_levels("production").await;
let mut proposals = Vec::new();
for group in ["monnaie-libre", "essai"] {
for code in ["I1", "E1"] {
let u = unit_id(&pool, group, code).await;
proposals.push((group, code, legacy_inertia_proposal(&pool, &u, "max").await));
}
}
let state = start_new_version(&pool).await;
let first = inertia_decisions(&pool).await;
assert_eq!(
decided(&first),
[
("essai", "E1", "applied", None),
("essai", "E2", "applied", None),
("monnaie-libre", "E1", "applied", None),
("monnaie-libre", "E2", "applied", None),
]
);
let mut c = Client::of(&state);
c.login("Yvv").await;
for group in ["monnaie-libre", "essai"] {
let doc = c.doc(group).await;
let levels: Vec<(&str, u64)> = ["I1", "E1", "E2", "E3"]
.iter()
.map(|code| (*code, unit(&doc, code)["inertia"].as_u64().expect("level")))
.collect();
assert_eq!(levels, [("I1", 3), ("E1", 4), ("E2", 4), ("E3", 3)], "{group}");
// The article says so.
let changes = unit(&doc, "E1")["inertiaChanges"].as_array().expect("changes").clone();
assert_eq!(changes.len(), 1);
assert_eq!(
(changes[0]["from"].as_u64(), changes[0]["to"].as_u64(), changes[0]["cause"].as_str()),
(Some(3), Some(4), Some("amendment"))
);
}
// The open changes stay open, their target read as 5, voted at the article's level.
for (group, code, id) in &proposals {
let d = c.get(&format!("/api/proposals/{id}")).await;
assert_eq!(d["proposal"]["status"], "open", "{group} {code}");
assert_eq!(d["proposal"]["inertiaTarget"], 5, "{group} {code}");
let at = if *code == "E1" { 4 } else { 3 };
assert_eq!(d["proposal"]["votedAtInertia"], at, "{group} {code}");
}
// And votable: in Monnaie libre, judged at level 4.
let (_, _, e1_ml) = &proposals[1];
c.vote(e1_ml, "for").await;
let d = c.get(&format!("/api/proposals/{e1_ml}")).await;
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
let p = d["participation"]["ratio"].as_f64().expect("p");
let scale = Scale { electorate: 5745, floor: 50.0, participation: p };
assert!((ratio - required_ratio(5745, 1, &scale.params(level(4)))).abs() < 1e-12);
assert!((ratio - required_ratio(5745, 1, &scale.params(level(3)))).abs() > 1e-6, "not at the old level");
assert_eq!(d["proposal"]["evaluation"]["required"], 50);
// Republished where the document publishes; never from the test group.
let queued: Vec<(String, String)> = sqlx::query_as(
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id
WHERE o.reason LIKE 'inertie%' ORDER BY o.id",
)
.fetch_all(&pool)
.await
.expect("outbox");
assert_eq!(
queued,
[
("monnaie-libre".to_owned(), "inertie de départ relevée : E1, 3 → 4".to_owned()),
("monnaie-libre".to_owned(), "inertie de départ relevée : E2, 3 → 4".to_owned()),
]
);
// Once only: the next start changes nothing.
let again = restart(&pool).await;
assert_eq!(inertia_decisions(&again.db).await, first);
let changes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&pool).await.expect("n");
assert_eq!(changes, 4);
}
#[tokio::test]
async fn an_inertia_changed_by_a_vote_keeps_its_level() {
let (pool, _) = base_before_levels("voted").await;
// The migration runs; a vote changes E2's inertia in the test group, before the
// version that knows the amendment starts.
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(&pool).await.expect("migrations");
let between = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
let mut a = Client::of(&between);
a.login("Yvv").await;
let mut b = a.other();
b.login("Chiara").await;
let e2 = unit_id(&pool, "essai", "E2").await;
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(json!({ "target": 5 }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let p = v["id"].as_str().expect("id").to_owned();
a.vote(&p, "for").await;
assert_eq!(b.vote(&p, "for").await["entered"][0]["kind"], "inertia");
let state = restart(&pool).await;
assert_eq!(
decided(&inertia_decisions(&state.db).await),
[
("essai", "E1", "applied", None),
("essai", "E2", "refused", Some("changed_by_vote")),
("monnaie-libre", "E1", "applied", None),
("monnaie-libre", "E2", "applied", None),
]
);
let mut c = Client::of(&state);
c.login("Yvv").await;
let doc = c.doc("essai").await;
assert_eq!(unit(&doc, "E2")["inertia"], 5, "the vote holds");
}
+33 -33
View File
@@ -29,7 +29,7 @@
"code": "I1",
"section": "introduction",
"position": 1,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -51,7 +51,7 @@
"code": "I2",
"section": "introduction",
"position": 2,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -73,7 +73,7 @@
"code": "T1",
"section": "fondamental",
"position": 3,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -95,7 +95,7 @@
"code": "E1",
"section": "fondamental",
"position": 4,
"inertia": "standard",
"inertia": 4,
"versions": [
{
"label": "1.0.0",
@@ -117,7 +117,7 @@
"code": "E2",
"section": "fondamental",
"position": 5,
"inertia": "standard",
"inertia": 4,
"versions": [
{
"label": "1.0.0",
@@ -139,7 +139,7 @@
"code": "T2",
"section": "technique",
"position": 6,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -161,7 +161,7 @@
"code": "E3",
"section": "technique",
"position": 7,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -197,7 +197,7 @@
"code": "E4",
"section": "technique",
"position": 8,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -219,7 +219,7 @@
"code": "E5",
"section": "technique",
"position": 9,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -241,7 +241,7 @@
"code": "E6",
"section": "technique",
"position": 10,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -263,7 +263,7 @@
"code": "E7",
"section": "technique",
"position": 11,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -299,7 +299,7 @@
"code": "T3",
"section": "technique",
"position": 12,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -321,7 +321,7 @@
"code": "E8",
"section": "technique",
"position": 13,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -343,7 +343,7 @@
"code": "E9",
"section": "technique",
"position": 14,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -365,7 +365,7 @@
"code": "E10",
"section": "technique",
"position": 15,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -387,7 +387,7 @@
"code": "E11",
"section": "technique",
"position": 16,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -409,7 +409,7 @@
"code": "K1",
"section": "conclusion",
"position": 17,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -431,7 +431,7 @@
"code": "K2",
"section": "conclusion",
"position": 18,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -453,7 +453,7 @@
"code": "X1",
"section": "annexe",
"position": 19,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -475,7 +475,7 @@
"code": "X1.1",
"section": "annexe",
"position": 20,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -497,7 +497,7 @@
"code": "X1.2",
"section": "annexe",
"position": 21,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -519,7 +519,7 @@
"code": "X1.3",
"section": "annexe",
"position": 22,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -541,7 +541,7 @@
"code": "X2",
"section": "annexe",
"position": 23,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -563,7 +563,7 @@
"code": "X2.1",
"section": "annexe",
"position": 24,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -585,7 +585,7 @@
"code": "X2.2",
"section": "annexe",
"position": 25,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -607,7 +607,7 @@
"code": "X2.3",
"section": "annexe",
"position": 26,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -629,7 +629,7 @@
"code": "F1",
"section": "formule",
"position": 27,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -651,7 +651,7 @@
"code": "F1.1",
"section": "formule",
"position": 28,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -673,7 +673,7 @@
"code": "F1.2",
"section": "formule",
"position": 29,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -695,7 +695,7 @@
"code": "F1.3",
"section": "formule",
"position": 30,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -717,7 +717,7 @@
"code": "N1",
"section": "inertie",
"position": 31,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -739,7 +739,7 @@
"code": "N1.1",
"section": "inertie",
"position": 32,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",
@@ -761,7 +761,7 @@
"code": "O1",
"section": "ordonnancement",
"position": 33,
"inertia": "standard",
"inertia": 3,
"versions": [
{
"label": "1.0.0",