Lot U3c : les tests d'abord, rouges sur le code de d66823b

- 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 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 23:38:07 +01:00
co-authored by Claude Opus 5.5
parent 842a546796
commit 7d8be18c3b
4 changed files with 244 additions and 4 deletions
+98
View File
@@ -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 &params {
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] #[test]
fn an_author_sets_a_quorum_from_two_to_w_from_one_in_the_test_group() { fn an_author_sets_a_quorum_from_two_to_w_from_one_in_the_test_group() {
assert_eq!(quorum_bounds(5745, false), (2, 5745)); assert_eq!(quorum_bounds(5745, false), (2, 5745));
+56
View File
@@ -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; 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 // After the retirement, the API refuses everything on them
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+2 -1
View File
@@ -38,8 +38,9 @@ describe('five levels of inertia', () => {
expect(fr['inertia.explain']).toBe( 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.", "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( 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."); expect(fr['quorum.error.voted']).toBe("Quelqu'un a déjà voté sur cet article : son quorum ne change pas.");
}); });
+88 -3
View File
@@ -4,6 +4,8 @@
// (docs/chantiers/acte-certification/t3c-liste-fermee.md) on the texts of M8, kept in // (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: // crates/ld-server/tests/fixtures/seed-m8/. The inventory of where each lives:
// docs/chantiers/acte-certification/U3-informations.md. // 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 { describe, expect, it } from 'vitest';
import { en } from '../../src/lib/i18n/en'; import { en } from '../../src/lib/i18n/en';
import { es } from '../../src/lib/i18n/es'; 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/…). // Each phrase, and the test that proves it (crates/ld-server/tests/…).
const PROVEN: [keyof typeof fr, string, string][] = [ 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); // 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', '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.", "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_…', '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). // 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_…'], ['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<keyof typeof CATALOGS, RegExp> = {
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(/<script[\s\S]*?<\/script>/g, '')
.replace(/<style[\s\S]*?<\/style>/g, '')
.replace(/<!--[\s\S]*?-->/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<keyof typeof CATALOGS, RegExp> = {
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]);
}
});
});