From 71819f9012102105171a174ba90f49c5a031ff32 Mon Sep 17 00:00:00 2001 From: Yvv Date: Fri, 30 Oct 2026 19:41:33 +0100 Subject: [PATCH] =?UTF-8?q?v3=20:=20relecture=20de=20a576b68=20=E2=80=94?= =?UTF-8?q?=20les=20propositions=20retir=C3=A9es=20sortent=20de=20la=20m?= =?UTF-8?q?=C3=A9diane,=20les=20r=C3=A9glages=20montrent=20la=20vraie=20pa?= =?UTF-8?q?rticipation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La médiane de participation ne compte plus les propositions retirées (invisibles, votes non repris). Quand l'échelle ramène la participation dans ses bornes, les réglages disent la médiane mesurée (ou deux fois C) et, à part, la valeur sur laquelle les seuils se calculent. Co-Authored-By: Claude Opus 5.5 --- crates/ld-server/src/participation.rs | 4 +- crates/ld-server/tests/inertia.rs | 36 ++++++++++++++++++ docs/adr/0007-inertie-cinq-crans.md | 9 +++-- web/src/lib/components/FormulaSettings.svelte | 8 ++-- web/src/lib/i18n/en.ts | 3 +- web/src/lib/i18n/es.ts | 3 +- web/src/lib/i18n/fr.ts | 3 +- web/src/lib/participation.ts | 12 ++++++ web/tests/unit/participation.test.ts | 37 +++++++++++++++++++ 9 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/participation.ts create mode 100644 web/tests/unit/participation.test.ts diff --git a/crates/ld-server/src/participation.rs b/crates/ld-server/src/participation.rs index 36b631f..9a16587 100644 --- a/crates/ld-server/src/participation.rs +++ b/crates/ld-server/src/participation.rs @@ -38,13 +38,15 @@ pub struct Measure { } /// The numbers of voters of the group's proposals voted on since `since`. +/// Drafts and withdrawn proposals stay out: neither is shown, and votes on a +/// withdrawn proposal can no longer be taken back. pub async fn sample(state: &AppState, group: &str, since: &str) -> sqlx::Result> { let rows: Vec = sqlx::query_scalar( "SELECT COUNT(*) FROM votes v JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id - WHERE d.group_slug = ? AND p.status <> 'draft' AND v.revision = p.revision + WHERE d.group_slug = ? AND p.status NOT IN ('draft', 'withdrawn') AND v.revision = p.revision GROUP BY v.proposal_id HAVING MAX(v.cast_at) >= ?", ) diff --git a/crates/ld-server/tests/inertia.rs b/crates/ld-server/tests/inertia.rs index 3904c68..2d627ff 100644 --- a/crates/ld-server/tests/inertia.rs +++ b/crates/ld-server/tests/inertia.rs @@ -274,6 +274,42 @@ async fn the_usual_participation_is_the_median_of_five_voted_proposals() { ); } +#[tokio::test] +async fn a_withdrawn_proposal_leaves_the_usual_participation() { + // Relecture of a576b68: withdrawn proposals vanish from the document and their + // votes can no longer be taken back, so they must not weigh on the median. + let (mut first, state) = Client::new().await; + let mut people = Vec::new(); + for name in ["W1", "W2", "W3", "W4"] { + let mut c = first.other(); + c.login(name).await; + people.push(c); + } + first.login("W1").await; + let doc = first.doc("essai").await; + let open: Vec = ["I2", "T1", "E4", "E5", "E6"] + .iter() + .map(|code| unit(&doc, code)["proposals"][0]["id"].as_str().expect("open").to_owned()) + .collect(); + for p in &open { + for c in people.iter_mut().take(2) { + c.vote(p, "against").await; + } + } + ld_server::participation::refresh(&state, true).await.expect("refresh"); + let p = first.doc("essai").await["doc"]["participation"].clone(); + assert_eq!((p["source"].as_str(), p["sample"].as_u64()), (Some("median"), Some(5))); + + sqlx::query("UPDATE proposals SET status = 'withdrawn' WHERE id = ?") + .bind(&open[0]) + .execute(&state.db) + .await + .expect("withdraw"); + ld_server::participation::refresh(&state, true).await.expect("refresh"); + let p = first.doc("essai").await["doc"]["participation"].clone(); + assert_eq!((p["source"].as_str(), p["sample"].as_u64()), (Some("reference"), Some(4))); +} + // --------------------------------------------------------------------------- // The base as production holds it on 2026-10-30, then the new version // --------------------------------------------------------------------------- diff --git a/docs/adr/0007-inertie-cinq-crans.md b/docs/adr/0007-inertie-cinq-crans.md index 66ad7c2..1b5b3dd 100644 --- a/docs/adr/0007-inertie-cinq-crans.md +++ b/docs/adr/0007-inertie-cinq-crans.md @@ -37,8 +37,8 @@ s_k = M_k + (1 − M_k)·(1 − p̃^G_k) ⇔ G_k = ln(1 − (s_k − M_k)/(1 **La participation habituelle p̃** d'un groupe (`crates/ld-server/src/participation.rs`) : -- l'échantillon : les propositions du groupe (tous ses documents, toutes sortes, brouillons - exceptés) qui portent au moins un vote sur leur révision actuelle, le dernier exprimé depuis +- l'échantillon : les propositions du groupe (tous ses documents, toutes sortes, brouillons et + propositions retirées exceptés) qui portent au moins un vote sur leur révision actuelle, le dernier exprimé depuis moins de 365 jours ; - T de chacune est compté **au moment de la mesure** : c'est le T que voit la formule, le vote étant permanent ; @@ -65,7 +65,10 @@ s_k = M_k + (1 − M_k)·(1 − p̃^G_k) ⇔ G_k = ln(1 − (s_k − M_k)/(1 La borne basse limite aussi l'effet de propositions à une voix multipliées pour faire baisser la médiane (une médiane basse adoucit les seuils au-dessus d'elle) : à 1 % près, l'effet est -borné, et il faudrait plus de propositions de ce genre que de vraies. +borné, et il faudrait plus de propositions de ce genre que de vraies. Les propositions retirées +sortent de l'échantillon (relecture de `a576b68`, 2026-10-30) : sinon, créer cinq propositions, y +voter puis les retirer tirerait la médiane vers le bas sans laisser de trace visible, et leurs +votes ne pourraient plus être repris. **Le recalcul : une fois par jour.** La mesure est gardée dans `settings` (`participation:` : médiane, taille de l'échantillon, date) ; elle est reprise au diff --git a/web/src/lib/components/FormulaSettings.svelte b/web/src/lib/components/FormulaSettings.svelte index 178c713..ddcd9bc 100644 --- a/web/src/lib/components/FormulaSettings.svelte +++ b/web/src/lib/components/FormulaSettings.svelte @@ -6,6 +6,7 @@ import { api } from '$lib/api'; import type { DocState } from '$lib/doc.svelte'; import { i18n, t } from '$lib/i18n/index.svelte'; + import { usualShown } from '$lib/participation'; import type { Inertia, LevelInfo } from '$lib/types'; let { doc, proposalId = null }: { doc: DocState; proposalId?: string | null } = $props(); @@ -33,6 +34,7 @@ const view = $derived(doc.view); const p = $derived(view?.doc.participation); + const shown = $derived(p && view ? usualShown(p, view.doc.electorate, view.doc.voteFloor) : null); const levels = $derived(version?.levels ?? view?.levels ?? []); const cast = $derived(levels[0]?.cast ?? 0); const current = $derived(version?.proposal.votedAtInertia ?? null); @@ -56,12 +58,12 @@

