From 3383f28739a3a5858e3eb314ee620446c6683b56 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 19:48:08 +0100 Subject: [PATCH] =?UTF-8?q?Lot=20T3c=20:=20les=20tests=20d'abord,=20rouges?= =?UTF-8?q?=20sur=20le=20texte=20de=20e7ab82f=20(8=20tests)=20=E2=80=94=20?= =?UTF-8?q?=C2=AB=20comme=20d'habitude=20=C2=BB=20contre=20le=20terme=20d?= =?UTF-8?q?=C3=A9fini=20=C2=AB=20participation=20habituelle=20=C2=BB=20;?= =?UTF-8?q?=20une=20grille=20commune=20(grid,=2085=20100=20cas=20:=20W=20d?= =?UTF-8?q?e=20100=20=C3=A0=2010=20000=20000,=20m=C3=A9dianes=20sous=201?= =?UTF-8?q?=20%=20et=20au-dessus=20de=2050=20%=20de=20W=20ramen=C3=A9es=20?= =?UTF-8?q?par=20Participation::of,=20chaque=20quorum=20d'auteur=20de=202?= =?UTF-8?q?=20=C3=A0=20W=20jusqu'=C3=A0=201=20000,=20les=20cinq=20crans)?= =?UTF-8?q?=20=C3=A0=20la=20place=20d'as=5Fusual=5Fgrid,=20qui=20sautait?= =?UTF-8?q?=20les=20m=C3=A9dianes=20hors=20des=20bornes=20;=20l'=C3=A9cart?= =?UTF-8?q?=20entre=20la=20valeur=20born=C3=A9e=20et=20la=20m=C3=A9diane?= =?UTF-8?q?=20(W=207=20224,=20m=C3=A9diane=2040,=20cran=202=20:=2028=20?= =?UTF-8?q?=C2=AB=20pour=20=C2=BB=20sur=2040,=20pas=2027)=20;=20la=20part?= =?UTF-8?q?=20exig=C3=A9e=20qui=20baisse=20au-del=C3=A0=20du=20quorum=20de?= =?UTF-8?q?=20l'article=20;=20=C2=AB=20une=20seule=20voix=20=C2=BB=20sur?= =?UTF-8?q?=20la=20grille=20;=20quorum.rs=20:=20la=20participation=20et=20?= =?UTF-8?q?G=20inchang=C3=A9s=20par=20le=20quorum=20d'un=20article,=20le?= =?UTF-8?q?=20cran=203=20d'un=20nouvel=20article?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ld-server/tests/quorum.rs | 12 + crates/ld-server/tests/seed_quorum_texts.rs | 731 ++++++++++++++++---- 2 files changed, 600 insertions(+), 143 deletions(-) diff --git a/crates/ld-server/tests/quorum.rs b/crates/ld-server/tests/quorum.rs index 33638ef..448a78f 100644 --- a/crates/ld-server/tests/quorum.rs +++ b/crates/ld-server/tests/quorum.rs @@ -94,6 +94,11 @@ fn numbers(levels: &Value, key: &str) -> Vec { levels.as_array().expect("levels").iter().map(|l| l[key].as_u64().expect("number")).collect() } +/// G of each level, as served. +fn gradients(doc: &Value) -> Vec { + doc["levels"].as_array().expect("levels").iter().map(|l| l["gradient"].clone()).collect() +} + /// (quorum, quorumDefault, canSetQuorum) of a unit. fn quorum_of(u: &Value) -> (Option, Option, Option) { (u["quorum"].as_u64(), u["quorumDefault"].as_u64(), u["canSetQuorum"].as_bool()) @@ -174,6 +179,8 @@ async fn the_author_of_an_article_sets_its_quorum_before_the_first_vote() { .await; let (u, text) = (created["unitId"].as_str().expect("unit").to_owned(), created["id"].as_str().expect("id").to_owned()); + // « Tout nouvel article commence à ce cran » (F1.2, « Par défaut »): the default level, 3. + assert_eq!(unit_by_id(&chiara.doc("essai").await, &u)["inertia"], 3); // Its author may, another member may not; the test group: its level's quorum, 1. assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (None, Some(1), Some(true))); assert_eq!(quorum_of(unit_by_id(&luna.doc("essai").await, &u)), (None, Some(1), Some(false))); @@ -270,6 +277,7 @@ async fn in_monnaie_libre_a_quorum_holds_two_voices_and_a_ballot_counts_even_pen chiara.login("Chiara", false).await; let doc = admin.doc("monnaie-libre").await; let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let usual = (doc["doc"]["participation"].clone(), gradients(&doc)); let i1 = unit(&doc, "I1"); let (u, p) = (i1["id"].as_str().expect("u").to_owned(), i1["proposals"][0]["id"].as_str().expect("p").to_owned()); // A text of the seed has no author: an administrator answers for it. @@ -287,6 +295,10 @@ async fn in_monnaie_libre_a_quorum_holds_two_voices_and_a_ballot_counts_even_pen } let doc = admin.doc("monnaie-libre").await; assert_eq!(unit(&doc, "I1")["proposals"][0]["evaluation"]["quorum"], 20); + // « Elle ne dépend pas du quorum d'un article » (N1.1, lot T3c): the participation habituelle, + // and G of each level, stay as they were (the reference rests on the document's, 2 × 50 / W). + assert_eq!((doc["doc"]["participation"].clone(), gradients(&doc)), usual); + assert!((doc["doc"]["participation"]["ratio"].as_f64().expect("p̃") - 100.0 / W as f64).abs() < 1e-12); // A refused ballot, an expired one, are no vote. ballot(&state, &doc_id, &p, "refused", 1).await; ballot(&state, &doc_id, &p, "expired", 2).await; diff --git a/crates/ld-server/tests/seed_quorum_texts.rs b/crates/ld-server/tests/seed_quorum_texts.rs index 2077621..d5b848b 100644 --- a/crates/ld-server/tests/seed_quorum_texts.rs +++ b/crates/ld-server/tests/seed_quorum_texts.rs @@ -27,6 +27,18 @@ //! 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. +//! +//! After the review of e7ab82f (lot T3c, before it went online: the same amendments, their +//! fingerprints `to` changed again): « comme d'habitude » reads first as the real median, and is +//! false where the median leaves its bounds (W 7 224, a median of 40, level 2, quorum 12: 28 « pour » +//! of 40, not 27). F1.2 and N1.1 write the defined term, « à la participation habituelle »: the +//! median brought back within 1 % and 50 % of W, the one G is computed on. « Sous le quorum de +//! l'article » where a bare « quorum » could be the level's; « la part exigée » where « la part » +//! could be the rounded share; « Tant qu'elle repose sur moins de 5 propositions » (the year counts). +//! Every sentence of F1.2 and N1.1 that says a number, a share or a direction is checked against the +//! engine on one grid (`grid`): W from 100, medians under 1 % and above 50 % of W, every quorum an +//! author may set, the five levels — the closed list given to the reviewer is +//! `docs/chantiers/acte-certification/t3c-liste-fermee.md`. 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}; @@ -302,7 +314,7 @@ fn claims(code: &str, lang: &str, f: &Figures, seed: &Value) -> Vec { ), format!("**B = {}**", dec(lang, f.base)), format!( - "Le cran par défaut est le {} ({}) : M = {}, un quorum de {qd} dans Monnaie libre, et, au-delà du quorum, le groupe demande {} de « pour » quand il vote comme d'habitude", + "Le cran par défaut est le {} ({}) : M = {}, un quorum de {qd} dans Monnaie libre, et, au-delà du quorum, le groupe demande {} de « pour » à sa participation habituelle", f.default_level, name(f.default_level), pct(lang, d.majority_pct()), @@ -330,7 +342,7 @@ fn claims(code: &str, lang: &str, f: &Figures, seed: &Value) -> Vec { 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, beyond the quorum, the group asks for {} of votes in favour when it votes as usual", + "The default level is {} ({}): M = {}, a quorum of {qd} in Monnaie libre, and, beyond the quorum, the group asks for {} of votes in favour at its usual turnout", f.default_level, name(f.default_level), pct(lang, d.majority_pct()), @@ -358,7 +370,7 @@ fn claims(code: &str, lang: &str, f: &Figures, seed: &Value) -> Vec { 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, más allá del quórum, el grupo pide un {} de votos a favor cuando vota como de costumbre", + "El nivel por defecto es el {} ({}): M = {}, un quórum de {qd} en Monnaie libre, y, más allá del quórum, el grupo pide un {} de votos a favor con su participación habitual", f.default_level, name(f.default_level), pct(lang, d.majority_pct()), @@ -390,17 +402,17 @@ fn claims(code: &str, lang: &str, f: &Figures, seed: &Value) -> Vec { ); match lang { "fr" => format!( - "- **Cran {} · {n}** : quorum {} ; {s} comme d'habitude, {mk} quand tout le monde vote. {what}", + "- **Cran {} · {n}** : quorum {} ; {s} à la participation habituelle, {mk} quand tout le monde vote. {what}", k.get(), q[i] ), "en" => format!( - "- **Level {} · {n}**: quorum {}; {s} as usual, {mk} when everyone votes. {what}", + "- **Level {} · {n}**: quorum {}; {s} at the usual turnout, {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}", + "- **Nivel {} · {n}**: quórum {}; {s} con la participación habitual, {mk} cuando vota todo el mundo. {what}", k.get(), q[i] ), @@ -416,20 +428,20 @@ fn claims(code: &str, lang: &str, f: &Figures, seed: &Value) -> Vec { 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!("Tant qu'elle repose sur moins de {ms} propositions, elle vaut deux fois le quorum de référence du document : {r} votants dans Monnaie libre"), + format!("Elle est ramenée 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!("While it rests on fewer than {ms} proposals, it is twice the document's reference quorum: {r} voters in Monnaie libre"), + format!("It is kept 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!("Mientras se base en menos de {ms} propuestas, vale dos veces el quórum de referencia del documento: {r} votantes en Monnaie libre"), + format!("Se acota 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})"), ], }); @@ -619,63 +631,129 @@ 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, 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] = [ +/// (article, language, the words of T2, the words of today): the only change of the amendments of +/// lot T3, with its reviews — 2ce2028 (lot T3b: the sentence of G; « autant de « pour » » in French) +/// and e7ab82f (lot T3c: the defined term « participation habituelle » where T3 wrote « comme +/// d'habitude »; « le quorum de l'article » where a bare « quorum » could be the level's; « la part +/// exigée »; « repose sur moins de 5 propositions »; « ramenée entre 1 % et 50 % »). The five lines +/// of the levels change by `LEVEL_LINES_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); 27] = [ ( "F1.2", "fr", "un quorum de 50 dans Monnaie libre, et le groupe demande 75 % de « pour » quand il vote comme d'habitude.", - "un quorum de 50 dans Monnaie libre, et, au-delà du quorum, le groupe demande 75 % de « pour » quand il vote comme d'habitude.", + "un quorum de 50 dans Monnaie libre, et, au-delà du quorum, le groupe demande 75 % de « pour » à sa participation habituelle.", ), ( "F1.2", "en", "a quorum of 50 in Monnaie libre, and the group asks for 75% of votes in favour when it votes as usual.", - "a quorum of 50 in Monnaie libre, and, beyond the quorum, the group asks for 75% of votes in favour when it votes as usual.", + "a quorum of 50 in Monnaie libre, and, beyond the quorum, the group asks for 75% of votes in favour at its usual turnout.", ), ( "F1.2", "es", "un quórum de 50 en Monnaie libre, y el grupo pide un 75 % de votos a favor cuando vota como de costumbre.", - "un quórum de 50 en Monnaie libre, y, más allá del quórum, el grupo pide un 75 % de votos a favor cuando vota como de costumbre.", + "un quórum de 50 en Monnaie libre, y, más allá del quórum, el grupo pide un 75 % de votos a favor con su participación habitual.", ), ( "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 « pour » que si le quorum avait voté.", + "Chaque cran a aussi son quorum : sous le quorum de l'article, les votants qui manquent comptent comme « non », et il faut autant de « pour » que si ce quorum avait voté.", ), ( "N1.1", "en", "Each level also has its quorum, which is not a minimum number of voters: below the quorum, the voters who are missing count as votes against.", - "Each level also has its quorum: below the quorum, the voters who are missing count as votes against, and it takes as many votes in favour as if the quorum had voted.", + "Each level also has its quorum: below the article's quorum, the voters who are missing count as votes against, and it takes as many votes in favour as if that quorum had voted.", ), ( "N1.1", "es", "Cada nivel tiene además su quórum, que no es un número mínimo de votantes: por debajo del quórum, los votantes que faltan cuentan como «no».", - "Cada nivel tiene además su quórum: por debajo del quórum, los votantes que faltan cuentan como «no», y hacen falta tantos votos a favor como si hubiera votado el quórum.", + "Cada nivel tiene además su quórum: por debajo del quórum del artículo, los votantes que faltan cuentan como «no», y hacen falta tantos votos a favor como si hubiera votado ese quórum.", + ), + ( + "N1.1", + "fr", + "La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : au-delà du quorum, plus il y a de votants,", + "La part exigée dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : au-delà de ce quorum, plus il y a de votants,", + ), + ( + "N1.1", + "en", + "The share depends on the level and on the number of voters, compared with the group's usual turnout: beyond the quorum, the more voters,", + "The share asked depends on the level and on the number of voters, compared with the group's usual turnout: beyond that quorum, the more voters,", + ), + ( + "N1.1", + "es", + "La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá del quórum, cuantos más votantes,", + "La proporción exigida depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá de ese quórum, cuantos más votantes,", + ), + ( + "N1.1", + "fr", + "où T' est le nombre de votants, ou le quorum s'il en manque.", + "où T' est le nombre de votants, ou le quorum de l'article s'il en manque.", + ), + ( + "N1.1", + "en", + "where T' is the number of voters, or the quorum if some are missing.", + "where T' is the number of voters, or the article's quorum if some are missing.", + ), + ( + "N1.1", + "es", + "donde T' es el número de votantes, o el quórum si faltan.", + "donde T' es el número de votantes, o el quórum del artículo si faltan.", ), ( "N1.1", "fr", "**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,", - "**Les cinq crans** : le quorum dans Monnaie libre, la part exigée au-delà du quorum, quand le groupe vote comme d'habitude, puis quand tout le monde vote,", + "**Les cinq crans** : le quorum dans Monnaie libre, la part exigée au-delà du quorum de l'article, à la participation habituelle, puis quand tout le monde vote,", ), ( "N1.1", "en", "**The five levels**: the quorum in Monnaie libre, the share asked when the group votes as usual, then when everyone votes,", - "**The five levels**: the quorum in Monnaie libre, the share asked beyond the quorum, when the group votes as usual, then when everyone votes,", + "**The five levels**: the quorum in Monnaie libre, the share asked beyond the article's quorum, at the usual turnout, then when everyone votes,", ), ( "N1.1", "es", "**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,", - "**Los cinco niveles**: el quórum en Monnaie libre, la proporción exigida más allá del quórum, cuando el grupo vota como de costumbre, luego cuando vota todo el mundo,", + "**Los cinco niveles**: el quórum en Monnaie libre, la proporción exigida más allá del quórum del artículo, con la participación habitual, luego cuando vota todo el mundo,", + ), + ( + "N1.1", + "fr", + "Tant que le groupe n'a pas voté sur 5 propositions, elle vaut", + "Tant qu'elle repose sur moins de 5 propositions, elle vaut", + ), + ( + "N1.1", + "en", + "Until the group has voted on 5 proposals, it is", + "While it rests on fewer than 5 proposals, it is", + ), + ( + "N1.1", + "es", + "Hasta que el grupo haya votado 5 propuestas, vale", + "Mientras se base en menos de 5 propuestas, vale", + ), + ("N1.1", "fr", "Elle reste entre 1 % et 50 % de l'électorat", "Elle est ramenée entre 1 % et 50 % de l'électorat"), + ("N1.1", "en", "It stays between 1% and 50% of the electorate", "It is kept between 1% and 50% of the electorate"), + ( + "N1.1", + "es", + "Se mantiene entre el 1 % y el 50 % del electorado", + "Se acota entre el 1 % y el 50 % del electorado", ), ( "N1.1", @@ -699,22 +777,31 @@ const PHRASES_T3: [(&str, &str, &str, &str); 15] = [ "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.", + "**G** se calcule pour que la part exigée, au-delà du quorum de l'article, soit exactement celle du cran à la participation habituelle : `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.", + "**G** is computed so that the share asked, beyond the article's quorum, is exactly that of the level at the usual turnout: `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.", + "**G** se calcula para que la proporción exigida, más allá del quórum del artículo, sea exactamente la del nivel con la participación habitual: `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 five lines of the levels of N1.1 (lot T3c): « 60 % comme d'habitude » becomes « 60 % à la +/// participation habituelle », and so on, in each line and nothing else (language, words of T2, +/// words of today). +const LEVEL_LINES_T3: [(&str, &str, &str); 3] = [ + ("fr", " comme d'habitude, ", " à la participation habituelle, "), + ("en", " as usual, ", " at the usual turnout, "), + ("es", " como de costumbre, ", " con la participación habitual, "), +]; + /// The texts of T2 with the phrases of lot T3 written in: what the seed gives today. fn t3_from_t2(code: &str, lang: &str, t2: &str) -> String { let mut b = t2.to_owned(); @@ -724,6 +811,11 @@ fn t3_from_t2(code: &str, lang: &str, t2: &str) -> String { b = b.replacen(old, new, 1); } } + if code == "N1.1" { + let (_, old, new) = LEVEL_LINES_T3.iter().find(|(l, ..)| *l == lang).expect("a language"); + assert_eq!(b.matches(old).count(), 5, "N1.1 {lang}: the five lines of the levels"); + b = b.replace(old, new); + } b } @@ -742,33 +834,192 @@ fn the_texts_of_lot_t3_change_these_phrases_only() { assert!(!body.contains(old), "{code} {lang}: still the words of T2\n{body}"); assert!(body.contains(new), "{code} {lang}: missing « {new} »\n{body}"); } + for (lang, old, new) in LEVEL_LINES_T3 { + let (_, body) = text(&today, "N1.1", lang); + assert!(!body.contains(old), "N1.1 {lang}: « {old} » is left\n{body}"); + assert_eq!(body.matches(new).count(), 6, "N1.1 {lang}: the five lines, and the list's introduction\n{body}"); + } // The example of N1.1 is gone whole, and nothing takes its place. for (lang, word) in [("fr", "Exemple"), ("en", "Example"), ("es", "Ejemplo")] { assert!(!text(&today, "N1.1", lang).1.contains(word), "N1.1 {lang}: an example is left"); } } -/// The participations of the grid, as shares of W: the bounds, the reference of the document -/// (`2 × voteFloor / W`, brought back within the bounds) and one between. -fn participations(w: u32, vote_floor: f64) -> [f64; 4] { - [Participation::LOWEST, Participation::of(w, vote_floor, None, 0).ratio, 0.1, Participation::HIGHEST] +// --------------------------------------------------------------------------- +// Lot T3c: one grid for every sentence that says a number, a share or a direction +// --------------------------------------------------------------------------- + +/// The medians of the grid on a web of `w` members (lot T3c), as measured, before the bounds: +/// under 1 % of W, at it, within, at 50 % and above. The participation habituelle is the median +/// brought back within its bounds, so a median out of them is not it. +fn medians(w: u32) -> Vec { + let one = w / 100; + let mut m: Vec = [1, 2, one.saturating_sub(1), one, one + 1, 12, 40, 50, 100, 120, 1000, w / 2, w / 2 + 1, w] + .into_iter() + .filter(|m| (1..=w).contains(m)) + .collect(); + m.sort_unstable(); + m.dedup(); + m } -/// N1.1, the phrase of lot T3: « sous le quorum, les votants qui manquent comptent comme « non », -/// 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 -/// its « pour » reach that threshold. The phrase of T2, « qui n'est pas un nombre minimal de -/// votants », is false: at a quorum of 2, two « pour » are asked, at every level, on every W. +/// A usual turnout of the grid: the median measured (`None`: fewer than 5 proposals, the reference +/// of the document stands in) and the participation habituelle the engine takes from it +/// (`Participation::of`: the median, or the reference, brought back within 1 % and 50 % of W). +#[derive(Clone, Copy, Debug)] +struct Usual { + median: Option, + part: Participation, +} + +impl Usual { + /// The participation habituelle, in voters: p̃ × W — not a whole number when it is a bound + /// (1 % of 7 224 is 72,24 voters). To a millionth of a voter: the division by W and the product + /// by W give 57,999 999 999 999 99 for a median of 58 on 5 707. + fn voters(&self, w: u32) -> f64 { + (self.part.voters(w) * 1e6).round() / 1e6 + } +} + +/// The usual turnouts of the grid on a web of `w`: the reference, then each median of `medians`. +fn usuals(w: u32, vote_floor: f64) -> Vec { + std::iter::once(None) + .chain(medians(w).into_iter().map(Some)) + .map(|median| { + let sample = if median.is_some() { 9 } else { 0 }; + Usual { median, part: Participation::of(w, vote_floor, median.map(f64::from), sample) } + }) + .collect() +} + +/// The participations of the grid, as shares of W, each once: those of `usuals`. Both bounds are +/// among them on every W of Monnaie libre, where a median under 1 % or above 50 % of W is brought +/// back to one. +fn participations(w: u32, vote_floor: f64) -> Vec { + let mut p: Vec = usuals(w, vote_floor).iter().map(|u| u.part.ratio).collect(); + p.sort_by(f64::total_cmp); + p.dedup(); + p +} + +/// The quorums an author may set on a web of `w` (ADR 0013), from the lowest bound to W: every one +/// up to W 1 000; beyond, `quorum_sample`. +fn author_quorums(w: u32, lowest: u32, around: &[f64]) -> Vec { + if w <= 1000 { (lowest..=w).collect() } else { quorum_sample(w, lowest, around) } +} + +/// Quorums from the lowest bound to W, where every one would be too many: the bounds and their +/// neighbours, the quorums of the levels and theirs, 100, 1 000, W/2, and around each number of +/// voters of `around` (the participation habituelle, the median). +fn quorum_sample(w: u32, lowest: u32, around: &[f64]) -> Vec { + let mut q = vec![lowest, 2, 3, 11, 12, 13, 49, 50, 51, 100, 1000, w / 2, w - 1, w]; + for a in around { + let (down, up) = (a.floor() as u32, a.ceil() as u32); + q.extend([down, up, up + 1]); + } + q.retain(|q| (lowest..=w).contains(q)); + q.sort_unstable(); + q.dedup(); + q +} + +/// One case of the grid: a web, a usual turnout of its group, a level, the quorum of the article. +struct Case { + w: u32, + usual: Usual, + k: InertiaLevel, + q: u32, +} + +impl Case { + fn at(&self) -> String { + format!("W {} median {:?} usual {} k {} Q {}", self.w, self.usual.median, self.voters(), self.k.get(), self.q) + } + + /// The participation habituelle, in voters. + fn voters(&self) -> f64 { + self.usual.voters(self.w) + } + + /// The letters of the formula (the quorums of the scale do not enter: the quorum is the case's). + fn params(&self) -> FormulaParams { + let scale = Scale { electorate: self.w, participation: self.usual.part.ratio, quorums: [12, 12, 50, 50, 50] }; + scale.params(self.k, self.q) + } + + /// s_k, the share of the level. + fn s(&self) -> f64 { + self.k.share_pct() / 100.0 + } + + /// M_k. + fn m(&self) -> f64 { + self.k.majority_pct() / 100.0 + } +} + +/// The grid of the closed list (lot T3c, `docs/chantiers/acte-certification/t3c-liste-fermee.md`), +/// on which every sentence of F1.2 and N1.1 that says a number, a share or a direction is checked +/// against the engine: every W of the grid, from 100, the lowest the server takes as true +/// (`wot::PLAUSIBLE`), to 10 000 000; every usual turnout of `usuals` — the reference, medians under +/// 1 % of W, within the bounds, above 50 %, each brought back within its bounds as the engine does; +/// the five levels; the quorums an author may set, from 2 to W (`author_quorums`). +fn grid() -> Vec { + let mut grid = Vec::new(); + for w in ELECTORATES { + let (lowest, _) = quorum_bounds(w, false); + for usual in usuals(w, 50.0) { + let around: Vec = std::iter::once(usual.voters(w)).chain(usual.median.map(f64::from)).collect(); + for k in InertiaLevel::ALL { + for q in author_quorums(w, lowest, &around) { + grid.push(Case { w, usual, k, q }); + } + } + } + } + grid +} + +/// The share the engine asks of `voters` voters on a web of `w` — `voters` the participation +/// habituelle, not a whole number when it is a bound (1 % of 7 224 is 72,24 voters). The engine +/// reads T'/W only (`required_ratio`): on a web a hundred times larger, the same share is asked of a +/// hundred times more voters, a whole number for every usual turnout of the grid. Checked against +/// the engine on whole numbers (`the_share_of_a_level_is_asked_at_the_usual_turnout_…`). +fn share_at(w: u32, voters: f64, params: &FormulaParams) -> f64 { + let scaled = voters * 100.0; + assert!((scaled - scaled.round()).abs() < 1e-6, "{voters} voters on {w}"); + required_ratio(w * 100, scaled.round() as u32, params) +} + +/// N1.1, « Chaque cran a aussi son quorum : sous le quorum de l'article, les votants qui manquent +/// comptent comme « non », et il faut autant de « pour » que si ce quorum avait voté » (lot T3; « de +/// voix pour que » read first as « pour que » until 2ce2028; a bare « sous le quorum », after « Chaque +/// cran a aussi son quorum », read first as the level's until e7ab82f, an article holding the one its +/// author set). F1.2, « Q, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme +/// « non » » and « Dans Monnaie libre, jamais moins de 2 : une seule voix ne fait jamais passer un +/// texte ». 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 of the grid (both bounds, where medians under +/// 1 % and above 50 % of W are brought back): the « pour » that pass with nobody against, fewer +/// voters than the quorum, are the threshold of the quorum; under it, a tally passes exactly when its +/// « pour » reach that threshold; in Monnaie libre, one « pour » never passes, whatever the +/// « contre ». The phrase of T2, « qui n'est pas un nombre minimal de votants », is false: at a +/// quorum of 2, two « pour » are asked, at every level, on every W. #[test] 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 « 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."), + ( + "fr", + "sous le quorum de l'article, les votants qui manquent comptent comme « non », et il faut autant de « pour » que si ce quorum avait voté.", + ), + ( + "en", + "below the article's quorum, the voters who are missing count as votes against, and it takes as many votes in favour as if that quorum had voted.", + ), + ( + "es", + "por debajo del quórum del artículo, los votantes que faltan cuentan como «no», y hacen falta tantos votos a favor como si hubiera votado ese quórum.", + ), ]; for (lang, phrase) in said { assert!(text(&today, "N1.1", lang).1.contains(phrase), "N1.1 {lang}: « {phrase} »"); @@ -789,7 +1040,11 @@ fn under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted() { for &w in electorates { let (lowest, highest) = quorum_bounds(w, test_group); assert_eq!(highest, w.max(lowest)); - for p in participations(w, vote_floor) { + let ps = participations(w, vote_floor); + if !test_group { + assert!(ps.contains(&Participation::LOWEST) && ps.contains(&Participation::HIGHEST), "W {w}: {ps:?}"); + } + for p in ps { let scale = Scale { electorate: w, participation: p, quorums: [lowest; 5] }; for k in InertiaLevel::ALL { for q in quorums(w, lowest) { @@ -810,6 +1065,13 @@ fn under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted() { } } } + // F1.2: in Monnaie libre, one « pour » never passes, whatever the « contre ». + if !test_group { + for n in [0, 1, q, w - 1] { + let lone = Tally { for_votes: 1, against: n }; + assert!(!passes(lone, w, ¶ms).expect("valid"), "{at}: 1 pour, {n} contre"); + } + } if at_quorum == q { minimum.push((test_group, w, k.get(), q)); } @@ -830,75 +1092,136 @@ 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) +/// N1.1, « La part exigée dépend du cran et du nombre de votants, comparé à la participation +/// habituelle du groupe : au-delà de ce quorum, plus il y a de votants, plus elle baisse, jusqu'à la +/// majorité M quand tout le monde vote » (lot T3c: « la part exigée », the one the formula below +/// names; « ce quorum », the article's). On the grid: under the article's quorum, the share asked +/// stays that of the quorum; from it, it falls with every voter more, strictly, down to M when +/// everyone votes; and it depends on the participation habituelle (at it, s_k: +/// `the_share_of_a_level_is_asked_at_the_usual_turnout_…`). « La part » alone could be read as the +/// share of « pour » the voters must bring in whole votes: that one rises at times beyond the quorum +/// — 9 « pour » of 12 voters, 75 %, then 10 of 13, 76,9 % (W 5 707, the reference turnout, level 2, +/// quorum 12), while the share asked falls from 73,5 % to 73,2 %. +#[test] +fn the_share_asked_falls_beyond_the_article_s_quorum_down_to_m() { + let today = today_seed(); + for (lang, phrase) in [ + ( + "fr", + "La part exigée dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : au-delà de ce quorum, plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.", + ), + ( + "en", + "The share asked depends on the level and on the number of voters, compared with the group's usual turnout: beyond that quorum, the more voters, the lower it gets, down to the majority M when everyone votes.", + ), + ( + "es", + "La proporción exigida depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá de ese quórum, cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.", + ), + ] { + assert!(text(&today, "N1.1", lang).1.contains(phrase), "N1.1 {lang}: « {phrase} »"); } -} - -/// 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 }); - } + let (mut under, mut beyond) = (0u64, 0u64); + for c in grid() { + let p = c.params(); + let share = |t: u32| required_ratio(c.w, judged_voters(t, c.q), &p); + let at = c.at(); + // Every number of voters on the smallest webs; beyond, around the quorum, the participation + // habituelle and the median, W/2, W. + let mut ts: Vec = if c.w <= 101 { + (0..=c.w).collect() + } else { + let usual = c.voters(); + let mut ts = vec![0, 1, c.q - 1, c.q, c.q + 1, c.q + 2, 2 * c.q, usual.floor() as u32, usual.ceil() as u32]; + ts.extend(c.usual.median); + ts.extend([c.w / 2, c.w - 1, c.w]); + ts + }; + ts.retain(|t| *t <= c.w); + ts.sort_unstable(); + ts.dedup(); + for pair in ts.windows(2) { + let (a, b) = (pair[0], pair[1]); + if a >= c.q { + assert!(share(b) < share(a), "{at}: from {a} to {b} voters, {} then {}", share(a), share(b)); + beyond += 1; + } else if b <= c.q { + assert_eq!(share(a), share(b), "{at}: under the quorum, {a} and {b} voters"); + under += 1; } } + assert!((share(c.w) - c.m()).abs() < 1e-12, "{at}: M when everyone votes"); } - grid + assert!(under > 0 && beyond > 0, "under {under}, beyond {beyond}"); + // The share of « pour » in whole votes is not the share asked: it rises from 12 to 13 voters. + let w = 5707; + let scale = Scale { electorate: w, participation: Participation::of(w, 50.0, None, 0).ratio, quorums: [12; 5] }; + let p = scale.params(level(2), 12); + let (at12, at13) = (wot_threshold(w, 12, &p).expect("valid"), wot_threshold(w, 13, &p).expect("valid")); + assert_eq!((at12, at13), (9, 10)); + assert!(f64::from(at13) / 13.0 > f64::from(at12) / 12.0); + assert!(required_ratio(w, 13, &p) < required_ratio(w, 12, &p)); } -/// 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 -/// author's quorum above the usual turnout), it is asked the threshold of the quorum, which is -/// not s_k of those who came. Every W, every median within the bounds, every level, quorums. +/// N1.1, « Les cinq crans : le quorum dans Monnaie libre, la part exigée au-delà du quorum de +/// l'article, à la participation habituelle, puis quand tout le monde vote » and its five lines +/// (« 60 % à la participation habituelle, 50 % quand tout le monde vote »…); F1.2, « au-delà du +/// quorum, le groupe demande 75 % de « pour » à sa participation habituelle ». Lot T3 wrote « comme +/// d'habitude », which reads first as the real median: false where the median leaves its bounds +/// (`the_participation_habituelle_is_the_median_brought_back_within_its_bounds`); the defined term +/// since the review of e7ab82f. On the grid: when the participation habituelle reaches the article's +/// quorum, the share asked there is s_k exactly; when it does not (a quorum above it: the author's, +/// or the level's on a small web), the voters are judged as the quorum, at a share lower than s_k, +/// asked of more voters than came; when everyone votes, M_k, at every quorum. #[test] -fn the_share_of_a_level_as_usual_is_asked_beyond_the_quorum() { - let mut under = 0; - 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); +fn the_share_of_a_level_is_asked_at_the_usual_turnout_beyond_the_article_s_quorum() { + let today = today_seed(); + for (lang, intro) in [ + ( + "fr", + "la part exigée au-delà du quorum de l'article, à la participation habituelle, puis quand tout le monde vote", + ), + ("en", "the share asked beyond the article's quorum, at the usual turnout, then when everyone votes"), + ( + "es", + "la proporción exigida más allá del quórum del artículo, con la participación habitual, luego cuando vota todo el mundo", + ), + ] { + assert!(text(&today, "N1.1", lang).1.contains(intro), "N1.1 {lang}: « {intro} »"); + } + // The five lines and F1.2 carry their numbers: `every_number_of_the_quorum_texts_is_the_engine_s`. + let (mut beyond, mut under, mut low, mut high) = (0u64, 0u64, 0u64, 0u64); + for c in grid() { + let (s, m, p) = (c.s(), c.m(), c.params()); let at = c.at(); - let asked = required_ratio(c.w, judged_voters(c.usual, c.q), ¶ms); - if c.usual >= c.q { + let usual = c.voters(); + if (usual - usual.round()).abs() < 1e-9 { + let whole = required_ratio(c.w, usual.round() as u32, &p); + assert!(close(share_at(c.w, usual, &p), whole), "{at}: the share on a web a hundred times larger"); + } + let asked = share_at(c.w, usual.max(f64::from(c.q)), &p); + if usual >= f64::from(c.q) { assert!((asked - s).abs() < 1e-9, "{at}: {asked}, not s"); + beyond += 1; } else { + assert_eq!(asked, required_ratio(c.w, c.q, &p), "{at}: judged as the quorum"); assert!(asked < s - 1e-9, "{at}: {asked}"); under += 1; } + if c.usual.part.bounded && usual >= f64::from(c.q) { + if c.usual.part.ratio == Participation::LOWEST { + low += 1; + } else { + high += 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!((required_ratio(c.w, judged_voters(c.w, c.q), &p) - m).abs() < 1e-12, "{at}: M"); } - assert!(under > 0); + assert!(beyond > 0 && under > 0 && low > 0 && high > 0, "beyond {beyond}, under {under}, low {low}, high {high}"); // The case of F1.2, its default level and quorum: W 3 000, a usual turnout of 40 voters - // (within the bounds: 1 % is 30). Voting as usual, the group is asked 38 « pour » of its 40 - // voters: 95 %, not 75 %. + // (within the bounds: 1 % is 30). At its participation habituelle, the group is asked 38 « pour » + // of its 40 voters: 95 %, not 75 % — under the quorum, which the sentence leaves out. let (w, usual) = (3000, 40); let part = Participation::of(w, 50.0, Some(f64::from(usual)), 9); assert!(!part.bounded); @@ -912,19 +1235,20 @@ 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. +/// N1.1, the sentence of G: « G se calcule pour que la part exigée, au-delà du quorum de l'article, +/// soit exactement celle du cran à la participation habituelle : `G = …`, où s est cette part du +/// cran (60, 67, 75, 83 ou 90 %) et p̃ la participation habituelle divisée par W » — after the +/// reviews of 2ce2028 (« au-delà du quorum »: under it, the voters are judged as the quorum) and of +/// e7ab82f (the defined term where T3b wrote « quand le groupe vote comme d'habitude »; « du quorum +/// de l'article »). s is the share of the level, the one of the list — never « la part exigée », +/// which the formula above names and which moves with T'. On the grid, with G as the text writes +/// it and p̃ the participation habituelle over W, the median brought back within its bounds: G is +/// the engine's, at every quorum; on the curve, the share at the participation habituelle is s; the +/// share asked there is s beyond the article's quorum, and lower, judged as the quorum, under it. #[test] -fn g_gives_the_share_of_the_level_beyond_the_quorum_when_the_group_votes_as_usual() { +fn g_gives_the_share_of_the_level_at_the_usual_turnout_beyond_the_article_s_quorum() { let today = today_seed(); - // The sentence of G closes N1.1, in each language; nothing of the sentence of T3 is left. + // The sentence of G closes N1.1, in each language; nothing of the sentence of T2 is left. for lang in LANGS { let (_, body) = text(&today, "N1.1", lang); let (_, _, before, now) = PHRASES_T3 @@ -933,44 +1257,50 @@ fn g_gives_the_share_of_the_level_beyond_the_quorum_when_the_group_votes_as_usua .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"); + assert!(!body.contains(said_before), "N1.1 {lang}: the sentence of T2 is left\n{body}"); let (said_now, _) = now.split_once(" = ").expect("the formula"); - assert!(said_now.contains(beyond), "N1.1 {lang}: « {beyond} »"); + for words in [ + named( + lang, + "au-delà du quorum de l'article", + "beyond the article's quorum", + "más allá del quórum del artículo", + ), + named(lang, "à la participation habituelle", "at the usual turnout", "con la participación habitual"), + ] { + assert!(said_now.contains(words), "N1.1 {lang}: « {words} »"); + } } - // 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. + // By the engine, with G as the text writes it (s, the share of the level; M; p̃). 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 (mut beyond, mut under) = (0u64, 0u64); + for c in grid() { + let (s, m) = (c.s(), c.m()); + let env: HashMap = + HashMap::from([("s".into(), s), ("M".into(), m), ("p̃".into(), c.usual.part.ratio)]); + let engine = c.params(); 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}"); + let usual = c.voters(); + assert!((share_at(c.w, usual, ¶ms) - s).abs() < 1e-9, "{at}: s on the curve"); + let asked = share_at(c.w, usual.max(f64::from(c.q)), ¶ms); + if usual >= f64::from(c.q) { 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 %. + // The case of the review of 2ce2028: 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); + let part = Participation::of(w, 50.0, Some(f64::from(usual)), 9); + let c = Case { w, usual: Usual { median: Some(usual), part }, k: level(3), q: 50 }; + let p = c.params(); 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); @@ -978,8 +1308,113 @@ fn g_gives_the_share_of_the_level_beyond_the_quorum_when_the_group_votes_as_usua 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 +/// N1.1, « La participation habituelle est la médiane du nombre de votants des propositions du +/// groupe votées depuis un an. Tant qu'elle repose sur moins de 5 propositions, elle vaut deux fois +/// le quorum de référence du document […]. Elle est ramenée entre 1 % et 50 % de l'électorat » — +/// and why F1.2 and N1.1 write this defined term where lot T3 wrote « comme d'habitude » (review of +/// e7ab82f). « Quand le groupe vote comme d'habitude » reads first as « when as many vote as the +/// median »: the engine computes G on the median brought back within its bounds, and where the +/// median is out of them, the share asked of as many voters as the median is not s_k — higher under +/// 1 % of W, lower above 50 %. The case of the review: W 7 224, a median of 40 voters (0,55 %), +/// level 2, quorum 12 — at 40 voters the share asked is 68,7 %, 28 « pour » of 40, not 27 (67 %); +/// at the participation habituelle, 72,24 voters (1 % of W), 67 % exactly. « Tant que le groupe n'a +/// pas voté sur 5 propositions » (T2) was false once the group had: the median rests on the +/// proposals voted on within the year, and with fewer than 5 there the reference stands in again +/// (`tests/inertia.rs`, `the_usual_participation_is_the_median_of_five_voted_proposals`); « Elle +/// reste entre » read as a fact, not as the rule that brings the median back. +#[test] +fn the_participation_habituelle_is_the_median_brought_back_within_its_bounds() { + let today = today_seed(); + // The texts write the defined term, never the paraphrase: in N1.1, the list, its five lines and + // G (and, in French and Spanish, « comparé à la participation habituelle du groupe », already + // there); in F1.2, « Par défaut ». + for (lang, paraphrase, term, times, its, rests, bounds) in [ + ( + "fr", + "comme d'habitude", + "à la participation habituelle", + 8, + "à sa participation habituelle", + "Tant qu'elle repose sur moins de", + "Elle est ramenée entre", + ), + ( + "en", + "as usual", + "at the usual turnout", + 7, + "at its usual turnout", + "While it rests on fewer than", + "It is kept between", + ), + ( + "es", + "como de costumbre", + "con la participación habitual", + 8, + "con su participación habitual", + "Mientras se base en menos de", + "Se acota entre", + ), + ] { + let (n11, f12) = (text(&today, "N1.1", lang).1, text(&today, "F1.2", lang).1); + for (code, body) in [("N1.1", n11), ("F1.2", f12)] { + assert!(!body.contains(paraphrase), "{code} {lang}: « {paraphrase} »\n{body}"); + } + assert_eq!(n11.matches(term).count(), times, "N1.1 {lang}: the list, its five lines, G\n{n11}"); + assert_eq!(f12.matches(its).count(), 1, "F1.2 {lang}: by default"); + assert!(n11.contains(rests) && n11.contains(bounds), "N1.1 {lang}: the definition"); + } + // The engine: the median — or, on fewer than 5 proposals, the reference — brought back within + // 1 % and 50 % of W. + for w in ELECTORATES { + for u in usuals(w, 50.0) { + let raw = match u.median { + Some(m) => f64::from(m) / f64::from(w), + None => Participation::reference_ratio(w, 50.0), + }; + let at = format!("W {w} {u:?}"); + assert_eq!(u.part.ratio, raw.clamp(Participation::LOWEST, Participation::HIGHEST), "{at}"); + assert_eq!(u.part.bounded, u.part.ratio != raw, "{at}"); + if let Some(m) = u.median { + let few = Participation::of(w, 50.0, Some(f64::from(m)), Participation::MIN_SAMPLE - 1); + assert_eq!(few.ratio, Participation::of(w, 50.0, None, 0).ratio, "{at}: the reference"); + } + } + } + // The case of the review. + let (w, median) = (7224, 40); + let part = Participation::of(w, 50.0, Some(f64::from(median)), 9); + assert_eq!((part.bounded, part.ratio), (true, Participation::LOWEST)); + let c = Case { w, usual: Usual { median: Some(median), part }, k: level(2), q: 12 }; + let p = c.params(); + assert_eq!((required_ratio(w, median, &p) * 1000.0).round() / 10.0, 68.7); + assert_eq!(wot_threshold(w, median, &p), Ok(28)); + assert_eq!((c.s() * f64::from(median)).ceil(), 27.0, "67 % of 40, rounded up"); + assert!(!passes(Tally { for_votes: 27, against: 13 }, w, &p).expect("valid")); + assert!(passes(Tally { for_votes: 28, against: 12 }, w, &p).expect("valid")); + assert!((c.voters() - 72.24).abs() < 1e-9); + assert!((share_at(w, c.voters(), &p) - c.s()).abs() < 1e-12, "67 % at the participation habituelle"); + // On the grid: every median out of its bounds that reaches the quorum — as many voters as the + // median are not asked s_k. + let (mut under_one, mut above_half) = (0u64, 0u64); + for c in grid() { + let Some(median) = c.usual.median.filter(|m| c.usual.part.bounded && *m >= c.q) else { continue }; + let at = c.at(); + let at_median = required_ratio(c.w, median, &c.params()); + if c.usual.part.ratio == Participation::LOWEST { + assert!(at_median > c.s() + 1e-9, "{at}: {at_median}"); + under_one += 1; + } else { + assert!(at_median < c.s() - 1e-9, "{at}: {at_median}"); + above_half += 1; + } + } + assert!(under_one > 0 && above_half > 0, "under 1 % {under_one}, above 50 % {above_half}"); +} + +/// N1.1, « Tant qu'elle repose sur moins de 5 propositions, elle vaut deux fois le quorum de +/// référence du document : 100 votants dans Monnaie libre. […] Elle est ramenée 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 @@ -1002,9 +1437,9 @@ fn the_reference_turnout_is_a_parameter_the_bounds_bring_back() { } // 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"), + ("fr", format!("{} votants dans Monnaie libre.", f.reference), "Elle est ramenée entre"), + ("en", format!("{} voters in Monnaie libre.", f.reference), "It is kept between"), + ("es", format!("{} votantes en Monnaie libre.", f.reference), "Se acota entre"), ] { let (_, body) = text(&seed, "N1.1", lang); let at = |s: &str| body.find(s).unwrap_or_else(|| panic!("N1.1 {lang}: « {s} »")); @@ -1017,7 +1452,9 @@ fn the_reference_turnout_is_a_parameter_the_bounds_bring_back() { /// web of trust (`wot::PLAUSIBLE`, 100 to 10 000 000) — the engine's threshold is the one /// without B, even where the share lands on a whole number (`threshold::settle`, `clamp_ceil`): /// M × W when everyone votes, s × the usual turnout. Where W is a handful of members (the test -/// group, whose W is not the web of trust these texts speak of), B^W does count. +/// group, whose W is not the web of trust these texts speak of), B^W does count. Lot T3c: on the +/// participations of the grid (both bounds, where medians under 1 % and above 50 % of W are brought +/// back) and quorums from 2 to W (`quorum_sample`). #[test] fn b_to_the_power_w_never_counts_in_the_web_of_trust() { assert_eq!((*PLAUSIBLE.start(), *PLAUSIBLE.end()), (ELECTORATES[0], ELECTORATES[ELECTORATES.len() - 1])); @@ -1027,7 +1464,7 @@ fn b_to_the_power_w_never_counts_in_the_web_of_trust() { let usual = (p * f64::from(w)).round() as u32; let scale = Scale { electorate: w, participation: p, quorums: [12, 12, 50, 50, 50] }; for k in InertiaLevel::ALL { - for q in [2, 12, 50, 100, w].into_iter().filter(|q| *q <= w) { + for q in quorum_sample(w, 2, &[p * f64::from(w)]) { let with_b = scale.params(k, q); assert_eq!(with_b.base, 0.1); let without = FormulaParams { base: 0.0, ..with_b }; @@ -1199,14 +1636,22 @@ fn the_formulas_of_the_texts_are_those_the_engine_computes() { 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; + // Lot T3c: every W of the grid, and two of a handful of members; every participation of the + // grid (both bounds among them, where medians under 1 % and above 50 % of W are brought + // back: p̃ is the participation habituelle over W); quorums from 1 to W. + for w in [2u32, 40].into_iter().chain(ELECTORATES) { + for p in participations(w, 50.0) { let scale = Scale { electorate: w, participation: p, quorums: [12, 12, 50, 50, 50] }; + let usual = p * f64::from(w); + let (down, up) = (usual.floor() as u32, usual.ceil() as u32); + let mut quorums = vec![1u32, 2, 12, 50, up, w / 2, w]; + quorums.retain(|q| (1..=w).contains(q)); + quorums.sort_unstable(); + quorums.dedup(); for k in InertiaLevel::ALL { - for q in [1u32, 2, 12, 50].into_iter().filter(|q| *q <= w) { + for &q in &quorums { 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) { + for t in [0u32, 1, 9, 12, 13, 50, 51, 120, 500, down, up, w].into_iter().filter(|t| *t <= w) { let mut env: HashMap = HashMap::from([ ("T".into(), f64::from(t)), ("Q".into(), f64::from(q)),