From 1fe31f567b93027a70695bd11d3744f4638dc246 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 18:28:43 +0100 Subject: [PATCH] =?UTF-8?q?Lot=20T3b=20:=20les=20tests=20d'abord,=20rouges?= =?UTF-8?q?=20sur=20le=20texte=20de=20T3=20(2ce2028)=20=E2=80=94=20la=20ph?= =?UTF-8?q?rase=20de=20G=20de=20N1.1=20vraie=20pour=20tout=20W=20et=20tout?= =?UTF-8?q?=20quorum,=20sur=20la=20grille=20de=20=C2=AB=20comme=20d'habitu?= =?UTF-8?q?de=20=C2=BB=20(as=5Fusual=5Fgrid,=20partag=C3=A9e=20avec=20the?= =?UTF-8?q?=5Fshare=5Fof=5Fa=5Flevel=5Fas=5Fusual=5Fis=5Fasked=5Fbeyond=5F?= =?UTF-8?q?the=5Fquorum)=20;=20=C2=AB=20autant=20de=20=C2=AB=20pour=20?= =?UTF-8?q?=C2=BB=20=C2=BB=20en=20fran=C3=A7ais=20;=20le=20test=20des=20no?= =?UTF-8?q?mbres=20honn=C3=AAte=20sur=20=C2=AB=20100=20votants=20=C2=BB=20?= =?UTF-8?q?(un=20param=C3=A8tre,=202=20=C3=97=20voteFloor=20;=20la=20parti?= =?UTF-8?q?cipation=20de=20r=C3=A9f=C3=A9rence=20ne=20vaut=20100=20votants?= =?UTF-8?q?=20qu'entre=20200=20et=2010=20000=20membres,=20un=20test=20?= =?UTF-8?q?=C3=A0=20part)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vus rouges sur le texte de T3 : every_number_of_the_quorum_texts_is_the_engine_s (les cinq parts de la phrase de G manquent), g_gives_the_share_of_the_level_beyond_the_quorum_when_the_group_votes_as_usual, the_texts_of_lot_t3_change_these_phrases_only, under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted. the_reference_turnout_is_a_parameter_the_bounds_bring_back décrit le moteur : vert d'emblée, et vu tomber quand on lui fait dire « de 100 à 10 000 membres » (W 100). Co-Authored-By: Claude Opus 5.5 --- crates/ld-server/tests/seed_quorum_texts.rs | 244 +++++++++++++++++--- 1 file changed, 207 insertions(+), 37 deletions(-) diff --git a/crates/ld-server/tests/seed_quorum_texts.rs b/crates/ld-server/tests/seed_quorum_texts.rs index 89800cd..2077621 100644 --- a/crates/ld-server/tests/seed_quorum_texts.rs +++ b/crates/ld-server/tests/seed_quorum_texts.rs @@ -21,6 +21,12 @@ //! only (N1.1, F1.2). A third guarded amendment, which follows lot T2's, writes them on a base //! made as production is after M6c; the texts of T2 are kept in //! `tests/fixtures/seed-quorum-t2.json`. F1.1 needs none: it is not touched. +//! +//! After the review of lot T3 (2ce2028, lot T3b, before it went online: the same amendment, its +//! fingerprint `to` changed): N1.1 says G gives the share of the level beyond the quorum, when the +//! group votes as usual — « à la participation habituelle » was false under the quorum; « autant de +//! « pour » » in French; and « 100 votants » is checked as the parameter it is, not as a turnout +//! that would hold on every W. use ld_engine::inertia::{InertiaLevel, Participation, Scale, gradient, quorum_bounds}; use ld_engine::threshold::{FormulaParams, Tally, judged_voters, passes, required_ratio, wot_threshold, yes_needed}; @@ -122,14 +128,18 @@ fn seed_dir(tag: &str, seed: &Value) -> PathBuf { /// Every figure the texts may hold: parameters of the seed and constants of the engine and /// the server. Since lot T3 (Yvv, 03/11: « pas de chiffre datés dans un texte voté »), none -/// depends on the web of trust W or on a turnout: no participation goes in, and the same -/// figures come out on every W of Monnaie libre (`figures`). +/// is computed from the web of trust W or from a turnout: no participation goes in. The one +/// the engine computes from W, the lowest quorum an author may set, comes out the same on +/// every W of Monnaie libre (`figures`); the others no W enters. #[derive(Debug, PartialEq)] 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, before - /// the bounds). + /// « deux fois le quorum de référence du document : 100 votants » — a parameter, 2 × + /// voteFloor, written before the bounds; not a turnout that holds on every W. The engine's + /// p0 = 2 × voteFloor / W is that many voters only on a web of 200 to 10 000 members; + /// beyond, the bounds N1.1 says after it, in the same paragraph, bring it back + /// (`the_reference_turnout_is_a_parameter_the_bounds_bring_back`). reference: u32, /// The lowest quorum an author may set in Monnaie libre. lowest_quorum: u32, @@ -155,8 +165,8 @@ fn figures_at(seed: &Value, w: u32) -> 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 reference = Participation::reference_ratio(w, vote_floor) * f64::from(w); + // Twice the reference quorum: a parameter, the same whatever W (no W enters it). + let reference = 2.0 * ml["voteFloor"].as_f64().expect("voteFloor"); assert!((reference - reference.round()).abs() < 1e-6, "a whole number of voters: {reference}"); let levels: Vec = seed["units"] .as_array() @@ -185,7 +195,9 @@ fn figures_at(seed: &Value, w: u32) -> Figures { } } -/// The figures of the texts: the same on every W Monnaie libre may have — none is dated. +/// The figures of the texts, on every W Monnaie libre may have: none is dated. Only the lowest +/// quorum is computed from W here; the reference turnout is a parameter, and where it is a +/// turnout is `the_reference_turnout_is_a_parameter_the_bounds_bring_back`. fn figures(seed: &Value) -> Figures { let mut all = ELECTORATES.iter().map(|w| (*w, figures_at(seed, *w))); let (_, first) = all.next().expect("an electorate"); @@ -397,21 +409,28 @@ fn claims(code: &str, lang: &str, f: &Figures, seed: &Value) -> Vec { .collect(); let (b0, b1) = (pct(lang, f.bounds.0), pct(lang, f.bounds.1)); let (ms, r) = (f.min_sample, f.reference); + // s of each level, as the sentence of G names them: the shares of the list, level 1 to 5. + let (top, lower) = InertiaLevel::ALL.split_last().expect("five levels"); + let head = lower.iter().map(|k| dec(lang, k.share_pct())).collect::>().join(", "); + let last = pct(lang, top.share_pct()); 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!("où s est cette part du cran ({head} ou {last})"), ], "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!("where s is that share of the level ({head} or {last})"), ], _ => [ "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!("donde s es esa proporción del nivel ({head} o {last})"), ], }); out @@ -600,9 +619,10 @@ fn the_three_phrases_of_the_review_of_m6b_are_written_true() { // Lot T3: no dated figure, every sentence true for every W and every quorum // --------------------------------------------------------------------------- -/// (article, language, the words of T2, the words of today): the only change of lot T3. F1.1 -/// has none: it holds no dated figure and every sentence of it is true (`b_to_the_power_w_…`). -const PHRASES_T3: [(&str, &str, &str, &str); 12] = [ +/// (article, language, the words of T2, the words of today): the only change of lot T3, with its +/// review (2ce2028: the sentence of G; « autant de « pour » » in French). F1.1 has none: it holds +/// no dated figure and every sentence of it is true (`b_to_the_power_w_…`). +const PHRASES_T3: [(&str, &str, &str, &str); 15] = [ ( "F1.2", "fr", @@ -625,7 +645,7 @@ const PHRASES_T3: [(&str, &str, &str, &str); 12] = [ "N1.1", "fr", "Chaque cran a aussi son quorum, qui n'est pas un nombre minimal de votants : sous le quorum, les votants qui manquent comptent comme « non ».", - "Chaque cran a aussi son quorum : sous le quorum, les votants qui manquent comptent comme « non », et il faut autant de voix pour que si le quorum avait voté.", + "Chaque cran a aussi son quorum : sous le quorum, les votants qui manquent comptent comme « non », et il faut autant de « pour » que si le quorum avait voté.", ), ( "N1.1", @@ -675,6 +695,24 @@ const PHRASES_T3: [(&str, &str, &str, &str); 12] = [ "\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.", "", ), + ( + "N1.1", + "fr", + "**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.", + "**G** se calcule pour que la part exigée, au-delà du quorum, soit exactement celle du cran quand le groupe vote comme d'habitude : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est cette part du cran (60, 67, 75, 83 ou 90 %) et p̃ la participation habituelle divisée par W.", + ), + ( + "N1.1", + "en", + "**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.", + "**G** is computed so that the share asked, beyond the quorum, is exactly that of the level when the group votes as usual: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is that share of the level (60, 67, 75, 83 or 90%) and p̃ the usual turnout divided by W.", + ), + ( + "N1.1", + "es", + "**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.", + "**G** se calcula para que la proporción exigida, más allá del quórum, sea exactamente la del nivel cuando el grupo vota como de costumbre: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es esa proporción del nivel (60, 67, 75, 83 o 90 %) y p̃ la participación habitual dividida por W.", + ), ]; /// The texts of T2 with the phrases of lot T3 written in: what the seed gives today. @@ -717,7 +755,8 @@ fn participations(w: u32, vote_floor: f64) -> [f64; 4] { } /// N1.1, the phrase of lot T3: « sous le quorum, les votants qui manquent comptent comme « non », -/// et il faut autant de voix pour que si le quorum avait voté ». True for every quorum an author +/// et il faut autant de « pour » que si le quorum avait voté » (after the review of 2ce2028: « de +/// voix pour que » read first as « pour que », a conjunction). True for every quorum an author /// may set (2 to W in Monnaie libre, 1 to W in the test group), every level, every W, every /// participation within the bounds: the « pour » that pass with nobody against, fewer voters /// than the quorum, are the threshold of the quorum; and under it, a tally passes exactly when @@ -727,7 +766,7 @@ fn participations(w: u32, vote_floor: f64) -> [f64; 4] { fn under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted() { let today = today_seed(); let said = [ - ("fr", "et il faut autant de voix pour que si le quorum avait voté."), + ("fr", "et il faut autant de « pour » que si le quorum avait voté."), ("en", "and it takes as many votes in favour as if the quorum had voted."), ("es", "y hacen falta tantos votos a favor como si hubiera votado el quórum."), ]; @@ -791,6 +830,49 @@ fn under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted() { assert!(minimum.iter().any(|m| m.0 && m.3 == 1)); } +/// One case of the grid of « comme d'habitude »: a web, the usual turnout of its group (in voters, +/// and as p̃), a level and a quorum. +struct AsUsual { + w: u32, + usual: u32, + p: f64, + k: InertiaLevel, + q: u32, +} + +impl AsUsual { + fn at(&self) -> String { + format!("W {} usual {} k {} Q {}", self.w, self.usual, self.k.get(), self.q) + } +} + +/// The grid of « comme d'habitude » (lot T3 and its review): every W of the grid, usual turnouts +/// within the bounds (a median out of them is brought back, and is then not the group's usual +/// turnout), every level, quorums from 2 to W, among them the usual turnout and one more. +fn as_usual_grid() -> Vec { + let mut grid = Vec::new(); + for w in ELECTORATES { + for usual in [w / 100 + 1, 12, 40, 50, 100, 120, 1000, w / 2] { + let part = Participation::of(w, 50.0, Some(f64::from(usual)), 9); + if part.bounded { + continue; + } + for k in InertiaLevel::ALL { + for q in [2, 12, 50, 100, 1000, usual, usual + 1, w].into_iter().filter(|q| (2..=w).contains(q)) { + grid.push(AsUsual { w, usual, p: part.ratio, k, q }); + } + } + } + } + grid +} + +/// The letters of the formula for a case of the grid (the quorums of the scale do not enter: the +/// quorum is the case's). +fn as_usual_params(c: &AsUsual) -> FormulaParams { + Scale { electorate: c.w, participation: c.p, quorums: [12, 12, 50, 50, 50] }.params(c.k, c.q) +} + /// N1.1 and F1.2, lot T3: the share of a level « comme d'habitude » is asked beyond the quorum /// only. When the usual turnout reaches the quorum, the group voting as usual is asked s_k; when /// it does not (a web of fewer than 5 000 members and a median under 50 at a quorum of 50, an @@ -799,31 +881,19 @@ fn under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted() { #[test] fn the_share_of_a_level_as_usual_is_asked_beyond_the_quorum() { let mut under = 0; - for w in ELECTORATES { - let medians = [w / 100 + 1, 12, 40, 50, 100, 120, 1000, w / 2]; - for usual in medians { - let part = Participation::of(w, 50.0, Some(f64::from(usual)), 9); - if part.bounded { - continue; - } - let scale = Scale { electorate: w, participation: part.ratio, quorums: [12, 12, 50, 50, 50] }; - for k in InertiaLevel::ALL { - let (s, m) = (k.share_pct() / 100.0, k.majority_pct() / 100.0); - for q in [2, 12, 50, 100, 1000, usual, usual + 1, w].into_iter().filter(|q| (2..=w).contains(q)) { - let params = scale.params(k, q); - let at = format!("W {w} usual {usual} k {} Q {q}", k.get()); - let asked = required_ratio(w, judged_voters(usual, q), ¶ms); - if usual >= q { - assert!((asked - s).abs() < 1e-9, "{at}: {asked}, not s"); - } else { - assert!(asked < s - 1e-9, "{at}: {asked}"); - under += 1; - } - // Everyone votes: M, at every quorum. - assert!((required_ratio(w, judged_voters(w, q), ¶ms) - m).abs() < 1e-12, "{at}: M"); - } - } + for c in as_usual_grid() { + let (s, m) = (c.k.share_pct() / 100.0, c.k.majority_pct() / 100.0); + let params = as_usual_params(&c); + let at = c.at(); + let asked = required_ratio(c.w, judged_voters(c.usual, c.q), ¶ms); + if c.usual >= c.q { + assert!((asked - s).abs() < 1e-9, "{at}: {asked}, not s"); + } else { + assert!(asked < s - 1e-9, "{at}: {asked}"); + under += 1; } + // Everyone votes: M, at every quorum. + assert!((required_ratio(c.w, judged_voters(c.w, c.q), ¶ms) - m).abs() < 1e-12, "{at}: M"); } assert!(under > 0); // The case of F1.2, its default level and quorum: W 3 000, a usual turnout of 40 voters @@ -842,6 +912,106 @@ fn the_share_of_a_level_as_usual_is_asked_beyond_the_quorum() { assert_eq!(wot_threshold(w, usual, &p), Ok(38), "but 40 voters are judged as 50"); } +/// N1.1, the sentence of G, after the review of 2ce2028. Lot T3 left « G se calcule pour que la +/// part exigée, à la participation habituelle, soit exactement celle du cran […], où s est la part +/// exigée comme d'habitude »: false where the usual turnout is under the quorum, the group voting +/// as usual being judged on the quorum (T' = Q), lower on the curve than s, and asked of more +/// voters than came. Written true for every W and every quorum: G is computed so that the share +/// asked, beyond the quorum, is exactly that of the level when the group votes as usual; s is that +/// share of the level, the one of the list (60, 67, 75, 83 or 90 %) — never « la part exigée », +/// which the formula above it names and which moves with T'. On the grid of « comme d'habitude », +/// with G as the text writes it. +#[test] +fn g_gives_the_share_of_the_level_beyond_the_quorum_when_the_group_votes_as_usual() { + let today = today_seed(); + // The sentence of G closes N1.1, in each language; nothing of the sentence of T3 is left. + for lang in LANGS { + let (_, body) = text(&today, "N1.1", lang); + let (_, _, before, now) = PHRASES_T3 + .iter() + .find(|(c, l, _, new)| (*c, *l) == ("N1.1", lang) && new.starts_with("**G**")) + .expect("the sentence of G"); + assert!(body.ends_with(now), "N1.1 {lang}: the sentence of G\n{body}"); + let (said_before, _) = before.split_once(" = ").expect("the formula"); + assert!(!body.contains(said_before), "N1.1 {lang}: the sentence of T3 is left\n{body}"); + let beyond = named(lang, "au-delà du quorum", "beyond the quorum", "más allá del quórum"); + let (said_now, _) = now.split_once(" = ").expect("the formula"); + assert!(said_now.contains(beyond), "N1.1 {lang}: « {beyond} »"); + } + // By the engine, with G as the text writes it (s, the share of the level; M; p̃). On the curve, + // the share at the usual turnout is s, whatever the quorum: that is what G is computed for. + // The share asked of the group voting as usual is s beyond the quorum; under it, the share of + // the quorum, lower — the sentence of T3 said s there. + let grad = code_spans(text(&today, "N1.1", "fr").1)[1]; + let (mut beyond, mut under) = (0, 0); + for c in as_usual_grid() { + let (s, m) = (c.k.share_pct() / 100.0, c.k.majority_pct() / 100.0); + let env: HashMap = HashMap::from([("s".into(), s), ("M".into(), m), ("p̃".into(), c.p)]); + let engine = as_usual_params(&c); + let params = FormulaParams { gradient: eval(grad, "G", &env), ..engine }; + let at = c.at(); + assert!(close(params.gradient, engine.gradient), "{at}: G"); + assert!((required_ratio(c.w, c.usual, ¶ms) - s).abs() < 1e-9, "{at}: s on the curve"); + let judged = judged_voters(c.usual, c.q); + let asked = required_ratio(c.w, judged, ¶ms); + if c.usual >= c.q { + assert_eq!(judged, c.usual, "{at}"); + assert!((asked - s).abs() < 1e-9, "{at}: {asked}, not s"); + beyond += 1; + } else { + assert_eq!(judged, c.q, "{at}"); + assert!(asked < s - 1e-9, "{at}: {asked}"); + under += 1; + } + } + assert!(beyond > 0 && under > 0, "beyond {beyond}, under {under}"); + // The case of the review: W 3 000, a usual turnout of 40 voters, level 3, quorum 50. On the + // curve, 75 % at 40 voters; the share asked is that of 50 voters, 74,23 %; 38 « pour » of the + // 40 who came, 95 %. + let (w, usual) = (3000, 40); + let c = AsUsual { w, usual, p: Participation::of(w, 50.0, Some(f64::from(usual)), 9).ratio, k: level(3), q: 50 }; + let p = as_usual_params(&c); + assert!((required_ratio(w, usual, &p) - 0.75).abs() < 1e-12); + let asked = required_ratio(w, judged_voters(usual, c.q), &p); + assert_eq!((asked * 10_000.0).round() / 100.0, 74.23); + let needed = wot_threshold(w, usual, &p).expect("valid"); + assert_eq!((needed, f64::from(needed) / f64::from(usual)), (38, 0.95)); +} + +/// N1.1, « 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 reste entre 1 % et 50 % de +/// l'électorat » (review of 2ce2028). 100 is a parameter, 2 × voteFloor, which the number test +/// checks as such (`figures`); it is not a turnout that holds on every W: the engine's p0 is 100 +/// voters on a web of 200 to 10 000 members only (1 % and 50 % of W); beyond, the bounds the +/// paragraph says after it bring it back. The voted text is not changed for this. +#[test] +fn the_reference_turnout_is_a_parameter_the_bounds_bring_back() { + let seed = today_seed(); + let f = figures(&seed); + let vote_floor = f64::from(f.reference) / 2.0; + for w in ELECTORATES.into_iter().chain([199, 200, 10_000, 10_001]) { + let p0 = Participation::of(w, vote_floor, None, 0); + let within = (200..=10_000).contains(&w); + assert_eq!(p0.bounded, !within, "W {w}"); + if within { + assert!((p0.voters(w) - f64::from(f.reference)).abs() < 1e-9, "W {w}: {} voters", p0.voters(w)); + } else { + let bound = if w < 200 { Participation::HIGHEST } else { Participation::LOWEST }; + assert_eq!(p0.ratio, bound, "W {w}"); + } + } + // The text says the parameter first, then the bounds, in each language. + for (lang, reference, bounds) in [ + ("fr", format!("{} votants dans Monnaie libre.", f.reference), "Elle reste entre"), + ("en", format!("{} voters in Monnaie libre.", f.reference), "It stays between"), + ("es", format!("{} votantes en Monnaie libre.", f.reference), "Se mantiene entre"), + ] { + let (_, body) = text(&seed, "N1.1", lang); + let at = |s: &str| body.find(s).unwrap_or_else(|| panic!("N1.1 {lang}: « {s} »")); + assert!(at(&reference) < at(bounds), "N1.1 {lang}: the bounds after the reference"); + } +} + /// F1.1, « B : la base ; à la puissance W, elle est si petite qu'elle ne compte pas », and F1.2, /// « dans la toile de confiance, `B^W` est si petit qu'il ne compte pas »: true on every W of the /// web of trust (`wot::PLAUSIBLE`, 100 to 10 000 000) — the engine's threshold is the one