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); + }); +});