v3 : relecture de a576b68 — les propositions retirées sortent de la médiane, les réglages montrent la vraie participation

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 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 19:41:33 +01:00
co-authored by Claude Opus 5.5
parent a576b68570
commit 71819f9012
9 changed files with 105 additions and 10 deletions
+3 -1
View File
@@ -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<Vec<u32>> {
let rows: Vec<i64> = 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) >= ?",
)
+36
View File
@@ -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<String> = ["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
// ---------------------------------------------------------------------------
+6 -3
View File
@@ -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:<groupe>` : médiane, taille de l'échantillon, date) ; elle est reprise au
@@ -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 @@
<h4>{t('settings.usual')}</h4>
<p>
{#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}
</p>
{#if p.bounded}<p class="faint small">{t('settings.usual.bounded', { lo: pct(p.bounds[0]), hi: pct(p.bounds[1]) })}</p>{/if}
{#if p.bounded}<p class="faint small">{i18n.plural('settings.usual.bounded', p.voters, { lo: pct(p.bounds[0]), hi: pct(p.bounds[1]), pct: pct(p.ratio) })}</p>{/if}
<p class="faint small">{p.measuredAt ? t('settings.usual.when', { date: i18n.date(p.measuredAt) }) : t('settings.usual.never')}</p>
</div>
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+12
View File
@@ -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 };
}
+37
View File
@@ -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);
});
});