diff --git a/CLAUDE.md b/CLAUDE.md index f84288c..b8fca09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,17 +33,20 @@ développer. ``` crates/ - ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle, presets), edit (coquille vs fond, - # diff), assist (découpage, similarité, longueur), compose (adoption, aperçus) + ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle), inertia (cinq crans, médiane, + # référence, échelle), edit (coquille vs fond, diff), assist (découpage, similarité, + # longueur), compose (adoption, aperçus) trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, groupes par défaut ld-server/ migrations/ # schéma SQLite — additif seulement src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations, # dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler, - # traduire) · adoption.rs (balayage, verrou) · publish.rs (file → git) · wot.rs (W - # sur la chaîne) · seed.rs · web.rs (sert web/build) + # traduire) · adoption.rs (balayage, verrou) · participation.rs (p̃, une fois par + # jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs · + # seed_amend.rs / inertia_amend.rs (amendements gardés) · web.rs (sert web/build) tests/api.rs # scénarios bout en bout -seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur) +seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur ; + # inertie 3, E1/E2 à 4) # upcoming/.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage web/src/ lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée @@ -52,7 +55,7 @@ web/src/ # g/[group]/docs · g/[group]/docs/[doc] (lecteur, # panneau dans l'URL ?onglet=&article=) · g/[group]/decisions · g/[group]/mandats · # g/[group]/protocoles (à venir) · acces (trustinWallet) ; /groupes, /decisions… redirigent -web/e2e/ # Playwright C1–C19 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 +web/e2e/ # Playwright C1–C20 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 docker/ # app.Dockerfile · app-compose.yml (sonic) .woodpecker/ # app.yml · maquette.yml docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/ @@ -73,7 +76,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v ```bash export PATH=$HOME/.cargo/bin:$PATH cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace -cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 20 contrôles +cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 21 contrôles LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build cd web && npm run dev # :3002 à chaud @@ -89,7 +92,8 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la `ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`. - **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ; une coquille ne demande pas de vote, un changement de fond si ; un signalement de - contradiction bloque ; l'inertie se vote au cran au-dessus ; le groupe d'essai ne publie jamais. + contradiction bloque ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ; + le groupe d'essai ne publie jamais. - **Écritures** : en-tête `x-requested-with: libredecision` obligatoire (CSRF) ; cookie `ld_session`. Les tests d'API passent par `Client` (`tests/api.rs`). - **Pièges vus** : `pkill -f target/debug/ld-server` tue son propre shell → `pkill -x ld-server` ; diff --git a/README.md b/README.md index aeed0fb..0620bab 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ Chantier en cours : **Acte d'engagement Certification** (Monnaie libre), en bin ouvre une nouvelle révision, dont les votes antérieurs sont à reconfirmer. - Deux propositions qui **se contredisent** ne passent pas ensemble : un signalement bloque la seconde tant qu'il n'est pas levé. -- L'**inertie** de chaque article se vote aussi (par défaut : un cran au-dessus). +- L'**inertie** de chaque article (cinq crans, 3 par défaut, calés sur la participation + habituelle du groupe) se vote aussi, au cran de l'article. - Tout ce qui entre en vigueur est **publié automatiquement** dans un dépôt git (fr/en/es + JSON) lu par les applications clientes. 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..f3fb3c8 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,10 @@ pub async fn run() -> anyhow::Result<()> { let state = AppState::new(cfg, db).await?; seed::ensure(&state).await?; + // A measure missed leaves the reference in place; the sweeper takes it again. + if let Err(e) = participation::refresh(&state, false).await { + tracing::warn!(error = %e, "usual participation"); + } 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/docs/adr/0002-vote-permanent.md b/docs/adr/0002-vote-permanent.md index 53a9b81..399b0a1 100644 --- a/docs/adr/0002-vote-permanent.md +++ b/docs/adr/0002-vote-permanent.md @@ -1,5 +1,9 @@ # 0002 — Vote permanent : marge du texte en place et plancher C +> **Complétée par [0007](0007-inertie-cinq-crans.md)** (2026-10-30) : l'inertie a cinq crans, +> calés sur la participation habituelle du groupe, et un changement d'inertie se vote au cran +> de l'article — il n'est plus voté au cran au-dessus. + **Contexte.** Chaque article est sous vote permanent ; « dès qu'une proposition atteint le score, elle remonte en vigueur ». La formule inertielle Ğ1 donne un seuil faible quand peu de gens votent : sans garde-fou, une seule voix ferait passer un texte. Sans règle de départage, deux @@ -12,7 +16,8 @@ versions qui atteignent leur seuil s'échangeraient la place à chaque vote. (pour − requis) strictement plus large que la version en place. La version déplacée redevient une proposition avec ses votes : rien n'est perdu, tout peut revenir. - Les textes de départ sont des *seeds a minima* (0 vote, marge −C) : visibles, non adoptés. -- Changer l'inertie d'un article est une proposition votée à l'inertie du cran au-dessus. +- Changer l'inertie d'un article est une proposition votée à l'inertie du cran au-dessus + (remplacé le 2026-10-30 : elle hérite du cran de l'article, ADR 0007). - Le balayage tourne après chaque geste et toutes les 5 minutes, sous un verrou global. **Coût.** Une version en place très soutenue est difficile à déloger — c'est voulu. La taille diff --git a/docs/adr/0007-inertie-cinq-crans.md b/docs/adr/0007-inertie-cinq-crans.md new file mode 100644 index 0000000..66ad7c2 --- /dev/null +++ b/docs/adr/0007-inertie-cinq-crans.md @@ -0,0 +1,135 @@ +# 0007 — Inertie à cinq crans, calée sur la participation du groupe + +> Complète [0002](0002-vote-permanent.md) : la règle « l'inertie se vote au cran au-dessus » +> y est remplacée par l'héritage décrit ici. Décision d'Yvv du 2026-10-30 (revue, L10). + +**Contexte.** Quatre niveaux (`low`, `standard`, `high`, `max`) fixaient M et G une fois pour +toutes. Yvv, 2026-10-30 : « L'indice d'inertie doit avoir 5 niveaux. Tu mets par défaut au +milieu (3) pour toutes les propositions, (4) pour les 2 engagements structurels » ; « par +défaut l'inertie de l'indice d'inertie lui-même hérite de l'inertie de l'article » ; « une +échelle cohérente qui s'adapte aux nombres de certifiés et à la participation médiane aux +votes du groupe ». Au formulaire, il a choisi l'échelle **calée sur la participation** : +chaque cran exige une part fixe de « pour » quand le groupe vote comme d'habitude, puis se +relâche vers la majorité M quand plus de monde vote. + +## Décision + +**La formule reste celle de la Ğ1**, adoptée si « pour » ≥ ⌈R⌉ : + +``` +R = C + B^W + (M + (1 − M)·(1 − (T/W)^G)) · max(0, T − C) +``` + +Seuls M et G dépendent du cran k. M_k est la majorité à pleine participation ; s_k la part de +« pour » exigée **au-delà de C** à la participation habituelle p̃ ; G_k s'en déduit : + +``` +s_k = M_k + (1 − M_k)·(1 − p̃^G_k) ⇔ G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃) +``` + +| Cran | Nom | M_k | s_k | +|---|---|---|---| +| 1 | très basse | 50 % | 60 % | +| 2 | basse | 50 % | 67 % | +| 3 | moyenne (par défaut) | 55 % | 75 % | +| 4 | haute (E1, E2 ; le titre du doc, lot D) | 60 % | 83 % | +| 5 | très haute | 66 % | 90 % | + +**La participation habituelle p̃** d'un groupe (`crates/ld-server/src/participation.rs`) : + +- l'échantillon : les propositions du groupe (tous ses documents, toutes sortes, brouillons + exceptés) qui portent au moins un vote sur leur révision actuelle, le dernier exprimé depuis + moins de 365 jours ; +- T de chacune est compté **au moment de la mesure** : c'est le T que voit la formule, le vote + étant permanent ; +- T̃ = la médiane de ces T (moyenne des deux du milieu pour un nombre pair) ; p̃ = T̃ / W, avec le + W du moment : quand la toile grandit, l'échelle demande toujours s_k au même nombre habituel + de votants ; +- il faut **au moins 5** propositions votées ; avant, la **référence** p0 = 2C/W s'applique + (Monnaie libre : 100 votants sur 7 224, 1,38 %) — deux fois le plancher, une participation + que le groupe doit de toute façon atteindre pour que C laisse place à la majorité. + +**Les bornes.** p̃ (mesurée ou de référence) est ramenée dans **[1 %, 50 %]**. Ainsi +0 < p̃ < 1, ln(p̃) est négatif et fini ; comme M_k < s_k < 1 pour chaque cran, +0 < 1 − (s_k − M_k)/(1 − M_k) < 1 et G_k est fini et positif (de 0,05 à 1,77). Alors : + +- **le seuil croît avec le cran** : la part exigée vaut 1 − (1 − M_k)·(T/W)^G_k, croissante en + M_k et en G_k, et M_k, G_k croissent avec k (à pleine participation, les crans 1 et 2 se + rejoignent à M = 50 %) ; +- **la part exigée décroît vers M quand la participation monte**, et vaut M à T = W ; +- **à p̃, la part au-delà de C vaut s_k** ; +- **C reste un plancher** : à T ≤ C, R = C + B^W ; une seule voix ne fait jamais passer un + texte dans Monnaie libre ; +- le groupe d'essai (W = 2, C = 1) : la référence vaut 100 %, ramenée à 50 % ; G reste fini ; + il faut les deux voix, à tout cran. + +La borne basse limite aussi l'effet de propositions à une voix multipliées pour faire baisser +la médiane (une médiane basse adoucit les seuils au-dessus d'elle) : à 1 % près, l'effet est +borné, et il faudrait plus de propositions de ce genre que de vraies. + +**Le recalcul : une fois par jour.** La mesure est gardée dans `settings` +(`participation:` : médiane, taille de l'échantillon, date) ; elle est reprise au +démarrage puis par le balayage (toutes les 5 minutes) dès qu'elle a 24 heures. Un seuil qui +bougerait à chaque vote exprimé ailleurs dans le groupe ne se lirait pas ; une fois par jour, +il reste stable dans la journée et suit pourtant la vie du groupe. W, lui, change à son rythme +(la toile, toutes les 6 heures), et p̃ = T̃ / W le suit aussitôt. + +**L'inertie de l'inertie hérite du cran de l'article.** Une proposition de changement +d'inertie se vote au cran actuel de l'article (`compose::voting_level`), comme son texte — +plus au cran au-dessus. Le cran se change en proposant l'un des cinq crans. + +**Un entier reste un entier.** Le calcul flottant de ln et de la puissance peut porter un +milliardième de voix : un seuil qui tombe juste sur un entier (90 % de 70 = 63) ne doit pas +monter d'une voix pour autant. La part inertielle est ramenée à l'entier quand elle en est à +moins de 10⁻⁹ (`threshold::settle`) ; cas réel : 101 membres qui votent tous, M = 55 %, C = 1, +il faut 56 voix, et non 57. + +## Les nombres + +W = 7 224, C = 50 ; voix pour exigées (tests `ld-engine/src/inertia.rs`) : + +| Médiane | T | cran 1 | cran 2 | cran 3 | cran 4 | cran 5 | +|---|---|---|---|---|---|---| +| 120 votants | 120 | 92 | 97 | 103 | 109 | 113 | +| 120 votants | 500 | 306 | 329 | 362 | 397 | 432 | +| 120 votants | 2 000 | 1 091 | 1 145 | 1 271 | 1 404 | 1 549 | +| 360 votants | 120 | 95 | 101 | 106 | 112 | 116 | +| 360 votants | 360 | 236 | 258 | 283 | 308 | 329 | +| 360 votants | 500 | 316 | 345 | 381 | 416 | 449 | +| 360 votants | 2 000 | 1 114 | 1 184 | 1 318 | 1 460 | 1 608 | + +Le cas de référence de la formule (vote forgeron v2.0.0 : W 7 224, 120 votants, M 50 %, +G 0,2, C 0 → 94 voix) reste le test de la formule elle-même, lettres données ; ce ne sont plus +les lettres d'aucun cran. + +## Les données + +- Migration `0005` : les quatre mots deviennent des crans (low → 2, standard → 3, high → 4, + max → 5 ; le cran 1 est nouveau) dans `units.inertia` et `proposals.inertia_target`. Chaque + mot est d'abord gardé tel quel (`inertia_legacy`, `inertia_target_legacy`) ; les `params` + des `adoptions` passées ne sont pas touchés (ils portent les M et G de leur jugement). Le + serveur lit toujours les quatre mots. Les adoptions nouvelles gardent en plus le cran, s_k + et p̃ (sa source, l'échantillon, la médiane). +- Le seed : 3 partout, 4 pour E1 et E2. +- Dans une base existante, E1 et E2 passent de 3 à 4 par un **amendement d'inertie** + (`crates/ld-server/src/inertia_amend.rs`), dans la lignée des amendements de seed : décidé + une fois par document, seulement si **aucun vote n'a changé l'inertie de l'article** (pas + d'adoption d'inertie, cran encore celui du seed). Une proposition d'inertie ouverte ne + l'arrête pas : elle reste ouverte et se vote au nouveau cran. Chaque décision est gardée + (`inertia_amendments`), écrite dans les journaux, se lit sur l'article (`inertia_changes` : + « passée de 3 à 4 : réglage de départ du texte ») et le document est republié. + +## Coût + +- Les seuils de Monnaie libre changent. Avec W = 7 224, C = 50 et la référence (100 votants), + le cran 3 demande 103 voix à 120 votants (`standard` : 105), 360 à 500 (369), 1 265 à 2 000 + (1 246), 3 996 quand tout le monde vote (3 637, M passant de 50 à 55 %) : un peu moins + jusqu'à un millier de votants, plus au-delà. Le cran 4 demande 108 à 120 votants (`high` : + 115), 395 à 500 (439), autant à pleine participation (M 60 %). Rien n'est en vigueur par un + vote aujourd'hui ; les seuils déjà affichés bougent. +- Le seuil d'une proposition dépend désormais des autres votes du groupe, par la médiane : une + fois par jour, un texte peut passer ou ne plus passer sans nouveau vote sur lui (comme quand W + change). Chaque entrée en vigueur garde la médiane qui l'a jugée. +- Le document publié (`document.json`) porte l'inertie en nombre (1 à 5), et non plus en mot. +- Le texte N1.1 du seed (« Niveaux d'inertie », quatre niveaux) est une proposition de la + communauté : il n'est pas modifié et décrit désormais autre chose que la règle en place. diff --git a/docs/adr/README.md b/docs/adr/README.md index 40e9e56..061724a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,3 +11,4 @@ pas une décision : on en écrit une nouvelle qui la remplace, et on le note dan | [0004](0004-contradictions-signalees.md) | Contradictions : signalées par les personnes, bloquantes | 2026-10-21 | | [0005](0005-publication-git.md) | Publication par file d'envoi vers un dépôt git | 2026-10-21 | | [0006](0006-acces-connect-trustinwallet.md) | Accès : connect.monnaie-libre.fr, invitations, trustinWallet | 2026-10-21 | +| [0007](0007-inertie-cinq-crans.md) | Inertie à cinq crans, calée sur la participation du groupe (complète 0002) | 2026-10-30 | diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index 90618f7..3685366 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -35,9 +35,11 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre. certifiante. ») et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués *seed a minima · 0 vote*. Au survol (ou toujours visibles sur mobile), trois gestes discrets : **Le vote** (en cours, inertie, formule, annexes) · **Les propositions** · **Proposer**. Ils - ouvrent un panneau latéral (une feuille sur mobile) dont l'état vit dans l'URL. + ouvrent un panneau latéral (une feuille sur mobile) dont l'état vit dans l'URL. Depuis + l'en-tête du document et depuis chaque vote, **« Réglages de la formule »** : W, C, B, la + participation habituelle du groupe et les cinq crans (onglet « Réglages » du panneau). 5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro - vote, inertie standard — rangées par section (introduction, fondamental, technique, + vote, inertie 3 / 5 — rangées par section (introduction, fondamental, technique, conclusion, annexes, formule, inertie, ordonnancement), **une ligne par article** : un article qui a plusieurs versions (variantes du seed, propositions rivales) montre leur nombre et la meilleure en tête (niveau d'eau, pour / contre) ; la ligne se déplie sur ses @@ -59,14 +61,31 @@ W = électorat (Monnaie libre : taille de la toile, lue sur la chaîne toutes le géré : ses membres), T = votes exprimés, M = majorité, G = gradient, B = 0,1, **C = plancher de votants « pour »** (Monnaie libre : **50** ; groupe d'essai : 1). Adoptée si « pour » ≥ ⌈R⌉. -| Inertie | M | G | -|---|---|---| -| basse | 50 % | 0,1 | -| standard (au départ, partout) | 50 % | 0,2 | -| haute | 60 % | 0,4 | -| maximale | 66 % | 0,6 | +**Cinq crans d'inertie** (Yvv, 2026-10-30 ; ADR 0007). Seuls M et G dépendent du cran k : +M_k est la majorité à pleine participation, s_k la part de « pour » exigée **au-delà de C** quand +le groupe vote comme d'habitude (participation p̃), et G_k s'en déduit : +G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃). -Moteur : `crates/ld-engine/src/threshold.rs` (testé : 7224 / 120 votants → 94 voix requises). +| Cran | Nom | M_k | s_k | Au départ | +|---|---|---|---|---| +| 1 | très basse | 50 % | 60 % | | +| 2 | basse | 50 % | 67 % | | +| 3 | moyenne | 55 % | 75 % | partout | +| 4 | haute | 60 % | 83 % | Unicité du compte, Réciprocité (et le titre du doc, à venir) | +| 5 | très haute | 66 % | 90 % | | + +**La participation habituelle p̃** = la médiane des votants des propositions du groupe votées +depuis un an (au moins 5), sur W ; avant, la référence 2C/W (Monnaie libre : 100 votants) ; +toujours ramenée entre 1 % et 50 %. Mesurée une fois par jour. Le seuil croît avec le cran, la +part exigée se relâche vers M quand plus de monde vote, et rien ne passe sous C voix pour. + +Avec W = 7 224, C = 50 et une médiane de 120 votants, il faut, à 120 votants, 92, 97, 103, 109 +et 113 voix pour ; à 500, 306, 329, 362, 397 et 432 ; à 2 000, 1 091, 1 145, 1 271, 1 404 et +1 549. + +Moteur : `crates/ld-engine/src/threshold.rs` (la formule ; son cas de référence, lettres +données : 7224 / 120 votants, M 50 %, G 0,2, C 0 → 94 voix) et `crates/ld-engine/src/inertia.rs` +(les crans, la médiane, la référence, les bornes) ; mesure : `crates/ld-server/src/participation.rs`. ### Le vote permanent @@ -80,8 +99,12 @@ Moteur : `crates/ld-engine/src/threshold.rs` (testé : 7224 / 120 votants → 94 ### L'inertie se vote aussi -Chaque article porte son inertie. La changer est une proposition, **votée elle-même à -l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande plus que modifier. +Chaque article porte son inertie, un cran de 1 à 5. La changer est une proposition, **votée +elle-même au cran actuel de l'article** : l'inertie de l'inertie hérite de celle de l'article +(Yvv, 2026-10-30 ; elle était votée au cran au-dessus, « inertie + », depuis le 2026-10-21). +L'article dit le passé de son inertie : passée de tel cran à tel autre, par un vote ou par le +réglage de départ du texte (E1 et E2 relevés de 3 à 4 dans une base existante, seulement si +aucun vote n'a changé leur inertie : `crates/ld-server/src/inertia_amend.rs`). ### Corriger n'est pas voter diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index ebc1ba8..39251fc 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -12,7 +12,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe | C1 | L'accueil, sans session | ouvrir `/` sans être connectée | « Rendre possible la décision collective », puis le leitmotiv en trois lignes : « contexte et cadrage », « processus et modalités », « boîte à outils » ; « Se connecter avec sa clé Ğ1 » et sa note ; pas de piliers ni de groupes ; le menu à gauche avec les quatre outils, trois éteints | | C2 | L'accueil, avec session | se connecter : on arrive sur `/` | le titre, le leitmotiv, puis directement le choix du groupe : Monnaie libre en grand, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal ; `/groupes` et « Mes groupes » (menu) ramènent ici | | C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (30 lignes, une par article) | -| C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie ; l'URL garde le panneau ; Échap le ferme | +| C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne », et le lien « Réglages de la formule » ; l'URL garde le panneau ; Échap le ferme | | C5 | Entrée en vigueur | groupe d'essai : à deux, voter « Pour » sur « Les deux garanties réciproques » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles | | C6 | Coquille sans vote | groupe d'essai : Unicité du compte → « Le vote » → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » | | C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît | @@ -21,7 +21,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe | C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) | | C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là | | C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien | -| M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance), les trois gestes visibles sans survol, le panneau s'ouvre en feuille | +| M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance), les trois gestes visibles sans survol, le panneau s'ouvre en feuille ; « Réglages de la formule » depuis le vote : l'onglet « Réglages » visible, le tableau des cinq crans tient dans la feuille | | C13 | Logo et ambiances | ouvrir `/` (appareil en clair, puis en sombre) ; choisir chaque pastille d'ambiance ; recharger | le logo « Clé de voûte » (signe, *libre*, **Decision**) droit, sans tampon ; le sceau 井 en bas à droite ; première visite : pierre (clair) ou nuit (sombre) ; les quatre pastilles (Pierre, Calcaire, Nuit, Ardoise) changent l'ambiance, qui reste après rechargement ; favicon, icône Apple et manifeste servis | | C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur | | C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) | @@ -29,6 +29,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe | C17 | Décisions | Monnaie libre → Décisions ; voter sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 33 modules à voter · dont 33 sans ton vote », puis « dont 32 » ; la ligne ouvre le lecteur | | C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification, puis EN, puis ES | en tête du texte en vigueur, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » | | C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version ouvre son vote ; un nouveau clic sur la ligne la replie | +| C20 | Réglages de la formule et cinq crans | Monnaie libre → Acte d'engagement : « Réglages de la formule » (en-tête) ; puis Unicité du compte → « Le vote » → « Réglages de la formule » ; dans le groupe d'essai, Réciprocité → « Le vote » → « Proposer un changement d'inertie » → 5 → « Soumettre au vote » | W 5 745 (toile de confiance Ğ1), C 50, B 0,1 ; « 100 votants sur 5 745 (1,74 %), deux fois C : la participation de référence… (0 pour l'instant) » ; le tableau des cinq crans : très basse, basse, moyenne, haute, très haute ; M 50, 50, 55, 60, 66 % ; part exigée 60, 67, 75, 83, 90 % ; voix pour à 100 votants 80, 84, 88, 92, 95 ; depuis le vote : « Inertie · 4 / 5 · haute », la ligne du cran 4 marquée « cet article », 50 voix à 0 votant ; le sélecteur montre les cinq crans, 4 « actuelle » et grisé ; la proposition s'ouvre : « inertie vers 5 / 5 · très haute », votée à « inertie 4 / 5 · haute » | ## Au-delà de la liste @@ -56,3 +57,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts | | 2026-10-30 | automate | local, lot R (Raison d'être, versions d'un article) | C1–C14, M1 | 15 / 15 verts | | 2026-10-30 | automate | local, lot A (entrée, logo, ambiances, menu, listes, Décisions) | C1–C17, M1 | 18 / 18 verts | +| 2026-10-30 | automate | local, lot C (inertie à cinq crans, réglages de la formule) | C1–C20, M1 | 21 / 21 verts | diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json index 59b1a1d..771e33a 100644 --- a/seeds/acte-engagement-certification.json +++ b/seeds/acte-engagement-certification.json @@ -1,5 +1,5 @@ { - "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote, inertie standard (Yvv, 2026-10-21). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30).", + "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30).", "slug": "acte-engagement-certification", "title": { "fr": "Acte d'engagement Certification", @@ -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", diff --git a/web/e2e/mobile.spec.ts b/web/e2e/mobile.spec.ts index eaea48f..d7ebfee 100644 --- a/web/e2e/mobile.spec.ts +++ b/web/e2e/mobile.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { DOC, signIn } from './helpers'; -test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => { +test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille, réglages lisibles', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto('/'); const brand = page.locator('header.topbar a.brand'); @@ -18,4 +18,10 @@ test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, pann const first = page.locator('section.text article.unit').first(); await first.getByRole('button', { name: 'Le vote' }).click(); await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible(); + // The settings of the formula read on a phone: the table of the five levels holds in the sheet. + await page.locator('aside.panel').getByRole('button', { name: 'Réglages de la formule' }).click(); + await expect(page.locator('aside.panel').getByRole('tab', { name: 'Réglages' })).toBeInViewport(); + await expect(page.locator('aside.panel table tbody tr')).toHaveCount(5); + const fits = await page.locator('aside.panel .scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1); + expect(fits).toBe(true); }); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index 5c79cfc..c4ee3dc 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -60,6 +60,9 @@ test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', asyn await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible(); await panel.getByText('La formule du seuil').click(); await expect(panel.getByText(/W — électorat/)).toBeVisible(); + // The level of the article sets M and G: the Raison d'être is at 3 / 5. + await expect(panel.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible(); + await expect(panel.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible(); // The panel lives in the URL. await expect(page).toHaveURL(/onglet=vote/); await page.keyboard.press('Escape'); @@ -242,3 +245,57 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page await expect(e3).toHaveAttribute('aria-expanded', 'false'); await expect(versions).toHaveCount(0); }); + +test('C20 — réglages de la formule et cinq crans', async ({ page }) => { + await signIn(page, 'Chiara'); + await page.goto(DOC); + // From the head of the document: the scale of the group, at its usual turnout. + await page.getByRole('button', { name: 'Réglages de la formule' }).click(); + await expect(page).toHaveURL(/onglet=reglages/); + const panel = page.locator('aside.panel'); + await expect(panel.getByRole('heading', { name: 'Réglages de la formule' })).toBeVisible(); + await expect(panel.getByText(/W — électorat : 5\s745/)).toBeVisible(); + await expect(panel.getByText('C — minimum de voix pour : 50')).toBeVisible(); + // No vote yet: the reference, twice C. + await expect(panel.getByText(/100 votants sur 5\s745 \(1,74 %\), deux fois C : la participation de référence/)).toBeVisible(); + const rows = panel.locator('table tbody tr'); + await expect(rows).toHaveCount(5); + await expect(rows.locator('th')).toHaveText([/1\s*très basse/, /2\s*basse/, /3\s*moyenne/, /4\s*haute/, /5\s*très haute/]); + await expect(rows.locator('td:nth-child(2)')).toHaveText(['50 %', '50 %', '55 %', '60 %', '66 %']); + await expect(rows.locator('td:nth-child(4)')).toHaveText(['60 %', '67 %', '75 %', '83 %', '90 %']); + // At 100 voters: C plus each level's share of the 50 votes beyond C. + const required = panel.locator('table tbody td.required'); + await expect(required).toHaveText(['80', '84', '88', '92', '95']); + await expect(panel.getByText('celles exigées à la participation habituelle, 100 votants')).toBeVisible(); + await page.keyboard.press('Escape'); + + // From the vote of an article: Unicité du compte is at 4 / 5, its row is marked. + const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' }); + await e1.hover(); + await e1.getByRole('button', { name: 'Le vote' }).click(); + await expect(panel.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible(); + await panel.getByRole('button', { name: 'Réglages de la formule' }).click(); + await expect(panel.locator('tr.current')).toHaveCount(1); + await expect(panel.locator('tr.current th')).toContainText('4'); + await expect(panel.locator('tr.current th')).toContainText('cet article'); + await expect(required).toHaveText(['50', '50', '50', '50', '50']); + await expect(panel.getByText('celles exigées à la participation de cette version, 0 votant.')).toBeVisible(); + await page.keyboard.press('Escape'); + + // The five levels to choose from, in the test group; the change is voted at the article's level. + await page.goto(TEST_DOC); + const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' }); + await e2.hover(); + await e2.getByRole('button', { name: 'Le vote' }).click(); + await panel.getByText("Proposer un changement d'inertie").click(); + const levels = panel.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio'); + await expect(levels).toHaveCount(5); + await expect(levels.nth(3)).toBeDisabled(); + await expect(levels.nth(3)).toContainText('actuelle'); + await expect(panel.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible(); + await levels.nth(4).click(); + await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true'); + await panel.getByRole('button', { name: 'Soumettre au vote' }).click(); + await expect(panel.getByRole('heading', { name: 'inertie vers 5 / 5 · très haute' })).toBeVisible(); + await expect(panel.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible(); +}); diff --git a/web/src/lib/components/DeepList.svelte b/web/src/lib/components/DeepList.svelte index e09907a..ea00f7a 100644 --- a/web/src/lib/components/DeepList.svelte +++ b/web/src/lib/components/DeepList.svelte @@ -4,7 +4,7 @@ // unfolds the others, each with its label, its water level and its marks — and // each opens its own vote. import { plain } from '$lib/markdown'; - import { relationOf, titleOf } from '$lib/labels'; + import { inertiaBadge, relationOf, titleOf } from '$lib/labels'; import { i18n, t, type Key } from '$lib/i18n/index.svelte'; import type { Proposal, Unit } from '$lib/types'; import { excerpts, type Article } from '$lib/versions'; @@ -73,7 +73,7 @@ {t('tally.short', { for: best.tally.for, against: best.tally.against })} {#if relationOf(best, unit)}{relationOf(best, unit)}{/if} - {#if unit.inertia !== 'standard'}{t(`inertia.${unit.inertia}`)}{/if} + {#if unit.inertia !== 3}{inertiaBadge(unit.inertia)}{/if} {@render marks(best)} @@ -94,7 +94,7 @@ {#if a.leads}{t('deep.lead', { label: labelOf(best, a) })}{/if} {t('tally.short', { for: best.tally.for, against: best.tally.against })} {#if relationOf(best, unit)}{relationOf(best, unit)}{/if} - {#if unit.inertia !== 'standard'}{t(`inertia.${unit.inertia}`)}{/if} + {#if unit.inertia !== 3}{inertiaBadge(unit.inertia)}{/if} {#if best.evaluation?.wellRated}{t('deep.wellrated')}{/if} {#if a.versions.some((p) => p.myVoteStale)}{t('deep.stale')}{/if} {#if a.versions.some((p) => p.mine)}{t('deep.mine')}{/if} diff --git a/web/src/lib/components/DocPanel.svelte b/web/src/lib/components/DocPanel.svelte index 6d299fe..4141a61 100644 --- a/web/src/lib/components/DocPanel.svelte +++ b/web/src/lib/components/DocPanel.svelte @@ -3,6 +3,7 @@ import { t } from '$lib/i18n/index.svelte'; import { relationOf, titleOf } from '$lib/labels'; import type { Lang } from '$lib/types'; + import FormulaSettings from './FormulaSettings.svelte'; import Icon from './Icon.svelte'; import ProposeView from './ProposeView.svelte'; import VoteView from './VoteView.svelte'; @@ -27,21 +28,30 @@ ['vote', t('panel.vote'), !!voteId], ['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit], ['propose', t('panel.propose'), true], + // Reached from the head of the document or from a vote; shown while it is open. + ['settings', t('panel.settings'), panel.tab === 'settings'], ] as const ).filter(([, , show]) => show), ); let body: HTMLElement | undefined = $state(); + let tablist: HTMLElement | undefined = $state(); $effect(() => { void panel.tab; void panel.proposalId; body?.scrollTo({ top: 0 }); }); + + // On a narrow sheet the tabs scroll: the open one stays in sight. + $effect(() => { + void panel.tab; + tablist?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); diff --git a/web/src/lib/components/Formula.svelte b/web/src/lib/components/Formula.svelte index b8d8b86..321d5b0 100644 --- a/web/src/lib/components/Formula.svelte +++ b/web/src/lib/components/Formula.svelte @@ -1,7 +1,8 @@
@@ -41,18 +45,23 @@
  • {t('formula.W', { n: i18n.number(electorate), source: t(source === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}
  • {t('formula.T', { n: tally.for + tally.against })}
  • {#if p} -
  • {t('formula.M', { n: p.majorityPct })} — {t(`inertia.${inertia}`)}
  • -
  • {t('formula.G', { n: p.gradient })}
  • +
  • {t('formula.M', { n: i18n.number(p.majorityPct) })} — {inertiaBadge(inertia)}
  • +
  • {t('formula.G', { n: i18n.number(Math.round(p.gradient * 1000) / 1000) })}
  • {/if} -
  • {t('formula.B', { n: base })}
  • -
  • {t('formula.C', { n: floor })}
  • +
  • {t('formula.B', { n: i18n.number(base) })}
  • +
  • {t('formula.C', { n: i18n.number(floor) })}
  • {#if evaluation}
  • {t('formula.R', { n: evaluation.required })}
  • {/if}

    {t('formula.reading')}

    +{#if onsettings} + +{/if} diff --git a/web/src/lib/components/FormulaSettings.svelte b/web/src/lib/components/FormulaSettings.svelte new file mode 100644 index 0000000..178c713 --- /dev/null +++ b/web/src/lib/components/FormulaSettings.svelte @@ -0,0 +1,195 @@ + + +{#if view && p} +
    +

    {t('settings.title')}

    +

    {t('settings.intro')}

    + +
      +
    • {t('formula.W', { n: i18n.number(view.doc.electorate), source: t(view.doc.electorateSource === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}
    • +
    • {t('formula.C', { n: i18n.number(view.doc.voteFloor) })}
    • +
    • {t('formula.B', { n: i18n.number(view.doc.formula.base) })}
    • +
    + +
    +

    {t('settings.usual')}

    +

    + {#if p.source === 'median'} + {i18n.plural('settings.usual.median', p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(p.ratio), sample: p.sample })} + {:else} + {i18n.plural('settings.usual.reference', p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(p.ratio), min: p.minSample, sample: p.sample })} + {/if} +

    + {#if p.bounded}

    {t('settings.usual.bounded', { lo: pct(p.bounds[0]), hi: pct(p.bounds[1]) })}

    {/if} +

    {p.measuredAt ? t('settings.usual.when', { date: i18n.date(p.measuredAt) }) : t('settings.usual.never')}

    +
    + +

    {t('settings.levels')}

    +
    + + + + + + + + + + + + {#each levels as l (l.level)} + + + + + + + + {/each} + +
    {t('settings.col.level')}MG{t('settings.col.share')}{t('settings.col.required')}
    + + {l.level} + {t(`inertia.name.${l.level}`)} + {#if l.level === current}{t('settings.current')}{/if} + + {whole(l.majorityPct)}{gradient(l.gradient)}{whole(l.sharePct)}{l.required === null ? '—' : i18n.number(l.required)}
    +
    +

    {i18n.plural('settings.share.note', p.voters)}

    +

    {i18n.plural(version ? 'settings.required.version' : 'settings.required.usual', cast)}

    +

    {t('settings.reading')}

    +
    +{/if} + + diff --git a/web/src/lib/components/InertiaPicker.svelte b/web/src/lib/components/InertiaPicker.svelte new file mode 100644 index 0000000..1ba35ce --- /dev/null +++ b/web/src/lib/components/InertiaPicker.svelte @@ -0,0 +1,171 @@ + + +
    +

    {t('inertia.title')} · {inertiaLevel(unit.inertia)}

    +

    {t('inertia.explain')}

    + {#each unit.inertiaChanges as c (c.at + c.to)} +

    {t(`inertia.history.${c.cause}`, { from: level(c.from), to: level(c.to), date: i18n.date(c.at) })}

    + {/each} + {#each open as ip (ip.id)} + + {/each} +
    + {t('inertia.change')} +
    + {#each INERTIA_LEVELS as k (k)} + + {/each} +
    + +

    {t('inertia.change.note', { level: inertiaLevel(unit.inertia) })}

    +
    + {#if error}{/if} +
    + + diff --git a/web/src/lib/components/VoteView.svelte b/web/src/lib/components/VoteView.svelte index 2ea98c6..a3495f8 100644 --- a/web/src/lib/components/VoteView.svelte +++ b/web/src/lib/components/VoteView.svelte @@ -2,11 +2,12 @@ import { api, ApiError } from '$lib/api'; import { shown, type DocState } from '$lib/doc.svelte'; import { i18n, t, type Key } from '$lib/i18n/index.svelte'; - import { titleOf } from '$lib/labels'; + import { inertiaBadge, titleOf } from '$lib/labels'; import { toasts } from '$lib/toast.svelte'; - import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types'; + import type { DiffPart, Entered, Inertia, Lang, LevelInfo, Proposal } from '$lib/types'; import DiffText from './DiffText.svelte'; import Formula from './Formula.svelte'; + import InertiaPicker from './InertiaPicker.svelte'; import Prose from './Prose.svelte'; import WaterLevel from './WaterLevel.svelte'; @@ -16,12 +17,14 @@ lang, onopen, onpropose, + onsettings, }: { doc: DocState; proposalId: string; lang: Lang; onopen: (proposalId: string) => void; onpropose: (prefill: { title: string; body: string }) => void; + onsettings?: () => void; } = $props(); interface Detail { @@ -36,13 +39,13 @@ }[]; electorate: number; voteFloor: number; + levels: LevelInfo[]; } let detail = $state(null); let busy = $state(false); let showDiff = $state(false); let error = $state(''); - let inertiaTarget = $state(''); let typoOpen = $state(false); let typoTitle = $state(''); let typoBody = $state(''); @@ -58,7 +61,6 @@ const source = $derived(view?.doc.sourceLang ?? 'fr'); const text = $derived(p ? shown(p, lang, source) : null); const votable = $derived(p?.status === 'open' || p?.status === 'in_force'); - const inertiaOpen = $derived((unit?.proposals ?? []).filter((x) => x.kind === 'inertia' && x.status === 'open')); const signalTargets = $derived( (view?.units ?? []).flatMap((u) => [u.inForce, ...u.proposals] @@ -122,21 +124,6 @@ } } - async function proposeInertia() { - if (!unit || !inertiaTarget) return; - busy = true; - try { - const r = await api.post<{ id: string }>(`/units/${unit.id}/inertia`, { target: inertiaTarget }); - inertiaTarget = ''; - await doc.reload(); - onopen(r.id); - } catch (e) { - error = message(e); - } finally { - busy = false; - } - } - async function saveTypo(e: SubmitEvent) { e.preventDefault(); busy = true; @@ -250,7 +237,7 @@

    {t('tally.required', { n: p.evaluation.required })}

    {p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}

    {/if} -

    {t(`inertia.${p.votedAtInertia}`)}

    +

    {inertiaBadge(p.votedAtInertia)}

    {#if p.blocked}

    {t('vote.blocked')}

    {/if} @@ -271,30 +258,9 @@ {/if} - {#if p.kind !== 'inertia' && unit} -
    -

    {t('inertia.title')} · {t(`inertia.level.${unit.inertia}`)}

    -

    {t('inertia.explain')}

    - {#each inertiaOpen as ip (ip.id)} - - {/each} -
    - {t('inertia.change')} -
    - - -
    -

    {t('inertia.change.note', { level: t(`inertia.level.${({ low: 'standard', standard: 'high', high: 'max', max: 'max' } as const)[unit.inertia]}`) })}

    -
    -
    - {/if} + {#if p.kind !== 'inertia' && unit}{/if} - + {#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
    @@ -474,11 +440,6 @@ color: var(--water); font-size: 0.92rem; } - .row { - display: flex; - gap: 0.5rem; - margin-top: 0.5rem; - } .form { display: grid; gap: 0.6rem; @@ -490,15 +451,6 @@ .textarea.short { min-height: 5rem; } - .link { - border: 0; - background: none; - padding: 0; - color: var(--water); - font-weight: 700; - text-align: left; - font-size: 0.9rem; - } .history { margin: 0; padding-left: 1rem; diff --git a/web/src/lib/doc.svelte.ts b/web/src/lib/doc.svelte.ts index c85aefe..99a42f8 100644 --- a/web/src/lib/doc.svelte.ts +++ b/web/src/lib/doc.svelte.ts @@ -7,7 +7,7 @@ import type { GroupView } from './group'; import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types'; export type View = 'inforce' | 'wellrated' | 'all'; -export type Tab = 'vote' | 'proposals' | 'propose'; +export type Tab = 'vote' | 'proposals' | 'propose' | 'settings'; export interface PanelState { unitId: string | null; diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 4848656..c4a8e1f 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -134,7 +134,7 @@ export const en: Messages = { 'deep.rival': 'would replace “{title}”', 'deep.addition': 'new article', 'deep.removal': 'removal of “{title}”', - 'deep.inertia': 'inertia: {level}', + 'deep.inertia': 'inertia to {level}', 'deep.blocked': 'blocked', 'deep.mine': 'your proposal', 'deep.stale': 'needs reconfirming', @@ -163,19 +163,19 @@ export const en: Messages = { 'tally.missing.other': '{n} more votes in favor needed.', 'tally.reached': 'Threshold reached.', 'tally.level': 'Level: {pct}% of the threshold', + 'inertia.name.1': 'very low', + 'inertia.name.2': 'low', + 'inertia.name.3': 'medium', + 'inertia.name.4': 'high', + 'inertia.name.5': 'very high', + 'inertia.level': '{k} / 5 · {name}', + 'inertia.badge': 'inertia {level}', - 'inertia.low': 'low inertia', - 'inertia.standard': 'standard inertia', - 'inertia.high': 'high inertia', - 'inertia.max': 'maximum inertia', - 'inertia.level.low': 'low', - 'inertia.level.standard': 'standard', - 'inertia.level.high': 'high', - 'inertia.level.max': 'maximum', 'panel.vote': 'Voting', 'panel.proposals': 'Proposals', 'panel.propose': 'Propose', + 'panel.settings': 'Settings', 'panel.close': 'Close panel', 'panel.inforce': 'In force', 'panel.proposal': 'Proposal', @@ -205,9 +205,12 @@ export const en: Messages = { 'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.', 'inertia.change': 'Propose an inertia change', 'inertia.change.to': 'New inertia', - 'inertia.change.note': 'An inertia change is itself voted on with the next inertia up: {level}.', + 'inertia.change.note': "A change of inertia is itself voted at the article's inertia: {level}.", 'inertia.change.submit': 'Submit to vote', 'inertia.change.open': 'Inertia change in progress: to {level}', + 'inertia.change.current': 'current', + 'inertia.history.vote': 'Went from {from} to {to} on {date}, by a vote.', + 'inertia.history.amendment': 'Went from {from} to {to} on {date}: starting setting of the text.', 'formula.title': 'The threshold formula', 'formula.intro': 'The number of votes in favor required, R, depends on turnout:', @@ -221,6 +224,29 @@ export const en: Messages = { 'formula.C': 'C — minimum votes in favor: {n}', 'formula.R': 'R — current threshold: {n} votes in favor', 'formula.reading': 'At low turnout, near-unanimity is needed; when everyone votes, majority M is enough. Nothing passes with fewer than C votes in favor.', + 'common.pct': '{n}%', + 'settings.title': 'Formula settings', + 'settings.intro': 'To change a text, the group asks for a number of votes in favour that depends on its electorate, its usual turnout and the inertia of the article.', + 'settings.usual': 'Usual turnout', + 'settings.usual.median.one': '{n} voter out of {electorate} ({pct}): the median of the {sample} proposals voted on over the past year.', + 'settings.usual.median.other': '{n} voters out of {electorate} ({pct}): the median of the {sample} proposals voted on over the past year.', + 'settings.usual.reference.one': '{n} voter out of {electorate} ({pct}), twice C: the reference turnout, until the group has voted on {min} proposals ({sample} so far).', + 'settings.usual.reference.other': '{n} voters out of {electorate} ({pct}), twice C: the reference turnout, until the group has voted on {min} proposals ({sample} so far).', + 'settings.usual.bounded': 'Kept between {lo} and {hi} of the electorate.', + 'settings.usual.when': 'Measured once a day, last on {date}.', + 'settings.usual.never': 'Measured once a day.', + 'settings.levels': 'The five levels', + 'settings.col.level': 'Level', + 'settings.col.share': 'Share asked', + 'settings.col.required': 'Votes for', + 'settings.current': 'this article', + 'settings.share.note.one': 'Share asked: the share of votes in favour beyond C when {n} person votes, as usual.', + 'settings.share.note.other': 'Share asked: the share of votes in favour beyond C when {n} people vote, as usual.', + 'settings.required.version.one': 'Votes for: those asked at the turnout of this version, {n} voter.', + 'settings.required.version.other': 'Votes for: those asked at the turnout of this version, {n} voters.', + 'settings.required.usual.one': 'Votes for: those asked at the usual turnout, {n} voter.', + 'settings.required.usual.other': 'Votes for: those asked at the usual turnout, {n} voters.', + 'settings.reading': 'At each level, the group asks for its share of votes in favour beyond C when it votes as usual; the more voters, the closer it comes to M. Nothing passes below C votes in favour.', 'history.title': 'Corrections', 'history.initial': 'initial text', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 4bb20ef..717aeeb 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -134,7 +134,7 @@ export const es: Messages = { 'deep.rival': 'reemplazaría «{title}»', 'deep.addition': 'artículo nuevo', 'deep.removal': 'retirada de «{title}»', - 'deep.inertia': 'inercia: {level}', + 'deep.inertia': 'inercia a {level}', 'deep.blocked': 'bloqueada', 'deep.mine': 'tu propuesta', 'deep.stale': 'por reconfirmar', @@ -163,19 +163,19 @@ export const es: Messages = { 'tally.missing.other': 'Faltan {n} votos a favor.', 'tally.reached': 'Umbral alcanzado.', 'tally.level': 'Nivel: {pct} % del umbral', + 'inertia.name.1': 'muy baja', + 'inertia.name.2': 'baja', + 'inertia.name.3': 'media', + 'inertia.name.4': 'alta', + 'inertia.name.5': 'muy alta', + 'inertia.level': '{k} / 5 · {name}', + 'inertia.badge': 'inercia {level}', - 'inertia.low': 'inercia baja', - 'inertia.standard': 'inercia estándar', - 'inertia.high': 'inercia alta', - 'inertia.max': 'inercia máxima', - 'inertia.level.low': 'baja', - 'inertia.level.standard': 'estándar', - 'inertia.level.high': 'alta', - 'inertia.level.max': 'máxima', 'panel.vote': 'Votación', 'panel.proposals': 'Propuestas', 'panel.propose': 'Proponer', + 'panel.settings': 'Ajustes', 'panel.close': 'Cerrar el panel', 'panel.inforce': 'En vigor', 'panel.proposal': 'Propuesta', @@ -205,9 +205,12 @@ export const es: Messages = { 'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.', 'inertia.change': 'Proponer un cambio de inercia', 'inertia.change.to': 'Nueva inercia', - 'inertia.change.note': 'Un cambio de inercia se vota a su vez con la inercia superior: {level}.', + 'inertia.change.note': 'Un cambio de inercia se vota a su vez con la inercia del artículo: {level}.', 'inertia.change.submit': 'Someter a votación', 'inertia.change.open': 'Cambio de inercia en curso: a {level}', + 'inertia.change.current': 'actual', + 'inertia.history.vote': 'Pasó de {from} a {to} el {date}, por votación.', + 'inertia.history.amendment': 'Pasó de {from} a {to} el {date}: ajuste inicial del texto.', 'formula.title': 'La fórmula del umbral', 'formula.intro': 'El número de votos a favor exigido, R, depende de la participación:', @@ -221,6 +224,29 @@ export const es: Messages = { 'formula.C': 'C — mínimo de votos a favor: {n}', 'formula.R': 'R — umbral actual: {n} votos a favor', 'formula.reading': 'Con poca participación, hace falta casi la unanimidad; cuando vota todo el mundo, basta la mayoría M. Nada pasa con menos de C votos a favor.', + 'common.pct': '{n} %', + 'settings.title': 'Ajustes de la fórmula', + 'settings.intro': 'Para cambiar un texto, el grupo pide un número de votos a favor que depende de su electorado, de su participación habitual y de la inercia del artículo.', + 'settings.usual': 'Participación habitual', + 'settings.usual.median.one': '{n} votante de {electorate} ({pct}): la mediana de las {sample} propuestas votadas en el último año.', + 'settings.usual.median.other': '{n} votantes de {electorate} ({pct}): la mediana de las {sample} propuestas votadas en el último año.', + 'settings.usual.reference.one': '{n} votante de {electorate} ({pct}), dos veces C: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).', + 'settings.usual.reference.other': '{n} votantes de {electorate} ({pct}), dos veces C: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).', + 'settings.usual.bounded': 'Mantenida entre el {lo} y el {hi} del electorado.', + 'settings.usual.when': 'Medida una vez al día, la última el {date}.', + 'settings.usual.never': 'Medida una vez al día.', + 'settings.levels': 'Los cinco niveles', + 'settings.col.level': 'Nivel', + 'settings.col.share': 'Proporción exigida', + 'settings.col.required': 'Votos a favor', + 'settings.current': 'este artículo', + 'settings.share.note.one': 'Proporción exigida: la parte de votos a favor más allá de C cuando vota {n} persona, como de costumbre.', + 'settings.share.note.other': 'Proporción exigida: la parte de votos a favor más allá de C cuando votan {n} personas, como de costumbre.', + 'settings.required.version.one': 'Votos a favor: los exigidos con la participación de esta versión, {n} votante.', + 'settings.required.version.other': 'Votos a favor: los exigidos con la participación de esta versión, {n} votantes.', + 'settings.required.usual.one': 'Votos a favor: los exigidos con la participación habitual, {n} votante.', + 'settings.required.usual.other': 'Votos a favor: los exigidos con la participación habitual, {n} votantes.', + 'settings.reading': 'En cada nivel, el grupo pide su parte de votos a favor más allá de C cuando vota como de costumbre; cuantos más votantes, más se acerca a M. Nada pasa con menos de C votos a favor.', 'history.title': 'Correcciones', 'history.initial': 'texto inicial', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index e511159..f8d87d3 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -137,7 +137,7 @@ export const fr = { 'deep.rival': 'remplacerait « {title} »', 'deep.addition': 'nouvel article', 'deep.removal': 'retrait de « {title} »', - 'deep.inertia': 'inertie : {level}', + 'deep.inertia': 'inertie vers {level}', 'deep.blocked': 'bloquée', 'deep.mine': 'ta proposition', 'deep.stale': 'à reconfirmer', @@ -166,19 +166,19 @@ export const fr = { 'tally.missing.other': 'Il manque {n} voix pour.', 'tally.reached': 'Seuil atteint.', 'tally.level': 'Niveau : {pct} % du seuil', + 'inertia.name.1': 'très basse', + 'inertia.name.2': 'basse', + 'inertia.name.3': 'moyenne', + 'inertia.name.4': 'haute', + 'inertia.name.5': 'très haute', + 'inertia.level': '{k} / 5 · {name}', + 'inertia.badge': 'inertie {level}', - 'inertia.low': 'inertie faible', - 'inertia.standard': 'inertie standard', - 'inertia.high': 'inertie haute', - 'inertia.max': 'inertie maximale', - 'inertia.level.low': 'faible', - 'inertia.level.standard': 'standard', - 'inertia.level.high': 'haute', - 'inertia.level.max': 'maximale', 'panel.vote': 'Le vote', 'panel.proposals': 'Propositions', 'panel.propose': 'Proposer', + 'panel.settings': 'Réglages', 'panel.close': 'Fermer le panneau', 'panel.inforce': 'En vigueur', 'panel.proposal': 'Proposition', @@ -208,9 +208,12 @@ export const fr = { 'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.", 'inertia.change': "Proposer un changement d'inertie", 'inertia.change.to': 'Nouvelle inertie', - 'inertia.change.note': "Un changement d'inertie se vote lui-même avec l'inertie au-dessus : {level}.", + 'inertia.change.note': "Un changement d'inertie se vote lui-même à l'inertie de l'article : {level}.", 'inertia.change.submit': 'Soumettre au vote', 'inertia.change.open': "Changement d'inertie en cours : vers {level}", + 'inertia.change.current': 'actuelle', + 'inertia.history.vote': 'Passée de {from} à {to} le {date}, par un vote.', + 'inertia.history.amendment': 'Passée de {from} à {to} le {date} : réglage de départ du texte.', 'formula.title': 'La formule du seuil', 'formula.intro': 'Le nombre de voix pour exigé, R, dépend de la participation :', @@ -224,6 +227,29 @@ export const fr = { 'formula.C': 'C — minimum de voix pour : {n}', 'formula.R': 'R — seuil actuel : {n} voix pour', 'formula.reading': "À faible participation, il faut presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Rien ne passe sous C voix pour.", + 'common.pct': '{n} %', + 'settings.title': 'Réglages de la formule', + 'settings.intro': "Pour changer un texte, le groupe demande un nombre de voix pour qui dépend de son électorat, de sa participation habituelle et de l'inertie de l'article.", + 'settings.usual': 'Participation habituelle', + 'settings.usual.median.one': '{n} votant sur {electorate} ({pct}) : la médiane des {sample} propositions votées depuis un an.', + 'settings.usual.median.other': '{n} votants sur {electorate} ({pct}) : la médiane des {sample} propositions votées depuis un an.', + 'settings.usual.reference.one': "{n} votant sur {electorate} ({pct}), deux fois C : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).", + 'settings.usual.reference.other': "{n} votants sur {electorate} ({pct}), deux fois C : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).", + 'settings.usual.bounded': "Ramenée entre {lo} et {hi} de l'électorat.", + 'settings.usual.when': 'Mesurée une fois par jour, la dernière fois le {date}.', + 'settings.usual.never': 'Mesurée une fois par jour.', + 'settings.levels': 'Les cinq crans', + 'settings.col.level': 'Cran', + 'settings.col.share': 'Part exigée', + 'settings.col.required': 'Voix pour', + 'settings.current': 'cet article', + 'settings.share.note.one': "Part exigée : la part de « pour » au-delà de C quand {n} personne vote, comme d'habitude.", + 'settings.share.note.other': "Part exigée : la part de « pour » au-delà de C quand {n} personnes votent, comme d'habitude.", + 'settings.required.version.one': 'Voix pour : celles exigées à la participation de cette version, {n} votant.', + 'settings.required.version.other': 'Voix pour : celles exigées à la participation de cette version, {n} votants.', + 'settings.required.usual.one': 'Voix pour : celles exigées à la participation habituelle, {n} votant.', + 'settings.required.usual.other': 'Voix pour : celles exigées à la participation habituelle, {n} votants.', + 'settings.reading': "À chaque cran, le groupe demande sa part de « pour » au-delà de C quand il vote comme d'habitude ; plus il y a de votants, plus elle se rapproche de M. Rien ne passe sous C voix pour.", 'history.title': 'Corrections', 'history.initial': 'texte initial', diff --git a/web/src/lib/labels.ts b/web/src/lib/labels.ts index f510a5d..25612f0 100644 --- a/web/src/lib/labels.ts +++ b/web/src/lib/labels.ts @@ -1,10 +1,20 @@ import { t } from './i18n/index.svelte'; -import type { Proposal, Unit } from './types'; +import type { Inertia, Proposal, Unit } from './types'; + +/** « 3 / 5 · moyenne ». */ +export function inertiaLevel(k: Inertia): string { + return t('inertia.level', { k, name: t(`inertia.name.${k}`) }); +} + +/** « inertie 3 / 5 · moyenne ». */ +export function inertiaBadge(k: Inertia): string { + return t('inertia.badge', { level: inertiaLevel(k) }); +} /** What a proposal is, in words, whatever its kind. */ export function titleOf(p: Proposal, unit: Unit | undefined): string { if (p.kind === 'inertia' && p.inertiaTarget) { - return t('deep.inertia', { level: t(`inertia.level.${p.inertiaTarget}`) }); + return t('deep.inertia', { level: inertiaLevel(p.inertiaTarget) }); } if (p.kind === 'remove') return t('deep.removal', { title: unit?.inForce?.title ?? p.title }); return p.title; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 952fdfd..123faa0 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1,6 +1,40 @@ export type Lang = 'fr' | 'en' | 'es'; export type LangMap = Partial>; -export type Inertia = 'low' | 'standard' | 'high' | 'max'; +/** The five levels of inertia, 1 (lightest) to 5 (heaviest); 3 by default. */ +export type Inertia = 1 | 2 | 3 | 4 | 5; +export const INERTIA_LEVELS: Inertia[] = [1, 2, 3, 4, 5]; + +/** One level on the group's scale: M and G of the formula, s_k, and the *for* votes asked at `cast` votes. */ +export interface LevelInfo { + level: Inertia; + majorityPct: number; + gradient: number; + sharePct: number; + cast: number; + required: number | null; +} + +/** p̃ — the usual turnout of the group, measured once a day (or the reference 2C/W). */ +export interface ParticipationInfo { + ratio: number; + source: 'median' | 'reference'; + sample: number; + median: number | null; + bounded: boolean; + voters: number; + minSample: number; + windowDays: number; + everyHours: number; + bounds: [number, number]; + measuredAt: string | null; +} + +export interface InertiaChange { + from: Inertia | null; + to: Inertia | null; + cause: 'vote' | 'amendment'; + at: string; +} export interface Me { id: string; @@ -75,6 +109,7 @@ export interface Unit { section: string | null; position: number; inertia: Inertia; + inertiaChanges: InertiaChange[]; inForce: Proposal | null; proposals: Proposal[]; } @@ -103,10 +138,12 @@ export interface DocView { electorate: number; electorateSource: 'wot' | 'group'; formula: { base: number }; + participation: ParticipationInfo; publication: Publication; }; units: Unit[]; - inertia: { key: Inertia; majorityPct: number; gradient: number }[]; + /** The five levels, with the *for* votes asked at the usual turnout. */ + levels: LevelInfo[]; } export type DiffPart = { op: 'same' | 'added' | 'removed'; text: string }; diff --git a/web/src/routes/g/[group]/docs/[doc]/+page.svelte b/web/src/routes/g/[group]/docs/[doc]/+page.svelte index 2aaab58..9ad0282 100644 --- a/web/src/routes/g/[group]/docs/[doc]/+page.svelte +++ b/web/src/routes/g/[group]/docs/[doc]/+page.svelte @@ -20,8 +20,8 @@ }); // The panel lives in the URL: a link (or a feedback report) reopens it. - const TABS: Record = { vote: 'vote', propositions: 'proposals', proposer: 'propose' }; - const TAB_NAMES: Record = { vote: 'vote', proposals: 'propositions', propose: 'proposer' }; + const TABS: Record = { vote: 'vote', propositions: 'proposals', proposer: 'propose', reglages: 'settings' }; + const TAB_NAMES: Record = { vote: 'vote', proposals: 'propositions', propose: 'proposer', settings: 'reglages' }; let prefill = $state(null); const panel = $derived.by((): PanelState | null => { const q = page.url.searchParams; @@ -77,6 +77,7 @@

    {i18n.plural('docs.inForce', doc.inForce.length)} · {i18n.plural('docs.open', doc.open.length)} {#if view.doc.publication.state === 'done'} · {t('doc.published')}{:else if view.doc.publication.state === 'pending'} · {t('doc.publish.pending')}{/if} + ·

    diff --git a/web/tests/unit/inertia.test.ts b/web/tests/unit/inertia.test.ts new file mode 100644 index 0000000..39fed47 --- /dev/null +++ b/web/tests/unit/inertia.test.ts @@ -0,0 +1,22 @@ +// The five levels of inertia, as the screen names them (ADR 0007). +import { describe, expect, it } from 'vitest'; +import { en } from '../../src/lib/i18n/en'; +import { es } from '../../src/lib/i18n/es'; +import { fr } from '../../src/lib/i18n/fr'; +import { INERTIA_LEVELS } from '../../src/lib/types'; + +describe('five levels of inertia', () => { + it('go from 1 to 5, 3 in the middle', () => { + expect(INERTIA_LEVELS).toEqual([1, 2, 3, 4, 5]); + }); + it('each level has its own short name, in the three languages', () => { + expect(INERTIA_LEVELS.map((k) => fr[`inertia.name.${k}`])).toEqual(['très basse', 'basse', 'moyenne', 'haute', 'très haute']); + expect(INERTIA_LEVELS.map((k) => en[`inertia.name.${k}`])).toEqual(['very low', 'low', 'medium', 'high', 'very high']); + expect(INERTIA_LEVELS.map((k) => es[`inertia.name.${k}`])).toEqual(['muy baja', 'baja', 'media', 'alta', 'muy alta']); + }); + it('a change of inertia is said to be voted at the level of the article, no longer one above', () => { + for (const cat of [fr, en, es]) expect(cat['inertia.change.note']).toContain('{level}'); + expect(fr['inertia.change.note']).toContain("à l'inertie de l'article"); + expect(fr['inertia.change.note']).not.toContain('au-dessus'); + }); +}); diff --git a/web/tests/unit/versions.test.ts b/web/tests/unit/versions.test.ts index 842b2b2..e8fa69b 100644 --- a/web/tests/unit/versions.test.ts +++ b/web/tests/unit/versions.test.ts @@ -8,13 +8,13 @@ function prop(id: string, unitId: string, over: Partial = {}): Proposa label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null, confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false }, - votedAtInertia: 'standard', blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {}, + votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {}, ...over, }; } function unit(id: string, proposals: Proposal[]): Unit { - return { id, code: id, section: null, position: 0, inertia: 'standard', inForce: null, proposals }; + return { id, code: id, section: null, position: 0, inertia: 3, inertiaChanges: [], inForce: null, proposals }; } /** The reader's list: every open proposal, unit by unit, in document order. */