Files
decision/crates/ld-server/tests/seed_quorum_texts.rs
T
YvvandClaude Opus 5.5 093d1ca471 Lot T4 : les tests d'avant suivent le seed de T4 sans rien perdre de ce qu'ils prouvent
Les tests qui lisaient F, N ou O rejouent leurs transitions sur le seed de M8
(tests/fixtures/seed-m8) : seed_quorum_texts, seed_rules, seed_proposals, api,
quorum ; ils disent en plus que ces amendements sont dépassés par le retrait
(superseded_by). Les comptes suivent le document de T4 : 27 articles, quatre
Propositions seed (I1, E1, E12, E2), 33 modules à voter (27 articles, le titre,
l'ordre de 5 sections). modules.rs : l'ordre sous vote se rejoue sur
« conclusion » (K1, K2) ; « un ordre d'un seul article n'a rien à décider » sur
une base née du seed de M8 (O1). seed_amend : tests unitaires de
Version::SeedProposal et de la ligne seed_proposal (put_forward). order.rs ne lit
que les colonnes d'avant 0012 : sections_now ne sert qu'à la naissance des
modules, et article_amend n'en garde que les articles vivants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:21:44 +01:00

2754 lines
136 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
//!
//! Lot T2 (2026-11-03, after the review of M6b): three phrases of these texts said more than
//! the rules do — F1.1 « presque l'unanimité quand peu de gens votent », F1.2 « ensuite, il
//! ne change plus », N1.1 « le groupe demande un quorum de votants ». A second guarded
//! amendment, which follows lot T's, writes them true on a base made as production is
//! after M6b; the texts of M6b are kept in `tests/fixtures/seed-quorum-m6b.json`.
//!
//! Lot T3 (2026-11-03, Yvv: « pas de chiffre datés dans un texte voté »): no figure of these
//! texts depends on the web of trust or on a turnout — the example of N1.1 goes; every sentence
//! is true for every W and every quorum in the bounds — N1.1 « qui n'est pas un nombre minimal
//! de votants » was false for a low quorum, and « comme d'habitude » holds beyond the quorum
//! 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.
//!
//! 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`.
//!
//! Lot T4 (2026-11-03, Yvv: « ordonnancement, inertie, et formule de vote sont des infos de
//! fonctionnement »): F1.1, F1.2 and N1.1 leave the document (ADR 0014); they are no texts to
//! vote any more. Their last texts — the seed of M8 (de1b7eb), kept in
//! `tests/fixtures/seed-m8/` — stay the reference of what the interface will say of the rules
//! (lot U3), and every truth this file checks of them against the engine still holds there. The
//! amendments that wrote them are replayed on that seed, as production played them.
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};
use ld_server::retire::RETIREMENT;
use ld_server::seed_amend::{self, AMENDMENTS, Version};
use ld_server::wot::PLAUSIBLE;
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"];
/// Sizes of the web of trust, W in Monnaie libre: the bounds the server takes as true
/// (`wot::PLAUSIBLE`), the web of 2024 (7 224) and of 02/11 (5 707), and some between.
const ELECTORATES: [u32; 9] = [100, 101, 1000, 3000, 5707, 7224, 10_000, 1_000_000, 10_000_000];
// ---------------------------------------------------------------------------
// The seeds
// ---------------------------------------------------------------------------
fn read(path: PathBuf) -> Value {
serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json")
}
/// The seed production runs since M8 (de1b7eb): the last that holds F1.1, F1.2 and N1.1, and
/// the reference of their texts since lot T4 took them out of the document.
fn m8_seed() -> Value {
read(fixtures().join("seed-m8/acte-engagement-certification.json"))
}
/// A start with the seed of M8: lot T3 decides.
fn m8_config(tag: &str) -> Config {
config(seed_dir(&format!("{tag}-m8"), &m8_seed()))
}
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"))
}
/// F1.1, F1.2 and N1.1 as lot T wrote them: production's since M6b (bb4bdf5), unchanged by M7.
fn m6b_texts() -> Value {
read(fixtures().join("seed-quorum-m6b.json"))
}
/// The seed production runs since M6b: today's, with the texts of lot T.
fn m6b_seed() -> Value {
with_texts(&m8_seed(), &m6b_texts(), &CODES)
}
/// F1.1, F1.2 and N1.1 as lot T2 wrote them: production's since M6c (84f015a), unchanged by M7b.
fn t2_texts() -> Value {
read(fixtures().join("seed-quorum-t2.json"))
}
/// The seed production runs since M6c: today's, with the texts of lot T2.
fn t2_seed() -> Value {
with_texts(&m8_seed(), &t2_texts(), &CODES)
}
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
// ---------------------------------------------------------------------------
/// 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
/// 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],
/// « 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,
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),
}
fn level(k: u8) -> InertiaLevel {
InertiaLevel::new(k).expect("1 to 5")
}
/// The figures as the engine gives them on an electorate of `w`.
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");
// 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<u8> = 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(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),
}
}
/// 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");
for (w, f) in all {
assert_eq!(f, first, "W {w}: a figure of the texts depends on the web of trust");
}
first
}
/// 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<String> {
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<String> {
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));
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, au-delà du quorum, le groupe demande {} de « pour » à sa participation habituelle",
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, 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()),
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, 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()),
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<String> = 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} à la participation habituelle, {mk} quand tout le monde vote. {what}",
k.get(),
q[i]
),
"en" => format!(
"- **Level {} · {n}**: quorum {}; {s} at the usual turnout, {mk} when everyone votes. {what}",
k.get(),
q[i]
),
_ => format!(
"- **Nivel {} · {n}**: quórum {}; {s} con la participación habitual, {mk} cuando vota todo el mundo. {what}",
k.get(),
q[i]
),
}
})
.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::<Vec<_>>().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 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!("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!("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})"),
],
});
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<String> {
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()],
}
}
/// Every number of the texts is a parameter of the seed or a constant of the engine (lot T3:
/// none depends on W or on a turnout, `figures`); a digit nothing stands for fails, every
/// article and language listed at once.
#[test]
fn every_number_of_the_quorum_texts_is_the_engine_s() {
let seed = m8_seed();
let f = figures(&seed);
let mut wrong = Vec::new();
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) {
if !rest.contains(&claim) {
wrong.push(format!("{code} {lang}: missing « {claim} »"));
}
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: String = rest.chars().filter(char::is_ascii_digit).collect();
if !stray.is_empty() {
wrong.push(format!("{code} {lang}: numbers no parameter stands for: {stray}\n{rest}"));
}
}
}
assert!(wrong.is_empty(), "{}", wrong.join("\n\n"));
}
// ---------------------------------------------------------------------------
// Lot T2: the three phrases the review of M6b left inexact
// ---------------------------------------------------------------------------
/// (article, language, the words of M6b, the words of T2): the only change of lot T2.
const PHRASES: [(&str, &str, &str, &str); 9] = [
(
"F1.1",
"fr",
"parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout le monde vote.",
"parmi tous les votants : cette part est la plus haute au quorum et baisse quand la participation monte, jusqu'à la majorité M quand tout le monde vote.",
),
(
"F1.1",
"en",
"among all the voters: near-unanimity when few people vote, the majority M when everyone votes.",
"among all the voters: that share is highest at the quorum and falls as turnout rises, down to the majority M when everyone votes.",
),
(
"F1.1",
"es",
"entre todos los votantes: casi la unanimidad cuando vota poca gente, la mayoría M cuando vota todo el mundo.",
"entre todos los votantes: esa proporción es la más alta en el quórum y baja cuando sube la participación, hasta la mayoría M cuando vota todo el mundo.",
),
(
"F1.2",
"fr",
"peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne change plus.",
"peut en fixer un autre tant que l'article ne garde la trace d'aucun vote.",
),
(
"F1.2",
"en",
"can set another one as long as nobody has voted on the article; after that, it no longer changes.",
"can set another one as long as the article keeps no trace of any vote.",
),
(
"F1.2",
"es",
"puede fijar otro mientras nadie haya votado sobre el artículo; después, ya no cambia.",
"puede fijar otro mientras el artículo no guarde rastro de ningún voto.",
),
(
"N1.1",
"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,",
"À chaque cran, le groupe demande une part de « pour » parmi les votants (voir « Formule du seuil WoT »). 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 ». 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, plus elle baisse,",
),
(
"N1.1",
"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,",
"At each level, the group asks for a share of votes in favour among the voters (see “WoT threshold formula”). 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. 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 lower it gets,",
),
(
"N1.1",
"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,",
"En cada nivel, el grupo pide una proporción de votos a favor entre los votantes (ver «Fórmula del umbral WoT»). 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». 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, más baja,",
),
];
#[test]
fn the_three_phrases_of_the_review_of_m6b_are_written_true() {
let (m6b, t2) = (m6b_seed(), t2_seed());
for (code, lang, old, new) in PHRASES {
assert!(text(&m6b, code, lang).1.contains(old), "{code} {lang}: the words of M6b");
let (_, body) = text(&t2, code, lang);
assert!(!body.contains(old), "{code} {lang}: still the words of M6b\n{body}");
assert!(body.contains(new), "{code} {lang}: missing « {new} »\n{body}");
}
// F1.1 and N1.1, by the engine: the share of a level is that of the quorum while fewer
// vote, its highest; beyond, it falls as turnout rises, down to M when everyone votes —
// never near-unanimity because few vote. Every level, quorum and electorate of the grid.
for (w, median) in [(5707u32, None), (7224, Some(120.0)), (40, Some(9.0)), (2, Some(1.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);
let share = |t: u32| required_ratio(w, judged_voters(t, q), &params);
let top = share(0);
let mut last = top;
for t in 0..=w {
let s = share(t);
assert!(s <= last, "W {w} k {} Q {q}: the share rises at {t} voters", k.get());
if t <= q {
assert_eq!(s, top, "W {w} k {} Q {q}: under the quorum, the share of the quorum", k.get());
}
last = s;
}
let m = k.majority_pct() / 100.0;
assert!((share(w) - m).abs() < 1e-12, "W {w} k {} Q {q}: M when everyone votes", k.get());
}
}
}
// N1.1 of T2: the quorum is no minimum of voters. True at the quorums of the levels in
// Monnaie libre (W 5 707 on 02/11, the reference turnout): fewer voters than the quorum pass
// when they all vote « pour », 8 of 12, 9 of 12, 39 of 50, 43 of 50, 46 of 50. False at a low
// quorum an author may set, where Q « pour » are asked: lot T3 writes it true for every quorum
// (`under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted`).
let w = 5707;
let f = figures(&t2);
let scale = Scale { electorate: w, participation: Participation::of(w, 50.0, None, 0).ratio, quorums: f.quorums };
let fewer: Vec<(u32, u32)> = InertiaLevel::ALL
.iter()
.map(|k| {
let q = scale.quorum(*k, None);
let p = scale.params(*k, q);
let yes = yes_needed(0, w, &p).expect("valid");
assert!(passes(Tally { for_votes: yes, against: 0 }, w, &p).expect("valid"));
(yes, q)
})
.collect();
assert_eq!(fewer, [(8, 12), (9, 12), (39, 50), (43, 50), (46, 50)]);
assert!(fewer.iter().all(|(yes, q)| yes < q));
}
// ---------------------------------------------------------------------------
// 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 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 » à 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 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 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 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 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 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 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 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 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",
"fr",
"\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.",
"",
),
(
"N1.1",
"en",
"\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.",
"",
),
(
"N1.1",
"es",
"\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 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 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 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();
for (c, l, old, new) in PHRASES_T3 {
if (c, l) == (code, lang) {
assert_eq!(b.matches(old).count(), 1, "{code} {lang}: « {old} » once in the text of T2");
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
}
#[test]
fn the_texts_of_lot_t3_change_these_phrases_only() {
let (t2, today) = (t2_seed(), m8_seed());
for code in CODES {
for lang in LANGS {
let (before, now) = (text(&t2, code, lang), text(&today, code, lang));
assert_eq!(before.0, now.0, "{code} {lang}: the title is kept");
assert_eq!(t3_from_t2(code, lang, before.1), now.1, "{code} {lang}: the phrases of T3, and nothing else");
}
}
for (code, lang, old, new) in PHRASES_T3 {
let (_, body) = text(&today, code, lang);
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");
}
}
// ---------------------------------------------------------------------------
// 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<u32> {
let one = w / 100;
let mut m: Vec<u32> = [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
}
/// 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<u32>,
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<Usual> {
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<f64> {
let mut p: Vec<f64> = 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<u32> {
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<u32> {
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<Case> {
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<f64> = 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 = m8_seed();
let said = [
(
"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} »");
}
let quorums = |w: u32, lowest: u32| -> Vec<u32> {
if w <= 10_000 {
(lowest..=w).collect()
} else {
[lowest, 2, 3, 12, 50, 100, 1000, w / 2, w - 1, w].into_iter().filter(|q| *q >= lowest).collect()
}
};
let mut cases = 0u64;
let mut minimum = Vec::new();
// Monnaie libre on every W of the grid (its reference quorum 50); the test group on a few
// members (its reference quorum 1).
let grid: [(bool, f64, &[u32]); 2] = [(false, 50.0, &ELECTORATES), (true, 1.0, &[1, 2, 3, 4, 10, 40])];
for (test_group, vote_floor, electorates) in grid {
for &w in electorates {
let (lowest, highest) = quorum_bounds(w, test_group);
assert_eq!(highest, w.max(lowest));
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) {
let params = scale.params(k, q);
let at = format!("essai {test_group} W {w} p̃ {p} k {} Q {q}", k.get());
// The « pour » asked had the quorum voted: never more than the quorum.
let at_quorum = wot_threshold(w, q, &params).expect("valid");
assert!((1..=q).contains(&at_quorum), "{at}: {at_quorum}");
assert_eq!(yes_needed(0, w, &params), Ok(at_quorum), "{at}");
for t in [0, 1, q / 2, q - 1].into_iter().filter(|t| *t < q) {
assert_eq!(wot_threshold(w, t, &params), Ok(at_quorum), "{at}, {t} voters");
}
if q <= 30 {
for y in 0..q {
for n in 0..q - y {
let ok = passes(Tally { for_votes: y, against: n }, w, &params).expect("valid");
assert_eq!(ok, y >= at_quorum, "{at}: {y} pour, {n} contre");
}
}
}
// 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, &params).expect("valid"), "{at}: 1 pour, {n} contre");
}
}
if at_quorum == q {
minimum.push((test_group, w, k.get(), q));
}
cases += 1;
}
}
}
}
}
assert!(cases > 500_000, "{cases}");
// « Pas un nombre minimal de votants » (T2) is false: the quorum is a minimum at Q = 2 in
// Monnaie libre, at every level and on every W; at Q = 1 in the test group.
for w in ELECTORATES {
for k in 1..=5 {
assert!(minimum.contains(&(false, w, k, 2)), "W {w} k {k}: two « pour » at a quorum of 2");
}
}
assert!(minimum.iter().any(|m| m.0 && m.3 == 1));
}
/// 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 = m8_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} »");
}
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<u32> = 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");
}
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));
}
/// 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_is_asked_at_the_usual_turnout_beyond_the_article_s_quorum() {
let today = m8_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 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), &p) - m).abs() < 1e-12, "{at}: M");
}
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). 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);
let scale = Scale { electorate: w, participation: part.ratio, quorums: [12, 12, 50, 50, 50] };
let d = InertiaLevel::DEFAULT;
let q = scale.quorum(d, None);
assert_eq!((d.get(), q, d.share_pct()), (3, 50, 75.0));
let p = scale.params(d, q);
assert_eq!(yes_needed(0, w, &p), Ok(38));
assert!((required_ratio(w, usual, &p) - 0.75).abs() < 1e-12, "75 % is the share of 40 voters on the curve");
assert_eq!(wot_threshold(w, usual, &p), Ok(38), "but 40 voters are judged as 50");
}
/// 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_at_the_usual_turnout_beyond_the_article_s_quorum() {
let today = m8_seed();
// 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
.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 T2 is left\n{body}");
let (said_now, _) = now.split_once(" = ").expect("the formula");
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̃).
let grad = code_spans(text(&today, "N1.1", "fr").1)[1];
let (mut beyond, mut under) = (0u64, 0u64);
for c in grid() {
let (s, m) = (c.s(), c.m());
let env: HashMap<String, f64> =
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");
let usual = c.voters();
assert!((share_at(c.w, usual, &params) - s).abs() < 1e-9, "{at}: s on the curve");
let asked = share_at(c.w, usual.max(f64::from(c.q)), &params);
if usual >= f64::from(c.q) {
assert!((asked - s).abs() < 1e-9, "{at}: {asked}, not s");
beyond += 1;
} else {
assert!(asked < s - 1e-9, "{at}: {asked}");
under += 1;
}
}
assert!(beyond > 0 && under > 0, "beyond {beyond}, under {under}");
// 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 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);
let needed = wot_threshold(w, usual, &p).expect("valid");
assert_eq!((needed, f64::from(needed) / f64::from(usual)), (38, 0.95));
}
/// 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 = m8_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
/// 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 = m8_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 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} »"));
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
/// 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. 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]));
let mut cases = 0;
for w in ELECTORATES.into_iter().chain([102, 5000, 5708]) {
for p in participations(w, 50.0) {
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 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 };
for t in [0, 1, q - 1, q, q + 1, usual, w / 2, w - 1, w].into_iter().filter(|t| *t <= w) {
assert_eq!(
wot_threshold(w, t, &with_b),
wot_threshold(w, t, &without),
"W {w} p̃ {p} k {} Q {q} T {t}",
k.get()
);
cases += 1;
}
}
}
}
}
assert!(cases > 5000, "{cases}");
// On a whole number: everyone votes on an even W at level 1, half exactly; the reference
// turnout of 100 voters, s_k of them exactly.
let level1 = Scale { electorate: 5708, participation: 0.1, quorums: [12; 5] }.params(level(1), 12);
assert_eq!(wot_threshold(5708, 5708, &level1), Ok(2854));
let w = 5707;
let scale = Scale { electorate: w, participation: Participation::of(w, 50.0, None, 0).ratio, quorums: [12; 5] };
let at_100: Vec<u32> = InertiaLevel::ALL.iter().map(|k| scale.required(100, *k, 12).expect("valid")).collect();
assert_eq!(at_100, [60, 67, 75, 83, 90]);
// The test group: two members, both vote. B^W = 0,01 asks for two « pour », not one — a tie
// never passes there.
let essai = Scale { electorate: 2, participation: Participation::of(2, 1.0, None, 0).ratio, quorums: [1; 5] };
let p = essai.params(level(1), 1);
assert_eq!(wot_threshold(2, 2, &p), Ok(2));
assert_eq!(wot_threshold(2, 2, &FormulaParams { base: 0.0, ..p }), Ok(1));
}
// ---------------------------------------------------------------------------
// 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<String>,
at: usize,
env: &'a HashMap<String, f64>,
}
fn tokens(src: &str) -> Vec<String> {
let mut out = Vec::new();
let chars: Vec<char> = 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::<f64>() {
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<String, f64>) -> 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 = m8_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;
// 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 &quorums {
let params = scale.params(k, q);
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<String, f64> = 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), &params);
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 = m8_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 (`quorum`) or lot T2 (`phrases`).
fn amendment(code: &str, lot: &str) -> &'static seed_amend::Amendment {
AMENDMENTS
.iter()
.find(|a| a.unit == code && a.id.starts_with("2026-11-03-") && a.id.ends_with(lot))
.unwrap_or_else(|| panic!("no amendment « {lot} » for {code}"))
}
#[test]
fn the_amendments_of_lot_t_went_from_m5_to_the_texts_of_m6b() {
let (prod, m6b) = (production_seed(), m6b_seed());
for code in CODES {
let a = amendment(code, "-quorum");
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 held before M6b");
let (t, b) = text(&m6b, code, "fr");
assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text production holds since M6b");
assert_ne!(a.from, a.to, "{code}");
// F1.1 was never amended before; 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}");
}
}
// Lot T2 replaces its text: it is superseded in turn.
assert_eq!(a.superseded_by, Some(amendment(code, "-phrases").id), "{code}");
}
}
#[test]
fn the_amendments_of_lot_t2_went_from_the_texts_of_m6b_to_those_of_t2() {
let (m6b, t2) = (m6b_seed(), t2_seed());
for code in CODES {
let a = amendment(code, "-phrases");
let t = amendment(code, "-quorum");
assert_eq!((a.doc, a.version), ("acte-engagement-certification", Version::Open("1.0.0")), "{code}");
let (title, body) = text(&m6b, code, "fr");
assert_eq!(seed_amend::fingerprint(title, body), a.from, "{code}: the text production held before M6c");
let (title, body) = text(&t2, code, "fr");
assert_eq!(seed_amend::fingerprint(title, body), a.to, "{code}: the text production holds since M6c");
assert_ne!(a.from, a.to, "{code}");
// It follows lot T's, which wrote the text it replaces; lot T3's replaces it in turn,
// where it changes the text (F1.2, N1.1); F1.1 leaves the seed with lot T4 (ADR 0014).
let later = AMENDMENTS.iter().find(|l| l.follows == Some(a.id)).map(|l| l.id).or(Some(RETIREMENT));
assert_eq!(
(a.follows, t.to, t.superseded_by, a.superseded_by),
(Some(t.id), a.from, Some(a.id), later),
"{code}"
);
// The titles stay, and only the phrases of the review change (`PHRASES`).
for lang in LANGS {
let (before, now) = (text(&m6b, code, lang), text(&t2, code, lang));
assert_eq!(before.0, now.0, "{code} {lang}: the title is kept");
let mut b = before.1.to_owned();
for (c, l, old, new) in PHRASES {
if (c, l) == (code, lang) {
b = b.replacen(old, new, 1);
}
}
assert_eq!(b, now.1, "{code} {lang}: nothing else changes");
}
}
}
/// Every proposal as it stands: (id, status, revision, title, body).
type Row = (String, String, i64, String, String);
async fn proposals(state: &AppState) -> Vec<Row> {
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(&m8_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<String> {
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 (`-quorum`) or lot T2 (`-phrases`): (group, unit, outcome, reason).
async fn decisions(state: &AppState, lot: &str) -> Vec<(String, String, String, Option<String>)> {
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 ? ORDER BY d.group_slug, s.unit_code",
)
.bind(format!("2026-11-03-%{lot}"))
.fetch_all(&state.db)
.await
.expect("decisions")
}
/// The rows the amendments of a lot added to the publication queue: (group, reason).
async fn queued(state: &AppState, lot: &str) -> Vec<(String, String)> {
let reasons: Vec<&str> = AMENDMENTS
.iter()
.filter(|a| a.id.starts_with("2026-11-03-") && a.id.ends_with(lot))
.map(|a| a.reason)
.collect();
assert!(!reasons.is_empty(), "no amendment « {lot} »");
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id ORDER BY o.id")
.fetch_all(&state.db)
.await
.expect("outbox");
rows.into_iter().filter(|(_, r)| reasons.contains(&r.as_str())).collect()
}
/// A start with the seed of M6b (bb4bdf5, and M7 since): lot T decides, lot T2 does not yet.
fn m6b_config(tag: &str) -> Config {
config(seed_dir(&format!("{tag}-m6b"), &m6b_seed()))
}
/// A start with the seed of M6c (84f015a, and M7b since): lot T2 decides, lot T3 does not yet.
fn t2_config(tag: &str) -> Config {
config(seed_dir(&format!("{tag}-t2"), &t2_seed()))
}
/// Lot T, as M6b played it on production (03/11, 08 h 02): started with the seed of M6b.
#[tokio::test]
async fn production_after_m6_gets_the_quorum_texts_once() {
let base = production_after_m6("apply").await;
let (prod, today) = (production_seed(), m6b_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, "-quorum").await.is_empty(), "the seed of M6 decides nothing of lot T");
let (before, before_tr) = (proposals(&base).await, translations(&base).await);
// Started with the seed of M6b.
let state = start(&base.db, m6b_config("apply")).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<String>) = 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<String>) = 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<String>)> = 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, "-quorum").await, expected);
assert!(decisions(&state, "-phrases").await.is_empty(), "the seed of M6b decides nothing of lot T2");
// Nothing else is touched: no other proposal, no other translation, no vote.
let keep = |rows: Vec<Row>| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>();
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::<Vec<_>>()
};
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, "-quorum").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, m6b_config("apply-again")).await;
assert_eq!(decisions(&again, "-quorum").await, expected);
assert_eq!(proposals(&again).await, proposals(&state).await);
assert_eq!(queued(&again, "-quorum").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");
}
/// Lot T's guard, as M6b played it: started with the seed of M6b.
#[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, m6b_config("refuse")).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, "-quorum").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(), m6b_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, "-quorum").await;
assert_eq!(
rows.iter().map(|(g, r)| (g.as_str(), r.contains("F1.1"))).collect::<Vec<_>>(),
[("monnaie-libre", true)]
);
// The refusal stands: decided once, it is not tried again.
let again = start(&state.db, m6b_config("refuse-again")).await;
assert_eq!(decisions(&again, "-quorum").await, decisions(&state, "-quorum").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, m8_config("new")).await;
let today = m8_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}");
}
}
// Lot T3's amendments find their text there: present.
let present = |g: &str, c: &str| (g.to_owned(), c.to_owned(), "present".to_owned(), None);
assert_eq!(
decisions(&state, "-chiffres").await,
[
present("essai", "F1.2"),
present("essai", "N1.1"),
present("monnaie-libre", "F1.2"),
present("monnaie-libre", "N1.1")
]
);
// Lot T2's: present on F1.1, which lot T3 leaves as it is.
assert_eq!(decisions(&state, "-phrases").await, [present("essai", "F1.1"), present("monnaie-libre", "F1.1")]);
// The amendments of M5, of lot T and of lot T2 on these articles, superseded, decide nothing
// on a new base.
let m5: Vec<String> = sqlx::query_scalar(
"SELECT amendment_id FROM seed_amendments
WHERE amendment_id IN ('2026-11-02-f1-2-regles', '2026-11-02-n1-1-regles', '2026-11-03-f1-2-phrases',
'2026-11-03-n1-1-phrases') OR amendment_id LIKE '2026-11-03-%-quorum'",
)
.fetch_all(&state.db)
.await
.expect("m5");
assert!(m5.is_empty(), "{m5:?}");
}
// ---------------------------------------------------------------------------
// Lot T2 in production: on a base made as production is after M6b
// ---------------------------------------------------------------------------
/// Production's history, for these articles, after M6b (03/11, 08 h 02): F1.1 at its
/// revision 2, F1.2 and N1.1 at their revision 3, each written by an amendment of the seed.
async fn production_after_m6b(tag: &str) -> AppState {
let base = production_after_m6(tag).await;
start(&base.db, m6b_config(tag)).await
}
async fn count(state: &AppState, sql: &str) -> i64 {
sqlx::query_scalar(sql).fetch_one(&state.db).await.expect("count")
}
/// The last revision of a proposal: (revision, kind, title, body, author).
async fn last_revision(state: &AppState, id: &str) -> (i64, String, String, String, Option<String>) {
sqlx::query_as(
"SELECT revision, kind, title, body, author_id FROM revisions WHERE proposal_id = ? ORDER BY id DESC LIMIT 1",
)
.bind(id)
.fetch_one(&state.db)
.await
.expect("revision")
}
#[tokio::test]
async fn production_after_m6b_gets_the_three_phrases_once() {
let base = production_after_m6b("t2-apply").await;
let (m6b, t2) = (m6b_seed(), t2_seed());
// The base is as production after M6b: the texts of lot T, at revision 2 and 3.
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(&m6b, 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(&base, &id).await, expected, "{group} {code}");
}
}
let lot_t = decisions(&base, "-quorum").await;
assert_eq!(lot_t.len(), 6);
assert!(lot_t.iter().all(|(_, _, o, _)| o == "applied"), "{lot_t:?}");
assert!(decisions(&base, "-phrases").await.is_empty(), "the seed of M6b decides nothing of lot T2");
let (before, before_tr) = (proposals(&base).await, translations(&base).await);
let outbox = count(&base, "SELECT COUNT(*) FROM outbox").await;
let revisions = count(&base, "SELECT COUNT(*) FROM revisions").await;
// Started with the seed of M6c.
let state = start(&base.db, t2_config("t2-apply")).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(&t2, code, "fr"), "{group} {code}");
let m5 = code != "F1.1";
assert_eq!(revision, if m5 { 4 } else { 3 }, "{group} {code}");
let mut expected = vec!["initial", "amendment", "amendment"];
if m5 {
expected.push("amendment");
}
assert_eq!(kinds(&state, &id).await, expected, "{group} {code}");
// The history keeps the text of lot T, and says the new one, without author.
assert_eq!(
last_revision(&state, &id).await,
(revision, "amendment".to_owned(), title.clone(), body.clone(), None),
"{group} {code}"
);
let (_, kind, t, b, _) = sqlx::query_as::<_, (i64, String, String, String, Option<String>)>(
"SELECT revision, kind, title, body, author_id FROM revisions WHERE proposal_id = ? AND revision = ?",
)
.bind(&id)
.bind(revision - 1)
.fetch_one(&state.db)
.await
.expect("revision of lot T");
assert_eq!(
(kind.as_str(), (t.as_str(), b.as_str())),
("amendment", text(&m6b, code, "fr")),
"{group} {code}"
);
for lang in ["en", "es"] {
let (t, b, hash, origin, by): (String, String, String, String, Option<String>) = 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(&t2, 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<String>)> = 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, "-phrases").await, expected);
// Lot T's decisions stay as they were taken; superseded, it decides nothing more.
assert_eq!(decisions(&state, "-quorum").await, lot_t);
// Nothing else is touched: no other proposal, no other translation, no vote, six revisions.
let keep = |rows: Vec<Row>| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>();
assert_eq!(keep(proposals(&state).await), keep(before));
let others = |rows: Vec<(String, String, String, String, String)>| {
rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>()
};
assert_eq!(others(translations(&state).await), others(before_tr));
assert_eq!(count(&state, "SELECT COUNT(*) FROM votes").await, 0);
assert_eq!(count(&state, "SELECT COUNT(*) FROM revisions").await, revisions + 6);
// Monnaie libre republishes, once per article; the test group never.
let rows = queued(&state, "-phrases").await;
assert_eq!(rows.len(), 3, "{rows:?}");
assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}");
assert_eq!(count(&state, "SELECT COUNT(*) FROM outbox").await, outbox + 3);
// Decided once: a second start replays nothing.
let again = start(&state.db, t2_config("t2-apply-again")).await;
assert_eq!(decisions(&again, "-phrases").await, expected);
assert_eq!(proposals(&again).await, proposals(&state).await);
assert_eq!(translations(&again).await, translations(&state).await);
assert_eq!(count(&again, "SELECT COUNT(*) FROM revisions").await, revisions + 6);
assert_eq!(count(&again, "SELECT COUNT(*) FROM outbox").await, outbox + 3);
}
/// A person, voting through the interface's own routes (no chain in the test group).
struct Voter {
router: axum::Router,
cookie: String,
}
impl Voter {
async fn call(&self, method: &str, uri: &str, body: Value, cookie: Option<&str>) -> axum::response::Response {
use tower::ServiceExt;
let mut req = axum::http::Request::builder()
.method(method)
.uri(uri)
.header(ld_server::CSRF_HEADER, ld_server::CSRF_VALUE)
.header(axum::http::header::CONTENT_TYPE, "application/json");
if let Some(c) = cookie {
req = req.header(axum::http::header::COOKIE, c);
}
let req = req.body(axum::body::Body::from(body.to_string())).expect("request");
self.router.clone().oneshot(req).await.expect("response")
}
async fn login(state: &AppState, name: &str) -> Self {
let mut v = Self { router: ld_server::app(state.clone()), cookie: String::new() };
let res = v.call("POST", "/api/auth/dev", serde_json::json!({ "name": name }), None).await;
assert_eq!(res.status(), axum::http::StatusCode::OK);
let set = res.headers().get(axum::http::header::SET_COOKIE).expect("cookie").to_str().expect("cookie");
v.cookie = set.split(';').next().expect("cookie").to_owned();
v
}
async fn vote(&self, proposal: &str, choice: Option<&str>) {
let uri = format!("/api/proposals/{proposal}/vote");
let res = self.call("POST", &uri, serde_json::json!({ "choice": choice }), Some(&self.cookie)).await;
assert_eq!(res.status(), axum::http::StatusCode::OK, "{uri} {choice:?}");
}
}
#[tokio::test]
async fn an_article_voted_on_keeps_the_text_of_m6b_and_says_why() {
let base = production_after_m6b("t2-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.1, at its revision 2…
let (f11, ..) = proposal(&base, "monnaie-libre", "F1.1").await;
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source)
VALUES (?, 'dev:vera', 'against', 2, '2026-11-03T09:00:00Z', 'simulated')",
)
.bind(&f11)
.execute(&base.db)
.await
.expect("vote");
// …and a ballot still pending on F1.2, at its revision 3: cast, not proven yet.
let (f12, ..) = proposal(&base, "monnaie-libre", "F1.2").await;
sqlx::query(
"INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, mode,
created_at, expires_at)
VALUES ('b2', ?, 'dev:vera', 'salt', 'digest-b2', 'remark-b2', '', 0, 'pending', 'simulated',
'2026-11-03T09:00:00Z', '2026-11-04T09:00:00Z')",
)
.bind(&doc)
.execute(&base.db)
.await
.expect("ballot");
sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b2', ?, 3, 'against')")
.bind(&f12)
.execute(&base.db)
.await
.expect("line");
// In the test group, Luna votes « contre » N1.1 and keeps her vote (a « pour » would pass
// there, quorum 1); she votes on F1.2, then withdraws: a direct vote withdrawn leaves no
// trace (the guard's known limit, `seed_amend`).
let luna = Voter::login(&base, "Luna").await;
let (n11, ..) = proposal(&base, "essai", "N1.1").await;
luna.vote(&n11, Some("against")).await;
let (f12_essai, ..) = proposal(&base, "essai", "F1.2").await;
luna.vote(&f12_essai, Some("against")).await;
luna.vote(&f12_essai, None).await;
let traces = |id: String| {
let state = base.clone();
async move {
let sql = "SELECT (SELECT COUNT(*) FROM votes WHERE proposal_id = ?1)
+ (SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?1)";
sqlx::query_scalar::<_, i64>(sql).bind(id).fetch_one(&state.db).await.expect("traces")
}
};
assert_eq!((traces(n11.clone()).await, traces(f12_essai.clone()).await), (1, 0));
let state = start(&base.db, t2_config("t2-refuse")).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, "-phrases").await,
[
applied("essai", "F1.1"),
applied("essai", "F1.2"),
refused("essai", "N1.1", "votes"),
refused("monnaie-libre", "F1.1", "votes"),
refused("monnaie-libre", "F1.2", "ballots"),
applied("monnaie-libre", "N1.1"),
]
);
let (m6b, t2) = (m6b_seed(), t2_seed());
for group in GROUPS {
for code in CODES {
let kept = matches!((group, code), ("essai", "N1.1") | ("monnaie-libre", "F1.1" | "F1.2"));
let (_, title, body, revision) = proposal(&state, group, code).await;
let expected = if kept { text(&m6b, code, "fr") } else { text(&t2, code, "fr") };
assert_eq!((title.as_str(), body.as_str()), expected, "{group} {code}");
let lot_t = if code == "F1.1" { 2 } else { 3 };
assert_eq!(revision, lot_t + i64::from(!kept), "{group} {code}");
}
}
// Republished for the one article applied in Monnaie libre only.
let rows = queued(&state, "-phrases").await;
assert_eq!(
rows.iter().map(|(g, r)| (g.as_str(), r.contains("N1.1"))).collect::<Vec<_>>(),
[("monnaie-libre", true)]
);
// The refusals stand: decided once, never tried again, even once the vote is gone.
sqlx::query("DELETE FROM votes WHERE proposal_id = ?").bind(&f11).execute(&state.db).await.expect("delete");
let again = start(&state.db, t2_config("t2-refuse-again")).await;
assert_eq!(decisions(&again, "-phrases").await, decisions(&state, "-phrases").await);
let (_, title, body, _) = proposal(&again, "monnaie-libre", "F1.1").await;
assert_eq!((title.as_str(), body.as_str()), text(&m6b, "F1.1", "fr"));
}
/// A base born on 02/11 with the seed of M5 (F1.2 and N1.1 at their revision 1, in M5's
/// words): lot T wrote their revision 2, and lot T2 follows it there too.
#[tokio::test]
async fn a_base_born_with_the_texts_of_m5_gets_the_three_phrases() {
let prod = production_seed();
let db = open_db("sqlite::memory:").await.expect("db");
let first = AppState::new(config(seed_dir("t2-born-m5", &prod)), db.clone()).await.expect("state");
seed::documents(&first).await.expect("born");
start(&db, config(seed_dir("t2-born-m5-m5", &prod))).await;
start(&db, config(seed_dir("t2-born-m5-m6", &with_texts(&m8_seed(), &prod, &CODES)))).await;
let base = start(&db, m6b_config("t2-born-m5")).await;
for group in GROUPS {
for code in CODES {
let (id, ..) = proposal(&base, group, code).await;
assert_eq!(kinds(&base, &id).await, ["initial", "amendment"], "{group} {code}: lot T, alone");
}
}
let state = start(&db, t2_config("t2-born-m5")).await;
let t2 = t2_seed();
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()), revision), (text(&t2, code, "fr"), 3), "{group} {code}");
assert_eq!(kinds(&state, &id).await, ["initial", "amendment", "amendment"], "{group} {code}");
}
}
let got = decisions(&state, "-phrases").await;
assert_eq!(got.len(), 6);
assert!(got.iter().all(|(_, _, o, _)| o == "applied"), "{got:?}");
}
// ---------------------------------------------------------------------------
// Lot T3 in production: on a base made as production is after M6c
// ---------------------------------------------------------------------------
/// The articles lot T3 rewrites: F1.1 is left as it is, without an amendment.
const T3_CODES: [&str; 2] = ["F1.2", "N1.1"];
#[test]
fn the_amendments_of_lot_t3_go_from_the_texts_of_t2_to_the_seed_of_today() {
let (t2, today) = (t2_seed(), m8_seed());
for code in CODES {
let t2a = amendment(code, "-phrases");
let found = AMENDMENTS.iter().find(|a| a.unit == code && a.id.ends_with("-chiffres"));
if !T3_CODES.contains(&code) {
assert!(found.is_none(), "{code}: no amendment for a text lot T3 does not change");
for lang in LANGS {
assert_eq!(text(&t2, code, lang), text(&today, code, lang), "{code} {lang}");
}
assert_eq!(t2a.superseded_by, Some(RETIREMENT), "{code}: out of the seed since lot T4");
continue;
}
let a = found.unwrap_or_else(|| panic!("{code}: the amendment of lot T3"));
assert_eq!((a.doc, a.version), ("acte-engagement-certification", Version::Open("1.0.0")), "{code}");
assert!(a.id.starts_with("2026-11-03-"), "{}", a.id);
let (title, body) = text(&t2, code, "fr");
assert_eq!(seed_amend::fingerprint(title, body), a.from, "{code}: the text production holds since M6c");
let (title, body) = text(&today, code, "fr");
assert_eq!(seed_amend::fingerprint(title, body), a.to, "{code}: the text of the seed today");
assert_ne!(a.from, a.to, "{code}");
// It follows lot T2's, which wrote the text it replaces; lot T4 takes the article out of
// the seed (ADR 0014), and with it this amendment.
assert_eq!(
(a.follows, t2a.to, t2a.superseded_by, a.superseded_by),
(Some(t2a.id), a.from, Some(a.id), Some(RETIREMENT)),
"{code}"
);
let t = amendment(code, "-quorum");
assert_eq!(
seed_amend::earlier(AMENDMENTS, a),
[t.follows.expect("the amendment of M5"), t.id, t2a.id],
"{code}: M5, lot T, lot T2"
);
}
}
/// Production's history, for these articles, after M6c (03/11, 11 h 05): F1.1 at its
/// revision 3, F1.2 and N1.1 at their revision 4, each written by an amendment of the seed.
async fn production_after_m6c(tag: &str) -> AppState {
let base = production_after_m6b(tag).await;
start(&base.db, t2_config(tag)).await
}
/// The history of a starting text at `revision`: « initial », then one « amendment » for each
/// text of the seed written over it.
fn history(revision: i64) -> Vec<&'static str> {
let amended = usize::try_from(revision - 1).expect("a revision");
std::iter::once("initial").chain(std::iter::repeat_n("amendment", amended)).collect()
}
#[tokio::test]
async fn production_after_m6c_gets_the_texts_without_dated_figures_once() {
let base = production_after_m6c("t3-apply").await;
let (t2, today) = (t2_seed(), m8_seed());
// The base is as production after M6c: the texts of lot T2, F1.1 at revision 3, F1.2 and
// N1.1 at revision 4.
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(&t2, code, "fr"), "{group} {code}");
assert_eq!(revision, if code == "F1.1" { 3 } else { 4 }, "{group} {code}");
assert_eq!(kinds(&base, &id).await, history(revision), "{group} {code}");
}
}
let lot_t2 = decisions(&base, "-phrases").await;
assert_eq!(lot_t2.len(), 6);
assert!(lot_t2.iter().all(|(_, _, o, _)| o == "applied"), "{lot_t2:?}");
assert!(decisions(&base, "-chiffres").await.is_empty(), "the seed of M6c decides nothing of lot T3");
let (before, before_tr) = (proposals(&base).await, translations(&base).await);
let outbox = count(&base, "SELECT COUNT(*) FROM outbox").await;
let revisions = count(&base, "SELECT COUNT(*) FROM revisions").await;
// Started with today's seed.
let state = start(&base.db, m8_config("t3-apply")).await;
let mut touched = Vec::new();
for group in GROUPS {
for code in T3_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}");
assert_eq!(revision, 5, "{group} {code}");
assert_eq!(kinds(&state, &id).await, history(5), "{group} {code}");
// The history keeps the text of lot T2, and says the new one, without author.
assert_eq!(
last_revision(&state, &id).await,
(5, "amendment".to_owned(), title.clone(), body.clone(), None),
"{group} {code}"
);
let (kind, t, b): (String, String, String) =
sqlx::query_as("SELECT kind, title, body FROM revisions WHERE proposal_id = ? AND revision = 4")
.bind(&id)
.fetch_one(&state.db)
.await
.expect("revision of lot T2");
assert_eq!(
(kind.as_str(), (t.as_str(), b.as_str())),
("amendment", text(&t2, code, "fr")),
"{group} {code}"
);
for lang in ["en", "es"] {
let (t, b, hash, origin, by): (String, String, String, String, Option<String>) = 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<String>)> = GROUPS
.iter()
.flat_map(|g| T3_CODES.iter().map(move |c| ((*g).to_owned(), (*c).to_owned(), "applied".to_owned(), None)))
.collect();
assert_eq!(decisions(&state, "-chiffres").await, expected);
// Lot T2's decisions stay as they were taken; superseded, it decides nothing more.
assert_eq!(decisions(&state, "-phrases").await, lot_t2);
// Nothing else is touched: F1.1, no other proposal, no other translation, no vote, four
// revisions.
let keep = |rows: Vec<Row>| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>();
assert_eq!(keep(proposals(&state).await), keep(before));
let others = |rows: Vec<(String, String, String, String, String)>| {
rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>()
};
assert_eq!(others(translations(&state).await), others(before_tr));
assert_eq!(count(&state, "SELECT COUNT(*) FROM votes").await, 0);
assert_eq!(count(&state, "SELECT COUNT(*) FROM revisions").await, revisions + 4);
// Monnaie libre republishes, once per article; the test group never.
let rows = queued(&state, "-chiffres").await;
assert_eq!(rows.len(), 2, "{rows:?}");
assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}");
assert_eq!(count(&state, "SELECT COUNT(*) FROM outbox").await, outbox + 2);
// Decided once: a second start replays nothing.
let again = start(&state.db, m8_config("t3-apply-again")).await;
assert_eq!(decisions(&again, "-chiffres").await, expected);
assert_eq!(proposals(&again).await, proposals(&state).await);
assert_eq!(translations(&again).await, translations(&state).await);
assert_eq!(count(&again, "SELECT COUNT(*) FROM revisions").await, revisions + 4);
assert_eq!(count(&again, "SELECT COUNT(*) FROM outbox").await, outbox + 2);
}
#[tokio::test]
async fn an_article_voted_on_keeps_the_text_of_t2_and_says_why() {
let base = production_after_m6c("t3-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 counted on N1.1, at its revision 4…
let (n11, ..) = proposal(&base, "monnaie-libre", "N1.1").await;
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source)
VALUES (?, 'dev:vera', 'for', 4, '2026-11-03T12:00:00Z', 'simulated')",
)
.bind(&n11)
.execute(&base.db)
.await
.expect("vote");
// …and a ballot still pending on F1.2, at its revision 4: cast, not proven yet.
let (f12, ..) = proposal(&base, "monnaie-libre", "F1.2").await;
sqlx::query(
"INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, mode,
created_at, expires_at)
VALUES ('b3', ?, 'dev:vera', 'salt', 'digest-b3', 'remark-b3', '', 0, 'pending', 'simulated',
'2026-11-03T12:00:00Z', '2026-11-04T12:00:00Z')",
)
.bind(&doc)
.execute(&base.db)
.await
.expect("ballot");
sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b3', ?, 4, 'against')")
.bind(&f12)
.execute(&base.db)
.await
.expect("line");
// In the test group, Luna votes « contre » N1.1 and keeps her vote; she votes on F1.2, then
// withdraws: a direct vote withdrawn leaves no trace (the guard's known limit, `seed_amend`).
let luna = Voter::login(&base, "Luna").await;
let (n11_essai, ..) = proposal(&base, "essai", "N1.1").await;
luna.vote(&n11_essai, Some("against")).await;
let (f12_essai, ..) = proposal(&base, "essai", "F1.2").await;
luna.vote(&f12_essai, Some("against")).await;
luna.vote(&f12_essai, None).await;
let state = start(&base.db, m8_config("t3-refuse")).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, "-chiffres").await,
[
applied("essai", "F1.2"),
refused("essai", "N1.1", "votes"),
refused("monnaie-libre", "F1.2", "ballots"),
refused("monnaie-libre", "N1.1", "votes"),
]
);
let (t2, today) = (t2_seed(), m8_seed());
for group in GROUPS {
for code in T3_CODES {
let kept = (group, code) != ("essai", "F1.2");
let (_, title, body, revision) = proposal(&state, group, code).await;
let expected = if kept { text(&t2, code, "fr") } else { text(&today, code, "fr") };
assert_eq!((title.as_str(), body.as_str()), expected, "{group} {code}");
assert_eq!(revision, 4 + i64::from(!kept), "{group} {code}");
}
}
// Nothing applied in Monnaie libre: nothing more to publish.
assert!(queued(&state, "-chiffres").await.is_empty());
// The refusals stand: decided once, never tried again, even once the vote is gone.
sqlx::query("DELETE FROM votes WHERE proposal_id = ?").bind(&n11).execute(&state.db).await.expect("delete");
let again = start(&state.db, m8_config("t3-refuse-again")).await;
assert_eq!(decisions(&again, "-chiffres").await, decisions(&state, "-chiffres").await);
let (_, title, body, _) = proposal(&again, "monnaie-libre", "N1.1").await;
assert_eq!((title.as_str(), body.as_str()), text(&t2, "N1.1", "fr"));
}
/// A base born on 02/11 with the seed of M5 (F1.2 and N1.1 at their revision 1, in M5's
/// words): lot T wrote their revision 2, lot T2 their revision 3, and lot T3 follows there too.
#[tokio::test]
async fn a_base_born_with_the_texts_of_m5_gets_the_texts_of_t3() {
let prod = production_seed();
let db = open_db("sqlite::memory:").await.expect("db");
let first = AppState::new(config(seed_dir("t3-born-m5", &prod)), db.clone()).await.expect("state");
seed::documents(&first).await.expect("born");
start(&db, config(seed_dir("t3-born-m5-m5", &prod))).await;
start(&db, config(seed_dir("t3-born-m5-m6", &with_texts(&m8_seed(), &prod, &CODES)))).await;
start(&db, m6b_config("t3-born-m5")).await;
let base = start(&db, t2_config("t3-born-m5")).await;
for group in GROUPS {
for code in T3_CODES {
let (id, ..) = proposal(&base, group, code).await;
assert_eq!(kinds(&base, &id).await, history(3), "{group} {code}: lots T and T2");
}
}
let state = start(&db, m8_config("t3-born-m5")).await;
let today = m8_seed();
for group in GROUPS {
for code in T3_CODES {
let (id, title, body, revision) = proposal(&state, group, code).await;
assert_eq!(((title.as_str(), body.as_str()), revision), (text(&today, code, "fr"), 4), "{group} {code}");
assert_eq!(kinds(&state, &id).await, history(4), "{group} {code}");
}
}
let got = decisions(&state, "-chiffres").await;
assert_eq!(got.len(), 4);
assert!(got.iter().all(|(_, _, o, _)| o == "applied"), "{got:?}");
}
/// A base born with the texts of T2 (a first start on the seed of M6c): their revision 1,
/// replaced as it is.
#[tokio::test]
async fn a_base_born_with_the_texts_of_t2_gets_the_texts_of_t3() {
let db = open_db("sqlite::memory:").await.expect("db");
start(&db, t2_config("t3-born-t2")).await;
let state = start(&db, m8_config("t3-born-t2")).await;
let today = m8_seed();
for group in GROUPS {
for code in CODES {
let (id, title, body, revision) = proposal(&state, group, code).await;
let changed = T3_CODES.contains(&code);
assert_eq!((title.as_str(), body.as_str()), text(&today, code, "fr"), "{group} {code}");
assert_eq!(revision, 1 + i64::from(changed), "{group} {code}");
assert_eq!(kinds(&state, &id).await, history(revision), "{group} {code}");
}
}
let got = decisions(&state, "-chiffres").await;
assert_eq!(got.len(), 4);
assert!(got.iter().all(|(_, _, o, _)| o == "applied"), "{got:?}");
}