diff --git a/crates/ld-engine/src/compose.rs b/crates/ld-engine/src/compose.rs index a97c89b..6f640e7 100644 --- a/crates/ld-engine/src/compose.rs +++ b/crates/ld-engine/src/compose.rs @@ -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, } @@ -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, ThresholdError> { - let e = evaluate(p.tally, electorate, ¶ms_for(unit, p.kind, floor))?; +fn score<'a>(unit: &UnitState, p: &'a ProposalState, scale: &Scale) -> Result, ThresholdError> { + let e = evaluate(p.tally, scale.electorate, ¶ms_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>) -> 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, ThresholdError> { +fn in_force_margin(unit: &UnitState, scale: &Scale) -> Result, ThresholdError> { let Some(v) = &unit.in_force else { return Ok(None) }; - let e = evaluate(v.tally, electorate, ¶ms_for(unit, ProposalKind::Text, floor))?; + let e = evaluate(v.tally, scale.electorate, ¶ms_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 Result, 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, 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::>(), ["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::>(), [("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::>(), [("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")); } } diff --git a/crates/ld-engine/src/inertia.rs b/crates/ld-engine/src/inertia.rs new file mode 100644 index 0000000..0025cb9 --- /dev/null +++ b/crates/ld-engine/src/inertia.rs @@ -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 { + (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 { + match raw.trim() { + "low" => Some(Self(2)), + "standard" => Some(Self(3)), + "high" => Some(Self(4)), + "max" => Some(Self(5)), + digits => digits.parse::().ok().and_then(Self::new), + } + } +} + +impl Default for InertiaLevel { + fn default() -> Self { + Self::DEFAULT + } +} + +impl Serialize for InertiaLevel { + fn serialize(&self, s: S) -> Result { + 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: D) -> Result { + #[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, + /// 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, 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 { + 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 { + evaluate(tally, self.electorate, &self.params(level)) + } + + /// ⌈R⌉ for a level at `cast` votes. + pub fn required(&self, cast: u32, level: InertiaLevel) -> Result { + 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 { + InertiaLevel::ALL.iter().map(|k| scale.required(cast, *k).expect("valid")).collect() + } + + #[test] + fn five_levels_three_in_the_middle() { + let m: Vec = InertiaLevel::ALL.iter().map(|k| k.majority_pct()).collect(); + let s: Vec = 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::(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 = + 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 = 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:?}"); + } + } +} diff --git a/crates/ld-engine/src/lib.rs b/crates/ld-engine/src/lib.rs index 616a534..a41ac17 100644 --- a/crates/ld-engine/src/lib.rs +++ b/crates/ld-engine/src/lib.rs @@ -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}; diff --git a/crates/ld-engine/src/threshold.rs b/crates/ld-engine/src/threshold.rs index 95f0bbe..52dfe97 100644 --- a/crates/ld-engine/src/threshold.rs +++ b/crates/ld-engine/src/threshold.rs @@ -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::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] diff --git a/crates/ld-server/migrations/0005_inertia_levels.sql b/crates/ld-server/migrations/0005_inertia_levels.sql new file mode 100644 index 0000000..43122f4 --- /dev/null +++ b/crates/ld-server/migrations/0005_inertia_levels.sql @@ -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) +); diff --git a/crates/ld-server/src/adoption.rs b/crates/ld-server/src/adoption.rs index 4e4eebc..951a11a 100644 --- a/crates/ld-server/src/adoption.rs +++ b/crates/ld-server/src/adoption.rs @@ -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> { 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> { .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> { .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(¶ms).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> { 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 = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await { Ok(ids) => ids, Err(e) => { diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 8776901..2c147b4 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -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, 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, } pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult { @@ -351,10 +357,40 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { } } 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 { - 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 { + 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::>(), + // 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>> { + #[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> = 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 { #[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 = units diff --git a/crates/ld-server/src/inertia_amend.rs b/crates/ld-server/src/inertia_amend.rs new file mode 100644 index 0000000..3af65c6 --- /dev/null +++ b/crates/ld-server/src/inertia_amend.rs @@ -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> { + let mut out = Vec::new(); + for a in list { + if !seed_says(seeds, a) { + continue; + } + let docs: Vec<(String, String, Option)> = + 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 { + 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> { + 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:?}"); + } + } +} diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index 187c161..102e59a 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -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"); diff --git a/crates/ld-server/src/participation.rs b/crates/ld-server/src/participation.rs new file mode 100644 index 0000000..36b631f --- /dev/null +++ b/crates/ld-server/src/participation.rs @@ -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:`. +//! +//! 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, + /// 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> { + let rows: Vec = 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 { + 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> { + let raw: Option = 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> { + let groups: Vec = + 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"); + } +} diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 0cfb385..1081296 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -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, User(user): User, Path(unit_id): Path, Json(b): Json, ) -> AppResult> { - let target = InertiaPreset::parse(&b.target).ok_or(AppError::Invalid("inertia"))?; + let target = serde_json::from_value::(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] diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index b9ad765..873d45d 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -347,7 +347,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result, 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, } @@ -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?; diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index fd0263d..426a17f 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -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> = + 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) diff --git a/crates/ld-server/tests/inertia.rs b/crates/ld-server/tests/inertia.rs new file mode 100644 index 0000000..3904c68 --- /dev/null +++ b/crates/ld-server/tests/inertia.rs @@ -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, +} + +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) -> (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 { + 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(¶ms).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 = ["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); + +async fn inertia_decisions(pool: &SqlitePool) -> Vec { + 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)> = + 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, Option) = + 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"); +} diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json index 59b1a1d..ac0f7b2 100644 --- a/seeds/acte-engagement-certification.json +++ b/seeds/acte-engagement-certification.json @@ -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",