From 92ba485f710deb349dfc89c291b0f844edfe1454 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 07:16:57 +0100 Subject: [PATCH] =?UTF-8?q?Lot=20T=20:=20F1.1,=20F1.2=20et=20N1.1=20disent?= =?UTF-8?q?=20le=20quorum=20par=20cran=20(seed,=20trois=20langues)=20;=20u?= =?UTF-8?q?n=20second=20amendement=20de=20seed=20gard=C3=A9=20les=20rempla?= =?UTF-8?q?ce=20en=20production?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Le seed : « Formule du seuil WoT », « Paramètres par défaut », « Niveaux d'inertie » réécrits d'après l'ADR 0013 (sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part du cran) et l'ADR 0011 (aucun article en vigueur au départ, les trois Propositions seed au cran 2, le titre au cran 4) ; le _source le dit. - Les nombres : chacun, en fr, en et es, calculé par le moteur ou lu dans le seed (tests/seed_quorum_texts.rs) ; les trois formules évaluées telles qu'écrites contre threshold et inertia ; les règles de forme vérifiées. - seed_amend : 2026-11-03-f1-1-quorum, -f1-2-quorum, -n1-1-quorum. Un amendement qui « follows » un autre admet la révision 2 « initial puis amendment » écrite par lui seul, rien d'autre ; ceux de M5 sur F1.2 et N1.1 sont « superseded_by » (plus rien décidé, sans erreur). La garde compte aussi les lignes de bulletin du tampon (« ballots »). - Tests vus rouges : sept du lot T, deux unitaires de la garde ; les tests de M5 jouent sur le seed de M5 (la fixture de production). Co-Authored-By: Claude Opus 5.5 --- crates/ld-server/src/seed_amend.rs | 162 ++- crates/ld-server/src/wot.rs | 3 +- crates/ld-server/tests/seed_proposals.rs | 6 +- crates/ld-server/tests/seed_quorum_texts.rs | 1058 +++++++++++++++++++ crates/ld-server/tests/seed_rules.rs | 50 +- seeds/acte-engagement-certification.json | 20 +- 6 files changed, 1269 insertions(+), 30 deletions(-) create mode 100644 crates/ld-server/tests/seed_quorum_texts.rs diff --git a/crates/ld-server/src/seed_amend.rs b/crates/ld-server/src/seed_amend.rs index 4ff8118..efd6894 100644 --- a/crates/ld-server/src/seed_amend.rs +++ b/crates/ld-server/src/seed_amend.rs @@ -7,6 +7,15 @@ //! proposal instead of a text in force (ADR 0011): where an article has no text //! in force, `Version::InForce` examines its seed proposal (a new base: present). //! +//! Since 2026-11-03 (lot T), a text may be amended twice: F1.1, F1.2 and N1.1 say +//! the quorum by level (ADR 0013) instead of the floor C, and F1.2 and N1.1 had +//! been rewritten by the amendments of 02/11. An amendment that `follows` an +//! earlier one replaces the text that one wrote: it admits, besides the seed's +//! own revision 1, the revision 2 whose history is « initial » then « amendment » +//! and on which that earlier amendment, and it alone, was applied — nothing else. +//! The earlier one is then `superseded_by` it: the seed no longer gives its text, +//! so it decides nothing more, without an error (the decisions already taken stay). +//! //! A seeded document is never instantiated again: once in the database, it is //! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30: //! the Préambule becomes « Raison d'être »), new bases get it from the seed; @@ -14,9 +23,11 @@ //! nobody has touched the article**: //! //! - its version in force is the seed's own, a *seed a minima* (never voted -//! into force, never confirmed), at revision 1; -//! - nobody ever voted on it, corrected it, edited one of its translations or -//! named it in a contradiction signal; +//! into force, never confirmed), at revision 1 — or 2, after the amendment it +//! follows; +//! - nobody ever voted on it (a vote, or a line of a ballot of the buffer, pending +//! or proven, whatever became of it: ADR 0012), corrected it, edited one of its +//! translations or named it in a contradiction signal (an objection); //! - its text is exactly the one the amendment replaces (fingerprint). //! //! Each document is decided once, at the first start that knows the @@ -58,6 +69,12 @@ pub struct Amendment { pub to: &'static str, /// Reason given to the publication queue, and so to the commit of the texts. pub reason: &'static str, + /// The earlier amendment of the same text whose text this one replaces, if any: + /// its revision 2 is then admitted (see the module). + pub follows: Option<&'static str>, + /// The later amendment whose text the seed gives instead, if any: this one then + /// decides nothing more, without an error. + pub superseded_by: Option<&'static str>, } pub const AMENDMENTS: &[Amendment] = &[ @@ -69,6 +86,8 @@ pub const AMENDMENTS: &[Amendment] = &[ from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375", to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a", reason: "texte de départ remplacé : I1", + follows: None, + superseded_by: None, }, // The rule articles, open proposals of the seed, after the rules applied (Yvv, 2026-11-02). Amendment { @@ -79,6 +98,8 @@ pub const AMENDMENTS: &[Amendment] = &[ from: "044c65d6b97e4278184cb1acfe6fa2227ddfe91465c717d8a81f52092314fae6", to: "b24ff4d1c95cfbaae0231885009ee3d600957a3ce498bede80b29bceca651a60", reason: "proposition de départ réécrite : F1.2", + follows: None, + superseded_by: Some("2026-11-03-f1-2-quorum"), }, Amendment { id: "2026-11-02-n1-regles", @@ -88,6 +109,8 @@ pub const AMENDMENTS: &[Amendment] = &[ from: "eed13441afae8d33eba37545163037ccf89b0801d84072ef55290575a1410142", to: "c1facdfd0c0f3aaa94b74192e146557558fe88f66304dc6d21279714d7fb727e", reason: "proposition de départ réécrite : N1", + follows: None, + superseded_by: None, }, Amendment { id: "2026-11-02-n1-1-regles", @@ -97,6 +120,8 @@ pub const AMENDMENTS: &[Amendment] = &[ from: "d5d06c5ae5f3a1c1b08e3c3721b2b09c3dcfd528c11d16d3368d6e14d8942bee", to: "865ed24d5eb18745a8c35dc727247219c449e078d39b6a18ec2fdcfd4f8289a0", reason: "proposition de départ réécrite : N1.1", + follows: None, + superseded_by: Some("2026-11-03-n1-1-quorum"), }, Amendment { id: "2026-11-02-o1-regles", @@ -106,6 +131,44 @@ pub const AMENDMENTS: &[Amendment] = &[ from: "40ab4c06a9e374bded6023b9dffbee5736fc4000d422c5a819f35cdb00b02642", to: "4173738055af10fceb74bb6e954bdfefd7ab364480d49afbc92ea5f68825c537", reason: "proposition de départ réécrite : O1", + follows: None, + superseded_by: None, + }, + // The articles that still described the floor C, after the quorum by level (ADR 0013; + // Yvv, 2026-11-02 and 03; lot T). F1.1 was never amended; F1.2 and N1.1 replace the + // text the amendments of 02/11 wrote. + Amendment { + id: "2026-11-03-f1-1-quorum", + doc: "acte-engagement-certification", + unit: "F1.1", + version: Version::Open("1.0.0"), + from: "258980be190192fe3c7b32f6319584b682621d01329d880b7056c28c84e9db0a", + to: "ca6d3c74a3e6de22ebf55eb0cdd6465dbe22d69cd86aae89ac335b960671b4fa", + reason: "proposition de départ réécrite : F1.1, le quorum par cran", + follows: None, + superseded_by: None, + }, + Amendment { + id: "2026-11-03-f1-2-quorum", + doc: "acte-engagement-certification", + unit: "F1.2", + version: Version::Open("1.0.0"), + from: "b24ff4d1c95cfbaae0231885009ee3d600957a3ce498bede80b29bceca651a60", + to: "66a9bc125c78120a76a81e8b53c427f4d2d5895581cb4ed4ade1aaab740944a9", + reason: "proposition de départ réécrite : F1.2, le quorum par cran", + follows: Some("2026-11-02-f1-2-regles"), + superseded_by: None, + }, + Amendment { + id: "2026-11-03-n1-1-quorum", + doc: "acte-engagement-certification", + unit: "N1.1", + version: Version::Open("1.0.0"), + from: "865ed24d5eb18745a8c35dc727247219c449e078d39b6a18ec2fdcfd4f8289a0", + to: "1bdd26a63cadb4529f5347863ced83a823439f53247b6aea8e703301bab3de48", + reason: "proposition de départ réécrite : N1.1, le quorum par cran", + follows: Some("2026-11-02-n1-1-regles"), + superseded_by: None, }, ]; @@ -125,6 +188,10 @@ pub struct Facts { pub revision: i64, /// Votes cast on it, whatever their revision. pub votes: i64, + /// Lines of ballots of the buffer naming it, pending or proven, whatever became of them. + pub ballots: i64, + /// The seed amendments applied on it, in order. + pub amendments: Vec, /// Kinds of its revisions, in order. pub revisions: Vec, /// Translations written or corrected by a member. @@ -155,17 +222,22 @@ pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict { if f.fingerprint == a.to { return Verdict::Present; } + // The text the amendment follows: written by it, and by it alone, at revision 2. + let after_earlier = a.follows.is_some_and(|id| f.revision == 2 && f.amendments == [id]); + let history: &[&str] = if after_earlier { &["initial", "amendment"] } else { &["initial"] }; let refusal = if f.origin != "seed" { "not_a_seed" } else if f.entered { "voted_into_force" } else if f.confirmed { "confirmed" - } else if f.revision != 1 { + } else if f.revision != 1 && !after_earlier { "revised" } else if f.votes > 0 { "votes" - } else if f.revisions != ["initial"] || f.typos > 0 { + } else if f.ballots > 0 { + "ballots" + } else if f.revisions != history || f.typos > 0 { "corrected" } else if f.member_translations > 0 { "translation_edited" @@ -234,6 +306,15 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &' let text = version.and_then(|v| Some((v.title.get(src)?, v.body.get(src)?))); match (version, text) { (Some(v), Some((title, body))) if fingerprint(title, body) == a.to => Some((seed, v)), + _ if a.superseded_by.is_some() => { + tracing::debug!( + amendment = a.id, + unit = a.unit, + superseded_by = a.superseded_by, + "seed amendment superseded: nothing decided" + ); + None + } _ => { tracing::error!( amendment = a.id, @@ -294,12 +375,25 @@ pub(crate) async fn facts( .bind(proposal_id) .fetch_one(&mut *conn) .await?; + // Every line of a ballot, chain or simulated, whatever its fate: a vote was cast. + let ballots: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?") + .bind(proposal_id) + .fetch_one(&mut *conn) + .await?; + let amendments: Vec = sqlx::query_scalar( + "SELECT amendment_id FROM seed_amendments WHERE proposal_id = ? AND outcome = 'applied' ORDER BY at, rowid", + ) + .bind(proposal_id) + .fetch_all(&mut *conn) + .await?; Ok(Some(Facts { origin: p.origin, entered: p.entered_at.is_some(), confirmed: p.confirmed_at.is_some(), revision: p.revision, votes, + ballots, + amendments, revisions, member_translations, signals, @@ -358,6 +452,7 @@ async fn decide( }; let mut v = verdict(found.as_ref(), a); let now = util::now(); + let judged = found.as_ref().map_or(1, |f| f.revision); if let (Verdict::Apply, Some((pid, status))) = (v, &examined) { let src = seed.source_lang.as_str(); let pick = |m: &util::LangMap| m.get(src).cloned().unwrap_or_default(); @@ -366,21 +461,23 @@ async fn decide( let done = sqlx::query( "UPDATE proposals SET title = ?, body = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND status = ? AND origin = 'seed' AND entered_at IS NULL - AND confirmed_at IS NULL AND revision = 1", + AND confirmed_at IS NULL AND revision = ?", ) .bind(&title) .bind(&body) .bind(&now) .bind(pid) .bind(*status) + .bind(judged) .execute(&mut *tx) .await?; if done.rows_affected() == 1 { sqlx::query( "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) - VALUES (?, 2, 'amendment', ?, ?, NULL, ?)", + VALUES (?, ?, 'amendment', ?, ?, NULL, ?)", ) .bind(pid) + .bind(judged + 1) .bind(&title) .bind(&body) .bind(&now) @@ -451,6 +548,8 @@ mod tests { from: "old", to: "new", reason: "texte de départ remplacé : I1", + follows: None, + superseded_by: None, }; fn intact() -> Facts { @@ -497,5 +596,54 @@ mod tests { let open = Amendment { version: Version::Open("1.0.0"), ..A }; assert_eq!(verdict(None, &open), Verdict::Refuse("no_open_version")); assert_eq!(verdict(Some(&intact()), &open), Verdict::Apply); + // A ballot of the buffer names it, pending or proven, whatever became of it (ADR 0012). + assert_eq!(verdict(Some(&Facts { ballots: 1, ..intact() }), &A), Verdict::Refuse("ballots")); + } + + /// The text an earlier amendment wrote, as production holds F1.2 and N1.1 since M5. + fn amended_by(earlier: &str) -> Facts { + Facts { + revision: 2, + revisions: vec!["initial".into(), "amendment".into()], + amendments: vec![earlier.into()], + ..intact() + } + } + + #[test] + fn a_second_amendment_admits_the_text_the_first_wrote_and_nothing_else() { + // Lot T (2026-11-03): F1.2 and N1.1 were amended by M5 (revision 2); the quorum + // texts replace them, after that amendment only. + let second = Amendment { version: Version::Open("1.0.0"), follows: Some("m5"), ..A }; + assert_eq!(verdict(Some(&amended_by("m5")), &second), Verdict::Apply); + // A base born with the text of M5: its revision 1, as the seed made it. + assert_eq!(verdict(Some(&intact()), &second), Verdict::Apply); + // Not the amendment it follows, or none: a revision 2 of something else. + assert_eq!(verdict(Some(&amended_by("other")), &second), Verdict::Refuse("revised")); + let typo = Facts { revisions: vec!["initial".into(), "typo".into()], amendments: vec![], ..amended_by("m5") }; + assert_eq!(verdict(Some(&typo), &second), Verdict::Refuse("revised")); + // A history that is not « initial then amendment ». + let odd = Facts { revisions: vec!["initial".into(), "substantive".into()], ..amended_by("m5") }; + assert_eq!(verdict(Some(&odd), &second), Verdict::Refuse("corrected")); + // Further on, nothing is admitted. + let third = Facts { + revision: 3, + revisions: vec!["initial".into(), "amendment".into(), "typo".into()], + ..amended_by("m5") + }; + assert_eq!(verdict(Some(&third), &second), Verdict::Refuse("revised")); + // The same traces of a person keep it. + for (f, why) in [ + (Facts { votes: 1, ..amended_by("m5") }, "votes"), + (Facts { ballots: 2, ..amended_by("m5") }, "ballots"), + (Facts { typos: 1, ..amended_by("m5") }, "corrected"), + (Facts { member_translations: 1, ..amended_by("m5") }, "translation_edited"), + (Facts { signals: 1, ..amended_by("m5") }, "signalled"), + (Facts { fingerprint: "other".into(), ..amended_by("m5") }, "text_differs"), + ] { + assert_eq!(verdict(Some(&f), &second), Verdict::Refuse(why), "{f:?}"); + } + // An amendment that follows none never admits a revision 2. + assert_eq!(verdict(Some(&amended_by("m5")), &A), Verdict::Refuse("revised")); } } diff --git a/crates/ld-server/src/wot.rs b/crates/ld-server/src/wot.rs index bb0a36b..3596034 100644 --- a/crates/ld-server/src/wot.rs +++ b/crates/ld-server/src/wot.rs @@ -10,7 +10,8 @@ use crate::util; use serde_json::json; use std::time::Duration; -const REFRESH: Duration = Duration::from_secs(6 * 3600); +/// W is read again this often. +pub const REFRESH: Duration = Duration::from_secs(6 * 3600); const PLAUSIBLE: std::ops::RangeInclusive = 100..=10_000_000; fn twox_128(data: &[u8]) -> [u8; 16] { diff --git a/crates/ld-server/tests/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs index 6b4f77e..b65f5bc 100644 --- a/crates/ld-server/tests/seed_proposals.rs +++ b/crates/ld-server/tests/seed_proposals.rs @@ -353,9 +353,13 @@ async fn the_production_base_turns_its_untouched_texts_into_seed_proposals_once( kept.push(v); } } + // The articles of the quorum (F1.1, F1.2, N1.1) are amended at the same start, by the + // seed amendments of lot T (`tests/seed_quorum_texts.rs`): left out here. let translations = |state: AppState| async move { sqlx::query_as::<_, (String, String, String, String, String)>( - "SELECT proposal_id, lang, title, body, source_hash FROM translations ORDER BY proposal_id, lang", + "SELECT t.proposal_id, t.lang, t.title, t.body, t.source_hash FROM translations t + JOIN proposals p ON p.id = t.proposal_id JOIN units u ON u.id = p.unit_id + WHERE u.code NOT IN ('F1.1', 'F1.2', 'N1.1') ORDER BY t.proposal_id, t.lang", ) .fetch_all(&state.db) .await diff --git a/crates/ld-server/tests/seed_quorum_texts.rs b/crates/ld-server/tests/seed_quorum_texts.rs new file mode 100644 index 0000000..3a4033c --- /dev/null +++ b/crates/ld-server/tests/seed_quorum_texts.rs @@ -0,0 +1,1058 @@ +//! Lot T (2026-11-03): the articles of the seed that still described the floor C of +//! « pour » votes — F1.1 « Formule du seuil WoT », F1.2 « Paramètres par défaut », +//! N1.1 « Niveaux d'inertie » — say the quorum by level (ADR 0013). +//! +//! Every number of these texts is computed here by the engine (or read from the seed), +//! in each language; their formulas are evaluated against the engine; their form follows +//! the rules of the texts (`docs/chantiers/acte-certification/textes-quorum-seed.md`). +//! A guarded seed amendment replaces them in production, on a base made as production +//! is after M6, only where nobody touched them. + +use ld_engine::inertia::{InertiaLevel, Participation, Scale, gradient, quorum_bounds}; +use ld_engine::threshold::{FormulaParams, judged_voters, no_needed, required_ratio, yes_needed}; +use ld_server::seed_amend::{self, AMENDMENTS, Version}; +use ld_server::{AppState, Config, open_db, seed}; +use serde_json::Value; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +const CODES: [&str; 3] = ["F1.1", "F1.2", "N1.1"]; +const LANGS: [&str; 3] = ["fr", "en", "es"]; +const GROUPS: [&str; 2] = ["essai", "monnaie-libre"]; +/// The illustration of N1.1: the web of trust and the usual turnout of the smith vote +/// of 2024 (ADR 0007, 0013), not the size of the web today. +const EXAMPLE_W: u32 = 7224; +const EXAMPLE_MEDIAN: u32 = 120; + +// --------------------------------------------------------------------------- +// The seeds +// --------------------------------------------------------------------------- + +fn read(path: PathBuf) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json") +} + +fn today_seed() -> Value { + read(Config::for_tests().seed_dir.join("acte-engagement-certification.json")) +} + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +/// The seed production runs since M5 (3f10a0e), unchanged by M6 for these articles. +fn production_seed() -> Value { + read(fixtures().join("seed-production-2026-11-02/acte-engagement-certification.json")) +} + +/// The four rule articles as production was born with them (the seed of 21/10). +fn rules_before() -> Value { + read(fixtures().join("seed-regles-avant.json")) +} + +fn unit<'a>(seed: &'a Value, code: &str) -> &'a Value { + seed["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") +} + +/// The title and body of the first version of an article, in a language. +fn text<'a>(seed: &'a Value, code: &str, lang: &str) -> (&'a str, &'a str) { + let v = &unit(seed, code)["versions"][0]; + (v["title"][lang].as_str().expect("title"), v["body"][lang].as_str().expect("body")) +} + +/// A seed with the first version of `codes` taken from `from` (title and body). +fn with_texts(base: &Value, from: &Value, codes: &[&str]) -> Value { + let mut seed = base.clone(); + for u in seed["units"].as_array_mut().expect("units") { + let code = u["code"].as_str().expect("code").to_owned(); + if codes.contains(&code.as_str()) { + let v = from["units"].get(&code).unwrap_or_else(|| &unit(from, &code)["versions"][0]); + u["versions"][0]["title"] = v["title"].clone(); + u["versions"][0]["body"] = v["body"].clone(); + } + } + seed +} + +fn seed_dir(tag: &str, seed: &Value) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ld-seed-quorum-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write"); + dir +} + +// --------------------------------------------------------------------------- +// The figures: from the engine and the seed, never by hand +// --------------------------------------------------------------------------- + +struct Figures { + /// Q_k of Monnaie libre (the seed). + quorums: [u32; 5], + /// Twice the reference quorum of the document, in voters (the engine's p0 · W). + reference: u32, + /// The lowest quorum an author may set in Monnaie libre. + lowest_quorum: u32, + base: f64, + refresh_hours: u64, + default_level: u8, + seed_level: u8, + title_level: u8, + order_level: u8, + /// The level of every other article of the seed. + others_level: u8, + min_sample: u32, + /// The bounds of the usual turnout, in percent. + bounds: (f64, f64), + /// « pour » asked at the example's 120 voters, level 1 to 5. + example: [u32; 5], + /// Under the quorum: (level, quorum, « pour » that pass, « contre » at most). + under: [(u8, u32, u32, u32); 2], +} + +fn level(k: u8) -> InertiaLevel { + InertiaLevel::new(k).expect("1 to 5") +} + +fn figures(seed: &Value) -> Figures { + let ml = + seed["instances"].as_array().expect("instances").iter().find(|i| i["group"] == "monnaie-libre").expect("ml"); + let quorums: [u32; 5] = serde_json::from_value(ml["quorums"].clone()).expect("quorums"); + let vote_floor = ml["voteFloor"].as_f64().expect("voteFloor"); + let p0 = Participation::of(EXAMPLE_W, vote_floor, None, 0); + assert!(!p0.bounded, "the reference turnout is not brought back within the bounds"); + let reference = p0.voters(EXAMPLE_W); + assert!((reference - reference.round()).abs() < 1e-9, "a whole number of voters: {reference}"); + let scale = + Scale { electorate: EXAMPLE_W, participation: f64::from(EXAMPLE_MEDIAN) / f64::from(EXAMPLE_W), quorums }; + let example = InertiaLevel::ALL.map(|k| scale.required(EXAMPLE_MEDIAN, k, scale.quorum(k, None)).expect("valid")); + let under = [2u8, 3].map(|k| { + let q = scale.quorum(level(k), None); + let p = scale.params(level(k), q); + let yes = yes_needed(0, EXAMPLE_W, &p).expect("valid"); + let most = no_needed(yes, EXAMPLE_W, &p).expect("valid") - 1; + assert!(yes + most <= q, "level {k}: the example stays under the quorum"); + (k, q, yes, most) + }); + let levels: Vec = seed["units"] + .as_array() + .expect("units") + .iter() + .filter_map(|u| u["inertia"].as_u64()) + .map(|k| u8::try_from(k).expect("level")) + .collect(); + assert!(levels.windows(2).all(|w| w[0] == w[1]), "every other article at one level: {levels:?}"); + let module = |m: &str| u8::try_from(seed["modules"][m]["inertia"].as_u64().expect("module level")).expect("level"); + assert_eq!(ld_server::participation::WINDOW_DAYS, 365, "« depuis un an »"); + assert_eq!(ld_server::participation::EVERY_HOURS, 24, "« une fois par jour »"); + Figures { + quorums, + reference: reference.round() as u32, + lowest_quorum: quorum_bounds(EXAMPLE_W, false).0, + base: FormulaParams::DEFAULT_BASE, + refresh_hours: ld_server::wot::REFRESH.as_secs() / 3600, + default_level: InertiaLevel::DEFAULT.get(), + seed_level: seed::SEED_PROPOSAL_LEVEL, + title_level: module("title"), + order_level: module("order"), + others_level: levels[0], + min_sample: Participation::MIN_SAMPLE, + bounds: (Participation::LOWEST * 100.0, Participation::HIGHEST * 100.0), + example, + under, + } +} + +/// A whole number as the language writes it: 7 224, 7,224, 7224. +fn int(lang: &str, n: u32) -> String { + let digits = n.to_string(); + if n < 1000 || lang == "es" { + return digits; + } + let sep = if lang == "fr" { " " } else { "," }; + let (head, tail) = digits.split_at(digits.len() - 3); + format!("{head}{sep}{tail}") +} + +/// A decimal: 0,1 or 0.1. +fn dec(lang: &str, v: f64) -> String { + let s = v.to_string(); + if lang == "en" { s } else { s.replace('.', ",") } +} + +/// A percentage: 60 %, 60%. +fn pct(lang: &str, v: f64) -> String { + if lang == "en" { format!("{}%", dec(lang, v)) } else { format!("{} %", dec(lang, v)) } +} + +/// The name of a level, as the interface says it (`inertia.name.k` in `web/src/lib/i18n`; +/// the image builds the server without the interface's sources, so they are written here). +fn level_name(lang: &str, k: u8) -> String { + let names = match lang { + "fr" => ["très basse", "basse", "moyenne", "haute", "très haute"], + "en" => ["very low", "low", "medium", "high", "very high"], + _ => ["muy baja", "baja", "media", "alta", "muy alta"], + }; + names[usize::from(k - 1)].to_owned() +} + +/// The titles of the seed proposals (Raison d'être, Unicité du compte, Réciprocité). +fn seed_proposal_titles(seed: &Value, lang: &str) -> Vec { + seed["units"] + .as_array() + .expect("units") + .iter() + .filter(|u| u["versions"][0]["seedProposal"] == true) + .map(|u| u["versions"][0]["title"][lang].as_str().expect("title").to_owned()) + .collect() +} + +/// « a, b et c », “a, b and c”, «a, b y c». +fn and_list(lang: &str, items: &[String]) -> String { + let and = match lang { + "fr" => "et", + "en" => "and", + _ => "y", + }; + let (last, head) = items.split_last().expect("items"); + format!("{} {and} {last}", head.join(", ")) +} + +/// What is at a level at the start, as N1.1 says it. +fn at_start(f: &Figures, seed: &Value, lang: &str, k: u8) -> String { + let proposals = k == f.seed_level; + let order = k == f.order_level; + let title = k == f.title_level; + let others = k == f.others_level; + let titles = seed_proposal_titles(seed, lang).join(", "); + match (proposals, order, title, others, lang) { + (false, false, false, false, "fr") => "Aucun article au départ.".into(), + (false, false, false, false, "en") => "No article at the start.".into(), + (false, false, false, false, _) => "Ningún artículo al inicio.".into(), + (true, true, false, false, "fr") => format!("{titles}, et l'ordre des articles de chaque section."), + (true, true, false, false, "en") => format!("{titles}, and the order of the articles of each section."), + (true, true, false, false, _) => format!("{titles}, y el orden de los artículos de cada sección."), + (false, false, false, true, l) => { + assert_eq!(k, f.default_level, "the other articles are at the default level"); + match l { + "fr" => "Le cran par défaut : tous les autres articles, et chaque nouvel article.".into(), + "en" => "The default level: all other articles, and every new article.".into(), + _ => "El nivel por defecto: todos los demás artículos, y todo artículo nuevo.".into(), + } + } + (false, false, true, false, "fr") => "Le titre du document.".into(), + (false, false, true, false, "en") => "The title of the document.".into(), + (false, false, true, false, _) => "El título del documento.".into(), + other => panic!("level {k}: the seed changed what starts there ({other:?}), rewrite « Niveaux d'inertie »"), + } +} + +/// The phrases of a text that carry its numbers, each built from the figures. +fn claims(code: &str, lang: &str, f: &Figures, seed: &Value) -> Vec { + let q = f.quorums; + let (lo, hi) = (InertiaLevel::LOWEST.get(), InertiaLevel::HIGHEST.get()); + let d = level(f.default_level); + let qd = q[usize::from(f.default_level - 1)]; + let name = |k: u8| level_name(lang, k); + let sp = and_list(lang, &seed_proposal_titles(seed, lang)); + let (m, w) = (EXAMPLE_MEDIAN, int(lang, EXAMPLE_W)); + let e = f.example; + let ed = e[usize::from(f.default_level - 1)]; + let [(k2, q2, y2, n2), (k3, q3, y3, n3)] = f.under; + match (code, lang) { + ("F1.1", _) => vec![], + ("F1.2", "fr") => vec![ + format!("relu sur la chaîne toutes les {} heures", f.refresh_hours), + format!( + "Chaque article prend celui de son cran : {}, {}, {}, {} et {} dans Monnaie libre, du cran {lo} au cran {hi}", + q[0], q[1], q[2], q[3], q[4] + ), + format!( + "Dans Monnaie libre, jamais moins de {} : une seule voix ne fait jamais passer un texte", + f.lowest_quorum + ), + format!("**B = {}**", dec(lang, f.base)), + format!( + "Le cran par défaut est le {} ({}) : M = {}, un quorum de {qd} dans Monnaie libre, et le groupe demande {} de « pour » quand il vote comme d'habitude", + f.default_level, + name(f.default_level), + pct(lang, d.majority_pct()), + pct(lang, d.share_pct()) + ), + format!( + "{sp} sont des Propositions seed, mises en avant pour lancer le vote, au cran {} ({})", + f.seed_level, + name(f.seed_level) + ), + format!( + "Le titre du document est au cran {} ({}), l'ordre des articles de chaque section au cran {}, tous les autres articles au cran {}", + f.title_level, + name(f.title_level), + f.order_level, + f.others_level + ), + ], + ("F1.2", "en") => vec![ + format!("read again from the chain every {} hours", f.refresh_hours), + format!( + "Each article takes the one of its level: {}, {}, {}, {} and {} in Monnaie libre, from level {lo} to level {hi}", + q[0], q[1], q[2], q[3], q[4] + ), + format!("In Monnaie libre, never less than {}: a single vote never passes a text", f.lowest_quorum), + format!("**B = {}**", dec(lang, f.base)), + format!( + "The default level is {} ({}): M = {}, a quorum of {qd} in Monnaie libre, and the group asks for {} of votes in favour when it votes as usual", + f.default_level, + name(f.default_level), + pct(lang, d.majority_pct()), + pct(lang, d.share_pct()) + ), + format!( + "{sp} are seed proposals, put forward to start the vote, at level {} ({})", + f.seed_level, + name(f.seed_level) + ), + format!( + "The title of the document is at level {} ({}), the order of the articles of each section at level {}, all other articles at level {}", + f.title_level, + name(f.title_level), + f.order_level, + f.others_level + ), + ], + ("F1.2", _) => vec![ + format!("leído de nuevo en la cadena cada {} horas", f.refresh_hours), + format!( + "Cada artículo toma el de su nivel: {}, {}, {}, {} y {} en Monnaie libre, del nivel {lo} al nivel {hi}", + q[0], q[1], q[2], q[3], q[4] + ), + format!("En Monnaie libre, nunca menos de {}: un solo voto nunca hace pasar un texto", f.lowest_quorum), + format!("**B = {}**", dec(lang, f.base)), + format!( + "El nivel por defecto es el {} ({}): M = {}, un quórum de {qd} en Monnaie libre, y el grupo pide un {} de votos a favor cuando vota como de costumbre", + f.default_level, + name(f.default_level), + pct(lang, d.majority_pct()), + pct(lang, d.share_pct()) + ), + format!( + "{sp} son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel {} ({})", + f.seed_level, + name(f.seed_level) + ), + format!( + "El título del documento está en el nivel {} ({}), el orden de los artículos de cada sección en el nivel {}, todos los demás artículos en el nivel {}", + f.title_level, + name(f.title_level), + f.order_level, + f.others_level + ), + ], + ("N1.1", _) => { + let mut out: Vec = InertiaLevel::ALL + .iter() + .map(|k| { + let i = usize::from(k.get() - 1); + let (n, s, mk, what) = ( + name(k.get()), + pct(lang, k.share_pct()), + pct(lang, k.majority_pct()), + at_start(f, seed, lang, k.get()), + ); + match lang { + "fr" => format!( + "- **Cran {} · {n}** : quorum {} ; {s} comme d'habitude, {mk} quand tout le monde vote. {what}", + k.get(), + q[i] + ), + "en" => format!( + "- **Level {} · {n}**: quorum {}; {s} as usual, {mk} when everyone votes. {what}", + k.get(), + q[i] + ), + _ => format!( + "- **Nivel {} · {n}**: quórum {}; {s} como de costumbre, {mk} cuando vota todo el mundo. {what}", + k.get(), + q[i] + ), + } + }) + .collect(); + let sd = pct(lang, d.share_pct()); + // « 75 % des 120 votants, soit 90 »: the share of the default level, exactly. + assert_eq!(f64::from(ed), (d.share_pct() * f64::from(m) / 100.0).ceil()); + let (b0, b1) = (pct(lang, f.bounds.0), pct(lang, f.bounds.1)); + let (ms, r) = (f.min_sample, f.reference); + out.extend(match lang { + "fr" => [ + "propositions du groupe votées depuis un an".to_owned(), + format!("Tant que le groupe n'a pas voté sur {ms} propositions, elle vaut deux fois le quorum de référence du document : {r} votants dans Monnaie libre"), + format!("Elle reste entre {b0} et {b1} de l'électorat, et se mesure une fois par jour"), + format!( + "avec {w} membres et une participation habituelle de {m} votants, quand {m} personnes votent, il faut {}, {}, {}, {} ou {} voix pour, du cran {lo} au cran {hi} ; au cran {}, {sd} des {m} votants, soit {ed}", + e[0], e[1], e[2], e[3], e[4], f.default_level + ), + format!("au cran {k2} (quorum {q2}), {y2} voix pour passent avec {n2} « contre » au plus ; au cran {k3} (quorum {q3}), il faut {y3} voix pour, avec {n3} « contre » au plus"), + ], + "en" => [ + "the group's proposals voted on over the past year".to_owned(), + format!("Until the group has voted on {ms} proposals, it is twice the document's reference quorum: {r} voters in Monnaie libre"), + format!("It stays between {b0} and {b1} of the electorate, and is measured once a day"), + format!( + "with {w} members and a usual turnout of {m} voters, when {m} people vote, it takes {}, {}, {}, {} or {} votes in favour, from level {lo} to level {hi}; at level {}, {sd} of the {m} voters, i.e. {ed}", + e[0], e[1], e[2], e[3], e[4], f.default_level + ), + format!("at level {k2} (quorum {q2}), {y2} votes in favour pass with {n2} votes against at most; at level {k3} (quorum {q3}), it takes {y3} votes in favour, with {n3} votes against at most"), + ], + _ => [ + "propuestas del grupo votadas en el último año".to_owned(), + format!("Hasta que el grupo haya votado {ms} propuestas, vale dos veces el quórum de referencia del documento: {r} votantes en Monnaie libre"), + format!("Se mantiene entre el {b0} y el {b1} del electorado, y se mide una vez al día"), + format!( + "con {w} miembros y una participación habitual de {m} votantes, cuando votan {m} personas, hacen falta {}, {}, {}, {} o {} votos a favor, del nivel {lo} al nivel {hi}; en el nivel {}, el {sd} de los {m} votantes, es decir {ed}", + e[0], e[1], e[2], e[3], e[4], f.default_level + ), + format!("en el nivel {k2} (quórum {q2}), {y2} votos a favor pasan con {n2} en contra como mucho; en el nivel {k3} (quórum {q3}), hacen falta {y3} votos a favor, con {n3} en contra como mucho"), + ], + }); + out + } + _ => unreachable!(), + } +} + +/// The spans of code of a text, in order. +fn code_spans(body: &str) -> Vec<&str> { + body.split('`').skip(1).step_by(2).collect() +} + +/// The word a formula is named by, in each language. +fn named(lang: &str, fr: &'static str, en: &'static str, es: &'static str) -> &'static str { + match lang { + "fr" => fr, + "en" => en, + _ => es, + } +} + +/// The code a text may hold: its formulas, exactly. +fn formulas(code: &str, lang: &str) -> Vec { + let threshold = named(lang, "Seuil", "Threshold", "Umbral"); + let share = named(lang, "Part exigée", "Share asked", "Proporción exigida"); + match code { + "F1.1" => vec!["T' = max(T, Q)".into(), format!("{threshold} = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'")], + "F1.2" => vec!["B^W".into()], + _ => vec![format!("{share} = M + (1 − M) × (1 − (T'/W)^G)"), "G = ln(1 − (s − M)/(1 − M)) / ln(p̃)".into()], + } +} + +#[test] +fn every_number_of_the_quorum_texts_is_the_engine_s() { + let seed = today_seed(); + let f = figures(&seed); + for code in CODES { + for lang in LANGS { + let (_, body) = text(&seed, code, lang); + let mut rest = body.to_owned(); + for claim in claims(code, lang, &f, &seed) { + assert!(rest.contains(&claim), "{code} {lang}: missing « {claim} »\n{body}"); + rest = rest.replacen(&claim, "", 1); + } + let spans = code_spans(body); + assert_eq!(spans, formulas(code, lang), "{code} {lang}: the formulas"); + for span in spans { + rest = rest.replacen(&format!("`{span}`"), "", 1); + } + let rest = rest.replace("Ğ1", ""); + let stray: Vec = rest.chars().filter(char::is_ascii_digit).collect(); + assert!(stray.is_empty(), "{code} {lang}: a number no engine figure stands for: {stray:?}\n{rest}"); + } + } +} + +// --------------------------------------------------------------------------- +// The formulas: evaluated as written, against the engine +// --------------------------------------------------------------------------- + +/// A tiny evaluator for the formulas of the texts: + − × / ^, parentheses, `max`, `ln`. +struct Formula<'a> { + tokens: Vec, + at: usize, + env: &'a HashMap, +} + +fn tokens(src: &str) -> Vec { + let mut out = Vec::new(); + let chars: Vec = src.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c.is_whitespace() { + i += 1; + } else if c.is_ascii_digit() { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { + i += 1; + } + out.push(chars[start..i].iter().collect()); + } else if c.is_alphabetic() { + let start = i; + // A name: letters, a combining mark (p̃), a prime (T'). + while i < chars.len() && (chars[i].is_alphabetic() || ('\u{300}'..='\u{36f}').contains(&chars[i])) { + i += 1; + } + if i < chars.len() && chars[i] == '\'' { + i += 1; + } + out.push(chars[start..i].iter().collect()); + } else { + out.push(c.to_string()); + i += 1; + } + } + out +} + +impl Formula<'_> { + fn peek(&self) -> Option<&str> { + self.tokens.get(self.at).map(String::as_str) + } + + fn take(&mut self, t: &str) -> bool { + let found = self.peek() == Some(t); + if found { + self.at += 1; + } + found + } + + fn expr(&mut self) -> f64 { + let mut v = self.term(); + loop { + if self.take("+") { + v += self.term(); + } else if self.take("−") { + v -= self.term(); + } else { + return v; + } + } + } + + fn term(&mut self) -> f64 { + let mut v = self.power(); + loop { + if self.take("×") { + v *= self.power(); + } else if self.take("/") { + v /= self.power(); + } else { + return v; + } + } + } + + fn power(&mut self) -> f64 { + let base = self.atom(); + if self.take("^") { base.powf(self.power()) } else { base } + } + + fn atom(&mut self) -> f64 { + let t = self.peek().expect("a term").to_owned(); + self.at += 1; + if t == "(" { + let v = self.expr(); + assert!(self.take(")"), "a closing parenthesis"); + return v; + } + if let Ok(n) = t.parse::() { + return n; + } + if self.take("(") { + let a = self.expr(); + let v = match t.as_str() { + "max" => { + assert!(self.take(","), "max(a, b)"); + a.max(self.expr()) + } + "ln" => a.ln(), + other => panic!("unknown function {other}"), + }; + assert!(self.take(")"), "a closing parenthesis"); + return v; + } + *self.env.get(&t).unwrap_or_else(|| panic!("unknown letter {t}")) + } +} + +/// The value of `name = expression`, the name checked. +fn eval(formula: &str, name: &str, env: &HashMap) -> f64 { + let (lhs, rhs) = formula.split_once(" = ").expect("name = expression"); + assert_eq!(lhs, name); + let mut f = Formula { tokens: tokens(rhs), at: 0, env }; + let v = f.expr(); + assert_eq!(f.at, f.tokens.len(), "the whole of {rhs}"); + v +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0) +} + +#[test] +fn the_formulas_of_the_texts_are_those_the_engine_computes() { + let seed = today_seed(); + for lang in LANGS { + let (_, f11) = text(&seed, "F1.1", lang); + let (_, n11) = text(&seed, "N1.1", lang); + let [voters, threshold] = <[&str; 2]>::try_from(code_spans(f11)).expect("two formulas in F1.1"); + let [share, grad] = <[&str; 2]>::try_from(code_spans(n11)).expect("two formulas in N1.1"); + let mut checked = 0; + for w in [2u32, 40, 5707, 7224] { + for median in [None, Some(1.0), Some(9.0), Some(120.0), Some(360.0)] { + let p = Participation::of(w, 50.0, median, 9).ratio; + let scale = Scale { electorate: w, participation: p, quorums: [12, 12, 50, 50, 50] }; + for k in InertiaLevel::ALL { + for q in [1u32, 2, 12, 50].into_iter().filter(|q| *q <= w) { + let params = scale.params(k, q); + for t in [0u32, 1, 9, 12, 13, 50, 51, 120, 500, w].into_iter().filter(|t| *t <= w) { + let mut env: HashMap = HashMap::from([ + ("T".into(), f64::from(t)), + ("Q".into(), f64::from(q)), + ("W".into(), f64::from(w)), + ("B".into(), params.base), + ("M".into(), params.majority_pct / 100.0), + ("G".into(), params.gradient), + ("s".into(), k.share_pct() / 100.0), + ("p̃".into(), p), + ]); + let judged = eval(voters, "T'", &env); + assert_eq!(judged, f64::from(judged_voters(t, q)), "{lang}: T'"); + env.insert("T'".into(), judged); + let ratio = required_ratio(w, judged_voters(t, q), ¶ms); + let engine = params.base.powf(f64::from(w)) + ratio * judged; + let written = eval(threshold, named(lang, "Seuil", "Threshold", "Umbral"), &env); + assert!( + close(written, engine), + "{lang} W {w} k {} Q {q} T {t}: {written} ≠ {engine}", + k.get() + ); + let asked = + eval(share, named(lang, "Part exigée", "Share asked", "Proporción exigida"), &env); + assert!(close(asked, ratio), "{lang}: the share"); + assert!(close(eval(grad, "G", &env), gradient(k, p)), "{lang}: G"); + checked += 1; + } + } + } + } + } + assert!(checked > 500, "{checked}"); + } +} + +// --------------------------------------------------------------------------- +// The rules of form (textes-regles-seed.md, « Avant de coller ») +// --------------------------------------------------------------------------- + +#[test] +fn the_quorum_texts_follow_the_rules_of_form() { + let seed = today_seed(); + let titles: HashMap<&str, Vec<&str>> = LANGS + .iter() + .map(|l| { + let all = seed["units"] + .as_array() + .expect("units") + .iter() + .flat_map(|u| u["versions"].as_array().expect("versions")) + .map(|v| v["title"][*l].as_str().expect("title")) + .collect(); + (*l, all) + }) + .collect(); + for code in CODES { + for lang in LANGS { + let (title, body) = text(&seed, code, lang); + let at = format!("{code} {lang}"); + // A title kept: the article still says what its title says. + assert_eq!(title, text(&production_seed(), code, lang).0, "{at}: the title is kept"); + // Length: under the limit of a proposal (`proposals.rs`, MAX_BODY, in characters). + assert!(body.chars().count() <= 4000, "{at}: {} characters", body.chars().count()); + // A list reads as a list only in a block of its own (`markdown.ts`, render). + for block in body.split("\n\n") { + let lines: Vec<&str> = block.lines().collect(); + if lines.iter().any(|l| l.starts_with("- ")) { + assert!(lines.iter().all(|l| l.starts_with("- ")), "{at}: a list without its blank line\n{block}"); + } + } + // No asterisk but in bold pairs: a formula never multiplies with one. + assert!(!body.replace("**", "").contains('*'), "{at}: a lone asterisk"); + assert_eq!(body.matches("**").count() % 2, 0, "{at}: bold pairs"); + for span in code_spans(body) { + assert!(!span.contains('*') && !span.contains('-'), "{at}: × and −, never * or -: {span}"); + } + // Straight apostrophes; the quotes of each language. + assert!(!body.contains('’'), "{at}: a curly apostrophe"); + let refs: Vec<&str> = match lang { + "fr" => { + assert!(!body.contains('“') && !body.contains('”'), "{at}"); + assert_eq!(body.matches("« ").count(), body.matches('«').count(), "{at}: « spaced"); + assert_eq!(body.matches(" »").count(), body.matches('»').count(), "{at}: » spaced"); + body.split("« ").skip(1).filter_map(|s| s.split(" »").next()).collect() + } + "en" => { + assert!(!body.contains('«') && !body.contains('»'), "{at}"); + assert!(!body.contains("favor ") && !body.contains("favor."), "{at}: British English, favour"); + body.split('“').skip(1).filter_map(|s| s.split('”').next()).collect() + } + _ => { + assert!(!body.contains('“') && !body.contains('”'), "{at}"); + assert!(!body.contains("« ") && !body.contains(" »"), "{at}: «» tight"); + body.split('«').skip(1).filter_map(|s| s.split('»').next()).collect() + } + }; + // Another article is named by its title, never by its code. + let words = ["non", "pour", "contre", "no"]; + for r in refs.iter().filter(|r| !words.contains(r)) { + assert!(titles[lang].contains(r), "{at}: « {r} » is the title of no article"); + } + for other in seed["units"].as_array().expect("units") { + let c = other["code"].as_str().expect("code"); + let named = body.split(|ch: char| !(ch.is_alphanumeric() || ch == '.')).any(|w| w == c); + assert!(!named, "{at}: names the article {c} by its code"); + } + } + } +} + +// --------------------------------------------------------------------------- +// Production: a guarded amendment, on a base made as production is after M6 +// --------------------------------------------------------------------------- + +/// The amendment of an article, by lot T. +fn amendment_t(code: &str) -> &'static seed_amend::Amendment { + AMENDMENTS + .iter() + .find(|a| a.unit == code && a.id.starts_with("2026-11-03-")) + .unwrap_or_else(|| panic!("no amendment of lot T for {code}")) +} + +#[test] +fn the_shipped_amendments_go_from_production_to_the_seed_of_today() { + let (prod, today) = (production_seed(), today_seed()); + for code in CODES { + let a = amendment_t(code); + assert_eq!((a.doc, a.version), ("acte-engagement-certification", Version::Open("1.0.0")), "{code}"); + let (t, b) = text(&prod, code, "fr"); + assert_eq!(seed_amend::fingerprint(t, b), a.from, "{code}: the text production holds"); + let (t, b) = text(&today, code, "fr"); + assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text of the seed today"); + assert_ne!(a.from, a.to, "{code}"); + assert_eq!(a.superseded_by, None, "{code}"); + // F1.1 was never amended; F1.2 and N1.1 follow the amendment of M5, which wrote the + // text they replace and which the seed no longer gives: it is superseded. + match a.follows { + None => assert_eq!(code, "F1.1"), + Some(earlier) => { + let m5 = AMENDMENTS.iter().find(|e| e.id == earlier).expect("the earlier amendment"); + assert_eq!((m5.unit, m5.to, m5.superseded_by), (code, a.from, Some(a.id)), "{code}"); + } + } + } +} + +/// Every proposal as it stands: (id, status, revision, title, body). +type Row = (String, String, i64, String, String); + +async fn proposals(state: &AppState) -> Vec { + sqlx::query_as("SELECT id, status, revision, title, body FROM proposals ORDER BY id") + .fetch_all(&state.db) + .await + .expect("proposals") +} + +async fn translations(state: &AppState) -> Vec<(String, String, String, String, String)> { + sqlx::query_as("SELECT proposal_id, lang, title, body, source_hash FROM translations ORDER BY proposal_id, lang") + .fetch_all(&state.db) + .await + .expect("translations") +} + +async fn start(db: &sqlx::SqlitePool, cfg: Config) -> AppState { + let state = AppState::new(cfg, db.clone()).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + state +} + +fn config(dir: PathBuf) -> Config { + let mut cfg = Config::for_tests(); + cfg.seed_dir = dir; + cfg +} + +/// Production's history, for these articles: born on 21/10 (the rule articles of +/// before), started by M5 (3f10a0e: its seed, its amendments), then by M6 (94ddb17: the +/// seed of today with the texts of M5 — seed proposals, quorums by level). +async fn production_after_m6(tag: &str) -> AppState { + let prod = production_seed(); + let born = with_texts(&prod, &rules_before(), &["F1.2", "N1", "N1.1", "O1"]); + let db = open_db("sqlite::memory:").await.expect("db"); + let first = AppState::new(config(seed_dir(&format!("{tag}-born"), &born)), db.clone()).await.expect("state"); + seed::documents(&first).await.expect("born"); + start(&db, config(seed_dir(&format!("{tag}-m5"), &prod))).await; + let m6 = with_texts(&today_seed(), &prod, &CODES); + start(&db, config(seed_dir(&format!("{tag}-m6"), &m6))).await +} + +/// The open proposal of the seed for an article: (id, title, body, revision). +async fn proposal(state: &AppState, group: &str, code: &str) -> (String, String, String, i64) { + sqlx::query_as( + "SELECT p.id, p.title, p.body, p.revision FROM proposals p JOIN units u ON u.id = p.unit_id + JOIN documents d ON d.id = u.doc_id + WHERE d.group_slug = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed' AND p.label = '1.0.0'", + ) + .bind(group) + .bind(code) + .fetch_one(&state.db) + .await + .expect("seed proposal") +} + +async fn kinds(state: &AppState, id: &str) -> Vec { + sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id") + .bind(id) + .fetch_all(&state.db) + .await + .expect("revisions") +} + +/// Each decision of lot T: (group, unit, outcome, reason). +async fn decisions(state: &AppState) -> Vec<(String, String, String, Option)> { + sqlx::query_as( + "SELECT d.group_slug, s.unit_code, s.outcome, s.reason FROM seed_amendments s + JOIN documents d ON d.id = s.doc_id + WHERE s.amendment_id LIKE '2026-11-03-%' ORDER BY d.group_slug, s.unit_code", + ) + .fetch_all(&state.db) + .await + .expect("decisions") +} + +async fn queued(state: &AppState) -> 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 '%quorum par cran%' ORDER BY o.id", + ) + .fetch_all(&state.db) + .await + .expect("outbox") +} + +#[tokio::test] +async fn production_after_m6_gets_the_quorum_texts_once() { + let base = production_after_m6("apply").await; + let (prod, today) = (production_seed(), today_seed()); + // The base is as production after M6: F1.1 as born, F1.2 and N1.1 as M5 wrote them. + for group in GROUPS { + for code in CODES { + let (id, title, body, revision) = proposal(&base, group, code).await; + assert_eq!((title.as_str(), body.as_str()), text(&prod, code, "fr"), "{group} {code}"); + let m5 = code != "F1.1"; + assert_eq!(revision, if m5 { 2 } else { 1 }, "{group} {code}"); + let expected: &[&str] = if m5 { &["initial", "amendment"] } else { &["initial"] }; + assert_eq!(kinds(&base, &id).await, expected, "{group} {code}"); + } + } + let seed_proposals: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM proposals WHERE seed_featured = 1") + .fetch_one(&base.db) + .await + .expect("count"); + let quorums: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE quorums IS NOT NULL") + .fetch_one(&base.db) + .await + .expect("count"); + assert_eq!((seed_proposals, quorums), (6, 2), "M6: seed proposals and quorums written"); + assert!(decisions(&base).await.is_empty(), "the seed of M6 decides nothing of lot T"); + let (before, before_tr) = (proposals(&base).await, translations(&base).await); + + // Started with today's seed. + let state = start(&base.db, Config::for_tests()).await; + let mut touched = Vec::new(); + for group in GROUPS { + for code in CODES { + let (id, title, body, revision) = proposal(&state, group, code).await; + assert_eq!((title.as_str(), body.as_str()), text(&today, code, "fr"), "{group} {code}"); + let m5 = code != "F1.1"; + assert_eq!(revision, if m5 { 3 } else { 2 }, "{group} {code}"); + let expected: &[&str] = if m5 { &["initial", "amendment", "amendment"] } else { &["initial", "amendment"] }; + assert_eq!(kinds(&state, &id).await, expected, "{group} {code}"); + // The history keeps the text it replaced, and says the new one, without author. + let last: (i64, String, String, Option) = sqlx::query_as( + "SELECT revision, title, body, author_id FROM revisions WHERE proposal_id = ? ORDER BY id DESC LIMIT 1", + ) + .bind(&id) + .fetch_one(&state.db) + .await + .expect("revision"); + assert_eq!(last, (revision, title.clone(), body.clone(), None), "{group} {code}"); + for lang in ["en", "es"] { + let (t, b, hash, origin, by): (String, String, String, String, Option) = sqlx::query_as( + "SELECT title, body, source_hash, origin, updated_by FROM translations WHERE proposal_id = ? AND lang = ?", + ) + .bind(&id) + .bind(lang) + .fetch_one(&state.db) + .await + .expect("translation"); + assert_eq!((t.as_str(), b.as_str()), text(&today, code, lang), "{group} {code} {lang}"); + assert_eq!(hash, seed_amend::fingerprint(&title, &body), "{group} {code} {lang}: up to date"); + assert_eq!((origin.as_str(), by), ("seed", None)); + } + touched.push(id); + } + } + let expected: Vec<(String, String, String, Option)> = GROUPS + .iter() + .flat_map(|g| CODES.iter().map(move |c| ((*g).to_owned(), (*c).to_owned(), "applied".to_owned(), None))) + .collect(); + assert_eq!(decisions(&state).await, expected); + // Nothing else is touched: no other proposal, no other translation, no vote. + let keep = |rows: Vec| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::>(); + assert_eq!(keep(proposals(&state).await), keep(before.clone())); + let others = |rows: Vec<(String, String, String, String, String)>| { + rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::>() + }; + assert_eq!(others(translations(&state).await), others(before_tr)); + let votes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("votes"); + assert_eq!(votes, 0); + // Monnaie libre republishes, once per article; the test group never. + let rows = queued(&state).await; + assert_eq!(rows.len(), 3, "{rows:?}"); + assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}"); + // Decided once: a second start changes nothing. + let again = start(&state.db, Config::for_tests()).await; + assert_eq!(decisions(&again).await, expected); + assert_eq!(proposals(&again).await, proposals(&state).await); + assert_eq!(queued(&again).await.len(), 3); +} + +async fn identity(state: &AppState, id: &str) { + sqlx::query( + "INSERT INTO identities (id, kind, display_name, created_at) VALUES (?, 'dev', ?, '2026-11-03T00:00:00Z')", + ) + .bind(id) + .bind(id) + .execute(&state.db) + .await + .expect("identity"); +} + +#[tokio::test] +async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() { + let base = production_after_m6("refuse").await; + let doc: String = sqlx::query_scalar("SELECT id FROM documents WHERE group_slug = 'monnaie-libre'") + .fetch_one(&base.db) + .await + .expect("doc"); + identity(&base, "dev:vera").await; + // In Monnaie libre, a simulated vote on F1.2 (the simulation of M6 counts it)… + let (f12, ..) = proposal(&base, "monnaie-libre", "F1.2").await; + sqlx::query( + "INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source) + VALUES (?, 'dev:vera', 'for', 2, '2026-11-03T00:00:00Z', 'simulated')", + ) + .bind(&f12) + .execute(&base.db) + .await + .expect("vote"); + // …and a ballot still pending on N1.1: the vote is cast, it is not proven yet. + let (n11, ..) = proposal(&base, "monnaie-libre", "N1.1").await; + sqlx::query( + "INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, mode, + created_at, expires_at) + VALUES ('b1', ?, 'dev:vera', 'salt', 'digest-b1', 'remark-b1', '', 0, 'pending', 'simulated', + '2026-11-03T00:00:00Z', '2026-11-04T00:00:00Z')", + ) + .bind(&doc) + .execute(&base.db) + .await + .expect("ballot"); + sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b1', ?, 2, 'against')") + .bind(&n11) + .execute(&base.db) + .await + .expect("line"); + // In the test group, a member corrected the English of F1.1. + let (f11, ..) = proposal(&base, "essai", "F1.1").await; + sqlx::query( + "UPDATE translations SET origin = 'member', updated_by = 'dev:vera' WHERE proposal_id = ? AND lang = 'en'", + ) + .bind(&f11) + .execute(&base.db) + .await + .expect("translation"); + + let state = start(&base.db, Config::for_tests()).await; + let refused = + |g: &str, c: &str, why: &str| (g.to_owned(), c.to_owned(), "refused".to_owned(), Some(why.to_owned())); + let applied = |g: &str, c: &str| (g.to_owned(), c.to_owned(), "applied".to_owned(), None); + assert_eq!( + decisions(&state).await, + [ + refused("essai", "F1.1", "translation_edited"), + applied("essai", "F1.2"), + applied("essai", "N1.1"), + applied("monnaie-libre", "F1.1"), + refused("monnaie-libre", "F1.2", "votes"), + refused("monnaie-libre", "N1.1", "ballots"), + ] + ); + let (prod, today) = (production_seed(), today_seed()); + for (group, code, kept) in [ + ("essai", "F1.1", true), + ("essai", "F1.2", false), + ("monnaie-libre", "F1.1", false), + ("monnaie-libre", "F1.2", true), + ("monnaie-libre", "N1.1", true), + ] { + let (_, title, body, revision) = proposal(&state, group, code).await; + let expected = if kept { text(&prod, code, "fr") } else { text(&today, code, "fr") }; + assert_eq!((title.as_str(), body.as_str()), expected, "{group} {code}"); + let m5 = i64::from(code != "F1.1"); + assert_eq!(revision, 1 + m5 + i64::from(!kept), "{group} {code}"); + } + // Republished for the one article applied in Monnaie libre only. + let rows = queued(&state).await; + assert_eq!( + rows.iter().map(|(g, r)| (g.as_str(), r.contains("F1.1"))).collect::>(), + [("monnaie-libre", true)] + ); + // The refusal stands: decided once, it is not tried again. + let again = start(&state.db, Config::for_tests()).await; + assert_eq!(decisions(&again).await, decisions(&state).await); +} + +#[tokio::test] +async fn a_new_base_is_born_with_the_quorum_texts() { + let db = open_db("sqlite::memory:").await.expect("db"); + let state = start(&db, Config::for_tests()).await; + let today = today_seed(); + for group in GROUPS { + for code in CODES { + let (_, title, body, revision) = proposal(&state, group, code).await; + assert_eq!(((title.as_str(), body.as_str()), revision), (text(&today, code, "fr"), 1), "{group} {code}"); + } + } + let got = decisions(&state).await; + assert_eq!(got.len(), 6); + assert!(got.iter().all(|(_, _, o, _)| o == "present"), "{got:?}"); + // The amendments of M5 on F1.2 and N1.1, superseded, decide nothing on a new base. + let m5: Vec = sqlx::query_scalar( + "SELECT unit_code FROM seed_amendments WHERE amendment_id IN ('2026-11-02-f1-2-regles', '2026-11-02-n1-1-regles')", + ) + .fetch_all(&state.db) + .await + .expect("m5"); + assert!(m5.is_empty(), "{m5:?}"); +} diff --git a/crates/ld-server/tests/seed_rules.rs b/crates/ld-server/tests/seed_rules.rs index f60cb48..ae8279d 100644 --- a/crates/ld-server/tests/seed_rules.rs +++ b/crates/ld-server/tests/seed_rules.rs @@ -1,6 +1,10 @@ //! The four rule articles of the seed rewritten after the rules applied (Yvv, //! 2026-11-02): F1.2, N1, N1.1 and O1 are open proposals at zero votes, not texts //! in force; a guarded seed amendment replaces them where nobody touched them. +//! +//! Played with the seed M5 shipped (3f10a0e, the fixture of production): since lot T +//! (2026-11-03), today's seed gives F1.2 and N1.1 the texts of the quorum, and their +//! amendments of 02/11 are superseded (`tests/seed_quorum_texts.rs`). use ld_server::seed_amend::{self, AMENDMENTS, Version}; use ld_server::{AppState, Config, open_db, seed}; @@ -21,6 +25,15 @@ fn today_seed() -> Value { read(Config::for_tests().seed_dir.join("acte-engagement-certification.json")) } +/// The seed M5 shipped: production's since 2026-11-02. +fn m5_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02") +} + +fn m5_seed() -> Value { + read(m5_dir().join("acte-engagement-certification.json")) +} + fn version<'a>(seed: &'a Value, code: &str) -> &'a Value { let u = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit"); &u["versions"][0] @@ -30,12 +43,12 @@ fn fr(v: &Value) -> (&str, &str) { (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body")) } -/// Today's seed with the four rule articles of before. +/// The seed of M5 with the four rule articles of before. fn seed_dir_before(tag: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("ld-seed-rules-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("dir"); - let mut seed = today_seed(); + let mut seed = m5_seed(); let old = before(); for u in seed["units"].as_array_mut().expect("units") { let code = u["code"].as_str().expect("code").to_owned(); @@ -59,9 +72,11 @@ async fn base_before(tag: &str) -> AppState { state } -/// The same base, started again with today's seed and its amendments. +/// The same base, started again with the seed of M5 and its amendments. async fn restart(before: &AppState) -> AppState { - let state = AppState::new(Config::for_tests(), before.db.clone()).await.expect("state"); + let mut cfg = Config::for_tests(); + cfg.seed_dir = m5_dir(); + let state = AppState::new(cfg, before.db.clone()).await.expect("state"); seed::ensure(&state).await.expect("seed"); state } @@ -92,19 +107,29 @@ async fn decisions(state: &AppState) -> Vec<(String, String, String, Option assert_eq!(now, a.to, "{code}: still the text of today"), + Some(later) => { + let l = AMENDMENTS.iter().find(|l| l.id == later).expect("the later amendment"); + assert_eq!((l.unit, l.follows, l.from, l.to), (code, Some(a.id), a.to, now.as_str()), "{code}"); + } + } } } @@ -123,7 +148,7 @@ async fn untouched_rule_articles_are_replaced_once_and_a_vote_keeps_its_text() { .await .expect("vote"); let state = restart(&base).await; - let (old, today) = (before(), today_seed()); + let (old, today) = (before(), m5_seed()); for group in ["essai", "monnaie-libre"] { for code in CODES { let (id, title, body, revision) = proposal(&state, group, code).await; @@ -202,6 +227,9 @@ async fn a_new_base_is_born_with_the_rules_of_today() { assert_eq!(((title.as_str(), body.as_str()), revision), (fr(version(&today, code)), 1), "{group} {code}"); } } - assert!(decisions(&state).await.iter().all(|(_, _, o, _)| o == "present")); - assert_eq!(decisions(&state).await.len(), 8); + // N1 and O1: present. F1.2 and N1.1: their amendments, superseded, decide nothing. + let got = decisions(&state).await; + assert!(got.iter().all(|(_, _, o, _)| o == "present"), "{got:?}"); + let units: Vec<&str> = got.iter().map(|(_, u, _, _)| u.as_str()).collect(); + assert_eq!(units, ["N1", "O1", "N1", "O1"]); } diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json index 3086ebe..079cc98 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). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs : 2 « basse », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2, en vigueur sans vote (seed a minima ; revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02). Quorums par cran (« quorums », ADR 0013, Yvv, 2026-11-02 et 03) : 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 partout dans le groupe d'essai ; sous le quorum, les votants qui manquent comptent comme « non » ; voteFloor reste le quorum de référence de la participation p0 = 2 × voteFloor / W. Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend, quorum_amend), jamais en réinstanciant le document.", + "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs : 2 « basse », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2, en vigueur sans vote (seed a minima ; revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02). Quorums par cran (« quorums », ADR 0013, Yvv, 2026-11-02 et 03) : 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 partout dans le groupe d'essai ; sous le quorum, les votants qui manquent comptent comme « non » ; voteFloor reste le quorum de référence de la participation p0 = 2 × voteFloor / W. Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend, quorum_amend), jamais en réinstanciant le document. F1.1, F1.2 et N1.1 réécrits pour le quorum par cran (ADR 0013 ; Yvv, 2026-11-02 et 03 ; lot T, textes relus dans docs/chantiers/acte-certification/textes-quorum-seed.md) : sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part du cran.", "slug": "acte-engagement-certification", "title": { "fr": "Acte d'engagement Certification", @@ -672,9 +672,9 @@ "es": "Fórmula del umbral WoT" }, "body": { - "fr": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nOù :\n- **C** = constante de base (plancher fixe)\n- **B** = exposant de base (B^W tend vers 0)\n- **W** = taille de la toile de confiance (électeurs éligibles)\n- **T** = total des votes exprimés (pour + contre)\n- **M** = ratio de majorité cible (M = majority_pct / 100)\n- **G** = exposant du gradient d'inertie\n\n**Comportement :** Faible participation → quasi-unanimité requise. Forte participation → majorité simple M suffit.", - "en": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nWhere:\n- **C** = base constant (fixed floor)\n- **B** = base exponent (B^W tends towards 0)\n- **W** = size of the web of trust (eligible voters)\n- **T** = total votes cast (for + against)\n- **M** = target majority ratio (M = majority_pct / 100)\n- **G** = inertia gradient exponent\n\n**Behaviour:** Low participation → near-unanimity required. High participation → simple majority M is enough.", - "es": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nDonde:\n- **C** = constante de base (mínimo fijo)\n- **B** = exponente de base (B^W tiende a 0)\n- **W** = tamaño de la red de confianza (votantes habilitados)\n- **T** = total de votos emitidos (a favor + en contra)\n- **M** = proporción de mayoría objetivo (M = majority_pct / 100)\n- **G** = exponente del gradiente de inercia\n\n**Comportamiento:** Baja participación → se requiere casi unanimidad. Alta participación → basta la mayoría simple M." + "fr": "Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout le monde vote.\n\n`T' = max(T, Q)`\n`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUne version passe quand ses voix pour atteignent le seuil, arrondi à l'entier au-dessus.\n\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q** : le quorum de l'article ; **T'** : les votants retenus, T, ou Q s'il en manque.\n- **W** : le nombre de membres de la toile de confiance Ğ1.\n- **B** : la base ; à la puissance W, elle est si petite qu'elle ne compte pas.\n- **M** : la majorité demandée quand tout le monde vote.\n- **G** : le gradient : plus il est grand, plus la part demandée monte quand peu de gens votent.\n\nLeurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie ».", + "en": "The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: near-unanimity when few people vote, the majority M when everyone votes.\n\n`T' = max(T, Q)`\n`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nA version passes when its votes in favour reach the threshold, rounded up to a whole number.\n\n- **T**: the votes cast on the version, for and against.\n- **Q**: the article's quorum; **T'**: the voters counted, T, or Q if some are missing.\n- **W**: the number of members of the Ğ1 web of trust.\n- **B**: the base; raised to the power W, it is so small that it does not count.\n- **M**: the majority asked for when everyone votes.\n- **G**: the gradient: the larger it is, the higher the share asked for when few people vote.\n\nTheir values are in “Default parameters” and “Inertia levels”.", + "es": "El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: casi la unanimidad cuando vota poca gente, la mayoría M cuando vota todo el mundo.\n\n`T' = max(T, Q)`\n`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUna versión pasa cuando sus votos a favor alcanzan el umbral, redondeado al entero superior.\n\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**: el quórum del artículo; **T'**: los votantes contados, T, o Q si faltan.\n- **W**: el número de miembros de la red de confianza Ğ1.\n- **B**: la base; elevada a la potencia W, es tan pequeña que no cuenta.\n- **M**: la mayoría pedida cuando vota todo el mundo.\n- **G**: el gradiente: cuanto mayor es, más alta es la proporción pedida cuando vota poca gente.\n\nSus valores están en «Parámetros por defecto» y «Niveles de inercia»." } } ] @@ -694,9 +694,9 @@ "es": "Parámetros por defecto" }, "body": { - "fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **C = 50** : le nombre minimal de votes « pour » dans Monnaie libre. Sous 50 voix pour, rien ne passe : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- Le cran par défaut est le 3 (moyenne) : M = 55 %, et le groupe demande 75 % de « pour » au-delà de C quand il vote comme d'habitude. Tout nouvel article commence à ce cran.\n- Au départ, Unicité du compte, Réciprocité et le titre du document sont au cran 4 (haute), l'ordre des articles de chaque section au cran 2 (basse), tous les autres articles au cran 3.", - "en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **C = 50**: the minimum number of votes in favour in Monnaie libre. Below 50 votes in favour, nothing passes: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, and the group asks for 75% of votes in favour beyond C when it votes as usual. Every new article starts at this level.\n- At the start, Uniqueness of the account, Reciprocity and the title of the document are at level 4 (high), the order of the articles of each section at level 2 (low), all other articles at level 3.", - "es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **C = 50**: el número mínimo de votos a favor en Monnaie libre. Con menos de 50 votos a favor, nada pasa: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, y el grupo pide un 75 % de votos a favor más allá de C cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, Unicidad de la cuenta, Reciprocidad y el título del documento están en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2 (baja), todos los demás artículos en el nivel 3." + "fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne change plus. Dans Monnaie libre, jamais moins de 2 : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- Le cran par défaut est le 3 (moyenne) : M = 55 %, un quorum de 50 dans Monnaie libre, et le groupe demande 75 % de « pour » quand il vote comme d'habitude. Tout nouvel article commence à ce cran.\n- Au départ, aucun article n'est en vigueur. Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed, mises en avant pour lancer le vote, au cran 2 (basse). Le titre du document est au cran 4 (haute), l'ordre des articles de chaque section au cran 2, tous les autres articles au cran 3.", + "en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as nobody has voted on the article; after that, it no longer changes. In Monnaie libre, never less than 2: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, a quorum of 50 in Monnaie libre, and the group asks for 75% of votes in favour when it votes as usual. Every new article starts at this level.\n- At the start, no article is in force. Purpose, Uniqueness of the account and Reciprocity are seed proposals, put forward to start the vote, at level 2 (low). The title of the document is at level 4 (high), the order of the articles of each section at level 2, all other articles at level 3.", + "es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras nadie haya votado sobre el artículo; después, ya no cambia. En Monnaie libre, nunca menos de 2: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, un quórum de 50 en Monnaie libre, y el grupo pide un 75 % de votos a favor cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, ningún artículo está en vigor. Razón de ser, Unicidad de la cuenta y Reciprocidad son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel 2 (baja). El título del documento está en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2, todos los demás artículos en el nivel 3." } } ] @@ -760,9 +760,9 @@ "es": "Niveles de inercia" }, "body": { - "fr": "À chaque cran, le groupe demande d'abord C voix pour, puis une part de « pour » parmi les votes au-delà de C. Cette part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.\n\n`Part exigée = M + (1 − M) × (1 − (T/W)^G)`\n\n**Les cinq crans** : la part exigée quand le groupe vote comme d'habitude, puis quand tout le monde vote, et ce qui est à ce cran au départ.\n\n- **Cran 1 · très basse** : 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : 67 % comme d'habitude, 50 % quand tout le monde vote. L'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : 83 % comme d'habitude, 60 % quand tout le monde vote. Unicité du compte, Réciprocité, le titre du document.\n- **Cran 5 · très haute** : 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois C : 100 votants dans Monnaie libre. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**G** se calcule pour que la part exigée, à la participation habituelle p̃, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude.\n\nExemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 92, 97, 103, 109 ou 113 voix pour, du cran 1 au cran 5. Au cran 3 : 50 voix pour, plus 75 % des 70 autres votes, soit 102,5, arrondi à 103.", - "en": "At each level, the group first asks for C votes in favour, then for a share of votes in favour among the votes beyond C. That share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T/W)^G)`\n\n**The five levels**: the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: 67% as usual, 50% when everyone votes. The order of the articles of each section.\n- **Level 3 · medium**: 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: 83% as usual, 60% when everyone votes. Uniqueness of the account, Reciprocity, the title of the document.\n- **Level 5 · very high**: 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice C: 100 voters in Monnaie libre. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout p̃, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 92, 97, 103, 109 or 113 votes in favour, from level 1 to level 5. At level 3: 50 votes in favour, plus 75% of the 70 other votes, i.e. 102.5, rounded up to 103.", - "es": "En cada nivel, el grupo pide primero C votos a favor, y luego una proporción de votos a favor entre los votos más allá de C. Esa proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T/W)^G)`\n\n**Los cinco niveles**: la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: 67 % como de costumbre, 50 % cuando vota todo el mundo. El orden de los artículos de cada sección.\n- **Nivel 3 · media**: 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: 83 % como de costumbre, 60 % cuando vota todo el mundo. Unicidad de la cuenta, Reciprocidad, el título del documento.\n- **Nivel 5 · muy alta**: 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces C: 100 votantes en Monnaie libre. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual p̃, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 92, 97, 103, 109 o 113 votos a favor, del nivel 1 al nivel 5. En el nivel 3: 50 votos a favor, más el 75 % de los otros 70 votos, es decir 102,5, redondeado a 103." + "fr": "À chaque cran, le groupe demande un quorum de votants, puis une part de « pour » parmi eux (voir « Formule du seuil WoT »). Sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.\n\n`Part exigée = M + (1 − M) × (1 − (T'/W)^G)`, où T' est le nombre de votants, ou le quorum s'il en manque.\n\n**Les cinq crans** : le quorum dans Monnaie libre, la part exigée quand le groupe vote comme d'habitude, puis quand tout le monde vote, et ce qui est à ce cran au départ.\n\n- **Cran 1 · très basse** : quorum 12 ; 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : quorum 12 ; 67 % comme d'habitude, 50 % quand tout le monde vote. Raison d'être, Unicité du compte, Réciprocité, et l'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : quorum 50 ; 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : quorum 50 ; 83 % comme d'habitude, 60 % quand tout le monde vote. Le titre du document.\n- **Cran 5 · très haute** : quorum 50 ; 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois le quorum de référence du document : 100 votants dans Monnaie libre. Elle ne dépend pas du quorum d'un article. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**G** se calcule pour que la part exigée, à la participation habituelle, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude et p̃ la participation habituelle divisée par W.\n\nExemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 72, 81, 90, 100 ou 108 voix pour, du cran 1 au cran 5 ; au cran 3, 75 % des 120 votants, soit 90. Sous le quorum, les absents comptent comme « non » : au cran 2 (quorum 12), 9 voix pour passent avec 3 « contre » au plus ; au cran 3 (quorum 50), il faut 39 voix pour, avec 11 « contre » au plus.", + "en": "At each level, the group asks for a quorum of voters, then for a share of votes in favour among them (see “WoT threshold formula”). Below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T'/W)^G)`, where T' is the number of voters, or the quorum if some are missing.\n\n**The five levels**: the quorum in Monnaie libre, the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: quorum 12; 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: quorum 12; 67% as usual, 50% when everyone votes. Purpose, Uniqueness of the account, Reciprocity, and the order of the articles of each section.\n- **Level 3 · medium**: quorum 50; 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: quorum 50; 83% as usual, 60% when everyone votes. The title of the document.\n- **Level 5 · very high**: quorum 50; 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice the document's reference quorum: 100 voters in Monnaie libre. It does not depend on the quorum of an article. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual and p̃ the usual turnout divided by W.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 72, 81, 90, 100 or 108 votes in favour, from level 1 to level 5; at level 3, 75% of the 120 voters, i.e. 90. Below the quorum, those missing count as votes against: at level 2 (quorum 12), 9 votes in favour pass with 3 votes against at most; at level 3 (quorum 50), it takes 39 votes in favour, with 11 votes against at most.", + "es": "En cada nivel, el grupo pide un quórum de votantes, y luego una proporción de votos a favor entre ellos (ver «Fórmula del umbral WoT»). Por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T'/W)^G)`, donde T' es el número de votantes, o el quórum si faltan.\n\n**Los cinco niveles**: el quórum en Monnaie libre, la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: quórum 12; 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: quórum 12; 67 % como de costumbre, 50 % cuando vota todo el mundo. Razón de ser, Unicidad de la cuenta, Reciprocidad, y el orden de los artículos de cada sección.\n- **Nivel 3 · media**: quórum 50; 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: quórum 50; 83 % como de costumbre, 60 % cuando vota todo el mundo. El título del documento.\n- **Nivel 5 · muy alta**: quórum 50; 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces el quórum de referencia del documento: 100 votantes en Monnaie libre. No depende del quórum de un artículo. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre y p̃ la participación habitual dividida por W.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 72, 81, 90, 100 o 108 votos a favor, del nivel 1 al nivel 5; en el nivel 3, el 75 % de los 120 votantes, es decir 90. Por debajo del quórum, los que faltan cuentan como «no»: en el nivel 2 (quórum 12), 9 votos a favor pasan con 3 en contra como mucho; en el nivel 3 (quórum 50), hacen falta 39 votos a favor, con 11 en contra como mucho." } } ]