{t('settings.usual')}

{#if p.source === 'median'} - {i18n.plural('settings.usual.median', p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(p.ratio), sample: p.sample })} + {i18n.plural('settings.usual.median', shown?.voters ?? p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(shown?.ratio ?? p.ratio), sample: p.sample })} {:else} - {i18n.plural('settings.usual.reference', p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(p.ratio), min: p.minSample, sample: p.sample })} + {i18n.plural('settings.usual.reference', shown?.voters ?? p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(shown?.ratio ?? p.ratio), min: p.minSample, sample: p.sample })} {/if}

- {#if p.bounded}

{t('settings.usual.bounded', { lo: pct(p.bounds[0]), hi: pct(p.bounds[1]) })}

{/if} + {#if p.bounded}

{i18n.plural('settings.usual.bounded', p.voters, { lo: pct(p.bounds[0]), hi: pct(p.bounds[1]), pct: pct(p.ratio) })}

{/if}

{p.measuredAt ? t('settings.usual.when', { date: i18n.date(p.measuredAt) }) : t('settings.usual.never')}

diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index c4a8e1f..2a41b52 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -232,7 +232,8 @@ export const en: Messages = { 'settings.usual.median.other': '{n} voters out of {electorate} ({pct}): the median of the {sample} proposals voted on over the past year.', 'settings.usual.reference.one': '{n} voter out of {electorate} ({pct}), twice C: the reference turnout, until the group has voted on {min} proposals ({sample} so far).', 'settings.usual.reference.other': '{n} voters out of {electorate} ({pct}), twice C: the reference turnout, until the group has voted on {min} proposals ({sample} so far).', - 'settings.usual.bounded': 'Kept between {lo} and {hi} of the electorate.', + 'settings.usual.bounded.one': 'Kept between {lo} and {hi} of the electorate: thresholds are worked out on {n} voter ({pct}).', + 'settings.usual.bounded.other': 'Kept between {lo} and {hi} of the electorate: thresholds are worked out on {n} voters ({pct}).', 'settings.usual.when': 'Measured once a day, last on {date}.', 'settings.usual.never': 'Measured once a day.', 'settings.levels': 'The five levels', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 717aeeb..4171450 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -232,7 +232,8 @@ export const es: Messages = { 'settings.usual.median.other': '{n} votantes de {electorate} ({pct}): la mediana de las {sample} propuestas votadas en el último año.', 'settings.usual.reference.one': '{n} votante de {electorate} ({pct}), dos veces C: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).', 'settings.usual.reference.other': '{n} votantes de {electorate} ({pct}), dos veces C: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).', - 'settings.usual.bounded': 'Mantenida entre el {lo} y el {hi} del electorado.', + 'settings.usual.bounded.one': 'Mantenida entre el {lo} y el {hi} del electorado: los umbrales se calculan sobre {n} votante ({pct}).', + 'settings.usual.bounded.other': 'Mantenida entre el {lo} y el {hi} del electorado: los umbrales se calculan sobre {n} votantes ({pct}).', 'settings.usual.when': 'Medida una vez al día, la última el {date}.', 'settings.usual.never': 'Medida una vez al día.', 'settings.levels': 'Los cinco niveles', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index f8d87d3..d6b382f 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -235,7 +235,8 @@ export const fr = { 'settings.usual.median.other': '{n} votants sur {electorate} ({pct}) : la médiane des {sample} propositions votées depuis un an.', 'settings.usual.reference.one': "{n} votant sur {electorate} ({pct}), deux fois C : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).", 'settings.usual.reference.other': "{n} votants sur {electorate} ({pct}), deux fois C : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).", - 'settings.usual.bounded': "Ramenée entre {lo} et {hi} de l'électorat.", + 'settings.usual.bounded.one': "Ramenée entre {lo} et {hi} de l'électorat : les seuils se calculent sur {n} votant ({pct}).", + 'settings.usual.bounded.other': "Ramenée entre {lo} et {hi} de l'électorat : les seuils se calculent sur {n} votants ({pct}).", 'settings.usual.when': 'Mesurée une fois par jour, la dernière fois le {date}.', 'settings.usual.never': 'Mesurée une fois par jour.', 'settings.levels': 'Les cinq crans', diff --git a/web/src/lib/participation.ts b/web/src/lib/participation.ts new file mode 100644 index 0000000..289dbe9 --- /dev/null +++ b/web/src/lib/participation.ts @@ -0,0 +1,12 @@ +import type { ParticipationInfo } from './types'; + +/** + * The usual participation as the settings tell it: the measured median, or the + * reference (twice C). When the scale brings it back within its bounds, the + * bounded value is what the formula uses, and is said apart. + */ +export function usualShown(p: ParticipationInfo, electorate: number, floor: number): { voters: number; ratio: number } { + if (!p.bounded || electorate <= 0) return { voters: p.voters, ratio: p.ratio }; + const voters = p.source === 'median' && p.median !== null ? Math.round(p.median) : Math.round(2 * floor); + return { voters, ratio: voters / electorate }; +} diff --git a/web/tests/unit/participation.test.ts b/web/tests/unit/participation.test.ts new file mode 100644 index 0000000..373c88f --- /dev/null +++ b/web/tests/unit/participation.test.ts @@ -0,0 +1,37 @@ +// What the formula settings say about the usual participation (relecture of a576b68): +// the measured median, or twice C, even when the scale brings it back within its bounds. +import { describe, expect, it } from 'vitest'; +import { usualShown } from '../../src/lib/participation'; +import type { ParticipationInfo } from '../../src/lib/types'; + +const base: ParticipationInfo = { + ratio: 0.3, + source: 'median', + sample: 5, + median: 3, + bounded: false, + voters: 3, + minSample: 5, + windowDays: 365, + everyHours: 24, + bounds: [0.01, 0.5], + measuredAt: null, +}; + +describe('usual participation as shown', () => { + it('is the scale itself when nothing was bounded', () => { + expect(usualShown(base, 10, 1)).toEqual({ voters: 3, ratio: 0.3 }); + }); + it('is the real median when the scale raised it to its floor', () => { + const p = { ...base, median: 1, ratio: 0.01, voters: 57, bounded: true }; + expect(usualShown(p, 5745, 50)).toEqual({ voters: 1, ratio: 1 / 5745 }); + }); + it('is twice C for the reference, even when the scale lowered it to its ceiling', () => { + const p = { ...base, source: 'reference' as const, median: null, sample: 0, ratio: 0.5, voters: 1, bounded: true }; + expect(usualShown(p, 2, 1)).toEqual({ voters: 2, ratio: 1 }); + }); + it('rounds a median between two numbers of voters', () => { + const p = { ...base, median: 2.5, ratio: 0.01, voters: 57, bounded: true }; + expect(usualShown(p, 5745, 50).voters).toBe(3); + }); +});