From 7d8be18c3b470cc9923d22f81be4852b9f106fd8 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 23:38:07 +0100 Subject: [PATCH] Lot U3c : les tests d'abord, rouges sur le code de d66823b MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ld-engine inertia::when_everyone_votes_the_share_asked_is_m_on_every_electorate : pour tout W de 1 à 10 000 000, les cinq crans, G aux deux bornes de la participation habituelle, les quorums 1, 2 et W : quand les W votent, la part exigée des T′ = max(W, Q) votants est M, exactement ; la phrase d'avant (« la majorité M suffit », lue ⌈M·W⌉ « pour ») fausse sur 17 couples (W, cran), tous W ≤ 15, une voix de plus (B^W) ; vert sur le moteur inchangé, ses dents vues rouges par trois mutations (le plafond de la participation retiré, B^W mis dans la part, B^W retiré de R). - web/tests/unit/rules.test.ts : formula.reading épinglée en fr, en, es (« la part exigée est la majorité M ») ; aucune chaîne des catalogues ni du texte des composants ne dit que la majorité suffit ; la liste fermée de ce que l'écran dit de la pleine participation (formula.M, formula.reading, settings.reading) ; inertia.test.ts suit. 5 tests rouges sur 116. - tests/amorce.rs the_queue_names_the_starter_proposals_as_the_screen_does : les quatre raisons « Proposition d'amorce réécrite / ajoutée » (I1, E1, E2, E12), dans les constantes et dans les cinq lignes de file que T4 écrit sur une base comme la production après M8 ; celles de M6 inchangées. Rouge sur les constantes, et sur la file quand les constantes sont sautées. Co-Authored-By: Claude Opus 5.5 --- crates/ld-engine/src/inertia.rs | 98 ++++++++++++++++++++++++++++++++ crates/ld-server/tests/amorce.rs | 56 ++++++++++++++++++ web/tests/unit/inertia.test.ts | 3 +- web/tests/unit/rules.test.ts | 91 ++++++++++++++++++++++++++++- 4 files changed, 244 insertions(+), 4 deletions(-) diff --git a/crates/ld-engine/src/inertia.rs b/crates/ld-engine/src/inertia.rs index 9b79e87..85d0331 100644 --- a/crates/ld-engine/src/inertia.rs +++ b/crates/ld-engine/src/inertia.rs @@ -430,6 +430,104 @@ mod tests { } } + /// Lot U3c (review of d66823b), `formula.reading`: « quand tout le monde vote, la part exigée est + /// la majorité M ». On every W from 1 to 10 000 000 — the groups the server counts by their + /// members (the test group: 2) and the web of trust it takes as true (`wot::PLAUSIBLE`, 100 to + /// 10 000 000) — at the five levels, on both bounds of the quorum an author may set (in Monnaie + /// libre and in the test group: 1, 2, W) and at both bounds of the usual participation (G at its + /// lowest and at its highest): when the W vote, the voters judged are T′ = max(W, Q), never fewer + /// than W, and the share asked of them is M_k, exactly. + /// + /// The phrase of before, « quand tout le monde vote, la majorité M suffit », read as « ⌈M·W⌉ + /// « pour » pass », was false: R = B^W + M·W, and B^W adds one « pour » where M·W is a whole + /// number, on 17 (W, level) pairs, every W up to 15 (W 4, level 2: 3 « pour » of 4, not 2; W 5, + /// level 4: 4, not 3; W 10, level 4: 7, not 6) — none from W = 16 to 200, where it is checked. + /// The new phrase says nothing of B^W: `formula.B.wot` does, in the web of trust only. + #[test] + fn when_everyone_votes_the_share_asked_is_m_on_every_electorate() { + const LARGEST: u32 = 10_000_000; + let params: Vec<(InertiaLevel, f64, FormulaParams)> = InertiaLevel::ALL + .iter() + .flat_map(|k| { + [Participation::LOWEST, Participation::HIGHEST].map(|p| { + let scale = Scale { electorate: 1, participation: p, quorums: [1; 5] }; + (*k, k.majority_pct() / 100.0, scale.params(*k, 1)) + }) + }) + .collect(); + let mut cases = 0u64; + for w in 1..=LARGEST { + let ((lowest, highest), (lowest_essai, _)) = (quorum_bounds(w, false), quorum_bounds(w, true)); + // The share reads the voters judged only: W for every quorum up to W, the quorum above + // it (2 on a web of 1, outside the test group). + let judged = [lowest_essai, lowest, highest].map(|q| judged_voters(w, q)); + assert!(judged.iter().all(|v| *v >= w), "W {w}: {judged:?}"); + for (i, voters) in judged.iter().enumerate() { + if judged[..i].contains(voters) { + continue; + } + for (k, m, p) in ¶ms { + let share = required_ratio(w, *voters, p); + assert_eq!(share, *m, "W {w}, level {}, G {}, {voters} voters judged", k.get(), p.gradient); + cases += 1; + } + } + } + // Every W, ten (level, G) each, and on a web of 1 the quorum of 2 besides. + assert_eq!(cases, u64::from(LARGEST) * 10 + 10); + // The share the evaluation gives (`Evaluation::required_ratio`), on every split of the W + // voters, on the smallest electorates and on the web of trust. + for w in (1..=40).chain([100, 101, 5707, 7224, LARGEST]) { + let scale = + Scale { electorate: w, participation: Participation::of(w, 50.0, None, 0).ratio, quorums: [2; 5] }; + for k in InertiaLevel::ALL { + for q in [quorum_bounds(w, false).0, w] { + for against in [0, w / 3, w / 2, w] { + let tally = Tally { for_votes: w - against, against }; + let e = scale.evaluate(tally, k, q).expect("valid"); + assert_eq!(e.required_ratio, k.majority_pct() / 100.0, "W {w}, level {}, Q {q}", k.get()); + } + } + } + } + // « La majorité M suffit » was false: the « pour » asked when the W vote, against ⌈M·W⌉. + let mut more = Vec::new(); + for w in 1..=200u32 { + for k in InertiaLevel::ALL { + let p = Scale { electorate: w, participation: 0.1, quorums: [1; 5] }.params(k, 1); + let asked = crate::threshold::wot_threshold(w, w, &p).expect("valid"); + let majority = (k.majority_pct() as u32 * w).div_ceil(100); + if asked != majority { + assert_eq!(asked, majority + 1, "W {w}, level {}: one « pour » more, B^W", k.get()); + assert_eq!(k.majority_pct() as u32 * w % 100, 0, "W {w}: M·W is a whole number"); + more.push((w, k.get())); + } + } + } + assert_eq!( + more, + [ + (2, 1), + (2, 2), + (4, 1), + (4, 2), + (5, 4), + (6, 1), + (6, 2), + (8, 1), + (8, 2), + (10, 1), + (10, 2), + (10, 4), + (12, 1), + (12, 2), + (14, 1), + (14, 2), + (15, 4) + ] + ); + } + #[test] fn an_author_sets_a_quorum_from_two_to_w_from_one_in_the_test_group() { assert_eq!(quorum_bounds(5745, false), (2, 5745)); diff --git a/crates/ld-server/tests/amorce.rs b/crates/ld-server/tests/amorce.rs index 9b11be1..631b82e 100644 --- a/crates/ld-server/tests/amorce.rs +++ b/crates/ld-server/tests/amorce.rs @@ -710,6 +710,62 @@ async fn production_after_m6c_gets_lot_t4_once_and_nothing_is_erased() { production_gets_lot_t4_once(production_after_m6c("m6c").await, 4).await; } +/// Lot U3c (review of d66823b): the reasons of the queue lines lot T4 writes become lines of the +/// public commit of the texts when the first text enters into force. They name the seed proposals +/// as the screen does, « Proposition d'amorce » (Yvv's word), never « Proposition seed ». The +/// amendments of M6, applied in production (`seed_proposal_amend`), keep theirs: their lines are +/// already in its queue. +#[tokio::test] +async fn the_queue_names_the_starter_proposals_as_the_screen_does() { + const REWRITTEN: [(&str, &str); 3] = [ + ("2026-11-03-i1-amorce", "Proposition d'amorce réécrite : I1, la Raison d'être (texte d'Yvv du 03/11)"), + ( + "2026-11-03-e1-amorce", + "Proposition d'amorce réécrite : E1, l'unicité du compte certifiant (texte d'Yvv du 03/11)", + ), + ( + "2026-11-03-e2-amorce", + "Proposition d'amorce réécrite : E2, la réciprocité des trois principes (texte d'Yvv du 03/11)", + ), + ]; + const ADDED: &str = "Proposition d'amorce ajoutée : E12, Responsabilité civique (texte d'Yvv du 03/11)"; + for (id, reason) in REWRITTEN { + let a = ld_server::seed_amend::AMENDMENTS.iter().find(|a| a.id == id).expect(id); + assert_eq!(a.reason, reason, "{id}"); + } + let e12 = ld_server::article_amend::AMENDMENTS.iter().find(|a| a.id == ADD).expect(ADD); + assert_eq!(e12.reason, ADDED); + // The lines lot T4 writes on a base as production holds it after M8: Monnaie libre only. + let base = production_after_m8("reasons").await; + let last: i64 = count(&base, "SELECT IFNULL(MAX(id), 0) FROM outbox").await; + let state = t4(&base).await; + let written: Vec<(String, String)> = sqlx::query_as( + "SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.id > ? ORDER BY o.id", + ) + .bind(last) + .fetch_all(&state.db) + .await + .expect("outbox"); + assert!(written.iter().all(|(g, _)| g == "monnaie-libre"), "{written:?}"); + let reasons: Vec<&str> = written.iter().map(|(_, r)| r.as_str()).collect(); + let between = format!("{ADDED}, entre E1 et E2"); + for line in REWRITTEN.iter().map(|(_, r)| *r).chain([between.as_str()]) { + assert_eq!(reasons.iter().filter(|r| **r == line).count(), 1, "« {line} » in {reasons:?}"); + } + assert_eq!(reasons.len(), 5, "and the retirement of the rules of operation: {reasons:?}"); + assert!(reasons.iter().all(|r| !r.contains("seed")), "{reasons:?}"); + // M6's, unchanged. + let m6: Vec<&str> = ld_server::seed_proposal_amend::AMENDMENTS.iter().map(|a| a.reason).collect(); + assert_eq!( + m6, + [ + "Proposition seed : I1 sort de la vigueur", + "Proposition seed : E1 sort de la vigueur", + "Proposition seed : E2 sort de la vigueur" + ] + ); +} + // --------------------------------------------------------------------------- // After the retirement, the API refuses everything on them // --------------------------------------------------------------------------- diff --git a/web/tests/unit/inertia.test.ts b/web/tests/unit/inertia.test.ts index 476be44..7f0edfe 100644 --- a/web/tests/unit/inertia.test.ts +++ b/web/tests/unit/inertia.test.ts @@ -38,8 +38,9 @@ describe('five levels of inertia', () => { expect(fr['inertia.explain']).toBe( "Plus l'inertie est haute, plus il faut de voix pour changer ce texte. Sous le quorum de l'article, les votants qui manquent comptent comme « non » ; au-delà, la part exigée baisse quand la participation monte.", ); + // Lot U3c: « la part exigée est la majorité M », no longer « la majorité M suffit » (rules.test.ts). expect(fr['formula.reading']).toBe( - "Sous le quorum Q de l'article, les votants qui manquent comptent comme « non » : il faut autant de « pour » que si Q personnes avaient voté. Au-delà, la part de « pour » demandée, la plus haute au quorum, baisse quand la participation monte ; quand tout le monde vote, la majorité M suffit.", + "Sous le quorum Q de l'article, les votants qui manquent comptent comme « non » : il faut autant de « pour » que si Q personnes avaient voté. Au-delà, la part de « pour » demandée, la plus haute au quorum, baisse quand la participation monte ; quand tout le monde vote, la part exigée est la majorité M.", ); expect(fr['quorum.error.voted']).toBe("Quelqu'un a déjà voté sur cet article : son quorum ne change pas."); }); diff --git a/web/tests/unit/rules.test.ts b/web/tests/unit/rules.test.ts index 46e5edb..bcc32f6 100644 --- a/web/tests/unit/rules.test.ts +++ b/web/tests/unit/rules.test.ts @@ -4,6 +4,8 @@ // (docs/chantiers/acte-certification/t3c-liste-fermee.md) on the texts of M8, kept in // crates/ld-server/tests/fixtures/seed-m8/. The inventory of where each lives: // docs/chantiers/acte-certification/U3-informations.md. +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { en } from '../../src/lib/i18n/en'; import { es } from '../../src/lib/i18n/es'; @@ -65,11 +67,13 @@ describe('the rules the screen says, true for every W and every quorum (lots T3 // Each phrase, and the test that proves it (crates/ld-server/tests/…). const PROVEN: [keyof typeof fr, string, string][] = [ // seed_quorum_texts.rs under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted (N1.1 2, F1.2 12); - // the_share_asked_falls_beyond_the_article_s_quorum_down_to_m (N1.1 3). + // the_share_asked_falls_beyond_the_article_s_quorum_down_to_m (N1.1 3); ld-engine + // inertia::when_everyone_votes_the_share_asked_is_m_on_every_electorate (« la part exigée est la + // majorité M », W from 1 to 10 000 000, lot U3c). [ 'formula.reading', - "Sous le quorum Q de l'article, les votants qui manquent comptent comme « non » : il faut autant de « pour » que si Q personnes avaient voté. Au-delà, la part de « pour » demandée, la plus haute au quorum, baisse quand la participation monte ; quand tout le monde vote, la majorité M suffit.", - 'under_the_quorum_… · the_share_asked_falls_…', + "Sous le quorum Q de l'article, les votants qui manquent comptent comme « non » : il faut autant de « pour » que si Q personnes avaient voté. Au-delà, la part de « pour » demandée, la plus haute au quorum, baisse quand la participation monte ; quand tout le monde vote, la part exigée est la majorité M.", + 'under_the_quorum_… · the_share_asked_falls_… · ld-engine inertia::when_everyone_votes_…', ], // the_formulas_of_the_texts_are_those_the_engine_computes (T' = max(T, Q), F1.1, N1.1 4). ['formula.Tprime', "T′ — votants retenus : {n}, T ou Q s'il en manque", 'the_formulas_…'], @@ -139,3 +143,84 @@ describe('the rules the screen says, true for every W and every quorum (lots T3 } }); }); + +// Lot U3c (review of d66823b): « quand tout le monde vote, la majorité M suffit » was false on a small +// electorate. When the W vote, R = B^W + M·W; when M·W is a whole number, B^W adds one « pour » on +// every W up to 15 (W 4, level 2: 3 « pour » of 4, not 2; W 5, level 4: 4 of 5, not 3). What holds for +// every W and every quorum is the share asked: M at T′ = W — ld-engine +// inertia::when_everyone_votes_the_share_asked_is_m_on_every_electorate, W from 1 to 10 000 000. +// B^W is said apart, in the web of trust only (`formula.B.wot`). +describe('when everyone votes, the share asked is M — never « M suffices » (lot U3c)', () => { + it('the reading of the formula says it, in the three languages', () => { + expect(fr['formula.reading']).toBe( + "Sous le quorum Q de l'article, les votants qui manquent comptent comme « non » : il faut autant de « pour » que si Q personnes avaient voté. Au-delà, la part de « pour » demandée, la plus haute au quorum, baisse quand la participation monte ; quand tout le monde vote, la part exigée est la majorité M.", + ); + expect(en['formula.reading']).toBe( + "Below the article's quorum Q, the missing voters count as against: it takes as many votes in favour as if Q people had voted. Beyond it, the share of votes in favour asked for, highest at the quorum, falls as turnout rises; when everyone votes, the share asked is the majority M.", + ); + expect(es['formula.reading']).toBe( + 'Por debajo del quórum Q del artículo, los votantes que faltan cuentan como «no»: hacen falta tantos votos a favor como si hubieran votado Q personas. Más allá, la proporción de votos a favor pedida, la más alta en el quórum, baja cuando sube la participación; cuando vota todo el mundo, la proporción exigida es la mayoría M.', + ); + }); + + // « M suffit », « la majorité suffit », « majority M is enough », « basta la mayoría M »…: false for + // W up to 15, so never said, in a catalogue or in the text of a component. + const SUFFICES: Record = { + fr: /\bM suffi|majorit[ée][^.;:]*suffi|suffi[^.;:]*majorit[ée]/i, + en: /majority[^.;:]*(enough|suffice|sufficient)|(enough|suffice|sufficient)[^.;:]*majority/i, + es: /mayor[íi]a[^.;:]*(basta|suficiente|alcanza)|(basta|suficiente|alcanza con)[^.;:]*mayor[íi]a/i, + }; + it('no string of the interface says the majority suffices', () => { + for (const [lang, cat] of Object.entries(CATALOGS)) { + for (const [k, v] of Object.entries(cat)) expect(v, `${lang} ${k}`).not.toMatch(SUFFICES[lang as keyof typeof CATALOGS]); + } + const svelte = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const p = join(dir, name); + return statSync(p).isDirectory() ? svelte(p) : p.endsWith('.svelte') ? [p] : []; + }); + for (const file of svelte(join(__dirname, '../../src'))) { + const shown = readFileSync(file, 'utf8') + .replace(//g, '') + .replace(//g, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' '); + for (const re of Object.values(SUFFICES)) expect(shown, file).not.toMatch(re); + } + }); + + // The closed list of what the screen says of full turnout, each judged against the engine: the + // share asked, M — true for every W (the grid above). A new one fails here until it is judged. + it('what the screen says of full turnout is the share asked, M', () => { + const FULL: Record = { + fr: /tout le monde vote|pleine participation/, + en: /everyone votes|full turnout/, + es: /vota todo el mundo|participación plena/, + }; + for (const [lang, cat] of Object.entries(CATALOGS)) { + const told = Object.entries(cat) + .filter(([, v]) => FULL[lang as keyof typeof CATALOGS].test(v)) + .map(([k]) => k); + // formula.M names the letter, « M — majorité à pleine participation » (the share asked when the W + // vote); formula.reading and settings.reading: « la part exigée … la majorité M ». + expect(told, lang).toEqual(['formula.M', 'formula.reading', 'settings.reading']); + } + const SHARE = [ + [ + 'formula.reading', + { fr: 'la part exigée est la majorité M', en: 'the share asked is the majority M', es: 'la proporción exigida es la mayoría M' }, + ], + [ + 'settings.reading', + { + fr: "plus la part exigée baisse, jusqu'à la majorité M quand tout le monde vote", + en: 'the lower the share asked gets, down to the majority M when everyone votes', + es: 'más baja la proporción exigida, hasta la mayoría M cuando vota todo el mundo', + }, + ], + ] as const; + for (const [k, share] of SHARE) { + for (const [lang, cat] of Object.entries(CATALOGS)) expect(cat[k], `${lang} ${k}`).toContain(share[lang as keyof typeof CATALOGS]); + } + }); +});