diff --git a/crates/ld-engine/src/threshold.rs b/crates/ld-engine/src/threshold.rs index 0d87d5e..8e8a504 100644 --- a/crates/ld-engine/src/threshold.rs +++ b/crates/ld-engine/src/threshold.rs @@ -6,9 +6,12 @@ //! ``` //! //! R is the minimum number of *for* votes; a version passes when `for ≥ ⌈R⌉`. -//! W is the size of the electorate, T the votes cast (for + against). At low -//! participation the formula asks for near-unanimity, at full participation the -//! simple majority M suffices. Q is the **quorum** (ADR 0013, Yvv, 2026-11-02: +//! W is the size of the electorate, T the votes cast (for + against). The share of +//! *for* asked is judged on T' voters, never fewer than the quorum: it is at its +//! highest at the quorum (the share of the level at T' = Q), then falls as turnout +//! rises above it, down to the simple majority M when the whole electorate votes. +//! Below the quorum it does not rise any further: the voters missing count as +//! against. Q is the **quorum** (ADR 0013, Yvv, 2026-11-02: //! « ce n'est pas un nombre de vote pour a priori, c'est plutôt un quorum, à partir //! de ce nombre, alors il faut la bonne proportion attendue par l'indice ») — and //! below it the voters missing count as against (`judged_voters`). It replaces the @@ -234,6 +237,28 @@ mod tests { assert_eq!(e.missing, 0); } + #[test] + fn the_share_is_highest_at_the_quorum_then_falls_to_the_majority() { + // What the head of this module says (lot U2, review of M6c): judged on T' voters, the + // share of *for* is at its highest at the quorum, falls as turnout rises, and is M when + // the whole electorate votes; below the quorum it stays that of the quorum. + let (w, q) = (5745, 12); + let p = standard(q); + let share = |cast: u32| required_ratio(w, judged_voters(cast, q), &p); + for cast in 0..q { + assert!((share(cast) - share(q)).abs() < 1e-12, "below the quorum, at {cast}"); + } + let mut last = share(q); + for cast in (q + 1..w).step_by(7).chain([w]) { + let now = share(cast); + assert!(now < last, "falls at {cast}: {now} after {last}"); + last = now; + } + assert!((share(w) - 0.5).abs() < 1e-12, "M at full turnout"); + // Never « near-unanimity »: at this level, 85 % of the quorum. + assert!(share(q) > 0.8 && share(q) < 0.9, "at the quorum: {}", share(q)); + } + #[test] fn parity_with_the_typescript_reference() { // Values computed by maquette/frontend/app/engine/threshold.ts (C = 0). diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 57b6641..e5371d2 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -209,8 +209,8 @@ export const en: Messages = { 'title.proposed.other': '{n} titles proposed', 'tally.short': '{for} for · {against} against', - 'tally.missing.one': '{n} more vote in favor needed.', - 'tally.missing.other': '{n} more votes in favor needed.', + 'tally.missing.one': '{n} more vote in favour needed.', + 'tally.missing.other': '{n} more votes in favour needed.', 'tally.reached': 'Threshold reached.', 'tally.level': 'Level: {pct}% of the threshold', 'inertia.name.1': 'very low', @@ -242,15 +242,15 @@ export const en: Messages = { 'vote.simulated.mark': 'simulated vote', 'vote.others': 'Other versions', 'vote.others.count': 'Other versions ({n})', - 'vote.need': '{yes} yes to pass · {no} no to block', - 'vote.need.quorum': '{yes} yes to pass · the quorum blocks it', - 'vote.need.first.one': '{yes} yes out of {quorum} voter to pass', - 'vote.need.first.other': '{yes} yes out of {quorum} voters to pass', - 'vote.need.short': 'passes at {yes} yes · blocked at {no} no', - 'vote.need.short.quorum': 'passes at {yes} yes', - 'vote.need.short.first': '{yes} yes out of {quorum}', - 'vote.need.title.one': 'Quorum: {n} voter. Below it, the missing voters count as “no”.', - 'vote.need.title.other': 'Quorum: {n} voters. Below it, the missing voters count as “no”.', + 'vote.need': '{yes} for to pass · {no} against to block', + 'vote.need.quorum': '{yes} for to pass · the quorum blocks it', + 'vote.need.first.one': '{yes} for out of {quorum} voter to pass', + 'vote.need.first.other': '{yes} for out of {quorum} voters to pass', + 'vote.need.short': 'passes at {yes} for · blocked at {no} against', + 'vote.need.short.quorum': 'passes at {yes} for', + 'vote.need.short.first': '{yes} for out of {quorum}', + 'vote.need.title.one': 'Quorum: {n} voter. Below it, the missing voters count as against.', + 'vote.need.title.other': 'Quorum: {n} voters. Below it, the missing voters count as against.', 'vote.retracted': 'Vote withdrawn.', 'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.', 'vote.reconfirm': 'Reconfirm: {choice}', @@ -295,7 +295,7 @@ export const en: Messages = { 'quorum.heading.other': 'Quorum · {n} voters', 'quorum.default.one': 'By default, that of its level: {n} voter.', 'quorum.default.other': 'By default, that of its level: {n} voters.', - 'quorum.explain': 'You set the quorum of this article as long as nobody has voted. Below it, the missing voters count as “no”.', + 'quorum.explain': 'You set the quorum of this article as long as nobody has voted. Below it, the missing voters count as against.', 'quorum.field': 'Quorum of the article', 'quorum.set': 'Set this quorum', 'quorum.reset': 'Back to the default ({n})', @@ -400,7 +400,7 @@ export const en: Messages = { 'ballots.verify.unreadable': 'This is not a readable vote receipt.', 'inertia.title': 'Inertia', - 'inertia.explain': 'The higher the inertia, the more votes it takes to change this text. Below the quorum, the missing voters count as “no”; beyond it, the share of “yes” asked for falls as turnout rises.', + 'inertia.explain': 'The higher the inertia, the more votes it takes to change this text. Below the quorum, the missing voters count as against; beyond it, the share of votes in favour asked for falls as turnout rises.', 'inertia.change': 'Propose an inertia change', 'inertia.change.to': 'New inertia', 'inertia.change.note': "A change of inertia is itself voted at the article's inertia: {level}.", @@ -411,7 +411,7 @@ export const en: Messages = { 'inertia.history.amendment': 'Went from {from} to {to} on {date}: starting setting of the text.', 'formula.title': 'The threshold formula', - 'formula.intro': 'The number of votes in favor required, R, depends on turnout:', + 'formula.intro': 'The number of votes in favour required, R, depends on turnout:', 'formula.W': 'W — electorate: {n} ({source})', 'formula.W.wot': 'members of the Ğ1 web of trust', 'formula.W.group': 'group members', @@ -420,7 +420,7 @@ export const en: Messages = { 'formula.G': 'G — gradient: {n}', 'formula.B': 'B — base: {n}', 'formula.Q': 'Q — quorum: {n}', - 'formula.R': 'R — current threshold: {n} votes in favor', + 'formula.R': 'R — current threshold: {n} votes in favour', 'formula.reading': 'Below the quorum Q, the missing voters count as against. Beyond it, the share of votes in favour asked for, highest at the quorum, falls as turnout rises; when everyone votes, majority M is enough.', 'common.pct': '{n}%', 'settings.title': 'Formula settings', @@ -447,7 +447,7 @@ export const en: Messages = { 'settings.required.version.other': "Votes for: at {n} voters, this version's turnout.", 'settings.required.usual.one': 'Votes for: at {n} voter, as usual.', 'settings.required.usual.other': 'Votes for: at {n} voters, as usual.', - 'settings.reading': 'At each level, the group asks for its share of votes in favour when it votes as usual; the more voters, the closer it comes to M. Below the quorum of the level, the missing voters count as against.', + 'settings.reading': 'At each level, the group asks for its share of votes in favour when it votes as usual; beyond the quorum, the more voters, the closer it comes to M. Below the quorum of the level, the missing voters count as against.', 'history.title': 'Corrections', 'history.initial': 'initial text', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index e81ad42..0ac74ad 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -447,7 +447,7 @@ export const es: Messages = { 'settings.required.version.other': 'Votos a favor: con {n} votantes, la participación de esta versión.', 'settings.required.usual.one': 'Votos a favor: con {n} votante, como de costumbre.', 'settings.required.usual.other': 'Votos a favor: con {n} votantes, como de costumbre.', - 'settings.reading': 'En cada nivel, el grupo pide su parte de votos a favor cuando vota como de costumbre; cuantos más votantes, más se acerca a M. Por debajo del quórum del nivel, los votantes que faltan cuentan como «no».', + 'settings.reading': 'En cada nivel, el grupo pide su parte de votos a favor cuando vota como de costumbre; más allá del quórum, cuantos más votantes, más se acerca a M. Por debajo del quórum del nivel, los votantes que faltan cuentan como «no».', 'history.title': 'Correcciones', 'history.initial': 'texto inicial', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index b7beb00..ea0ef24 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -451,7 +451,7 @@ export const fr = { 'settings.required.version.other': 'Voix pour : à {n} votants, la participation de cette version.', 'settings.required.usual.one': "Voix pour : à {n} votant, comme d'habitude.", 'settings.required.usual.other': "Voix pour : à {n} votants, comme d'habitude.", - 'settings.reading': "À chaque cran, le groupe demande sa part de « pour » quand il vote comme d'habitude ; plus il y a de votants, plus elle se rapproche de M. Sous le quorum du cran, les votants qui manquent comptent comme « non ».", + 'settings.reading': "À chaque cran, le groupe demande sa part de « pour » quand il vote comme d'habitude ; au-delà du quorum, plus il y a de votants, plus elle se rapproche de M. Sous le quorum du cran, les votants qui manquent comptent comme « non ».", 'history.title': 'Corrections', 'history.initial': 'texte initial', diff --git a/web/tests/unit/lexicon.test.ts b/web/tests/unit/lexicon.test.ts index 29943d1..c577ff3 100644 --- a/web/tests/unit/lexicon.test.ts +++ b/web/tests/unit/lexicon.test.ts @@ -69,8 +69,10 @@ describe('the reading of the formula', () => { describe('English: one vocabulary', () => { it('« Pour » and « Contre » are for and against, never yes and no', () => { for (const [k, v] of Object.entries(en)) { - expect(v, k).not.toMatch(/\byes\b/i); - expect(v, k).not.toMatch(/“no”|\{no\} no\b/); + // What is read, the placeholders ({yes}, {no}: the numbers) left out. + const shown = v.replace(/\{\w+\}/g, '#'); + expect(shown, k).not.toMatch(/\byes\b/i); + expect(shown, k).not.toMatch(/“no”|# no\b/); } }); it('one spelling, the one that dominates: favour', () => {